feat(runtime): drain in-flight runs on shutdown instead of dropping them - #186
Merged
RatulMaharaj merged 1 commit intoAug 1, 2026
Merged
Conversation
RatulMaharaj
force-pushed
the
feat/176-drain-on-shutdown
branch
from
August 1, 2026 18:34
f9fbd61 to
bc133f3
Compare
SIGTERM used to mean exit. Docker waits 10s before killing the container and that window was spent doing nothing: an in-flight run died mid-tool- call and its work went with it. Draining is a state rather than a slower stop. RunScheduler.drain() stops accepting work and resolves when everything already accepted has finished; submit() then throws DrainingError, which the service turns into a refusal the sender actually sees, audited like a queue-full one. Events already queued still run: they were accepted. limits.drain_timeout (default 8s, under docker stop's 10s grace) bounds the wait. Past it the agent stops anyway and the runs still going are left with open rows, which the crash sweep from #60 marks error_crashed at next start. That is the difference this makes: a run that outlasts its drain is recorded rather than vanishing. /healthz reports draining and in_flight, so a deployment can watch a drain rather than infer it from failures, and anything routing to this agent can stop. One thing worth knowing: a timed-out drain deliberately does not close the store. Closing it under a run that is still going turns a slow run into a failed write, since it would reach closeRun and find the database gone. Everything else shuts down and the file is left to the process exit; WAL makes that safe to reopen. Scoped to the runtime half. Triggers are not told to stop reading, and a tty session gets no goodbye frame -- both are in the issue and both belong with #173. Refs #176 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPftW3F8JTuivRhFmoY1wC
RatulMaharaj
force-pushed
the
feat/176-drain-on-shutdown
branch
from
August 1, 2026 18:44
bc133f3 to
747a0b2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #185. Fifth in the series.
Refs #176, deliberately not closing it. See scope below.
The problem
SIGTERM meant exit. Docker waits 10 seconds before killing the container, and that window was spent doing nothing: an in-flight run died mid-tool-call, its side effects already committed to the world.
#182 made the evidence survive. This makes the run survive.
What changed
Draining is a state, not a slower stop.
RunScheduler.drain()stops accepting work and resolves once everything already accepted has finished.submit()then throwsDrainingError, which the service turns into a refusal the sender actually sees, audited like a queue-full refusal. Events already queued still run, because they were accepted.limits.drain_timeout, default 8 seconds, underdocker stop's 10s grace so the normal path completes rather than being killed partway. Past it the agent stops anyway.Runs that outlast the drain are recorded, not lost. They keep their open row, and the crash sweep from #182 marks them
error_crashedat next start. That pairing is the point: draining alone would turn "died mid-run" into "died mid-run slightly later", and only the row makes it visible./healthzreportsdrainingandin_flight. Both states areok: trueand only one should be sent more work, so a deployment can watch a drain rather than infer it from failures.A bug this turned up
The first version had
drain()callstop()unconditionally, which closes the store. A run that outlasted the timeout then reachedcloseRunand gotError: database is not open- a slow run turned into a failed write by the shutdown meant to protect it. A test caught it.A timed-out drain now shuts down everything except the store and leaves the file to the process exit. WAL makes an unclosed database safe to reopen, and a run that finishes in the last moments still records itself.
Testing
496 pass,
deno task okclean. Five new:scheduler: accepted work finishes while new submits are refused, on both lane and laneless paths; an idle scheduler drains immediately; a drain waits for work queued behind a lane rather than only what is runningservice: the in-flight run completes, a late event is refused with an audit row, and the drain reports zero remaining; a run that outlasts the deadline leaves an open row and the drain reports it rather than hangingScope
Points 1, 2 and 5 of the issue. Not done, and why:
Decisions from the issue