Skip to content

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 (rows agent.followup, run.cancel_requested); 4.5 "Invocation token" (points 1 to 10, the reason table); 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 (row user with isReplay), 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.followup names 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 (kind status, id created by the adapter as UUIDv7, detail cut to run.step_detail_max_bytes), run_followups (states), agent_sessions (started, end_reason).
  • docs/spec/05-actions.md: 4.6 row agent_session.report; 4.7 row message.read; 4.10 rows run.update, run.finish, agent.heartbeat; 6 rows agent.followup and run.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" (module runs: "follow-up states", "cancel callback"; module testing); docs/spec/23-quality.md 3 (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: operations getMessage, patchRun, finishRun, startRun, getRun, reportAgentSession, sendHeartbeat, pollAgentEvents, ackAgentEvents; schemas AgentFollowupPayload, AgentFollowupEvent, RunCancelRequestedPayload, RunCancelRequestedEvent, AgentInvocationPayload, AgentSession, AgentSessionReport, RunPatch, JsonPatchOperation, RunStep, RunFollowup, RunFinish, CostReport, Heartbeat, Message, PrincipalRef, Problem (x-problem-codes). docs/api/asyncapi.yaml: messages event.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 of docs/OPEN-QUESTIONS.md concerns 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 attribute service.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 of finishRun, requestCancel, the cancel reasons, cancel_timeout), M2-10-run-document-live-box.md (path table, UUIDv7 step ids, the 400 validation_failed for a follow-up the run does not have, the 409 conflict for a patch of an ended run, the server's status step steps_summarized of its note 6), M2-11-run-followups.md (server side of follow-ups: delivered sets followup_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-Match of 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 targets generate and generate-check), M2-29-python-sdk.md (Design notes 8 and 9: verifier, inbox, run loop, cancel, restart, active_runs; note 10: RunPatcher) and M2-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 and testing with the FakeServer; M2-23 generated core).

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. 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).
  4. 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.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. 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.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/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:

  1. With followups = steer reported in the heartbeat, every follow-up of a run whose turn runs is written as one further user line with its own uuid on the turn's stdin; the runner then reports delivered, and the echo with the same uuid (--replay-user-messages) reports absorbed, forward only (09 2.2, 4.7, 5.3, 7, 18.5; 04 15.4 point 3).
  2. After a result with follow-ups not yet absorbed, stdin stays open for runner.followup_grace; then the runner closes it, and 09 7 applies (18.5).
  3. 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).
  4. A stop sends SIGINT to the process group; without an end after runner.stop_grace follows SIGTERM, after runner.kill_grace SIGKILL; the runner reports cancelled with the reason of the request (09 8 point 3, 18.6, 04 15.5).
  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).
  6. A session missing in Claude Code gives a new session, the prompt again, agent_session.report with 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).
  7. A session that exists already at the first turn is continued with --resume, and started = true is reported (18.9, 09 9.1).
  8. A runtime without login ends failed with runtime_not_authenticated; a result with an error subtype ends failed with runtime_error and the error texts in the last step (18.9, 09 5.2, BR-12).
  9. 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).
  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).
  11. 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).
  12. 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 table ERROR_TEXTS, TurnEvidence, TurnEnd and the pure classify_turn_end(evidence: TurnEvidence) -> TurnEnd (Design note 7).
  • src/<pkg>/recovery.py: RecoveryState, the action types, the pure next_action(end: TurnEnd, state: RecoveryState, session_mode: str, worked: bool) -> Action, the constant RETRY_HINT and the builder box_note(code, status, detail) of a status step (Design notes 8 to 11).
  • src/<pkg>/followups.py: the pure followup_verdict(...) -> FollowupVerdict (Design note 2), build_followup_line(author, text, line_uuid) -> bytes (note 3), PendingFollowups (written, echoed, pending, by uuid and message_id, note 4), the pure stdin_close_due(now, last_result_at, pending, grace) -> bool (note 5), and the async handler of agent.followup.
  • src/<pkg>/stop.py: the pure escalation_due(now, sigint_at, alive, stop_grace, kill_grace) -> signal or None and the async stop_turn(turn, reason) (Design note 6).
  • src/<pkg>/adoption.py: ProcessIdentity, process_matches(record) -> bool, the pure adoption_decision(alive, run_state, result_in_log) -> AdoptionAction and the async adopt_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_end and next_action; restarts within a run (notes 8 and 9); several results per process (note 5); the fields of the turn record of note 12 b; stdout and stderr into files in runner.state_dir if the merged code reads pipes (note 12 a).
  • M2-34's stream reader: user with isReplay maps 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 hook on_cancel below, run.cancel_requested (note 6).
  • M2-34's heartbeat: capabilities.followups = steer in details; active_runs with 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_grace and runner.kill_grace with 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 /compact available in print mode of the pinned version?" with its evidence; if it still names the tool class full, write host (D-W-16). CHANGELOG.md (English) under "Unreleased".
  • sdk/python run loop (bothy_sdk.adapter, M2-29 Design note 9), only as far as notes 6 b and c need it: (a) run.cancel_requested for a started run calls a handler hook on_cancel(run, reason) when the handler implements it (11 11.2 module runs: "cancel callback"); the handler stops the turn and finishes, and the SDK then sends no finishRun of its own. (b) For an entry still accepted, the SDK cancels its start task and sends finishRun cancelled with the request's reason (09 8 point 3). Each change gets a test in sdk/python/tests/test_adapter.py, a line in sdk/python/CHANGELOG.md and the ruling D-M2-35-12.
  • sdk/python FakeServer, beside the run-loop hooks above (only where a test of this brief needs a capability the FakeServer of M2-29 lacks): inject agent.followup and run.cancel_requested, answer getMessage, set the state that getRun returns, answer a patch of an ended run with the 409 conflict of M2-10 and any call with 401 unauthenticated, record the bodies and the If-Match header of reportAgentSession; each with a test in sdk/python and 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 of AgentFollowupPayload.message_id (Design note 3). Then make api-lint and make api-checks, and regenerate what make codegen-check and make api-clients report stale (the route generator of M1-17, make -C sdk/python generate, make -C sdk/typescript generate).
  • docs/DECISIONS.md section 10: rows D-M2-35-1 and 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 status and the counter-check system/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 of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and 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_path or tempfile.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.started and AgentSessionReport.bind_failed and the mode AgentInvocationPayload.session_mode, which M2-12 adds to docs/api/openapi.yaml (M2-12 Design notes 9, 11 and 17; 05 4.6 row agent_session.report and 09 9.5 already name started and bind_failed). If the base branch lacks them, stop with Verdict: 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 check runs.

Design notes

  1. What exists and where this goes. M2-34 built the runner: its configuration (09 21), the consumer through the SDK (bothy_sdk modules gateway, invocation, runs, heartbeat, outbox), the inbox in runner.state_dir (09 4.3), the token check of 4.5, the pre-check and the counter-check of 18.3, the start of claude -p with 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 with started = true at system/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.

  2. 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 same message_id of 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 with adapter.invocation_token_leeway; point 7, the replay check, ran at acceptance), plus: run_id is the run of the turn, trigger_kind is followup, and the message read in note 3 belongs to the run's thread and has the author sub. 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 of followup_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 stays queued and the server defers it at the end of the run (09 7), so its next delivery is checked again. A follow-up token whose billing_mode differs from the mode of the running turn, or whose tool_class is below runner.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 example personal with runner.allow_personal), the check at writing time gives skip (log code followup_check_failed). The follow-up stays queued and 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 code followup_not_deliverable); the server defers it.

  3. Text of a follow-up. AgentFollowupPayload carries no text (04 2.2 rule 4). The runner reads the message with getMessage (action message.read, rl.read; the agent reads a message of the thread it operates). A 404, a message with deleted_at or an empty content_md is not written (log code followup_no_text); the follow-up stays queued (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 checked agent.invoke for its author at the delivery (04 15.4 point 2), which requires effective trigger, and 09 4.6 rates such an author trigger or authorized, neither of which is marked (ADR 0054). Ruling for docs/DECISIONS.md, with the openapi change: run.context.read carries no follow-up of the running run (M2-13 "Out of scope"), so the description of AgentFollowupPayload.message_id becomes "Follow-up message; the adapter reads its content with getMessage (message.read); its author is sub of the invocation token of this delivery." The line is one user line from M2-34's builder of the prompt line, with a fresh uuid (UUIDv4) and the text Follow-up from <display_name> (@<handle>):, a blank line and content_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 /compact or /clear) never becomes a command of the CLI.

  4. Writing, acknowledging, holding. a. Turn running with open stdin: write the line and flush, then patch replace /followups/<message_id>/state with delivered through the SDK runs helper (base_version, throttled by agent.run_update_min_interval, rebase after 409 base_version_conflict). A failed write (the process is gone) leaves the follow-up queued. b. Echo: a user message with isReplay on stdout whose uuid is that of a written follow-up patches absorbed. absorbed never goes out before delivered (same or a later patch); a second echo of the same uuid changes nothing; the echo of the prompt line and unknown uuids are ignored. c. Turn not started yet (run in the inbox, waiting for run.start, a lock or a slot of runner.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 original uuid, so its echo still maps; it stays delivered. e. An adopted turn (note 12) takes no follow-up: its stdin ended with the old runner. Its follow-ups stay queued and the server defers them. f. A patch that the server refuses with 400 validation_failed because the run has no such follow-up (M2-10 note 4) is logged as a code; the turn goes on. g. The heartbeat reports capabilities.followups = steer (09 2.2, 4.8) from this ticket on. Ruling for docs/DECISIONS.md: c, d and e.

  5. 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 a result without a written follow-up that has no echo: close stdin. At a result with such follow-ups: keep stdin open until their echoes or until runner.followup_grace after that result; a follow-up that passes note 2 within this window is written and joins the set; when the set is empty at a later result, close; when the window ends with follow-ups left, close, and they stay delivered for the server to defer (09 7). The process then ends, and the run finishes once, after the process exited. Every result without 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 last result (18.7: the values are cumulative per session). Ruling for docs/DECISIONS.md.

  6. 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 hook on_cancel for a started run. a. Live turn: os.killpg(pgid, SIGINT), then close stdin. Ruling for docs/DECISIONS.md: with --input-format stream-json SIGINT ends the model turn, and the closed stdin makes the process end after its result instead of waiting for input. If a process of the group is still alive runner.stop_grace after SIGINT, send SIGTERM to the group; if one is still alive runner.kill_grace later, SIGKILL. Then wait until no process of the group is left (os.killpg(pgid, 0) raises ProcessLookupError). b. Only then: finishRun with state cancelled and reason the reason of the request exactly as received (also a value the SDK keeps raw, G5), costs from the last result if one came; no answer message; locks released; the run leaves active_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, finishRun cancelled with the reason of the request, both done by the SDK's run loop (Scope, change b) (M2-09 accepts cancelled from queued; a run the server already cancelled answers 200). 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: patchRun answered with the 409 conflict that M2-10 gives for an ended run, or any call of the run answered 401 unauthenticated (agent disabled, 09 2.1): stop the turn with a, and do not finish. Ruling for docs/DECISIONS.md. f. A finish answered 200 without change or 409 after a stop is logged as a code; the SDK's retry rules apply, nothing more. g. Every SIGTERM and SIGKILL is logged with the code stop_escalated and the signal name. h. Documentation in 09 21 and the README: runner.stop_grace plus runner.kill_grace stays below agent.cancel_grace of the server, else the server ends the run with cancel_timeout first; the runner cannot read the server value, like adapter.invocation_token_leeway and auth.clock_leeway (09 4.5).

  7. Classification of a process end (failures.py; 18.9 last paragraph). a. ERROR_TEXTS is a tuple of ErrorText(kind, fragment, channel, source): kind from ErrorKind; fragment the stable part of a text of the pinned CLI (no ids, no numbers that vary); channel where the pinned CLI writes it (result, assistant or stderr); source the 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/init seen, the last result, the text of the last assistant message, the stderr file of the process, the exit status. Rules in this order: a stop was requested: stopped; the last result has subtype success and no error flag (is_error): completed; the first entry of ERROR_TEXTS in table order whose fragment occurs in its channel gives its kind, but session_missing and session_exists only while no system/init came (afterwards runtime_error); otherwise runtime_error with the error texts of the result (the members the SDK reference names for them, none without result) and the exit status. Check subtype, is_error and 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 the system/init condition.

  8. Context full (18.9 row 1). a. Before any code of this note: find out whether the pinned CLI takes /compact as 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: at context_full with 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 a result without error. Then a retry process with --resume <ref> and the first input line of the failed turn (M2-34 keeps it in runner.state_dir; keep it there if not, mode 0600), followed by the follow-ups of note 4 d. When the failed process had emitted at least one assistant message after system/init, the retry gets a runner prompt file in runner.state_dir that is M2-34's runner prompt plus RETRY_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 second context_full in the run: the run ends as in note 10 b (runtime_error), never a second compaction. c. Not available: context_full ends 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 note context_compacted (note 11): completed after a successful compaction, failed after a failed one.

  9. Missing and existing sessions (18.9 rows 2 and 3; 09 9.1, 9.5). a. session_missing (modes resume and bind, 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 note session_missing with detail {"mode": "<mode>"} when this process starts. After its system/init: reportAgentSession with external_session_ref the new reference, started: true and, only in mode bind, bind_failed: session_missing, with If-Match the current version of the session row (from agent_session.version of the invocation or the last report answer); this report replaces M2-34's report of started for this process. On 412 version_conflict retry once with the version of current, then log a code and go on (fail open). b. session_exists (mode new, process started with --session-id <ref>), first time in the run: a new process with --resume <ref> and the same first input line; at its system/init M2-34's report of started: true (with If-Match as above) follows unchanged. No box note. c. Mode resume reports the new reference without bind_failed (a reference change of a started row, M2-12 note 11); mode bind with 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 before system/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 before system/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 run failed with billing_mode_mismatch, an absent login with runtime_not_authenticated (I3 per process). Restarts use the run's slot of runner.max_parallel_runs. Ruling for docs/DECISIONS.md: a to d.

  10. 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, failed with runtime_not_authenticated, BR-12); the scenario of row 4 asserts it again. not_authenticated at a process end: failed with runtime_not_authenticated, no recovery, no box note. b. runtime_error: box note runtime_error with status failed and detail {"errors": [<texts of note 7 b>], "exit": "<code or signal name>"}, patched before finishRun (patches after the end are refused, M2-10), then finishRun failed with runtime_error and costs from the last result if one came. The server cuts detail at run.step_detail_max_bytes. This is "error texts in the last step".

  11. Box notes. A box note is a run step of kind status (03 run_steps) with name one of the codes context_compacted, session_missing, runtime_error, an empty title, status completed or failed, finished_at set, detail as above, a UUIDv7 step id from M2-34's step-id helper, added with one add /steps/<id>. Clients render the code as a translated line (M4-21), like the server's step steps_summarized (M2-10 note 6). One note per occurrence; session_missing at most once per run ("note once in the box"). Ruling for docs/DECISIONS.md; 09 18.9 lists the codes.

  12. 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, mode 0600): 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 stored pid or pgid is signalled only when os.getpgid(pid) equals the stored pgid and the output of ps -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 code adoption_lock_busy and the record stays for the next start. Then it reads the run with getRun, then by adoption_decision: process alive and run running: adopt, read the stream file from the stored offset (a line read twice reuses its step id; answers go through the outbox with their stored client_msg_id, 09 15), list the run in active_runs (adopted runs enter active_runs through 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 a result after the offset: finish by that result, completed when it has no error and otherwise as in note 10 (classification of note 7, no recovery); gone without such a result: no finish and not in active_runs, so the server ends the run interrupted (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. Never run.start and never a new process for an adopted run (R-AG-17). A run running without a turn process (crash between run.start and the spawn) is left out of active_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 last result read, and an API error message before it) while the run is not finished. So the next runner always reads that result again, 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: Each result line posts at most one answer: its client_msg_id is stored in the record, under the line's offset, before the reply goes to the outbox; a line read again sends the same client_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 last result (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 own uuid. It counts as answered when a success result names it (user_message_uuid, user_message_uuids), or when its echo (isReplay with that uuid) came before a success result: the CLI echoes a line when a turn takes it, also when it folds the line into a running turn whose result names nothing (a background task's notification; 2.1.259 emits a folded queued_command as a user echo with the line's uuid). A line still queued has no echo, so a result of another turn never answers it. There is no positional rule. A process of a recovery gets exactly the inputs not answered. A success result ends 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 its result, and the end is runtime_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 it absorbed (a follow-up left delivered would be deferred into a run of its own and answered twice). Rule 4, kind: The kind of a process (turn or compaction) 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.

  13. 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 --resume and a known one at --session-id write the text of ERROR_TEXTS on its channel and exit non-zero before system/init; a system/init creates the session); context_full after a scripted number of messages; /compact as the only input line gives a compaction result; the echo of every stdin user line with isReplay and its uuid (scriptable: never, or after a delay); one result per input line, so a process can emit several; a result with an error subtype and texts; the not-authenticated text; SIGINT gives a result and 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 in ERROR_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).

  14. The switch test. tests/cli/test_pinned_cli.py::test_pinned_cli_knows_error_texts_and_events runs 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 resolves runner.claude_bin like the runner, asserts that the version output equals M2-34's pin, and asserts that every fragment of ERROR_TEXTS and 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.

  15. 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 in runner.state_dir have mode 0600, directories 0700; retention stays runner.stream_log_retention (M2-34).

  16. Rulings for docs/DECISIONS.md section 10, one row each with reason and place: D-M2-35-1 checks of a follow-up at writing time and their outcomes (note 2 b and c); D-M2-35-2 text through getMessage, no marking, the lead-in (note 3); D-M2-35-3 held, re-written and adopted follow-ups (note 4 c to e); D-M2-35-4 stdin rule, one answer per result, cost from the last result (note 5); D-M2-35-5 closing stdin after SIGINT, finish only after the group is gone, no answer after a stop (note 6 a and b); D-M2-35-6 runs ended by the server (note 6 e); D-M2-35-7 classification order (note 7); D-M2-35-8 compaction, hint and the case without /compact (note 8); D-M2-35-9 session restarts and their reports (note 9); D-M2-35-10 box notes (note 11); D-M2-35-11 identity 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 with adoption_lock_busy); D-M2-35-12 the 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 (every ErrorKind has an entry; fragments not empty and unique; every entry has channel and source); test_classify_turn_end (table: stop requested with an error result gives stopped; result success without error flag gives completed; an error result with the context_full fragment gives context_full; the session_missing fragment on stderr without system/init gives session_missing, the same after system/init gives runtime_error; the session_exists fragment without system/init gives session_exists; the not_authenticated fragment in the last assistant text gives not_authenticated; an error result with unknown texts gives runtime_error with those texts; an exit by signal without result gives runtime_error without 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 of TurnEnd); test_fixture_texts_are_in_table.
  • test_recovery.py: test_next_action (table: first context_full gives compact; after a successful compaction retry with hint exactly when worked; second context_full and a failed compaction give fail(runtime_error); session_missing in mode resume gives new_session without bind_failed, in mode bind with it, a second one fail(runtime_error); session_exists in mode new gives resume_same_reference, a second one fail(runtime_error); not_authenticated gives fail(runtime_not_authenticated); runtime_error gives fail(runtime_error); completed gives finish; stopped gives cancelled with the request's reason; a pre-check mismatch before a restart gives fail(billing_mode_mismatch); context_full without /compact support gives fail(runtime_error)); test_box_note_shape (kind status, code as name, empty title, UUIDv7 id, finished_at set).
  • test_followups.py: test_followup_verdict (table: valid gives write; turn not started gives hold; expired beyond adapter.invocation_token_leeway at writing time gives skip, inside it write; wrong signature, wrong aud, other run_id, trigger_kind other than followup, author other than sub, message of another thread each give skip; other billing_mode gives skip; tool_class below the loaded class gives skip, above it write; a message_id already written gives duplicate); test_build_followup_line (valid JSON line of type user, fresh uuid per call, lead-in with display name and handle, the text /compact is not at the start of the text); test_echo_matching (prompt echo and unknown uuid ignored; echo before the delivered patch gives delivered then absorbed; a second echo changes nothing); test_stdin_close_due (fake clock: result with nothing pending closes; pending keeps open; echo then result closes; runner.followup_grace elapsed closes; a follow-up written in the window joins the set).
  • test_stop.py: test_escalation_due (fake clock: SIGINT at 0; nothing before runner.stop_grace; SIGTERM at it when alive; SIGKILL at runner.stop_grace plus runner.kill_grace when alive; an exit at any point ends the schedule).
  • test_adoption.py: test_adoption_decision (alive and running adopts; alive and ended stops without finish; gone with result finishes from the log; gone without result does nothing); test_identity_mismatch_counts_as_gone (other pgid, 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 emits system/init, a tool_use and then the context_full text; 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 contains RETRY_HINT; nothing of the compaction output in any patch or answer; one box note context_compacted completed; one run.start, one answer, one finishRun completed. Variants: test_context_full_before_work_retries_without_hint (error before any assistant message: the retry's prompt file has no hint); test_context_full_twice_fails_without_second_compaction (no third process with /compact; failed runtime_error, the last step the box note runtime_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 modes resume and bind: --resume <old> fails before system/init; a second process with --session-id <new> and the same first line; one box note session_missing with the mode; after system/init one reportAgentSession with the new reference, started: true, If-Match of the row and bind_failed: session_missing only in mode bind; the run finishes completed. Variant test_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"): mode new with reference R that Fake-Claude already knows; --session-id R fails before system/init; a second process with --resume R; one report started: true; one run.start; completed.
  • test_runtime_not_authenticated (row "Runtime not logged in"): a pre-check without login gives no run.start, no turn process and finishRun failed runtime_not_authenticated (M2-34's path, BR-12); the not-authenticated text at a process end gives failed runtime_not_authenticated and no further process.
  • test_result_error_subtype_fails_with_texts (row "result with an error subtype"): a result with an error subtype and two error texts gives the box note runtime_error with both texts as the last step, patched before finishRun failed runtime_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 same runner.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 second run.start appear, the rest of the stream reaches the run document, the answer is posted once, the run finishes completed once. Variants: test_crash_run_ended_meanwhile_stops_turn (the FakeServer reports the run failed with stale: the new runner stops the group and calls no finish); test_crash_turn_finished_while_down_finishes_from_log (the turn wrote its result while no runner ran: finish once from the log); test_crash_turn_died_without_result_left_to_server (no finish, the run is not in active_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: one getMessage, one stdin line with the lead-in and a uuid, patch delivered, after the echo patch absorbed; the heartbeat shows followups steer.
  • 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 first result; stdin stays open, the second result closes it; two answers, one finishRun completed with the cost difference from the second result.
  • test_followup_grace_expires_leaves_delivered: Fake-Claude never echoes; stdin closes after runner.followup_grace; no absorbed patch; the run finishes completed.
  • 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: a getMessage answer with deleted_at, an empty text and a 404 each 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 whose sub is not the author give no line and no patch; the turn goes on and finishes completed.
  • test_followup_target_change_not_written_run_continues (BR-05 runner side): a follow-up token with billing_mode personal against the running organization turn, and one with tool_class chat against the loaded scoped, give no stdin line and no patch; the turn goes on and finishes completed.
  • test_followup_text_with_slash_is_plain: a follow-up /compact reaches Fake-Claude as text after the lead-in; no compaction happens.
  • test_unechoed_followups_rewritten_after_compaction: a follow-up written before context_full without echo appears again in the retry process with the same uuid and is absorbed there.

Stop (test_stop.py):

  • test_stop_sigint_ends_turn_and_reports_reason (AG-14 and AG-13 adapter part): run.cancel_requested with user_cancelled while Sam's follow-up is delivered: SIGINT to the group, stdin closed, Fake-Claude's result, no process of the group left (its child included), then finishRun cancelled user_cancelled with costs, no answer, no further process.
  • test_stop_escalates_to_sigterm (Fake-Claude ignores SIGINT): SIGTERM after runner.stop_grace; finishRun cancelled only after the group is gone; no costs without a result.
  • test_stop_escalates_to_sigkill (Fake-Claude ignores SIGINT and SIGTERM): SIGKILL after runner.stop_grace plus runner.kill_grace; the group is gone; finishRun cancelled.
  • test_stop_reason_passes_through: access_revoked, agent_disabled, user_disabled and channel_read_only (M2-09 adds it to RunCancelRequestedPayload.reason) each arrive unchanged in finishRun.
  • test_stop_before_start_never_starts: a run waiting for its session lock is dropped: no run.start, no process, finishRun cancelled with the reason.
  • test_stop_during_compaction_starts_no_retry: a stop while the /compact process runs ends it; no retry process.
  • test_stop_request_twice_changes_nothing: one SIGINT, one finishRun.
  • test_run_ended_by_server_stops_turn_without_finish: a patch answered with M2-10's 409 conflict for an ended run, and in a second case 401 unauthenticated: the group is stopped and no finishRun follows.

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 pytest
  • make fmt-check, make lint, make typecheck, make test, make coverage, make licenses, make osv
  • make api-lint, make api-checks, make codegen-check, make api-clients
  • make docs
  • make 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_HINT exactly when the turn had worked; a second full context ends runtime_error (or, without /compact in 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_failed only in mode bind), one box note, the run goes on (AG-18 runner side); an existing session at the first turn is resumed and started = true is reported.
  • A missing login ends runtime_not_authenticated; an error result ends runtime_error with 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.start or process for the run; signals only to processes whose identity is verified (AG-10 runner side).
  • Follow-ups over stdin: delivered after the write, absorbed on the echo with the same uuid, forward only, never twice, held until the turn starts, runner.followup_grace after a result; 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 reports steer.
  • A stop sends SIGINT, SIGTERM after runner.stop_grace and SIGKILL after runner.kill_grace to the process group, leaves no process of the group, and only then finishes cancelled with the reason of the request; a run not started is never started.
  • ERROR_TEXTS holds 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 /compact in 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 and D-M2-35-1 to D-M2-35-12 are written.
  • make check is 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-runner updated (follow-ups, stop, error paths and their box notes, adoption after a crash, the answer on /compact with its evidence, the note on the sum of the graces); README of sdk/python if its FakeServer or run loop changed.
  • Rulings of note 16 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 09 and openapi.yaml of the Scope made in the same change.
  • plugins/claude-code-runner/CHANGELOG.md entry under 'Unreleased' (and sdk/python/CHANGELOG.md if 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 check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. 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.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. 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>.