You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The graceful-shutdown handler in src/app.ts:181-195 drains HTTP connections only — it does not wait for the fire-and-forget async pipeline started by processRequest(). When Kubernetes sends SIGTERM (rolling deploy, HPA scale-down, node drain):
isReady = false flips /readyz to 503 (src/app.ts:183) — correct.
server.close(cb) at src/app.ts:185 fires cb as soon as all open HTTP connections close. Per the Node.js docs, the callback waits on connection lifecycle only — it does not know about module-level promises.
Every active webhook handler — handleIssueComment (src/webhook/events/issue-comment.ts:35-37) and handleReviewComment (src/webhook/events/review-comment.ts:35-37) — calls processRequest(ctx).catch(...)fire-and-forget after the webhook has already returned 200 OK in milliseconds. By the time SIGTERM arrives, the HTTP request is long closed, so the close() callback fires almost instantly.
The callback then calls process.exit(0) (src/app.ts:187), killing the process before the 290 s force-exit timer at src/app.ts:191-194 can fire. That timer is effectively dead code: it only triggers if server.close hangs, which never happens in this topology because fire-and-forget promises are not HTTP connections.
The router already tracks the right signal: activeCount (src/webhook/router.ts:29, incremented at :161, decremented at :261 inside a finally) counts exactly the in-flight processRequest invocations. Nothing awaits it at shutdown. processRequest has 10 sequential steps — tracking-comment creation, GraphQL fetch, repo clone, Claude Agent SDK execution, finalize comment, temp-dir cleanup — any of which can be interrupted.
User-visible failure modes on every pod restart:
Orphaned "Working..." tracking comments — createTrackingComment (src/core/tracking-comment.ts:48-66) posted the spinner, but finalizeTrackingComment never ran. The durable <!-- delivery:... --> marker (src/core/tracking-comment.ts:12-14) sits in a permanently unresolved comment.
Leaked temp clone dirs and credential helpers — checkoutRepo's cleanup() runs in the finally block of src/webhook/router.ts:204-241, which never executes after process.exit. The startup sweep at src/app.ts:139-165 removes stale *.cred.sh files but not the clone directories.
Silent money loss — agent turns already billed by executeAgent (src/core/executor.ts:83-96) deliver no output. No retry happens because GitHub does not automatically redeliver failed webhooks — the user must re-@mention manually.
Diagram
flowchart TD
sigterm["SIGTERM<br/>rolling deploy / HPA / node drain"]:::signal
ready["isReady = false<br/>readyz -> 503"]:::ok
fire["Fire-and-forget processRequest<br/>webhook already returned 200 OK"]:::warn
noconn["Zero open HTTP connections<br/>activeCount = N > 0"]:::warn
close["server.close callback<br/>waits ONLY on HTTP conns"]:::warn
cbfast["callback fires within ms<br/>290s force-exit never reached"]:::bad
exit["process.exit 0<br/>aborts in-flight processRequest"]:::bad
orphan["Orphaned Working... comment<br/>with delivery marker"]:::bad
leak["Leaked temp clone dir"]:::bad
wallet["API cost billed<br/>no output delivered"]:::bad
noretry["GitHub does NOT auto-redeliver<br/>user must re-@mention"]:::bad
fix["MISSING: await activeCount == 0<br/>before process.exit"]:::fix
sigterm --> ready --> close
fire -.-> noconn
close --> noconn --> cbfast --> exit
exit --> orphan --> noretry
exit --> leak
exit --> wallet
exit -.->|should wait for| fix
classDef signal fill:#1e3a8a;color:#ffffff;stroke:#0c2050
classDef ok fill:#166534;color:#ffffff;stroke:#052e16
classDef warn fill:#92400e;color:#ffffff;stroke:#451a03
classDef bad fill:#7f1d1d;color:#ffffff;stroke:#450a0a
classDef fix fill:#0f766e;color:#ffffff;stroke:#042f2e
Loading
Rationale
Single-pod Claude Agent SDK runs routinely take 30-120 s (multi-turn conversation + repo clone + MCP tool calls, bounded at maxTurns: 50 per src/core/executor.ts:72). A rolling deploy on every merge to main, Kubernetes HPA scale-down on every traffic dip, and node-drain on every infra rotation all truncate an arbitrary slice of in-flight requests. Conservatively — one deploy/day, one in-flight request at deploy time, mean duration 60 s, two extra SIGTERM events/day from other churn — that is roughly 3 orphaned tracking comments per day per pod. After a week the PR timeline is polluted with "Working..." stubs that require admin GC.
The setTimeout(..., 290_000) at src/app.ts:191-194 is documented as "Force exit after terminationGracePeriodSeconds if server.close hangs", but server.close never hangs here — its callback fires as soon as HTTP connections drain (immediate). The 290 s budget the operator paid for with terminationGracePeriodSeconds is thrown away. Draining on activeCount instead of HTTP-connection count recovers that budget and delivers the graceful behaviour operators already expect.
activeCount is the correct signal: the invariant is preserved across success (src/webhook/router.ts:229-237) and failure (src/webhook/router.ts:242-262) branches via finally. No new state is required; it simply needs to be awaited. The pattern matches the Node.js production shutdown contract — "stop accepting new work → drain in-flight work → close external deps → exit".
References
Internal:
src/app.ts:181-195 — shutdown() drains HTTP only, not background promises
Export waitForDrain(timeoutMs): Promise<void> from src/webhook/router.ts that polls activeCount === 0 on a short interval (e.g. 250 ms) and resolves early or rejects on timeout. Keep the pure-function style used for cleanupStaleIdempotencyEntries so the logic is unit-testable.
In src/app.tsshutdown(), after isReady = false, await waitForDrain(270_000) (≈20 s headroom below the assumed terminationGracePeriodSeconds). Then call server.close() and process.exit(0). Move process.exitout of the server.close callback so drain gates exit, not HTTP connection closure.
Add a defensive startup sweep that finalizes stale "Working..." comments older than ~10 min (symmetric with the *.cred.sh sweep at src/app.ts:139-165). The durable marker <!-- delivery:... --> already exists; isAlreadyProcessed at src/core/tracking-comment.ts:23-40 gives the lookup primitive.
Add a regression test in test/webhook/router.test.ts that asserts waitForDrain resolves only after all active processRequest invocations complete (use a fake executeAgent bound to a controlled promise). A grep of test/ for shutdown|SIGTERM|server.close|drain returns zero hits today.
Document the new shutdown contract in CLAUDE.md under "How It Runs" so contributors know fire-and-forget work is explicitly drained, not implicitly abandoned.
Finding
The graceful-shutdown handler in
src/app.ts:181-195drains HTTP connections only — it does not wait for the fire-and-forget async pipeline started byprocessRequest(). When Kubernetes sendsSIGTERM(rolling deploy, HPA scale-down, node drain):isReady = falseflips/readyzto 503 (src/app.ts:183) — correct.server.close(cb)atsrc/app.ts:185firescbas soon as all open HTTP connections close. Per the Node.js docs, the callback waits on connection lifecycle only — it does not know about module-level promises.handleIssueComment(src/webhook/events/issue-comment.ts:35-37) andhandleReviewComment(src/webhook/events/review-comment.ts:35-37) — callsprocessRequest(ctx).catch(...)fire-and-forget after the webhook has already returned 200 OK in milliseconds. By the timeSIGTERMarrives, the HTTP request is long closed, so theclose()callback fires almost instantly.process.exit(0)(src/app.ts:187), killing the process before the 290 s force-exit timer atsrc/app.ts:191-194can fire. That timer is effectively dead code: it only triggers ifserver.closehangs, which never happens in this topology because fire-and-forget promises are not HTTP connections.The router already tracks the right signal:
activeCount(src/webhook/router.ts:29, incremented at:161, decremented at:261inside afinally) counts exactly the in-flightprocessRequestinvocations. Nothing awaits it at shutdown.processRequesthas 10 sequential steps — tracking-comment creation, GraphQL fetch, repo clone, Claude Agent SDK execution, finalize comment, temp-dir cleanup — any of which can be interrupted.User-visible failure modes on every pod restart:
createTrackingComment(src/core/tracking-comment.ts:48-66) posted the spinner, butfinalizeTrackingCommentnever ran. The durable<!-- delivery:... -->marker (src/core/tracking-comment.ts:12-14) sits in a permanently unresolved comment.checkoutRepo'scleanup()runs in thefinallyblock ofsrc/webhook/router.ts:204-241, which never executes afterprocess.exit. The startup sweep atsrc/app.ts:139-165removes stale*.cred.shfiles but not the clone directories.executeAgent(src/core/executor.ts:83-96) deliver no output. No retry happens because GitHub does not automatically redeliver failed webhooks — the user must re-@mention manually.Diagram
flowchart TD sigterm["SIGTERM<br/>rolling deploy / HPA / node drain"]:::signal ready["isReady = false<br/>readyz -> 503"]:::ok fire["Fire-and-forget processRequest<br/>webhook already returned 200 OK"]:::warn noconn["Zero open HTTP connections<br/>activeCount = N > 0"]:::warn close["server.close callback<br/>waits ONLY on HTTP conns"]:::warn cbfast["callback fires within ms<br/>290s force-exit never reached"]:::bad exit["process.exit 0<br/>aborts in-flight processRequest"]:::bad orphan["Orphaned Working... comment<br/>with delivery marker"]:::bad leak["Leaked temp clone dir"]:::bad wallet["API cost billed<br/>no output delivered"]:::bad noretry["GitHub does NOT auto-redeliver<br/>user must re-@mention"]:::bad fix["MISSING: await activeCount == 0<br/>before process.exit"]:::fix sigterm --> ready --> close fire -.-> noconn close --> noconn --> cbfast --> exit exit --> orphan --> noretry exit --> leak exit --> wallet exit -.->|should wait for| fix classDef signal fill:#1e3a8a;color:#ffffff;stroke:#0c2050 classDef ok fill:#166534;color:#ffffff;stroke:#052e16 classDef warn fill:#92400e;color:#ffffff;stroke:#451a03 classDef bad fill:#7f1d1d;color:#ffffff;stroke:#450a0a classDef fix fill:#0f766e;color:#ffffff;stroke:#042f2eRationale
Single-pod Claude Agent SDK runs routinely take 30-120 s (multi-turn conversation + repo clone + MCP tool calls, bounded at
maxTurns: 50persrc/core/executor.ts:72). A rolling deploy on every merge tomain, Kubernetes HPA scale-down on every traffic dip, and node-drain on every infra rotation all truncate an arbitrary slice of in-flight requests. Conservatively — one deploy/day, one in-flight request at deploy time, mean duration 60 s, two extra SIGTERM events/day from other churn — that is roughly 3 orphaned tracking comments per day per pod. After a week the PR timeline is polluted with "Working..." stubs that require admin GC.The
setTimeout(..., 290_000)atsrc/app.ts:191-194is documented as "Force exit after terminationGracePeriodSeconds if server.close hangs", butserver.closenever hangs here — its callback fires as soon as HTTP connections drain (immediate). The 290 s budget the operator paid for withterminationGracePeriodSecondsis thrown away. Draining onactiveCountinstead of HTTP-connection count recovers that budget and delivers the graceful behaviour operators already expect.activeCountis the correct signal: the invariant is preserved across success (src/webhook/router.ts:229-237) and failure (src/webhook/router.ts:242-262) branches viafinally. No new state is required; it simply needs to be awaited. The pattern matches the Node.js production shutdown contract — "stop accepting new work → drain in-flight work → close external deps → exit".References
Internal:
src/app.ts:181-195—shutdown()drains HTTP only, not background promisessrc/app.ts:185-188—server.closecallback immediately callsprocess.exit(0)src/app.ts:191-194— 290 s force-exit timer unreachable in this topologysrc/webhook/router.ts:29—activeCountmodule-level counter (never awaited at shutdown)src/webhook/router.ts:161,261— increment/decrement intry/finallysrc/webhook/events/issue-comment.ts:35-37— fire-and-forgetprocessRequest(ctx).catch(...)src/webhook/events/review-comment.ts:35-37— same patternsrc/core/tracking-comment.ts:12-14,48-66— delivery marker + "Working..." comment creationsrc/core/executor.ts:83-96— agent loop bounded by internal timeout only, not shutdownCLAUDE.md— "Async processing: Webhook must respond within 10 seconds. All heavy work runs asynchronously after 200 OK."External:
http.Server.close()docs — callback waits on HTTP connections onlySuggested Next Steps
waitForDrain(timeoutMs): Promise<void>fromsrc/webhook/router.tsthat pollsactiveCount === 0on a short interval (e.g. 250 ms) and resolves early or rejects on timeout. Keep the pure-function style used forcleanupStaleIdempotencyEntriesso the logic is unit-testable.src/app.tsshutdown(), afterisReady = false,await waitForDrain(270_000)(≈20 s headroom below the assumedterminationGracePeriodSeconds). Then callserver.close()andprocess.exit(0). Moveprocess.exitout of theserver.closecallback so drain gates exit, not HTTP connection closure.*.cred.shsweep atsrc/app.ts:139-165). The durable marker<!-- delivery:... -->already exists;isAlreadyProcessedatsrc/core/tracking-comment.ts:23-40gives the lookup primitive.test/webhook/router.test.tsthat assertswaitForDrainresolves only after all activeprocessRequestinvocations complete (use a fakeexecuteAgentbound to a controlled promise). A grep oftest/forshutdown|SIGTERM|server.close|drainreturns zero hits today.CLAUDE.mdunder "How It Runs" so contributors know fire-and-forget work is explicitly drained, not implicitly abandoned.Areas Evaluated
src/webhook/router.ts— idempotency fast path, durable check ordering,activeCountlifecycle, owner allowlist gate, concurrency limit, retry wrappers, error pathsrc/webhook/events/— all 5 handlers;issue-comment.ts+review-comment.tsactive, rest are placeholderssrc/webhook/authorize.ts—isOwnerAllowedordering relative to durable idempotencysrc/app.ts— signal handlers, readiness, startup checks, stale credential sweepsrc/core/tracking-comment.ts— delivery marker, create/finalize flowsrc/core/executor.ts— agent-loop wall-clock timeouttest/webhook/router.test.ts— confirmed zero shutdown/drain coveragegh issue list --label research --state all— empty; no duplicate riskGenerated by scheduled research workflow run #24312419817 on 2026-04-12