Skip to content

feat(runtime): drain in-flight runs on shutdown instead of dropping them - #186

Merged
RatulMaharaj merged 1 commit into
feat/174b-inert-declarationsfrom
feat/176-drain-on-shutdown
Aug 1, 2026
Merged

feat(runtime): drain in-flight runs on shutdown instead of dropping them#186
RatulMaharaj merged 1 commit into
feat/174b-inert-declarationsfrom
feat/176-drain-on-shutdown

Conversation

@RatulMaharaj

Copy link
Copy Markdown
Member

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 throws DrainingError, 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, under docker 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_crashed at 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.

/healthz reports draining and in_flight. Both states are ok: true and 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() call stop() unconditionally, which closes the store. A run that outlasted the timeout then reached closeRun and got Error: 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 ok clean. 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 running
  • service: 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 hanging

Scope

Points 1, 2 and 5 of the issue. Not done, and why:

Decisions from the issue

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
RatulMaharaj force-pushed the feat/176-drain-on-shutdown branch from bc133f3 to 747a0b2 Compare August 1, 2026 18:44
@RatulMaharaj
RatulMaharaj merged commit d9c2c5e into main Aug 1, 2026
2 checks passed
@RatulMaharaj
RatulMaharaj deleted the feat/176-drain-on-shutdown branch August 1, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant