test(qa): add the session-control cells to the agent release gate - #6518
Conversation
Port the durable-cancel spike's 13-cell driver (refresh_live.py) into the release-gate skill as resources/session_control.py, per qa-audit-2026-09-03.md section 4, so the standing regression check survives outside one evidence folder. Matches the gate's env contract (AGENTA_BASE, AGENTA_ADMIN_KEY, QA_OPENAI_API_KEY, no file fallback), moves the Docker/Postgres-only helpers behind an OperatorHooks interface so six cells run over HTTP against any deployment and the rest SKIP by name without --project, emits the gate's PASS/FAIL/SKIP result shape into a timestamped ~/agenta-qa-evidence/ run folder, and adds --resume so a lost agent costs one cell, not the run. Adds two new cells (repeat-stop, stop-during-completion) from qa-audit section 3, a path_triggers.py rule that makes the suite mandatory for session-code changes, a SKILL.md section naming the command and the model-key locations, and a pytest-and-standalone-runnable unit test for the pure parts (cell registry, hooks skip path, resume, verdict shape, env resolution). Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
The live smoke run against the integration stack showed /sessions/{id}/cancel
returns 202 Accepted (a pending command plus a stopping execution), the
correct async-acceptance status. The verdict checks in stop-warm,
stop-approval, repeat-stop, and stop-during-completion hardcoded 200 and
FAILed every real Stop. Accept 200 or 202 in each.
Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…ontrol.py Add HARNESSES["claude"] (kind claude, model sonnet, provider anthropic, vault connection) so the session-control cells can drive the Claude Code harness, and stock an Anthropic provider key into the bootstrapped account's vault the same way the OpenAI key is stocked, gated on --harness claude so a pi_core/codex-only run does not need ANTHROPIC_API_KEY set. Also widen wait_for_turn/wait_for_tool by a configurable SANDBOX_STARTUP_SLACK_S (25s) when --sandbox daytona is selected, since a Daytona sandbox takes 10 to 20s to start on top of local timings. Record the session's distinct sandbox ids (via /sessions/turns/query, HTTP-only) in every HTTP-only cell's evidence as sandbox_ids / warm_same_sandbox, so a resume that silently rebuilt the sandbox is visible in the result instead of only in the recalled codeword. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
The Daytona smoke run FAILed stop-approval with only "resume did not recall the codeword" and no reply text to check why, so a driver replay bug (the reconstructed output-denied tool part) could not be told apart from a genuine product miss. Add resume_text, resume_frames, and resume_errors to the cell's evidence. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Two invocations started in the same second (e.g. Claude Code and Daytona smoke runs fired in parallel tonight) shared a run folder, since the timestamp alone has 1-second resolution -- the second writer silently overwrote the first one's results.json mid-run and one run's evidence was lost until recovered from its redirected stdout log. Add the PID to the folder name so concurrent invocations never collide. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (3)
Disabled knowledge base sources:
📝 SummarySummary by CodeRabbit
WalkthroughThe release gate now documents and triggers standalone session-control regression cells for session-related paths. It validates recorded session-control results, reports their status, and includes network-free coverage for session-control behavior. ChangesSession control release validation
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🟡 Moderate · up to This change adds session-control coverage to release paths, but its standalone result is not currently enforced by the release gate, so affected changes can proceed without a passing session-control outcome. The hook-requirement documentation is also inconsistent and can lead to incorrect execution expectations. Sequence Diagram(s)sequenceDiagram
participant ChangedPaths
participant qa_product
participant session_control_results
ChangedPaths->>qa_product: trigger session_control.py
qa_product->>session_control_results: load results.json
session_control_results-->>qa_product: return cell verdicts
qa_product-->>ChangedPaths: return release-gate status
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 25.44% which is insufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 114 functions across 2 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…imeout
assistant_message() indexed turn["segments"] unconditionally. When the
driver's own wait for a turn times out (handle["out"] stays None,
observed when the runner is unhealthy after a restart), the cell passed
an empty {} dict in and the KeyError masked the real signal, which is a
driver-side timeout rather than a cell result.
Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Add cell_runner_gone, ported from cell_runner_gone in refresh_live.py, as cell "runner-gone" in the session-control driver's registry. It restarts the runner right after a Stop is claimed, then checks that the sweep settles the command as lost (not claimed) in session_commands, the session_streams row reads is_running: false, and a Send sent after that runs. Register it in CELLS and in the registry's stable-names unit test, and update SKILL.md's cell count and Docker-needing cell list. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…ncurrent-stops Add OperatorHooks.ensure_runner_healthy(), implemented in DockerComposeHooks, that unpauses, restarts, and health-checks the runner as needed. Extract the per-cell execution in main() into run_cell(), which calls it in a finally block after every needs_hooks cell, so a cell that raises before its own restore code runs (as cell_stale_tail did tonight, leaving the runner paused) cannot strand the runner for the next cell. Also wrap cell_stale_tail's pause/unpause and cell_records_outage's stop/start Postgres in their own try/finally, so each cell restores what it touched even on an exception in between. Add cell "concurrent-stops": five sessions started at once with a long turn, Stop sent to all five within about a second, each expected to return HTTP 202, settle exactly one terminal record, and recall its own codeword on a warm resume. HTTP-only, no hooks needed. Add unit tests for run_cell's finally path (NullHooks skips the recovery call without crashing; a stub hooks object confirms the recovery call fires when a cell raises, and is skipped for a cell that does not need hooks) and add "ensure_runner_healthy" to the NullHooks-raises coverage. Update SKILL.md's cell count and lists. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
… both races Split cell_runner_gone into two: the new cell_runner_gone pauses the runner BEFORE sending the Stop, so the command can never be claimed or reported and must settle lost off a deterministic sweep, with an explicit check for the watchdog's execution_lost ending. cell_runner_gone_late keeps the old restart-after-stop timing, which mostly loses that hard race because the runner often reports the Stop's outcome before it actually dies. Both races satisfy the same invariant: exactly one effective terminal outcome, no command left pending or claimed, is_running false, and the next Send succeeds. Factor that shared PASS rule into _judge_runner_gone(), used by cell_runner_gone_late (cell_runner_gone keeps its own stricter assertion since pausing first is meant to force the lost/execution_lost shape every time). Both record which race landed on evidence["race"]. Register runner-gone-late in CELLS and the registry's stable-names test. Update SKILL.md's cell count and Docker-needing cell list. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Add nine unit tests against _judge_runner_gone with synthetic evidence dicts: both accepted races (outcome lost -> "never-reported", outcome stopped -> "outcome-reported-then-died") record the right evidence["race"] and PASS, and each failure path (no terminal record, no command row, an unexpected command state, the command still pending or claimed, is_running still true, the next Send not running) FAILs without setting evidence["race"]. The cell-name registry coverage for runner-gone and runner-gone-late already existed in test_cell_names_are_stable_and_known. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
The desktop client sends only the trailing user message on each invoke (web/packages/agenta-playground/src/state/execution/agentRequest.ts), while this driver always replays the whole transcript, masking continuity bugs that only show up under the desktop's actual request shape (found by a browser QA pass: a codeword from an earlier completed turn was lost on resume). Add --client-shape full|last-message, default full so existing results stay comparable. Under last-message, invoke() reshapes every outbound `messages` list the way agentRequest.ts does: _has_answer()/_is_answer_part() mirror its hasAnswer/isAnswerPart to strip answer-less assistant turns, then _client_shape_messages() sends only the trailing message when it is a fresh user turn. A resume whose trailing turn carries a settled HITL answer (not a user turn) keeps the full history, matching agentRequest.ts's `lastMessage?.role === "user"` guard, so the answer still binds to its tool call. Record client_shape in results.json. Add four unit tests against _client_shape_messages() covering: full is a no-op, last-message produces exactly one message for a fresh user turn, a HITL resume keeps full history, and an answer-less assistant turn is stripped before the trailing-turn check. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Both cells assumed a local sandbox: sandbox-gone found and killed the
sandbox-agent process via docker exec into the runner container, and
codex-child listed processes the same way. Neither works for --sandbox
daytona, where the sandbox is a remote machine docker exec cannot see
or touch (confirmed live: sandbox-gone reported "no process to kill"
and codex-child reported "never observed the child process").
Add DaytonaAwareHooks (subclasses DockerComposeHooks, reusing its
Postgres/runner-container hooks) that ends the sandbox and lists its
processes through the same Daytona REST API the runner itself uses:
DELETE /sandbox/{id} (what Sandbox.delete() calls) for kill_sandbox,
and GET /sandbox/{id}/toolbox-proxy-url + POST {proxy}/process/execute
with the exact `ps -eo pid=,ppid=,etimes=,args=` reap-exec.ts uses for
sandbox_procs. Every call is scoped to the one sandbox id the cell
observed for its own session (sandbox_ids(session_id)), never a list
or wildcard. Credentials come from AGENTA_RUNNER_DAYTONA_API_KEY/URL,
export only, never logged.
Pulled hook selection into select_hooks() so the provider switch is
unit-testable without a live stack; both cells keep their existing
PASS/FAIL rules. Not run against a live Daytona sandbox yet (the stack
is being redeployed with product fixes first) - the toolbox-proxy-url
shape was verified by reading the SDK/API-client source rather than
a live call, so treat the first Daytona run with these hooks as also
validating the hooks themselves.
Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…-stop A recalled codeword after a runner restart is not proof the native harness session resumed: the runner can recover it by reconstructing the conversation from persisted records (the [reconstruct] path) even when the native session itself never hydrated, so recall alone is a false pass for the thing this cell claims to test. Require the recall AND one independent signal: the sandbox id after the restart equals the one before (no rebuild happened), or the runner log for the resume shows session/load ... loaded=true (a genuine native hydrate). Neither present -> FAIL "native session not resumed, recovered by transcript replay". Also force this cell's resume onto --client-shape last-message (the shape the desktop actually sends) regardless of the run's own --client-shape, so a future change to this cell's message construction can't quietly reintroduce a client-side replay that papers over the same gap. Pulled the PASS rule into a pure _judge_restart_after_stop(), mirroring _judge_runner_gone, so it is unit-testable without a live stack. 6 new tests. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…runner-gone The repeat-stop false pass (2026-09-04, session 190e9118): its session_commands row was stuck `claimed` forever with zero session_executions rows, yet every other assertion the cell made (one terminal trace record, a warm resume) still passed. Only _judge_runner_gone checked command settlement; every other Stop-issuing cell only ever looked at the trace/tracing-record layer, which can look healthy while the durable command/execution layer never actually closed the Stop out. Add assert_command_settled(): polls up to 20s for the session_commands row to reach applied/obsolete (never left pending/claimed) and for exactly one session_executions row to exist with a non-empty terminal outcome. A hookless run (NullHooks) reads as settled=True so an HTTP-only cell keeps running against any deployment. Extracted _match_stop_command() (the "find the command row this Stop produced" lookup) out of cell_runner_gone/cell_runner_gone_late so it is shared, not duplicated a third time. Wired into every Stop-issuing cell where a genuine in-flight execution is expected to settle: stop-warm, stale-stop (the real bare-stop, not the intentionally-refused stale one), stop-approval, post-stop-row, codex-child, repeat-stop, concurrent-stops (checked per-session, in parallel). Deliberately NOT wired into: - stop-after-finish and stop-during-completion: both race a Stop against an ALREADY-naturally-finished turn, where "obsolete" with NO execution row is the documented healthy outcome (verified in Postgres: a naturally-completed turn with no Stop leaves zero session_executions rows) — the strict check would false-fail these. - runner-gone / runner-gone-late: already have a stronger, more specific settlement check via _judge_runner_gone, on their own much longer --sweep-wait timeline; a rigid 20s check would false-fail a cell that is DESIGNED to take longer than that. These four are flagged in the PR/report for a maintainer call rather than silently guessed at. 13 new unit tests for assert_command_settled/_match_stop_command, including the exact repeat-stop scenario reproduced with a stub hook (stuck claimed, zero execution rows -> FAILS with a reason naming the stuck state). 46/46 total pass. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
… 1.5x Pi concurrent-stops hit an httpx.ReadTimeout on the Claude Code harness: the SSE stream client used a flat 600s timeout regardless of harness, and Claude Code/Codex (agentic CLIs behind an ACP bridge) can legitimately take longer per turn than Pi under load. A driver timeout that is too tight for a harness gets misread as a product failure. Add STREAM_TIMEOUT_S, a per-harness-kind table (cfg["harness"]["kind"], the same key HARNESSES sets): Pi stays at 600s, Codex and Claude Code get 900s (about 1.5x). invoke()'s httpx.Client now reads its timeout from stream_timeout_s(cfg) instead of a hardcoded constant. Unknown or missing harness kind falls back to the Pi budget. This does not paper over the settlement bug the same run actually hit (session 190e9118, and the concurrent-stops sessions from run 20260904-121933-225924: a command stuck `claimed` forever, tracked separately) — assert_command_settled (previous commit) still catches that within its own fixed 20s regardless of harness, well before this timeout would ever matter. This change is about not confusing a too-tight driver timeout with a product defect on a harness that is simply, legitimately slower. 3 new unit tests. 49/49 total pass. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…cell The sandbox-gone cell killed the wrong process. On local it ran `ps | grep "sandbox-agent server"` and killed EVERY match, but the keep-alive pool keeps other sessions' parked daemons alive in the same runner container, and two sessions can share one mount key. In the final matrix it killed a different, idle session's parked sandbox; the tested turn ran on and completed normally, so the cell recorded a false negative against a healthy product. Map the tested session to its OWN sandbox instead: - Derive the session's sandbox port from the runner log `prepare_workspace` line for that exact session id, cross-checked against the turn ledger's `local/<host>:<port>` id; a disagreement is refused as ambiguous. - Resolve the pid listening on that port inside the runner container (`ss`, with a /proc/net/tcp + /proc/*/fd fallback for an image without `ss`), and assert the pid's cmdline is a sandbox-agent daemon before killing it. - Refuse with WrongSandboxTarget (a `wrong target` cell failure) whenever the mapping cannot be made, so the driver never kills a guess. The port and pid are recorded in the cell evidence. - Daytona already addressed its remote sandbox by id; that path now shares the same per-session entry point and refuses when no id was observed. Derive the wait window from the runner's sandbox-liveness probe defaults (three failures at the probe interval, plus slack) and never wait less than the slow command, whose duration the cell prints. This stops a slow-but-healthy turn from being misread as "still running". Add unit tests for the port parsing, the ss pid parsing, the port-to-pid mapping, and every wrong-target refusal, all with the container calls mocked. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Run 1b refused correctly but for the wrong reason: it read the port once, right after the turn started, while a cold acquire on the gate stack takes about 35 s to write the session's `prepare_workspace` line (`acquire_total ms=34766`), so the line was not there yet and the cell gave up. Poll instead. `wait_for_sandbox_ready` polls the runner log and the turn ledger for this session's own sandbox for up to 120 s, then the cell waits a few more seconds so the slow command is running, then resolves the pid and kills. A transient log/ledger disagreement during acquire is retried, not fatal; the refusal is kept for the case where the line never appears within the wait. The Daytona path polls for the remote sandbox id with the same budget. Derive the slow command's duration from the parts it must outlast: the acquire budget, the resolve poll window, and the probe's design window, plus margin (300 s). The cell prints the command duration, the resolve timeout, and the resolve time it actually took. Add unit tests for the poll (the line appears on the third read), the timeout refusal, the ledger fallback, and the command-duration invariant, all with the clock and the container calls mocked. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Run 2 of runner-gone-late failed as a race, not a product defect. The Stop settled cleanly (command applied/stopped, is_running false, stopping_turn_id null), but the recovery Send fired while the runner was taking SIGTERM from the cell's own restart, so the API returned "All connection attempts failed" and new_message_ran was false. The cell restarted the runner and then sent without waiting for it to come back. Poll the runner's health after the restart and before the Send. A new `_recover_then_send` helper polls `runner_healthy()` (one Docker health check) until healthy, bounded to 60 s at 2 s, and only then issues the Send; if the runner never recovers it does not send a doomed request and the cell fails with a clear reason. `wait_for_runner` now shares the same single-shot health check. Add unit tests: the Send is not issued until the health poll returns healthy (fail twice, then succeed, asserting call order), and no Send is attempted when health never recovers. Both use a mocked clock. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Run 2b of runner-gone failed as a race, not a product defect. The cell read is_running after it unpaused the runner, and the returning runner started a new turn on the same session that legitimately set is_running true (watchdog settled the lost turn and cleared is_running at 14:03:51 while paused; unpause at 14:03:59; a new turn at 14:04:09 set is_running true; the driver read that true). Measure "runner gone" while the runner is still gone. A new `_measure_runner_gone_while_paused` helper pauses the runner, fires the Stop, polls the durable rows until the Stop command settles obsolete/applied+lost (or an execution row carries a terminal outcome), then reads is_running from session_streams WHILE STILL PAUSED, and only then unpauses. The gone-and-stays -gone verdict asserts settled-lost, a watchdog execution_lost ending, and is_running false — all from the paused reads. The paused-read and settle timestamps are recorded. The unpause and the following Send are now only a restore step plus an OPTIONAL, separately recorded resumability check (via the health-gated _recover_then_send); they are no longer part of the runner-gone pass. Add unit tests that the is_running read happens between pause and unpause (mocked pause/unpause and DB reads), and that a no-settle window still unpauses and still reads while paused. Both use a mocked clock. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
…d settlement check Matrix run 4 (Claude Code local) failed stale-stop with "expected exactly one session_executions row for the stopped session, saw 0". Post-hoc the shape was correct: the valid Stop returned 202, but the fast Claude Code turn had already finished naturally, the runner had nothing to cancel, session_commands settled obsolete/not_running, and zero execution rows is right for that case. Teach the shared assert_command_settled about it: when the Stop command settles obsolete with outcome not_running, accept zero execution rows, set natural_finish=True, and record "stop landed after a natural finish". Keep the strict exactly-one-row requirement when the outcome is stopped or lost. Because stale-stop and the other Stop-issuing cells route their settlement check through this one function, they all inherit the correct behavior; stop-after-finish and stop-during-completion already accepted this shape. Add a unit test for the not_running shape and a companion test that a real stopped outcome with zero rows still fails; keep the existing strict tests. Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Team
Run ID: 6f7a2183-5c0d-494a-9d0d-dd325d48b729
📒 Files selected for processing (4)
.agents/skills/agent-release-gate/SKILL.md.agents/skills/agent-release-gate/resources/path_triggers.py.agents/skills/agent-release-gate/resources/session_control.py.agents/skills/agent-release-gate/resources/test_session_control.py
Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.
Require a complete standalone session-control artifact whenever path rules make the driver mandatory. Carry any recorded failure into the product gate exit code. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Describe the eight hook-dependent cells separately from the stop-after-finish abort-log subcheck. Document how the standing gate consumes the standalone result. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Apply Ruff 0.15.12 formatting to the new release-result coverage. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
The thirteen-cell driver that proved warm Stop on 2 and 3 September lived in an evidence folder outside the repo. This PR moves it into the release-gate skill as a standing check, with two new cells the QA audit asked for: repeated Stop, and Stop racing a natural completion.
What changes
.agents/skills/agent-release-gate/resources/session_control.py: fifteen cells. Seven run over HTTP alone against any deployment; six that need container or Postgres access sit behind an operator-hooks interface and skip by name without--project. Results land as PASS, FAIL, or SKIP with a one-line reason in a timestamped run folder, and--resumereplays a priorresults.jsonso a lost agent costs one cell.path_triggers.py: the cells become mandatory for any change under the runner sessions and sandbox-agent code or the API sessions core, tasks, and routers.SKILL.md: the one-line command, the flags, and the three environment variables by name.test_session_control.py, 8 pass).Runs tonight
The stop-approval cell found a real defect on both providers: after a Stop cancels an approval, the next message takes the runner's approval-resume path and the new text never reaches the model. The fix is on PR #6496.
Added on 2026-09-04
runner-gone(the runner is paused before the Stop, so delivery cannot reach it; the sweep must settle the command lost),runner-gone-late(the runner is killed right after the Stop is accepted), andconcurrent-stops(five sessions stopped within one second). All three found real defects on their first run; the fixes are on PR fix(sessions): settle executions after runner or sandbox loss #6501 and PR feat(runner): preserve the warm sandbox after Stop #6496.finallyblock, and the run ends with the runner running and healthy even when a cell throws (5ce3d7e982).--client-shape full|last-message:last-messagemirrors the desktop's request semantics fromagentRequest.ts(send only the trailing user message; keep full history when the trailing turn carries a settled tool answer). The browser pass found a continuity defect thefullshape hides, so continuity cells must run in both shapes (3c07ceb242).client_shape.Nineteen cells; 23 unit tests.
d5bbdc3ee6). Per-harness stream timeouts: Pi 600 s, Codex and Claude Code 900 s (e33a1aa3ea). Nineteen cells; 49 unit tests.Agent-generated, low weight. Not merged.
https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Update 2026-09-04 afternoon
The sandbox-gone cell killed the wrong process on the local provider: it matched the first sandbox-agent daemon under a shared mount key and hit the parked idle sandbox of another session, while the tested turn ran on and finished. Head a769ce9 makes the cell target the tested session's own sandbox port from the runner log, verify the pid's command line, refuse a guess as "wrong target", record port and pid in the evidence, and wait the design window (three liveness probe failures at 30 s plus slack, never shorter than the slow command). 63 driver tests pass.
Head f6bf53c adds the second half: the cell polls the runner log and the turn ledger for the tested session's own sandbox for up to 120 s (a cold acquire on the stack takes about 35 s), waits until the slow command runs, then kills only that daemon; the slow command lasts 300 s so it outlasts the acquire, the poll, and the 90 s probe window. 67 driver tests pass.
Head 74ad9f9 adds a third cell fix: runner-gone-late polls the runner health (up to 60 s at 2 s) after it restarts the runner and before the recovery Send, so the restart race can no longer read as a product failure. 69 driver tests pass.
Head 4fbfcb6 adds the fourth cell fix: runner-gone reads is_running from session_streams while the runner is still paused, after the Stop command settles lost, and records the paused-read and settle timestamps. The unpause and the following Send are a separate resumability check, because the returning runner starts a new turn that sets is_running true again. 71 driver tests pass.
Live results on the integration stack (head dfb94a0c85 and later): runner-gone-late passed on 74ad9f9, and sandbox-gone passed on f6bf53c (the tested session's own sandbox died mid-turn and the turn ended with a terminal record). runner-gone re-runs on 4fbfcb6.
Head 97b42fd adds the fifth cell fix: the shared settlement assertion now accepts a Stop that lands after a natural finish — command obsolete/not_running with zero execution rows — recording "stop landed after a natural finish", while keeping the strict one-row requirement when the outcome is stopped or lost. This is the shape stale-stop hit on a fast Claude Code turn in run 4. Because every Stop-issuing cell routes its settlement check through this one function, they all inherit it. 73 driver tests pass.