Skip to content

feat(runnerd): idle auto-stop for sessions whose agent has finished - #87

Open
jiashuoz wants to merge 7 commits into
mainfrom
feat/runner-idle-stop
Open

feat(runnerd): idle auto-stop for sessions whose agent has finished#87
jiashuoz wants to merge 7 commits into
mainfrom
feat/runner-idle-stop

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Sep 11, 2026

Copy link
Copy Markdown
Member

What this does

runnerd now stops a session whose child process has exited and that has had no attachment — viewer or controller, on either attach front — for --idle-stop (a duration flag, default 30m, 0 disables), by exactly the path rainier stop takes: a cold suspend, so the container is stopped, the workspace volume is kept, the slot is released, the registry entry lands on "suspended", the announce and the unsolicited event both say suspended_cold, the session row reads suspended_cold (which rainier ls prints as stopped), and rainier attach resumes it; the idle clock starts at the later of the child's exit and the last detach, so reading a finished agent's scrollback for an hour resets nothing while attached and gives the full timeout after detaching; a session whose child is still running is never stopped however long nobody has watched it, a session with any attachment open is never idle, a session the operator already stopped is never stopped again, a resumed session becomes eligible again only through its new child's exit, and nothing is ever deleted.

Design: docs/design/runner-idle-stop.md. This is the near-term half of #85, which supersedes it: resource-aware admission, queueing instead of refusal, warm/cold suspension policy, one shared activity signal, and the status report are that issue's, and #85 names this half as the agreed small one.

How it is safe

The decision and the claim are one locked registry call (claimIdle): it re-checks the whole rule and marks the entry "suspending" in the same critical section, so two sweeps — or a sweep and a stop arriving from controld — cannot both stop one session, and a session that stops being idle in between is simply not claimed. Both stops go through one coldSuspend, so the operator's and the runner's can never drift apart; a driver refusal rolls the entry back to "running" and the next sweep tries again. "suspending" is the marker that keeps the register goroutine from reading the resulting sessiond conn death as a crash and destroying the container. Every duration is now.Sub(stored) with both times from one clock (time.Now in production, a fake in tests), so a wall-clock step cannot make a session look idle early.

Saying it

  • One structured line per stop: runnerd: idle auto-stop session=… idle=… slots_free=… slots_total=… active=… idle_exited=….
  • An unsolicited suspended_cold event to controld. controlapp's transition table already accepts exactly that ({running, suspended_cold} → suspended_cold), so only runnerplane's translation arm was needed and no control-plane change was.
  • Two additive counts beside the used/total that already ride every runner message: active (sandbox up, child running) and idle_exited (sandbox up, child gone). active + idle_exited ≤ used — a warm-suspended or still-creating sandbox is in neither count.

What the reviews changed

Two Opus reviews (independent + adversarial) and three verification rounds, each waited for before the next change. Fourteen findings, fourteen regression tests, and every fix verified by mutating it back out and watching its test fail (14/14 caught). Two of the rounds found defects in the previous round's fixes, which is why there were three.

Round 1 (1698ce0)

  • A control frame can outlive the sandbox boot that sent it. A hub read loop stalled writing to a wedged viewer drains its buffered frames whenever it comes back — possibly after the sandbox has been stopped, resumed and re-registered. That buffered child_exited landed on the new boot, and half an hour later the runner stopped a session whose agent was working. Every control frame now carries the boot token its /register minted, and the registry drops a child exit naming any other — the same guard, for the same reason, as hubDied's deadHub.
  • The auto-stop event now carries no placement generation. A cold resume opens a new generation on the row but sends the runner none, so the runner's is stale from a session's second life onwards and the event would be fenced — leaving the row reading running over a stopped container, which rainier attach will not resume and cannot reach. Zero fences nothing, and a re-placement onto a different runner is still fenced by runner identity. (See follow-up 2 for the real fix.)
  • A warm suspend, a resume and a snapshot now take a session out of the sweep for their duration: each leaves the entry reading "running" while it runs, so a sweep could have turned an operator's docker pause — which deliberately keeps the slot — into a stop, or stopped a container mid-docker commit.
  • A session whose boot chain failed is never auto-stopped. Its child exits with the failing stage, so it looks exactly like a finished agent; but attaching to read the log that says why is the only thing left to do with it, and neither a stopped sandbox (no hub) nor a failed row (not resumable) can serve that. It holds its slot until someone removes it — deliberate, and reclaiming those is Runner capacity: replace the fixed slot count with resource-aware admission and idle auto-suspend #85's.
  • The stop's rollback and landing state are compare-and-swap on "suspending", so a Delete that overtook the stop keeps its "destroying" marker rather than having it wiped.
  • The attach front counts a viewer before the websocket upgrade, not after the first client frame — that frame crosses the client's network, and a sweep in that window stopped the session under them.
  • Both driver calls on the stop path are bounded (30s/5s), so a wedged daemon cannot park the sweep goroutine for good; a failed capacity reading logs unknown rather than a plausible-looking number.

Plus: the new wire tags are pinned, and comments/design doc corrected where they over-claimed (the monotonic clock does not survive host suspend; reconciliation runs per connection, not "on the next announce"; the counts are not an arithmetic partition of used; cmd/runnerd's wiring has no test).

Round 2 (cfc3214) — including a defect a round-1 fix introduced

  • The boot token was minted per registration, not per boot. A plain sessiond redial — container unchanged, child unchanged — opened a new epoch and dropped a child_exited already in flight across it. sessiond re-sends only what it never delivered, so that was the only copy: the session would have held its slot for the life of the runner, silently, which is the incident this change exists to end. register now reads the epoch; only a cold resume moves it (sandboxes run with no restart policy, so nothing else can change the child).
  • Bounding docker stop manufactured a session-destroying case. The CLI is killed at the deadline while the daemon goes on stopping the container; believing that error rolled the entry back to "running", the container died seconds later, and the register goroutine read that as a crash — destroying the container and reporting a merely idle session dead. A failed stop now asks the driver what the container is actually doing, and leaves the entry parked when there is no answer.
  • A failed attach handshake no longer stamps the detach time (the local /attach surface has no auth, so a client looping on a failed dial could have pushed every deadline on the runner out indefinitely). markBootFailed is boot-guarded and cleared by a cold resume. The dial_attach front counts its viewer from before the dial. The in-flight bracket is taken before the guard it protects. beginColdSuspend joins its siblings in respecting a Delete's marker. Recover says in the log that the sessions it rebuilt are exempt.

Round 3 (b246779, 6db15bc) — the last door into the invariant

Whether a resumed session's agent is a new process was inferred from a flag the runner set when it asked for a cold suspend. Intent and outcome come apart both ways: a warm-paused container that the docker daemon restarts underneath a surviving runnerd (an upgrade, an OOM kill — the runner is a host process) is resumed with docker start while the entry still reads paused, so the new agent inherited the old one's exit and was stopped half an hour into its work. And an entry left marked cold by a stop whose outcome could not be read moved the epoch under a connection that was still live, deafening it.

Nothing above the driver can tell the two apart — Inspect folds paused, exited and created into one state — so Driver.Resume now reports restarted bool, the driver contract suite pins that every driver agrees on it, and the reset follows that answer rather than the runner's intent. It clears only on the resume that actually unparks the entry, since docker start on an already-started container also exits 0 and two racing resumes are a real shape.

Follow-ups, deliberately not in this PR

  1. The CLI half. Nothing consumes active/idle_exited yet — this PR carries them on the wire and wires nothing further. Making rainier status say "16 slots, 3 active, 13 idle" instead of "no free capacity" needs control.Runner, the fleet repository and the CLI, which is Runner capacity: replace the fixed slot count with resource-aware admission and idle auto-suspend #85's status report.
  2. Cold resume does not refresh the runner's placement generation. ResumeSession opens a new placement generation for a cold resume, but the resume command carries none, so the runner keeps echoing its create-time generation and ApplyRunnerEvent fences every later event about that session — dead and child_exited included, today, before this change. An auto-stop event about a cold-resumed session is fenced the same way; the runner still frees the slot and the row heals on the next announce. Fixing it means carrying the generation on resume (protocol + controlapp).
  3. Sessions rebuilt by Recover after a runnerd restart are not auto-stop candidates until they report a new child exit — the exit fact lived only in memory. Safe direction; a durable activity record is Runner capacity: replace the fixed slot count with resource-aware admission and idle auto-suspend #85's.

--slots' default is unchanged at 16; the hosted runner passes its own. Both flags are now documented in cmd/runnerd's help and the README's new "Runner capacity" section.

Tests

No test needs Docker (internal/driver.Fake plus this package's in-process /register sandbox is the whole stack below the runner):

  • internal/runnerd/idlestop_test.go — table-driven on a fake clock: 29m59s vs 30m, stopped once and not once per sweep, a child still running for hours, a viewer attached, the timer running from the detach, one of two viewers leaving, --idle-stop 0, a session the operator already stopped, a resumed session whose child exits again, warm pause/unpause keeping the fact, eight concurrent sweeps stopping it exactly once, a driver refusal rolling back, a re-delivered child_exited not moving the deadline, the sweep-interval clamp, and the loop itself.
  • internal/runnerd/idlestop_e2e_test.go — the real path end to end: a websocket sessiond reporting child_exited over the real control channel, a real /attach client holding the session open past the timeout, the stop after it leaves, the workspace still there, the event and its counts arriving at a fake controld, and the sandbox conn then dying as docker stop kills it. Same for the dial_attach front.
  • runnerplane/plane_test.go — the new event state translating to control.StateSuspendedCold.

One interface change comes with this: driver.Driver.Resume returns (restarted bool, err error). Both implementations, the contract suite and every call site are updated; the bool exists because idle auto-stop's correctness depends on it and only a driver can answer it.

make verify is green with PostgreSQL 17 up (RAINIER_TEST_PG_DSN pointed at the maintenance database, so internal/controld/pgstore and internal/e2e run rather than skip), and go test ./internal/runnerd/ -race passes.

🤖 Generated with Claude Code

jiashuoz and others added 7 commits September 11, 2026 05:08
The near-term half of #85: a session whose child has exited and that has
had no attachment for --idle-stop is cold-suspended by the runner, exactly
as `rainier stop` does — files kept, slot released, resumable by attach.

Records what a stop actually is end to end (suspended_cold, not warm), the
rule, the claim-under-lock that makes the sweep race-free, the two additive
capacity counts, and the cold-resume placement-generation limitation this
change does not introduce and does not fix.

Co-Authored-By: Claude <noreply@anthropic.com>
A sandbox held one of --slots from creation until somebody stopped or
deleted its session, whether or not its agent had finished hours earlier:
fourteen finished sessions filled a hosted runner while three agents worked.

runnerd now stops a session whose child has exited and that has had no
attachment for --idle-stop (default 30m, 0 disables), by the same cold
suspend `rainier stop` performs — container stopped, files kept, slot
released, state suspended_cold, resumable by attach. A session whose child
is still running is never stopped however long nobody has watched it, and
nothing is ever deleted.

The decision and the claim are one locked registry call, so two sweeps, or a
sweep and a stop arriving from controld, cannot both stop one session, and a
session that stops being idle in between is simply not claimed. Both stops go
through one coldSuspend, so the operator's and the runner's can never drift.
The idle clock starts at the later of the child's exit and the last detach.

Reports it three ways: one structured log line per stop, an unsolicited
suspended_cold event (which controlapp's transition table already accepts, so
the session row follows the sandbox), and two additive counts — active and
idle_exited — beside the used/total that already ride every runner message.

Design: docs/design/runner-idle-stop.md. Near-term half of #85.

Co-Authored-By: Claude <noreply@anthropic.com>
Drives the feature end to end with no docker: a websocket standing in for the
container's sessiond reports child_exited over the real control channel, a
real client on /attach holds the session open past the timeout, and only once
it leaves and the timeout passes does the runner stop it — slot released,
workspace kept, registry and announce on suspended_cold, and the event with
its two counts arriving at a fake controld. The sandbox conn is then killed,
as `docker stop` kills it in production, to pin that the entry survives it.

The dial_attach front gets the same coverage, because a viewer who came
through controld's attach plane must hold a session open exactly as a local
one does.

Fixes a bug the end-to-end test found in the commit before it: the detach was
recorded as `defer s.reg.attachEnded(id, s.now())`, and a deferred call's
arguments are evaluated where the defer is written — so every detach was
stamped with the time of its own ATTACH, and a session being watched right now
looked like one nobody had touched since the viewer arrived. Both fronts now
take the clock inside a closure.

Also walks the new suspended_cold event through runnerplane's translation
table into control.StateSuspendedCold.

Co-Authored-By: Claude <noreply@anthropic.com>
Seven findings, each with the regression test that catches it (every one
verified by mutating the fix back out and watching the test fail):

A control frame can outlive the sandbox boot that sent it — a hub read loop
stalled writing to a wedged viewer drains its buffered frames whenever it
comes back, which can be after the sandbox has been stopped, resumed and
re-registered. That buffered child_exited landed on the NEW boot, and half an
hour later the runner stopped a session whose agent was working: the one thing
this must never do. Every control frame now carries the boot token its
/register minted, and the registry drops a child exit naming any other — the
same guard, for the same reason, as hubDied's deadHub.

The auto-stop event no longer carries a placement generation. A cold resume
opens a new generation on the session row but sends the runner none, so the
runner's is stale from a session's second life onwards and the event would be
fenced — leaving the row reading "running" over a stopped container, which
`rainier attach` will not resume and cannot reach. Zero fences nothing, and a
re-placement to a different runner is still fenced by runner identity.

A warm suspend, a resume and a snapshot now take the session out of the sweep
for their duration: each leaves the entry reading "running" while it runs, so
a sweep could turn an operator's pause — which deliberately keeps the slot —
into a stop, or stop a container mid-`docker commit`.

A session whose boot chain failed is never auto-stopped. Its child exits with
the failing stage, so it looks exactly like a finished agent; but attaching to
read the log that says why is the only thing left to do with it, and neither a
stopped sandbox nor a failed row can serve that.

The stop's rollback and its landing state are compare-and-swap on
"suspending", so a Delete that overtook the stop keeps its "destroying"
marker instead of having it wiped — the marker that stops the register
goroutine destroying the container twice and reporting the session dead.

The attach front counts a viewer from the moment the session is known to be
attaching, not after the first client frame: that frame crosses the client's
network, and a sweep in that window stopped the session under them.

Both driver calls on the stop path are bounded (30s, 5s, the bounds this
package already uses off a background goroutine), so a wedged daemon cannot
park the sweep goroutine for good, and a failed capacity reading logs
"unknown" rather than a plausible-looking number.

Also pins the new wire tags in protocol/runner, and corrects comments and the
design doc where they over-claimed: the monotonic clock does not survive host
suspend, reconciliation runs per connection rather than "on the next
announce", counts are not an arithmetic partition of used, and cmd/runnerd's
wiring has no test.

Co-Authored-By: Claude <noreply@anthropic.com>
Both reviewers re-read the fixes and landed on the same worst one: the boot
token was minted per REGISTRATION, so a plain sessiond redial — where the
container never restarted and the child never changed — opened a new epoch and
dropped a child_exited that was already in flight across it. sessiond re-sends
only events it never delivered, so that was the only copy: the session would
have held its slot for the life of the runner, silently, which is the incident
this whole change exists to end. register now READS the epoch and only a cold
resume moves it — the only thing that can change the child, since sandboxes run
with --rm and nothing sets a restart policy.

Bounding `docker stop` turned out to manufacture a case that destroyed
sessions: the CLI is killed at the deadline while the daemon goes on stopping
the container, and believing that error rolled the entry back to "running" —
the container then died seconds later, the register goroutine read a "running"
entry as a crash, destroyed the container and reported a merely idle session
dead. A failed stop now asks the driver what the container is actually doing
and lands the entry on the answer, leaving it parked when there is no answer.

Also: a failed attach handshake no longer stamps the detach time (the local
/attach surface has no auth, so a client looping on a failed dial could have
pushed every deadline on the runner out indefinitely); markBootFailed is
boot-guarded like the child exit and cleared by a cold resume, so a late stage
failure cannot pin a healthy session out of auto-stop for ever; the dial_attach
front counts its viewer from before the dial, not after it and the hub wait;
the in-flight bracket is taken before the guard it protects rather than after;
beginColdSuspend joins its two siblings in respecting a Delete's marker; and
Recover says in the log that the sessions it rebuilt are exempt.

Nine mutations, nine caught — including one gap the reviewers found in my own
first round: the resume-window epoch bump was not pinned by any test, and the
redial case needed a test that goes through the real /register handler rather
than the registry underneath it.

Co-Authored-By: Claude <noreply@anthropic.com>
The third review pass found the last door into the one invariant. Whether a
resumed session's agent is a NEW process was inferred from a flag this runner
set when it ASKED for a cold suspend — the runner's intent, not what the
container did — and the two come apart in both directions:

A warm-paused container that the docker daemon restarts underneath a surviving
runnerd (a daemon upgrade, an OOM kill; the runner is a host process, not a
container) is resumed with `docker start` while the entry still reads paused.
The new agent inherited the old one's exit and was stopped half an hour into
its work — exactly what idle auto-stop must never do.

And an entry left marked cold by a stop whose outcome could not be read is
resumed by an unpause, or by nothing at all, while the boot epoch moved anyway
— under a connection that is still alive, whose captured token then went
stale. That sandbox's child exit was dropped, sessiond re-sends only what it
never delivered, and the session would have held its slot for the life of the
runner in silence.

Nothing above the driver can tell the two apart: Inspect folds paused, exited
and created into one StateSuspended. So Driver.Resume now reports `restarted
bool` — true only where it ran a start — the contract suite pins that every
driver agrees on it (unpause and already-running report false, a cold resume
reports true), the fake models it, and resumed() clears the idle bookkeeping
and closes the boot epoch on exactly that answer. The intent flag is gone.

Also converts Delete's own rollback to the same compare-and-swap as its three
siblings, so a cold suspend that landed while drv.Destroy was in flight is not
stamped back to "running" over a container that really is stopped.

Four more mutations, four caught.

Co-Authored-By: Claude <noreply@anthropic.com>
The last review round's residual. `docker start` on an already-started
container exits 0, so a second resume reports a restart as honestly as the
first — and two are a real shape, since the control plane dispatches the
command to the runner before it transitions the row, so two racing clients
both send one. The second bump moved the boot epoch out from under the
connection the restarted sandbox had already opened, whose child exit was then
dropped: a slot that never comes back, silently.

resumed() now clears the bookkeeping only when the resume is also the one that
brought a parked entry back, which consumes the transition the way the deleted
intent flag used to. The design doc records why the hub pointer is not the
guard here (it would be per-connection again, and would drop an exit in flight
across a plain redial), and names the one corner this leaves: an entry the
runner keeps as "running" because Inspect failed, resumed through the local
dev surface, restarts without clearing.

Also drops the stale comment about the flag that is gone.

Co-Authored-By: Claude <noreply@anthropic.com>
@jiashuoz
jiashuoz marked this pull request as ready for review September 11, 2026 06:37
@jiashuoz

Copy link
Copy Markdown
Member Author

Independent Opus review of head 6db15bc (semantics, races, wire additivity and scope verified as stated). Verbatim:

Review complete. Head 6db15bc confirmed; go build, go vet ./..., and go test -race -count=2 on internal/runnerd, internal/driver, protocol/runner, runnerplane all green (cmd/runnerd has no test files).

Findings, most severe first

1. Medium — mixed-version deploy reproduces exactly the "no way back into the session" outcome the design doc treats as unacceptable. runnerplane/events.go:167 (default arm) / README.md "Runner capacity". ProtocolVersion stays 1 and the new event state is additive, so a new runnerd against an old controld gets suspended_cold dropped on the unknown-state arm. The row then reads running over a stopped container; rainier attach dials, finds no hub, and fails, and ResumeSession refuses a running row — for up to however long until that runner reconnects and reconciles. This is the same end state the design doc's "cold-resume fence" section calls out and spends a whole round fixing; the runner has no way to detect an old control plane, and --idle-stop is on by default. Fix: state the deploy order (controld before runners) in the README's new section and the design doc, or add the gate — e.g. have fleet-up.sh/the hosted launcher pass --idle-stop 0 until controld is rolled.

2. Medium — boot epoch 0 is shared by every never-resumed entry, so the stale-frame guard does not fire on a delete-and-recreate of the same id. internal/runnerd/registry.go:115 (nextBoot starts at 0), registry.go:426-441, entry created at runnerd.go:337 with boot left at its zero value. nextBoot's own doc says the counter is registry-wide "so that a value can never be reused by a session id that was deleted and recreated, which is exactly the case a stale frame would otherwise be accepted in" — but both the old and the new entry carry boot == 0, so a child_exited (or stage_failed) goroutine already spawned by the old container's hub read loop is accepted against the brand-new entry. Consequences are the two failures the feature exists to avoid: a bogus childExitedAt auto-stops a working agent 30m later, or markBootFailed pins a healthy session out of auto-stop for the life of the runner. Narrow (needs id reuse — runnerctl/dev surface, not controld-minted ids) but uncovered by any test. Fix: mint the boot in putIfAbsent/put (r.nextBoot++; e.boot = r.nextBoot) so 0 is never a live entry's epoch.

3. Low/Medium — the capacity counts are actively misleading after a runnerd restart, which is the one case the feature cannot handle. registry.go:519-533, protocol/runner/messages.go:125-141. Recover rebuilds entries with childExitedAt zero, so 16 finished sessions report as active=16, idle_exited=0 — the report says "16 working agents" about precisely the box that the sweep will reclaim nothing on. The design doc names the exemption; neither counts()' doc nor the protocol field doc mentions the skew, and the only intended consumer (#85's rainier status) would print the wrong sentence. Fix: say it in the Active/IdleExited doc comment, or carry recovered entries in neither count until they report an exit.

4. Low — two comments still describe the mechanism that round 3 deleted. internal/runnerd/runnerd.go:573-576 ("beginColdSuspend also records that this park is a STOP and not a pause, which is what a later resume reads to decide whether the child it knew about survived — see registry.resumed") and registry.go:554 ("marks the entry \"suspending\" and cold — the claim"). There is no cold flag on sessionEntry any more; beginColdSuspend/claimIdle set only state, and resumed keys on the driver's restarted bool. Both comments point a future reader at the exact inference round 3 removed as unsound. Fix: delete the "and cold"/"records that this park is a STOP" clauses.

5. Low — a resume that overtakes an in-flight stop can leave the entry "running" over a stopped container. runnerd.go:447-456 (opTarget rejects only "starting"), registry.go:650-686. A resume arriving after claimIdle but before drv.Suspend returns sees state == "suspending", so resumed treats it as parked, clears childExitedAt and bumps the boot; the stop then lands, finishColdSuspend's CAS fails, and the entry claims running over a stopped container — which the register hub-death tail reads as a crash and destroys. Unreachable from controld (it resumes only a suspended_* row) but reachable from the local dev surface. The design-doc table covers Delete-overtakes-stop and resume-on-an-unconfirmed-dead-container but not this ordering. Fix: have Op's resume refuse state == "suspending" (409), or name the case in the table.

6. Low — an operator cannot tell an auto-stop from their own stop on the session. runnerplane/events.go:113-118 deliberately drops the runner's "idle for 30m0s" detail, and the row lands on suspended_cold either way, so rainier info/rainier ls are identical for both. The audit event does distinguish (ActorID is the runner ID, controlapp/fleet.go:814), which is the saving grace — but this is not recorded as a follow-up in the PR body or the design doc's "Other known limits". Fix: add it to the follow-up list, or carry the reason in a non-error field.

7. Low — no operator knob on the hosted launcher. scripts/fleet-up.sh:180 passes --slots "${SLOTS:-16}" and no --idle-stop, so the hosted runner silently takes the 30m default with no way to disable it short of editing the script. Add --idle-stop "${IDLE_STOP:-30m}". (Relatedly: #85's near-term relief has two bullets and this PR ships one — --slots default and the hosted value are both still 16. Deliberate per the PR body, but the issue's "raise the default" half remains open.)

8. Nit — the PR body's follow-up 2 contradicts the code it describes. It says "An auto-stop event about a cold-resumed session is fenced the same way", but internal/runnerd/agent.go:301 deliberately sends no placement generation for suspended_cold precisely so it is not fenced (and controlapp/fleet.go:723 confirms zero fences nothing, with idlestop_e2e_test.go:350 pinning it). A reviewer reading only the follow-up list would believe the main hazard is unhandled. PR text only, no code change.

Verified clean

  • Semantics. State inputs are state, attachments, childExitedAt, lastDetachAt, driverOps, bootFailed, and the decision is one function (sessionEntry.idleFor, registry.go:94-106) used by both the candidate list and the claim. Each rule has a pinning row or test: timeout boundary 29m59s/30m, stop-once-not-per-sweep, child-still-running-for-hours, viewer-attached, clock-from-detach, one-of-two-viewers, --idle-stop 0, operator-already-stopped, resumed-then-exited-again, warm-pause-keeps-the-fact, warm-paused-not-stopped (TestIdleStopRule), plus TestFailedBootIsNeverIdleStopped, TestRecoveredSessionsAreNeverIdleStopped, TestColdResumeForgetsThePreviousChild, TestChildExitIsRecordedOnce. Every non-"running" state (starting, suspending, suspended, destroying) falls out as not-idle; no unnamed reachable state found other than finding 2's epoch collision.
  • Races. claimIdle (registry.go:567-580) is one locked check-and-mark — eight concurrent sweeps stop once (TestIdleStopClaimIsExclusive); releaseColdSuspend/finishColdSuspend/restoreAfterFailedDestroy are all CAS'd so a Delete's "destroying" marker survives; driverOps keeps a warm suspend/resume/snapshot out of the sweep (TestWarmSuspendInFlightIsNotStopped, TestSnapshotInFlightIsNotStopped); the clock is one injected now func() on both sides of every idle subtraction, so production uses Go's monotonic reading and nothing on the path calls UTC/Round/marshals those times; a bounded-but-actually-succeeded docker stop is settled by asking Inspect rather than rolling back (settleFailedColdSuspend, TestAStopThatFailedButLandedIsNotRolledBackToRunning); a driver refusal rolls back to "running" and the slot is not released, retried next sweep (TestIdleStopRollsBackWhenTheDriverRefuses); an attach that beats the claim prevents the stop, one that loses gets a failed dial and resumes on retry (documented in the design-doc table). Both driver calls are bounded (30s/5s) so the single sweep goroutine cannot be parked.
  • Compatibility. protocol/runner.FromRunner.Active/IdleExited are additive ints at ProtocolVersion 1, non-omitempty, tags pinned on the wire by TestCapacityCountsOnTheWire; no decoder in the repo uses DisallowUnknownFields on this message, so an old controld ignores them and a new controld reads an old runner's absence as zero. controlapp's eventTransitions already admitted {running, suspended_cold} → suspended_cold — no control-plane change needed, confirmed at controlapp/fleet.go:661.
  • Scope. No file under attachplane/, controlapp/, or internal/session/ is touched. Of +3041, 1759 lines are tests, 353 are the design doc + README, and ~700 of the rest are comments — actual new executable code is ~320 lines (registry.go 169, idlestop.go 81, runnerd.go 50, agent.go 9, protocol 7). The driver.Resume signature change is load-bearing for the feature (only a driver can distinguish start from unpause) and is pinned in the contract suite for both implementations. registry_test.go's diff is gofmt only. Nothing in the diff is unrelated to idle auto-stop or the counts.
  • Operability. One log line per stop, session id and durations only — no env values, tokens, or workspace content; a failed Capacity prints unknown rather than a plausible zero. --idle-stop documented in cmd/runnerd -h (verified rendered) and in the README's new "Runner capacity" section. Recover logs the exemption explicitly.
  • No goroutine leak or unbounded growth. One sweep goroutine for the whole runner, ticker stopped on return, returns on ctx cancel (TestRunIdleStopLoopStopsAnIdleSession) and immediately on idle <= 0 (TestRunIdleStopDisabledReturnsImmediately, with a never-cancelled context so only the disabling value can make it pass). idleSessions allocates a slice bounded by the slot count. No test in the new files is a no-op assertion; sweepIdle's zero-guard is separately tested so "off" is a property of the decision, not only the loop.

Not exercised here (no Docker): internal/driver.Docker's new Resume branch mapping (paused→unpause/false, running→false, exited|created→start/true, unknown→start/true) — the restarted bit that the whole round-3 invariant rests on is only verified against driver.Fake; RunContract pins the rule but its Docker run needs a daemon. Also unexercised: real docker stop timing out at the 30s bound while the daemon keeps stopping (the case settleFailedColdSuspend exists for), and cmd/runnerd's wiring (deleting the go s.RunIdleStop(...) line leaves the suite green — acknowledged in the design doc).

Merge recommendation: merge after fixes 1 and 2 — everything else is comment/doc or genuinely narrow. 1 is a deploy-sequencing hazard that costs a user access to a live session and needs only a documented order or a flag default; 2 is a one-line nextBoot change that closes a hole the registry's own doc claims is already closed. The semantics, the race discipline, the wire additivity, and the scope are all as stated.

A fix session has been dispatched for findings 1–8.

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