M2-35: Claude Code runner: follow-ups, stop and error paths¶
Status: ready | Depends on: M2-34 | Milestone: M2
Goal¶
After this ticket the Claude Code runner of M2-34 handles everything that can happen to a turn after it started, in the final state of 09 18.5, 18.6 and 18.9. Follow-ups of people reach the running turn as further user lines on stdin and come back as delivered and absorbed; after the result, stdin stays open for runner.followup_grace while follow-ups are not yet absorbed. A stop sends SIGINT to the turn's process group, then SIGTERM after runner.stop_grace and SIGKILL after runner.kill_grace, and reports cancelled with the reason of the request only when no process of the group is left. A full context is compacted exactly once and the same prompt runs again, with a hint when the turn had already worked; a session that Claude Code no longer knows is replaced by a new one without blocking the run (fail open), and a session that already exists at the first turn is resumed; a missing login ends the run with runtime_not_authenticated, an error result with runtime_error and the error texts in the last step; a crash of the runner kills and repeats nothing, because a new runner takes the turn over or ends it. Every row of 09 18.9 "Error paths" has one green Fake-Claude scenario (plan row M2-35, exit criterion 5 of M2), the recognised error texts of the CLI live in one table in the runner, and a test behind the explicit switch checks them against the installed, pinned CLI (RK-14). Why: an unattended turn must never hang, run twice, outlive a stop or carry a follow-up that the server did not authorize into a runtime (09 1 points 4 and 5, R-AG-17, T-AI-08), and the CLI changes its messages without notice (RK-14).
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (row "Test doubles": Fake-Claude M2-34, FakeServer M2-29); section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope "reference adapters for ... the Claude Code runner", Out of scope "Files in agent invocations ... (M3)", Exit criterion 5, Test strategy "fake Claude with recorded stream fixtures", Red team, Risks "the Claude Code CLI changes stream messages") and the rows M2-29, M2-03 and M2-34 (what exists), M2-35 (this ticket), M3-12 and V2-03 (who builds on it next), M2-11, M2-12 and M2-13 (server side of follow-ups, sessions and context), M2-31 (conformance suite); section 19 P-09, P-10, P-11.- Dependencies: the header names M2-34. Through it this ticket builds on M2-29 and M2-03, and through those on M2-23, M2-10, M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02, M1-66, M1-65, M1-56, M1-54, M1-17, M1-03, M1-01 and everything below them (all implied, so not in the header). It also needs M2-12, which is in this closure through M2-34 (Boundaries).
docs/spec/09-agents.md: 1 (points 4 and 5); 2.2 "Features" (row "Follow-ups"); 4.3 "Connection and cursor" (inbox, acknowledgement, "Duplicate detection"); 4.4 (rowsagent.followup,run.cancel_requested); 4.5 "Invocation token" (points 1 to 10, thereasontable); 4.5.1; 4.7 (rows "Acknowledge follow-up", "Finish run", "Report session"); 4.8 (capabilities,active_runs); 4.9 "Order in the adapter: commitment first, then runtime"; 5.1; 5.2 (runtime_error,runtime_not_authenticated,adapter_error,interrupted, the cancel reasons); 5.3 (paths/steps/<id>,/followups/<message_id>/state); 5.5; 7 "Follow-ups during a run"; 8 "Stop"; 9.1 "Lifecycle"; 9.5 "Import binding and fail open"; 15 "Reliability"; 16.1 (row Fake Claude); 16.2 (AG-10, AG-13, AG-14, AG-18); the whole of 18, above all 18.2 "Process contract" (rows "Detachment", "Locks", "Input", "Recording"), 18.3, 18.4 (rowuserwithisReplay), 18.5, 18.6, 18.7, 18.9, 18.11; 19 (R-AG-17, R-AG-20, R-AG-21); 21 (runner.*).docs/spec/04-sync-protocol.md(core contract, read only): 2.2 (payload rules 1, 3 and 4:agent.followupnames the message only by id); 11.1; 11.2; 15.2; 15.3 points 3 and 4; 15.4 (all six points); 15.5 "Cancellation".docs/spec/03-data-model.md(core contract, read only):runs(state_reason),run_steps(kindstatus,idcreated by the adapter as UUIDv7,detailcut torun.step_detail_max_bytes),run_followups(states),agent_sessions(started,end_reason).docs/spec/05-actions.md: 4.6 rowagent_session.report; 4.7 rowmessage.read; 4.10 rowsrun.update,run.finish,agent.heartbeat; 6 rowsagent.followupandrun.cancel_requested.docs/spec/10-backend-policy.md: 7 "Adapter check (I3)"; 10.2 "Claude-Code-Runner"; 15 rows BR-05, BR-12, BR-13.docs/spec/11-extensions.md: 11.2 "Hand-written layer" (moduleruns: "follow-up states", "cancel callback"; moduletesting);docs/spec/23-quality.md3 (row "Python project") and "Documentation in code" (row Python);docs/spec/19-security.md: SEC-LOG-2, SEC-LOG-4, SEC-AI-1.docs/api/openapi.yaml: operationsgetMessage,patchRun,finishRun,startRun,getRun,reportAgentSession,sendHeartbeat,pollAgentEvents,ackAgentEvents; schemasAgentFollowupPayload,AgentFollowupEvent,RunCancelRequestedPayload,RunCancelRequestedEvent,AgentInvocationPayload,AgentSession,AgentSessionReport,RunPatch,JsonPatchOperation,RunStep,RunFollowup,RunFinish,CostReport,Heartbeat,Message,PrincipalRef,Problem(x-problem-codes).docs/api/asyncapi.yaml: messagesevent.agent.followup,event.run.cancel_requested,op.run.update.- ADRs 0022, 0023, 0025, 0026, 0053, 0054.
docs/DECISIONS.md: D-R-RUNNER, D-L8, D-AG-06, D-AG-09, D-AG-20, D-AG-21, D-AG-22, D-W-16, D-W-100; section 10 for the format of new rows.docs/RISKS.md: RK-14, RK-13.docs/security/threat-model.md: T-AI-01, T-AI-08, T-DEV-01. No item ofdocs/OPEN-QUESTIONS.mdconcerns this ticket.docs/operations/configuration.md(section 09):runner.followup_grace,runner.stop_grace,runner.kill_grace,runner.state_dir,runner.claude_bin,runner.session_lock_wait,runner.max_parallel_runs,runner.stream_log_retention,adapter.invocation_token_leeway; server keys read only for documentation:agent.cancel_grace,agent.presence_timeout,agent.invocation_token_ttl,agent.run_update_min_interval,run.step_detail_max_bytes.docs/observability/signals.md: resource attributeservice.name(bothy-adapter-<kind>) and the adapter spans; the catalogue names no metric for runner error paths, so this ticket adds none.- Briefs:
docs/briefs/M2-09-run-lifecycle.md(answers offinishRun,requestCancel, the cancel reasons,cancel_timeout),M2-10-run-document-live-box.md(path table, UUIDv7 step ids, the400 validation_failedfor a follow-up the run does not have, the409 conflictfor a patch of an ended run, the server'sstatusstepsteps_summarizedof its note 6),M2-11-run-followups.md(server side of follow-ups:deliveredsetsfollowup_delivered, deferral at the end, discarding of deleted follow-ups),M2-12-agent-sessions.md(Design notes 9 to 12:session_mode,started,bind_failed,If-Matchof reports, the fail-open system line),M2-13-run-context.md(Out of scope: the content of follow-ups of the current run),M2-23-generated-clients.md(bothy_sdk, component targetsgenerateandgenerate-check),M2-29-python-sdk.md(Design notes 8 and 9: verifier, inbox, run loop, cancel, restart,active_runs; note 10:RunPatcher) andM2-34-claude-code-runner-core.md(Design notes 3 to 5 and 7 to 17); merged code wins on names. - Code on the base branch:
plugins/claude-code-runner(M2-34: package, configuration, Fake-Claude, fixtures, tests, README, CHANGELOG),sdk/python(M2-29 hand layer andtestingwith the FakeServer; M2-23 generated core).
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- With
followups = steerreported in the heartbeat, every follow-up of a run whose turn runs is written as one furtheruserline with its ownuuidon the turn's stdin; the runner then reportsdelivered, and the echo with the sameuuid(--replay-user-messages) reportsabsorbed, forward only (09 2.2, 4.7, 5.3, 7, 18.5; 04 15.4 point 3). - After a
resultwith follow-ups not yet absorbed, stdin stays open forrunner.followup_grace; then the runner closes it, and 09 7 applies (18.5). - A follow-up reaches the runtime only with a token that passes the checks of 09 4.5, and never twice (09 4.3, 4.5, R-SY-07); its text is read through the public API (09 1 point 2).
- A stop sends SIGINT to the process group; without an end after
runner.stop_gracefollows SIGTERM, afterrunner.kill_graceSIGKILL; the runner reportscancelledwith the reason of the request (09 8 point 3, 18.6, 04 15.5). - A full context is compacted exactly once, then the same prompt runs again, never twice; when the turn had already worked, the new turn gets a hint in the runner prompt (18.9).
- A session missing in Claude Code gives a new session, the prompt again,
agent_session.reportwith the new reference and a hint once in the box; the run goes on (fail open; 18.9, 09 9.5 point 4, AG-18 runner side). - A session that exists already at the first turn is continued with
--resume, andstarted = trueis reported (18.9, 09 9.1). - A runtime without login ends
failedwithruntime_not_authenticated; aresultwith an error subtype endsfailedwithruntime_errorand the error texts in the last step (18.9, 09 5.2, BR-12). - A crash of the runner leaves the turn running; a new runner takes it over or ends it; there is never a new turn for the run (18.2 "Detachment", 18.9, 09 1 point 5, R-AG-17, AG-10).
- The recognised error texts of the CLI stand in one table in the runner; a test against the installed, pinned CLI checks that these texts and the events the runner reads exist there, only behind an explicit switch (18.9, RK-14, PLAN 3.5).
- Every process the runner starts for a run, also for a compaction or a restart, uses the environment, flags, pre-check and counter-check of M2-34 (18.2, 18.3, I3).
- No token, secret, environment value or message content in log lines; the runner never reads, copies or writes credential files of the runtime (SEC-LOG-2, SEC-LOG-4, 18.3, BR-13).
Scope¶
All paths under plugins/claude-code-runner/ unless noted. <pkg> stands for the runner's import package under src/ that M2-34 created (expected claude_code_runner). Names of M2-34 and M2-29 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
src/<pkg>/failures.py:ErrorKind(context_full,session_missing,session_exists,not_authenticated),ErrorText(kind, fragment, channel, source), the tableERROR_TEXTS,TurnEvidence,TurnEndand the pureclassify_turn_end(evidence: TurnEvidence) -> TurnEnd(Design note 7).src/<pkg>/recovery.py:RecoveryState, the action types, the purenext_action(end: TurnEnd, state: RecoveryState, session_mode: str, worked: bool) -> Action, the constantRETRY_HINTand the builderbox_note(code, status, detail)of astatusstep (Design notes 8 to 11).src/<pkg>/followups.py: the purefollowup_verdict(...) -> FollowupVerdict(Design note 2),build_followup_line(author, text, line_uuid) -> bytes(note 3),PendingFollowups(written, echoed, pending, byuuidandmessage_id, note 4), the purestdin_close_due(now, last_result_at, pending, grace) -> bool(note 5), and the async handler ofagent.followup.src/<pkg>/stop.py: the pureescalation_due(now, sigint_at, alive, stop_grace, kill_grace) -> signal or Noneand the asyncstop_turn(turn, reason)(Design note 6).src/<pkg>/adoption.py:ProcessIdentity,process_matches(record) -> bool, the pureadoption_decision(alive, run_state, result_in_log) -> AdoptionActionand the asyncadopt_turns(...)(Design note 12). If M2-34 already has a module for the takeover after a restart, extend that module instead and report it.- Fake-Claude extensions and stream fixtures in M2-34's Fake-Claude and fixture directories (expected
tests/fake_claude/and its fixtures), one fixture per scenario of the Tests section (Design note 13). - Tests:
tests/unit/test_failures.py,test_recovery.py,test_followups.py,test_stop.py,test_adoption.py;tests/scenarios/test_error_paths.py,test_followups.py,test_stop.py,test_privacy.py;tests/cli/test_pinned_cli.py(behind the switch). Follow M2-34's test layout if it differs.
Change:
- M2-34's turn runner (process start and end): stdin stays open from the prompt line until the rule of Design note 5; the end of every process goes through
classify_turn_endandnext_action; restarts within a run (notes 8 and 9); severalresults per process (note 5); the fields of the turn record of note 12 b; stdout and stderr into files inrunner.state_dirif the merged code reads pipes (note 12 a). - M2-34's stream reader:
userwithisReplaymaps to the echo of a follow-up (note 4 b), never to a tool result; it keeps the evidence for note 7; the compaction process is not mapped into the run document (note 8). - M2-34's event handling: handlers for
agent.followup(notes 2 to 4) and, through the run-loop hookon_cancelbelow,run.cancel_requested(note 6). - M2-34's heartbeat:
capabilities.followups = steerindetails;active_runswith adopted runs and without lost ones (notes 4 g and 12). - M2-34's startup: adoption before the first event from the inbox and before the first heartbeat (note 12 d).
- M2-34's configuration:
runner.followup_grace,runner.stop_graceandrunner.kill_gracewith the defaults of 09 21, only if missing. README.md(English, like the file): status, follow-ups, stop, error paths, adoption, the answer to "Is/compactavailable in print mode of the pinned version?" with its evidence; if it still names the tool classfull, writehost(D-W-16).CHANGELOG.md(English) under "Unreleased".sdk/pythonrun loop (bothy_sdk.adapter, M2-29 Design note 9), only as far as notes 6 b and c need it: (a)run.cancel_requestedfor astartedrun calls a handler hookon_cancel(run, reason)when the handler implements it (11 11.2 moduleruns: "cancel callback"); the handler stops the turn and finishes, and the SDK then sends nofinishRunof its own. (b) For an entry stillaccepted, the SDK cancels its start task and sendsfinishRuncancelledwith the request's reason (09 8 point 3). Each change gets a test insdk/python/tests/test_adapter.py, a line insdk/python/CHANGELOG.mdand the rulingD-M2-35-12.sdk/pythonFakeServer, beside the run-loop hooks above (only where a test of this brief needs a capability the FakeServer of M2-29 lacks): injectagent.followupandrun.cancel_requested, answergetMessage, set the state thatgetRunreturns, answer a patch of an ended run with the409 conflictof M2-10 and any call with401 unauthenticated, record the bodies and theIf-Matchheader ofreportAgentSession; each with a test insdk/pythonand a line in its CHANGELOG.docs/spec/09-agents.md(English, like the chapter): 18.5 (Design notes 2 to 5), 18.6 (note 6), 18.9 (notes 7 to 12, the evidence of note 8 a, the box note codes of note 11), 21 (the note of note 6 h), 16.1 row Fake Claude (error paths and echo).docs/api/openapi.yaml: the description ofAgentFollowupPayload.message_id(Design note 3). Thenmake api-lintandmake api-checks, and regenerate whatmake codegen-checkandmake api-clientsreport stale (the route generator of M1-17,make -C sdk/python generate,make -C sdk/typescript generate).docs/DECISIONS.mdsection 10: rowsD-M2-35-1and following (Design note 16), English like the file.
This ticket adds no configuration key, no operation, no event, no metric, no migration, no hand-written server code and no new top-level make target.
Out of scope¶
- The process contract, the environment as an allowlist, the pre-check
claude auth statusand the counter-checksystem/init.apiKeySource, the flags per tool class, the stream mapping of the other rows of 18.4, titles, the cost difference of 18.7, Fake-Claude itself, AG-20, AG-30 and BR-12's own test: M2-34. This ticket changes them only where a design note says so. - Answer attachments (M3-12); files in invocations and follow-ups (M3, PLAN M2 "Out of scope").
- Starting only in the environment of the token (
dev_environment_id, DEV-T05): V2-03. - The server side of follow-ups (M2-11), sessions and the fail-open system line (M2-12), the run context (M2-13), cancelling and
cancel_timeout(M2-09), patches (M2-10), heartbeat evaluation (M2-20). waiting_input, cards, components and interactions: the runner never asks (--permission-prompts none, R-AG-21), so its runs never wait for input.- Taking a runner thread over into the terminal (V3, D-T5); the conformance suite with the Fake-Agent (M2-31).
- Real logins, real sessions and model calls of any kind.
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-35-claude-code-runner-paths; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(claude-code-runner): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0 (P-10); the FakeServer of M2-29 runs in the test process. This ticket needs no database. - Stop only processes you started, by PID. Tests record the PID and process group of every runner process they start and of every Fake-Claude process the runner under test starts, and kill only those in teardown; a test never signals any other process. Temporary directories only via pytest
tmp_pathortempfile.mkdtemp, removed at the end. - Never read or print secrets; the API key of the tests is a canary value. No network calls except package downloads for pinned tools and Python packages, and plain reads without account of the public documentation pages cited in 09 18 (CLI reference, headless mode, Agent SDK reference and its pages on slash commands) for Design notes 7 a and 8 a.
- No real Claude Code session, no login and no model call: the runtime of every test is Fake-Claude. The installed CLI runs only behind the switch of Design note 14, only to read its files and print its version.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for M2-34, M2-29, M2-23, M2-10 and M2-09: their merged names win over the expected names here; list each difference. Where M2-34's merged code lacks a part this brief expects from it (detached stdout and stderr, the turn record, the inbox, the session report with
started), add it in the runner and report it. - M2-12 must be merged into the base branch before this ticket starts, because the fail-open report needs
AgentSessionReport.startedandAgentSessionReport.bind_failedand the modeAgentInvocationPayload.session_mode, which M2-12 adds todocs/api/openapi.yaml(M2-12 Design notes 9, 11 and 17; 05 4.6 rowagent_session.reportand 09 9.5 already namestartedandbind_failed). If the base branch lacks them, stop withVerdict: BLOCKED M2-12 not merged (AgentSessionReport.started, bind_failed, AgentInvocationPayload.session_mode). - Signals and process groups are POSIX; support the platforms M2-34 supports. The scenario tests run wherever
make checkruns.
Design notes¶
-
What exists and where this goes. M2-34 built the runner: its configuration (09 21), the consumer through the SDK (
bothy_sdkmodulesgateway,invocation,runs,heartbeat,outbox), the inbox inrunner.state_dir(09 4.3), the token check of 4.5, the pre-check and the counter-check of 18.3, the start ofclaude -pwith the flags of 18.2 per tool class in its own process group (start_new_session), the session and thread locks, the stream reader of 18.4, the answer, the cost difference of 18.7, the session report withstarted = trueatsystem/init, Fake-Claude and its fixtures. This ticket adds what happens after a turn started. Every decision is a pure function without I/O (classify_turn_end,next_action,followup_verdict,stdin_close_due,escalation_due,adoption_decision), so it gets a table test; the I/O stays in M2-34's turn runner, which calls them. Where the merged code of M2-34 already does one of these things, extend it instead of building a second path, and list it. -
Follow-up intake (
agent.followup; 09 4.4, 7, 18.5; 04 15.4 points 2 and 3). a. Acceptance into the inbox is 4.3 unchanged (M2-34): the token passes the checks of 4.5 except point 5, event and token are stored, then the event is acknowledged. The samemessage_idof the same run again (a redelivery with a new token) replaces the stored token while the follow-up is not yet written and is never written twice ("Duplicate detection"). b. At the moment of writing, the runner checks the stored token again with points 1 to 6 and 8 to 10 of 4.5 as M2-34 applies them before a start, point 5 included (expiry withadapter.invocation_token_leeway; point 7, the replay check, ran at acceptance), plus:run_idis the run of the turn,trigger_kindisfollowup, and the message read in note 3 belongs to the run's thread and has the authorsub. Ruling for docs/DECISIONS.md: the expiry is checked when the text reaches the runtime, because that bounds how old the server's rights decision behind the follow-up can be (agent.invocation_token_ttl); a follow-up starts nothing here, so 4.5.1 does not apply (renewal takes only start tokens, 5.1). c. Outcomes offollowup_verdict: all checks pass:write(note 4); the turn has not started yet:hold; any other failed check:skip, the follow-up is not written and not acknowledged in the run document, the turn goes on, the follow-up staysqueuedand the server defers it at the end of the run (09 7), so its next delivery is checked again. A follow-up token whosebilling_modediffers from the mode of the running turn, or whosetool_classis belowrunner.tool_class, is refused by the SDK at acceptance (09 4.5 points 8 and 10; M2-29 Design note 9: not handed to the handler, the run is not ended). If it passes acceptance (for examplepersonalwithrunner.allow_personal), the check at writing time givesskip(log codefollowup_check_failed). The follow-up staysqueuedand the server defers it to the next run, which uses the new target (10 10.2). Ruling for docs/DECISIONS.md: unlike at a start (4.5 "reports the reason"), a broken follow-up token never ends the run, because the run belongs to the person who started it and a follow-up must never end another person's run (M2-11 requirement 18). d. A follow-up for a run the runner does not hold, or for a turn whose stdin is closed (note 5), is acknowledged and dropped (log codefollowup_not_deliverable); the server defers it. -
Text of a follow-up.
AgentFollowupPayloadcarries no text (04 2.2 rule 4). The runner reads the message withgetMessage(actionmessage.read,rl.read; the agent reads a message of the thread it operates). A404, a message withdeleted_ator an emptycontent_mdis not written (log codefollowup_no_text); the follow-up staysqueued(M2-11 discards a deleted one). The text reaches the runtime as an instruction, not as marked data: a valid follow-up token proves that the server checkedagent.invokefor its author at the delivery (04 15.4 point 2), which requires effectivetrigger, and 09 4.6 rates such an authortriggerorauthorized, neither of which is marked (ADR 0054). Ruling for docs/DECISIONS.md, with the openapi change:run.context.readcarries no follow-up of the running run (M2-13 "Out of scope"), so the description ofAgentFollowupPayload.message_idbecomes "Follow-up message; the adapter reads its content withgetMessage(message.read); its author issubof the invocation token of this delivery." The line is oneuserline from M2-34's builder of the prompt line, with a freshuuid(UUIDv4) and the textFollow-up from <display_name> (@<handle>):, a blank line andcontent_md, the lead-in in the language of M2-34's runner prompt. Because the line never starts with the message text, a follow-up that starts with/(for example/compactor/clear) never becomes a command of the CLI. -
Writing, acknowledging, holding. a. Turn running with open stdin: write the line and flush, then patch
replace /followups/<message_id>/statewithdeliveredthrough the SDKrunshelper (base_version, throttled byagent.run_update_min_interval, rebase after409 base_version_conflict). A failed write (the process is gone) leaves the follow-upqueued. b. Echo: ausermessage withisReplayon stdout whoseuuidis that of a written follow-up patchesabsorbed.absorbednever goes out beforedelivered(same or a later patch); a second echo of the sameuuidchanges nothing; the echo of the prompt line and unknownuuids are ignored. c. Turn not started yet (run in the inbox, waiting forrun.start, a lock or a slot ofrunner.max_parallel_runs): keep the follow-up with the run and write it right after the prompt line when the turn starts, in event order, with the checks of note 2 b at that moment. d. Restart within a run (notes 8 and 9): every follow-up written to the failed process without echo is written again into the next process after the prompt line, with its originaluuid, so its echo still maps; it staysdelivered. e. An adopted turn (note 12) takes no follow-up: its stdin ended with the old runner. Its follow-ups stayqueuedand the server defers them. f. A patch that the server refuses with400 validation_failedbecause the run has no such follow-up (M2-10 note 4) is logged as a code; the turn goes on. g. The heartbeat reportscapabilities.followups = steer(09 2.2, 4.8) from this ticket on. Ruling for docs/DECISIONS.md: c, d and e. -
End of a turn with follow-ups (18.5). The runner closes stdin only by this rule (
stdin_close_due); change M2-34 if it closes stdin after the prompt line. At aresultwithout a written follow-up that has no echo: close stdin. At aresultwith such follow-ups: keep stdin open until their echoes or untilrunner.followup_graceafter thatresult; a follow-up that passes note 2 within this window is written and joins the set; when the set is empty at a laterresult, close; when the window ends with follow-ups left, close, and they staydeliveredfor the server to defer (09 7). The process then ends, and the run finishes once, after the process exited. Everyresultwithout error posts one answer through M2-34's answer path, because each answers a different input; the cost difference of the run comes from its lastresult(18.7: the values are cumulative per session). Ruling for docs/DECISIONS.md. -
Stop (
run.cancel_requested; 09 8 point 3, 18.6, 04 15.5). The event carries no token; the SDK's run loop receives and acknowledges it (M2-29 Design note 9) and, with the run-loop changes of the Scope, calls the hookon_cancelfor a started run. a. Live turn:os.killpg(pgid, SIGINT), then close stdin. Ruling for docs/DECISIONS.md: with--input-format stream-jsonSIGINT ends the model turn, and the closed stdin makes the process end after itsresultinstead of waiting for input. If a process of the group is still aliverunner.stop_graceafter SIGINT, send SIGTERM to the group; if one is still aliverunner.kill_gracelater, SIGKILL. Then wait until no process of the group is left (os.killpg(pgid, 0)raisesProcessLookupError). b. Only then:finishRunwithstatecancelledandreasonthe reason of the request exactly as received (also a value the SDK keeps raw, G5), costs from the lastresultif one came; no answer message; locks released; the run leavesactive_runs. Ruling for docs/DECISIONS.md: no answer after a stop, because a stop means "nothing more" (R-AG-10) and the box keeps the last text. c. Run in the inbox, not started: drop it, never start it,finishRuncancelledwith the reason of the request, both done by the SDK's run loop (Scope, change b) (M2-09 acceptscancelledfromqueued; a run the server already cancelled answers200). A request for a run the runner does not know: acknowledge and drop. d. A stop wins: after a stop request no restart, compaction or follow-up write starts; a second request for the same run changes nothing. A stop during the follow-up grace, a compaction or a restart applies a and b to the process that runs. e. Run ended by the server while its turn runs:patchRunanswered with the409 conflictthat M2-10 gives for an ended run, or any call of the run answered401 unauthenticated(agent disabled, 09 2.1): stop the turn with a, and do not finish. Ruling for docs/DECISIONS.md. f. A finish answered200without change or409after a stop is logged as a code; the SDK's retry rules apply, nothing more. g. Every SIGTERM and SIGKILL is logged with the codestop_escalatedand the signal name. h. Documentation in 09 21 and the README:runner.stop_graceplusrunner.kill_gracestays belowagent.cancel_graceof the server, else the server ends the run withcancel_timeoutfirst; the runner cannot read the server value, likeadapter.invocation_token_leewayandauth.clock_leeway(09 4.5). -
Classification of a process end (
failures.py; 18.9 last paragraph). a.ERROR_TEXTSis a tuple ofErrorText(kind, fragment, channel, source):kindfromErrorKind;fragmentthe stable part of a text of the pinned CLI (no ids, no numbers that vary);channelwhere the pinned CLI writes it (result,assistantorstderr);sourcethe evidence (file of the installed pinned CLI, or documentation URL with retrieval date). Take the fragments from the files of the pinned CLI version (Design note 14) and the documentation cited in 09 18; record each source. At least one entry per kind. b.classify_turn_end(evidence). Inputs: stop requested,system/initseen, the lastresult, the text of the lastassistantmessage, the stderr file of the process, the exit status. Rules in this order: a stop was requested:stopped; the lastresulthassubtypesuccessand no error flag (is_error):completed; the first entry ofERROR_TEXTSin table order whose fragment occurs in its channel gives its kind, butsession_missingandsession_existsonly while nosystem/initcame (afterwardsruntime_error); otherwiseruntime_errorwith the error texts of theresult(the members the SDK reference names for them, none withoutresult) and the exit status. Checksubtype,is_errorand the member of the error texts against the Agent SDK reference of the pinned version (09 18.4 cites it); follow the reference and list a difference. c. stderr is read only to match fragments; its text never goes into a step, a log line or a finish body, because it can echo content. Ruling for docs/DECISIONS.md: the order and thesystem/initcondition. -
Context full (18.9 row 1). a. Before any code of this note: find out whether the pinned CLI takes
/compactas an input line in print mode with--input-format stream-json, from the documentation cited in 09 18 and from the files of the installed pinned CLI (no session, no model call). Record the evidence in 09 18.9 as[verified]with URL and retrieval date, or as not found, and answer the question in the README. b. Available: atcontext_fullwith no compaction yet in this run, start a compaction process with M2-34's command line and--resume <ref>, the same environment, pre-check and counter-check, and the single input line/compact(stdin closed after it). Its output is not mapped into the run document and never posted; success is aresultwithout error. Then a retry process with--resume <ref>and the first input line of the failed turn (M2-34 keeps it inrunner.state_dir; keep it there if not, mode0600), followed by the follow-ups of note 4 d. When the failed process had emitted at least oneassistantmessage aftersystem/init, the retry gets a runner prompt file inrunner.state_dirthat is M2-34's runner prompt plusRETRY_HINT: "The previous attempt at this request stopped because the conversation reached the context limit and was compacted. It may already have changed files or run commands. Check the current state before you repeat any action." A failed compaction, or a secondcontext_fullin the run: the run ends as in note 10 b (runtime_error), never a second compaction. c. Not available:context_fullends the run as in note 10 b, with 09 18.9 and the README saying so. Ruling for docs/DECISIONS.md; list it in the report as an owner decision with the recommendation to check again with the next pinned version. d. Box notecontext_compacted(note 11):completedafter a successful compaction,failedafter a failed one. -
Missing and existing sessions (18.9 rows 2 and 3; 09 9.1, 9.5). a.
session_missing(modesresumeandbind, process started with--resume <ref>), first time in the run: a new UUID becomes the reference and is stored in the turn record before the process starts; the session lock moves to it, the thread lock stays; a new process with--session-id <new>and the same first input line, then the follow-ups of note 4 d. Box notesession_missingwithdetail{"mode": "<mode>"}when this process starts. After itssystem/init:reportAgentSessionwithexternal_session_refthe new reference,started: trueand, only in modebind,bind_failed: session_missing, withIf-Matchthe current version of the session row (fromagent_session.versionof the invocation or the last report answer); this report replaces M2-34's report ofstartedfor this process. On412 version_conflictretry once with the version ofcurrent, then log a code and go on (fail open). b.session_exists(modenew, process started with--session-id <ref>), first time in the run: a new process with--resume <ref>and the same first input line; at itssystem/initM2-34's report ofstarted: true(withIf-Matchas above) follows unchanged. No box note. c. Moderesumereports the new reference withoutbind_failed(a reference change of a started row, M2-12 note 11); modebindwith it (09 9.5 point 4: the server ends the bound row and writes the system line, M2-12 note 12). d. Each recovery kind at most once per run; a second occurrence, or any failure of the new process beforesystem/init, ends the run as in note 10 b. No other end starts a process (09 1 point 5, R-AG-17); the session restarts happen beforesystem/init, so no work of the runtime runs twice. Every new process passes M2-34's pre-check and counter-check: a mismatch ends the runfailedwithbilling_mode_mismatch, an absent login withruntime_not_authenticated(I3 per process). Restarts use the run's slot ofrunner.max_parallel_runs. Ruling for docs/DECISIONS.md: a to d. -
Missing login and error results (18.9 rows 4 and 5). a. The pre-check result without login is M2-34's (no
run.start, no process,failedwithruntime_not_authenticated, BR-12); the scenario of row 4 asserts it again.not_authenticatedat a process end:failedwithruntime_not_authenticated, no recovery, no box note. b.runtime_error: box noteruntime_errorwith statusfailedanddetail{"errors": [<texts of note 7 b>], "exit": "<code or signal name>"}, patched beforefinishRun(patches after the end are refused, M2-10), thenfinishRunfailedwithruntime_errorand costs from the lastresultif one came. The server cutsdetailatrun.step_detail_max_bytes. This is "error texts in the last step". -
Box notes. A box note is a run step of kind
status(03run_steps) withnameone of the codescontext_compacted,session_missing,runtime_error, an emptytitle, statuscompletedorfailed,finished_atset,detailas above, a UUIDv7 step id from M2-34's step-id helper, added with oneadd /steps/<id>. Clients render the code as a translated line (M4-21), like the server's stepsteps_summarized(M2-10 note 6). One note per occurrence;session_missingat most once per run ("note once in the box"). Ruling for docs/DECISIONS.md; 09 18.9 lists the codes. -
Crash of the runner (18.2 "Detachment", 18.9 row 6, 09 4.9, AG-10). a. Detachment: stdout and stderr of the turn are files in
runner.state_dir(the "Recording" of 18.2) that the runner reads, never pipes owned by the runner, so the CLI never writes into a closed pipe; stdin is a pipe from the runner, so the CLI sees the end of its input when the runner dies and finishes its current work. Change M2-34 if it reads pipes (the crash scenario fails otherwise). b. Turn record (M2-34's, extended), written atomically (temporary file and rename, mode0600): run id, thread id, session reference and mode,pid,pgid, the process identity of note c taken at the spawn, the offset of the stream file processed, the map of step ids, the follow-up map (message_id,uuid, written, echoed), the recovery flags, a stop request. Persist before the action that depends on it: a reference before its process starts, an offset after the patches of its lines. c. Identity before every signal: a storedpidorpgidis signalled only whenos.getpgid(pid)equals the storedpgidand the output ofps -ww -o lstart= -o command= -p <pid>equals the stored start time and contains the session reference; otherwise the process counts as gone. The runner never signals a process it did not start. Ruling for docs/DECISIONS.md. d. At start, before the first event from the inbox and before the first heartbeat, for each record: check the process by note c. A live process keeps its session and thread lock through its inherited descriptors (M2-34 Design note 11), so the runner takes no lock for it. For a process that is gone, the runner takes both locks with one non-blocking attempt; a busy lock is logged with the codeadoption_lock_busyand the record stays for the next start. Then it reads the run withgetRun, then byadoption_decision: process alive and runrunning: adopt, read the stream file from the stored offset (a line read twice reuses its step id; answers go through the outbox with their storedclient_msg_id, 09 15), list the run inactive_runs(adopted runs enteractive_runsthrough the runner's heartbeat members, M2-34 Design note 15), end it by note 5 and finish once; alive and run ended: stop it by note 6 a without a finish; gone and the stream file has aresultafter the offset: finish by thatresult,completedwhen it has no error and otherwise as in note 10 (classification of note 7, no recovery); gone without such aresult: no finish and not inactive_runs, so the server ends the runinterrupted(09 5.5, AG-10). Remove the record when done. Ruling for docs/DECISIONS.md: the last case is left to the server, because the outcome of the turn is unknown. e. Neverrun.startand never a new process for an adopted run (R-AG-17). A runrunningwithout a turn process (crash betweenrun.startand the spawn) is left out ofactive_runs(09 4.9). f. Recovery invariants (added 2026-10-25 after three red team rounds found crash regressions; ruling D-M2-35-28). They hold for an own turn and an adopted turn alike, each at one place, the turn ledger (ledger.TurnLedger), whose state is part of the turn record. A runner may die after any write (record, usage file, server call), and a second runner may die the same way; the rules make every such crash safe. Rule 1, offset: The stored offset moves only through the ledger's checkpoint, after the patches of the lines before it went out (a failed flush keeps the old offset), and never past the first line of the run's end evidence (the lastresultread, and an API error message before it) while the run is not finished. So the next runner always reads thatresultagain, classifies the end by it and books its costs. A line read again is harmless: its step reuses its id (note b), its echo is known, its answer is known (rule 2). Rule 2, booked once: Eachresultline posts at most one answer: itsclient_msg_idis stored in the record, under the line's offset, before the reply goes to the outbox; a line read again sends the sameclient_msg_id(the server answers a repeat with the original, 04 13.2) or nothing once the reply was stored as sent. The costs of a run are booked only by its finish, from its lastresult(the record keeps that result's usage, numbers only, across the processes of a recovery), through the session's usage file, which keeps the reports of a run once computed; the decided end (state and reason) goes into the record before the costs go into the usage file, and a runner that finds a decided end repeats the finish with the same costs; the run is finished once. Rule 3, inputs by their own id: Every input line of a turn process (the prompt line and each follow-up) carries its ownuuid. It counts as answered when a successresultnames it (user_message_uuid,user_message_uuids), or when its echo (isReplaywith thatuuid) came before a successresult: the CLI echoes a line when a turn takes it, also when it folds the line into a running turn whoseresultnames nothing (a background task's notification; 2.1.259 emits a foldedqueued_commandas auserecho with the line'suuid). A line still queued has no echo, so aresultof another turn never answers it. There is no positional rule. A process of a recovery gets exactly the inputs not answered. A successresultends a process well only while no input taken after it (its echo came) is without an answer; otherwise the turn of that input died without itsresult, and the end isruntime_error(18.9). An input enters the ledger, and a follow-up the record, before its line is written; an adopting runner takes only the ids and reads echoes and answers again from the stored offset. So a runner that adopts the turn maps a follow-up's echo and patches itabsorbed(a follow-up leftdeliveredwould be deferred into a run of its own and answered twice). Rule 4, kind: The kind of a process (turnorcompaction) is fixed when the process starts and written with every record; an adopting runner takes it from the record and never changes it, so an adopted compaction is never posted and never completes the run. The crash-point test (tests/unit/test_crash_points.py) runs the scenarios turn, follow-ups queued and folded, compaction and import baseline, enumerates every first crash point and every second crash point of the adopting runner with a fake clock, and asserts one end per run, costs booked once, each follow-up answered once and no run left running. -
Fake-Claude (09 16.1). Extend M2-34's Fake-Claude, driven by its scenario mechanism (never an environment variable outside the allowlist of 18.2, which the runner removes): a session store in the configuration directory the runner passes (an unknown reference at
--resumeand a known one at--session-idwrite the text ofERROR_TEXTSon its channel and exit non-zero beforesystem/init; asystem/initcreates the session);context_fullafter a scripted number of messages;/compactas the only input line gives a compactionresult; the echo of every stdinuserline withisReplayand itsuuid(scriptable: never, or after a delay); oneresultper input line, so a process can emit several; aresultwith an error subtype and texts; the not-authenticated text; SIGINT gives aresultand the exit (M2-34); ignoring SIGINT, and ignoring SIGINT and SIGTERM; a child process in its group; a pause until a marker file exists; a record of its argv, its stdin lines and the runner prompt file it got, in a file the test reads. A unit test asserts that every error text in a fixture occurs inERROR_TEXTS. The fixtures of this ticket follow the message shapes the pinned version documents; mark each as constructed or recorded in M2-34's fixture metadata and list the constructed ones under "What is not proven" (RK-14: a recording needs a paid run behind the switch, which this ticket does not make). -
The switch test.
tests/cli/test_pinned_cli.py::test_pinned_cli_knows_error_texts_and_eventsruns only behind M2-34's explicit switch for the installed CLI (expected: a pytest marker and an environment variable; use M2-34's names). It resolvesrunner.claude_binlike the runner, asserts that the version output equals M2-34's pin, and asserts that every fragment ofERROR_TEXTSand every stream message type and subtype the runner reads (M2-34's mapping,isReplay, and the compaction message if note 8 uses one) occurs in the bytes of the installed CLI's files. It starts no session, uses no credential and calls no model. With the switch on and no CLI installed it fails; it never skips. -
Logs and files. Log lines carry codes (
followup_not_deliverable,followup_no_text,followup_check_failed,stop_escalated,recovery_started,turn_adopted, ...) and ids of runs and messages, never message text, prompts, error texts, stderr, tokens, environment values or paths under a home directory (SEC-LOG-2, SEC-LOG-4, 18.3). Files the runner writes inrunner.state_dirhave mode0600, directories0700; retention staysrunner.stream_log_retention(M2-34). -
Rulings for
docs/DECISIONS.mdsection 10, one row each with reason and place:D-M2-35-1checks of a follow-up at writing time and their outcomes (note 2 b and c);D-M2-35-2text throughgetMessage, no marking, the lead-in (note 3);D-M2-35-3held, re-written and adopted follow-ups (note 4 c to e);D-M2-35-4stdin rule, one answer perresult, cost from the lastresult(note 5);D-M2-35-5closing stdin after SIGINT, finish only after the group is gone, no answer after a stop (note 6 a and b);D-M2-35-6runs ended by the server (note 6 e);D-M2-35-7classification order (note 7);D-M2-35-8compaction, hint and the case without/compact(note 8);D-M2-35-9session restarts and their reports (note 9);D-M2-35-10box notes (note 11);D-M2-35-11identity check and adoption (note 12 c and d; a live process keeps its locks through its inherited descriptors, so adoption takes no lock for it, and for a gone process both locks are taken with one non-blocking attempt, a busy lock logged withadoption_lock_busy);D-M2-35-12the run-loop changes of the SDK for the stop (Scope; note 6 b and c). The rulings also go into 09 18.5, 18.6 and 18.9 (Scope).
Tests (write them first)¶
Unit, pure functions (tests/unit/):
test_failures.py:test_error_text_table_is_complete(everyErrorKindhas an entry; fragments not empty and unique; every entry haschannelandsource);test_classify_turn_end(table: stop requested with an errorresultgivesstopped;resultsuccesswithout error flag givescompleted; an errorresultwith thecontext_fullfragment givescontext_full; thesession_missingfragment on stderr withoutsystem/initgivessession_missing, the same aftersystem/initgivesruntime_error; thesession_existsfragment withoutsystem/initgivessession_exists; thenot_authenticatedfragment in the lastassistanttext givesnot_authenticated; an errorresultwith unknown texts givesruntime_errorwith those texts; an exit by signal withoutresultgivesruntime_errorwithout texts and the signal name; two fragments give the first in table order);test_stderr_text_never_in_turn_end(a stderr canary appears in no field ofTurnEnd);test_fixture_texts_are_in_table.test_recovery.py:test_next_action(table: firstcontext_fullgivescompact; after a successful compactionretrywith hint exactly whenworked; secondcontext_fulland a failed compaction givefail(runtime_error);session_missingin moderesumegivesnew_sessionwithoutbind_failed, in modebindwith it, a second onefail(runtime_error);session_existsin modenewgivesresume_same_reference, a second onefail(runtime_error);not_authenticatedgivesfail(runtime_not_authenticated);runtime_errorgivesfail(runtime_error);completedgivesfinish;stoppedgivescancelledwith the request's reason; a pre-check mismatch before a restart givesfail(billing_mode_mismatch);context_fullwithout/compactsupport givesfail(runtime_error));test_box_note_shape(kindstatus, code asname, emptytitle, UUIDv7 id,finished_atset).test_followups.py:test_followup_verdict(table: valid giveswrite; turn not started giveshold; expired beyondadapter.invocation_token_leewayat writing time givesskip, inside itwrite; wrong signature, wrongaud, otherrun_id,trigger_kindother thanfollowup, author other thansub, message of another thread each giveskip; otherbilling_modegivesskip;tool_classbelow the loaded class givesskip, above itwrite; amessage_idalready written givesduplicate);test_build_followup_line(valid JSON line of typeuser, freshuuidper call, lead-in with display name and handle, the text/compactis not at the start of the text);test_echo_matching(prompt echo and unknownuuidignored; echo before thedeliveredpatch givesdeliveredthenabsorbed; a second echo changes nothing);test_stdin_close_due(fake clock:resultwith nothing pending closes; pending keeps open; echo thenresultcloses;runner.followup_graceelapsed closes; a follow-up written in the window joins the set).test_stop.py:test_escalation_due(fake clock: SIGINT at 0; nothing beforerunner.stop_grace; SIGTERM at it when alive; SIGKILL atrunner.stop_graceplusrunner.kill_gracewhen alive; an exit at any point ends the schedule).test_adoption.py:test_adoption_decision(alive andrunningadopts; alive and ended stops without finish; gone withresultfinishes from the log; gone withoutresultdoes nothing);test_identity_mismatch_counts_as_gone(otherpgid, other start time, command without the session reference).
Scenarios (tests/scenarios/): the real runner code with real Fake-Claude processes, real signals and the FakeServer of M2-29 on 127.0.0.1 and port 0; graces through the runner configuration keys with short values; waits on events with a bounded timeout, never fixed sleeps. World: the agent Lotse runs the runner in mode organization with tool class scoped for the forum Dev of a space Engineering; Alex triggers runs in a thread of Dev; Sam writes follow-ups; Robin is the owner; the runner's secret provider holds a canary API key; the FakeServer signs tokens with its test key. A fixture asserts after every scenario that no process group started by the test or by the runner under test is left.
Error paths, one scenario per row of 09 18.9 "Error paths" (test_error_paths.py):
test_context_full_compacts_once_and_retries(row "Context full"): the turn emitssystem/init, atool_useand then thecontext_fulltext; then exactly two more processes, both with--resume <ref>: the first with the single input line/compact, the second with the original first line and a runner prompt file that containsRETRY_HINT; nothing of the compaction output in any patch or answer; one box notecontext_compactedcompleted; onerun.start, one answer, onefinishRuncompleted. Variants:test_context_full_before_work_retries_without_hint(error before anyassistantmessage: the retry's prompt file has no hint);test_context_full_twice_fails_without_second_compaction(no third process with/compact;failedruntime_error, the last step the box noteruntime_error). If note 8 c applies, this scenario asserts the end of note 10 b instead, and the variants are dropped with the reason in the report.test_session_missing_starts_new_session_fail_open(row "Session missing in Claude Code", AG-18 runner side), for modesresumeandbind:--resume <old>fails beforesystem/init; a second process with--session-id <new>and the same first line; one box notesession_missingwith the mode; aftersystem/initonereportAgentSessionwith the new reference,started: true,If-Matchof the row andbind_failed: session_missingonly in modebind; the run finishescompleted. Varianttest_new_session_fails_too_ends_runtime_error: no third process.test_session_exists_on_first_turn_resumes(row "Session already exists at the first turn"): modenewwith reference R that Fake-Claude already knows;--session-id Rfails beforesystem/init; a second process with--resume R; one reportstarted: true; onerun.start;completed.test_runtime_not_authenticated(row "Runtime not logged in"): a pre-check without login gives norun.start, no turn process andfinishRunfailedruntime_not_authenticated(M2-34's path, BR-12); the not-authenticated text at a process end givesfailedruntime_not_authenticatedand no further process.test_result_error_subtype_fails_with_texts(row "resultwith an error subtype"): aresultwith an error subtype and two error texts gives the box noteruntime_errorwith both texts as the last step, patched beforefinishRunfailedruntime_error; no further process.test_runner_crash_turn_survives_and_is_adopted(row "Crash of the runner"): the runner runs as a subprocess; Fake-Claude pauses on a marker file; the test kills the runner with SIGKILL, starts a new runner on the samerunner.state_dir, waits (bounded) until its heartbeat lists the run while the Fake-Claude PID is the same and alive, then releases the marker: no second Fake-Claude process and no secondrun.startappear, the rest of the stream reaches the run document, the answer is posted once, the run finishescompletedonce. Variants:test_crash_run_ended_meanwhile_stops_turn(the FakeServer reports the runfailedwithstale: the new runner stops the group and calls no finish);test_crash_turn_finished_while_down_finishes_from_log(the turn wrote itsresultwhile no runner ran: finish once from the log);test_crash_turn_died_without_result_left_to_server(no finish, the run is not inactive_runs, no new process, AG-10 runner side);test_crash_identity_mismatch_never_signals(the record's start time is altered: the new runner sends no signal to that PID).
Follow-ups (test_followups.py):
test_followup_reaches_running_turn(AG-13 adapter part,steer): Sam's follow-up during Alex's turn: onegetMessage, one stdin line with the lead-in and auuid, patchdelivered, after the echo patchabsorbed; the heartbeat showsfollowupssteer.test_followups_held_until_turn_starts: two follow-ups delivered while the run waits for its session lock are written right after the prompt line, in event order.test_followup_grace_keeps_stdin_until_absorbed: Fake-Claude echoes after its firstresult; stdin stays open, the secondresultcloses it; two answers, onefinishRuncompletedwith the cost difference from the secondresult.test_followup_grace_expires_leaves_delivered: Fake-Claude never echoes; stdin closes afterrunner.followup_grace; noabsorbedpatch; the run finishescompleted.test_followup_after_stdin_closed_is_not_written: no stdin line, no patch, the event is acknowledged.test_followup_redelivery_written_once: the same follow-up redelivered with a new token gives one stdin line.test_followup_deleted_or_empty_not_written: agetMessageanswer withdeleted_at, an empty text and a404each give no line and no patch.test_followup_invalid_token_not_written_run_continues(M2 Red Team, T-AI-08): a token with a wrong signature, one for another run and one whosesubis not the author give no line and no patch; the turn goes on and finishescompleted.test_followup_target_change_not_written_run_continues(BR-05 runner side): a follow-up token withbilling_modepersonalagainst the runningorganizationturn, and one withtool_classchatagainst the loadedscoped, give no stdin line and no patch; the turn goes on and finishescompleted.test_followup_text_with_slash_is_plain: a follow-up/compactreaches Fake-Claude as text after the lead-in; no compaction happens.test_unechoed_followups_rewritten_after_compaction: a follow-up written beforecontext_fullwithout echo appears again in the retry process with the sameuuidand isabsorbedthere.
Stop (test_stop.py):
test_stop_sigint_ends_turn_and_reports_reason(AG-14 and AG-13 adapter part):run.cancel_requestedwithuser_cancelledwhile Sam's follow-up isdelivered: SIGINT to the group, stdin closed, Fake-Claude'sresult, no process of the group left (its child included), thenfinishRuncancelleduser_cancelledwith costs, no answer, no further process.test_stop_escalates_to_sigterm(Fake-Claude ignores SIGINT): SIGTERM afterrunner.stop_grace;finishRuncancelledonly after the group is gone; no costs without aresult.test_stop_escalates_to_sigkill(Fake-Claude ignores SIGINT and SIGTERM): SIGKILL afterrunner.stop_graceplusrunner.kill_grace; the group is gone;finishRuncancelled.test_stop_reason_passes_through:access_revoked,agent_disabled,user_disabledandchannel_read_only(M2-09 adds it toRunCancelRequestedPayload.reason) each arrive unchanged infinishRun.test_stop_before_start_never_starts: a run waiting for its session lock is dropped: norun.start, no process,finishRuncancelledwith the reason.test_stop_during_compaction_starts_no_retry: a stop while the/compactprocess runs ends it; no retry process.test_stop_request_twice_changes_nothing: one SIGINT, onefinishRun.test_run_ended_by_server_stops_turn_without_finish: a patch answered with M2-10's409 conflictfor an ended run, and in a second case401 unauthenticated: the group is stopped and nofinishRunfollows.
Privacy (test_privacy.py):
test_no_secret_or_content_in_runner_output(BR-13 for the paths of this ticket, SEC-LOG-4): the scenarios of context full, missing session, error result and a follow-up run with the canary API key and a canary follow-up text; the key appears in no patch, report, finish body, log line or file written by the runner; the follow-up text appears in no log line and no patch (only on stdin and in the stream files); no stderr text appears in any patch.
Switch (tests/cli/):
test_pinned_cli_knows_error_texts_and_events(Design note 14), only with the switch on.
Red team focus of the milestone (PLAN 8 "Red team": token reuse, faked actual mode, a personal subscription reached through a follow-up): test_followup_invalid_token_not_written_run_continues, test_followup_redelivery_written_once, test_followup_target_change_not_written_run_continues, test_followup_text_with_slash_is_plain, test_crash_identity_mismatch_never_signals and test_stop_escalates_to_sigkill; name them in the red team request, together with: escaping the process group, signalling a foreign process after a restart, error texts or stderr leaking content or secrets.
Run the switch test once with the pinned CLI installed into a temporary directory (a package download of the pinned version only; no login, no session) and report the result. If the installation needs more than a package download, do not run it and say so under "What is not proven".
Commands that must pass, from the repository root:
cd plugins/claude-code-runner && uv run --locked pytestmake fmt-check,make lint,make typecheck,make test,make coverage,make licenses,make osvmake api-lint,make api-checks,make codegen-check,make api-clientsmake docsmake check
Acceptance criteria¶
- One Fake-Claude scenario per row of 09 18.9 "Error paths" is green:
test_context_full_compacts_once_and_retries,test_session_missing_starts_new_session_fail_open,test_session_exists_on_first_turn_resumes,test_runtime_not_authenticated,test_result_error_subtype_fails_with_texts,test_runner_crash_turn_survives_and_is_adopted. - A full context is compacted at most once per run and the same prompt runs again, with
RETRY_HINTexactly when the turn had worked; a second full context endsruntime_error(or, without/compactin print mode, the documented end of note 8 c). - A missing session fails open: new session, same prompt, one report with the new reference (
bind_failedonly in modebind), one box note, the run goes on (AG-18 runner side); an existing session at the first turn is resumed andstarted = trueis reported. - A missing login ends
runtime_not_authenticated; an errorresultendsruntime_errorwith its texts in the last step; no other end starts a process. - After a crash of the runner the turn runs on and a new runner adopts it or ends it; never a second
run.startor process for the run; signals only to processes whose identity is verified (AG-10 runner side). - Follow-ups over stdin:
deliveredafter the write,absorbedon the echo with the sameuuid, forward only, never twice, held until the turn starts,runner.followup_graceafter aresult; a broken token is never written and ends no run; a follow-up with a changed target is not written and ends no run; a text starting with/stays text; the heartbeat reportssteer. - A stop sends SIGINT, SIGTERM after
runner.stop_graceand SIGKILL afterrunner.kill_graceto the process group, leaves no process of the group, and only then finishescancelledwith the reason of the request; a run not started is never started. -
ERROR_TEXTSholds every recognised text with its source; the switch test passed once against the installed pinned CLI (or the report says why it did not run). - The availability of
/compactin print mode was checked before the code of note 8, with the evidence in 09 18.9 and the README. - No token, secret, environment value, stderr text or message content in log lines or run outputs.
- 09 16.1, 18.5, 18.6, 18.9 and 21, the description of
AgentFollowupPayload.message_id, the runner README and CHANGELOG andD-M2-35-1toD-M2-35-12are written. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- README of
plugins/claude-code-runnerupdated (follow-ups, stop, error paths and their box notes, adoption after a crash, the answer on/compactwith its evidence, the note on the sum of the graces); README ofsdk/pythonif its FakeServer or run loop changed. - Rulings of note 16 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 09 andopenapi.yamlof the Scope made in the same change. plugins/claude-code-runner/CHANGELOG.mdentry under 'Unreleased' (andsdk/python/CHANGELOG.mdif changed).- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.