| D-M2-35-1 |
2026-10-24 |
At the moment a follow-up is written to stdin the runner checks its token again as before a start: expiry with adapter.invocation_token_leeway (09 4.5 point 5), the actual mode and tool class (points 8 and 10), the run of the turn, trigger_kind followup, and the thread and author sub of the message read with getMessage; signature, iss, aud, the payload's run, thread and message and the replay check ran at acceptance in the SDK. Outcomes: write; hold while the turn has not started; skip (not written, not acknowledged, the turn goes on, the follow-up stays queued and the server defers it); duplicate for a message already written. A broken follow-up token never ends the run. |
The expiry bounds how old the server's rights decision behind the follow-up can be (agent.invocation_token_ttl); a follow-up starts nothing, so 4.5.1 does not apply; 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-M2-35-2 |
2026-10-24 |
The text of a follow-up is read with getMessage (message.read) and written as an instruction, not as marked data, behind the lead-in Follow-up from <display_name> (@<handle>): and a blank line; 404, deleted_at or an empty content_md writes nothing (followup_no_text). The description of AgentFollowupPayload.message_id names getMessage. The runner prompt says that such lines are requests of people of the thread. |
run.context.read carries no follow-up of the running run (M2-13); a valid follow-up token proves the server checked agent.invoke for its author (04 15.4 point 2), whose trust is trigger or authorized (09 4.6, ADR 0054); the lead-in keeps a text that starts with / from becoming a CLI command. |
| D-M2-35-3 |
2026-10-24 |
A follow-up of a run whose turn has not started (inbox, lock, slot, pre-check, or between the processes of a recovery) is held and written right after the prompt line of the next turn process, in event order and with the checks of D-M2-35-1 at that moment; a follow-up written to a process that failed without echo is written again with its original uuid into the next process and stays delivered; an adopted turn takes no follow-up. The SDK hands a follow-up of a held, not started run to on_followup. |
Its stdin ended with the old runner; the original uuid keeps the echo mapped; the SDK dropped such follow-ups before. |
| D-M2-35-4 |
2026-10-24 |
The runner closes stdin only by the rule of 09 18.5: at a result without written follow-ups lacking an echo; else after their echoes or runner.followup_grace after that result. A result with an error closes stdin at once. Every result without error posts one answer; the cost of the run comes from its last result; the run finishes once, after the process exited. |
Each result answers another input; the values of a session are cumulative (18.7); a failed turn absorbs nothing more, and its follow-ups go to the next process of a recovery. |
| D-M2-35-5 |
2026-10-24 |
A stop sends SIGINT to the group and closes stdin, escalates to SIGTERM after runner.stop_grace and SIGKILL after runner.kill_grace, and finishes cancelled with the request's reason exactly as received only when no process of the group is left, with the costs of the last result if one came and without an answer; a second request changes nothing, and after a stop no restart, compaction or follow-up write starts. |
With --input-format stream-json SIGINT ends the model turn and the closed stdin makes the process end after its result; a stop means "nothing more" (R-AG-10) and the box keeps the last text. |
| D-M2-35-6 |
2026-10-24 |
A run the server ended while its turn runs (409 conflict of an ended run on a patch, or 401 unauthenticated on any call of the run) has its turn stopped as in D-M2-35-5 and is not finished; bothy_sdk.runs.is_run_ended counts 401 unauthenticated (not use_dpop_nonce) as an ended run. |
The server has decided the end (agent disabled, 09 2.1, or another end); a finish would be refused. |
| D-M2-35-7 |
2026-10-24 |
Classification order of a process end: a requested stop; a result with subtype success and no is_error; the first entry of ERROR_TEXTS whose fragment occurs in its channel (result text and errors, the text of the last API error the CLI writes as an assistant message with the stream-json member is_api_error_message and no parent_tool_use_id, stderr), where session_missing and session_exists count only before system/init; otherwise runtime_error with the errors of the last result. The fragments come from the files of the pinned CLI 2.1.259 (npm package of the platform, retrieved 2026-10-24): Prompt is too long (assistant), No conversation found with session ID (stderr), is already in use (stderr), Not logged in and Invalid API key (assistant). stderr is only matched, never reported. |
The order keeps a stop and a success from being read as an error; a session text after system/init is not about the session flag; stderr can echo content. |
| D-M2-35-8 |
2026-10-24 |
/compact is available in print mode of the pinned version (the command compact of 2.1.259 has supportsNonInteractive: true; the Agent SDK page on slash commands sends it as a prompt). At the first full context of a run: a compaction process with --resume, the single input line /compact (as a user line) and stdin closed, its output never mapped or posted; after its result without error a retry with --resume, the first input line and the follow-ups without echo, and RETRY_HINT appended to the runner prompt when the failed turn had emitted an assistant message after system/init that was not an API error. A failed compaction or a second full context ends runtime_error. Owner decision: check again with the next pinned version. |
09 18.9 row 1; no work runs twice without the model being told; an API error message is no work. |
| D-M2-35-9 |
2026-10-24 |
session_missing (modes resume and bind, first time): a new UUID, stored before its process starts, the session lock moves to it, the thread lock stays; --session-id <new> with the same first line; after its system/init one report with the new reference, started = true, bind_failed = session_missing only in mode bind, If-Match the version of the row (one retry with current after 412); box note session_missing with the mode when the process starts. session_exists (mode new, first time): --resume <ref> and the report of 18.4. Each at most once per run; a second occurrence or a failure of the new process before system/init ends runtime_error; every further process passes the pre-check and the counter-check. |
09 9.5 point 4, AG-18 runner side; the restarts happen before system/init, so no work runs twice (R-AG-17). |
| D-M2-35-10 |
2026-10-24 |
A box note is a step of kind status with name context_compacted, session_missing or runtime_error, an empty title, status completed or failed, finished_at set and a UUIDv7 ID, added with one add /steps/<id> after the pending patches; runtime_error carries {"errors": [...], "exit": ...} and goes out before finishRun. |
Clients translate the code (M4-21) like the server's steps_summarized; patches after the end are refused (M2-10). |
| D-M2-35-11 |
2026-10-24 |
The turn record holds the process identity (ps -ww -o lstart= -o command= at the spawn) and is written atomically before the action that depends on it; a stored PID or group is signalled only when os.getpgid(pid) equals the stored group and ps gives the stored start time and a command with the session reference, else the process counts as gone. At start, before the first inbox event and heartbeat: a live process keeps its locks through its inherited descriptors and the runner takes none; for a gone process it takes both locks with one non-blocking attempt (adoption_lock_busy keeps the record). Then by getRun: adopt (live, running), stop without finish (live, ended), finish from the log (gone, result after the offset), or leave it to the server (gone, no result); adopted runs join active_runs through RuntimeStatus.active_runs; a cancel request of an adopted run stops it. |
A restart never signals a foreign process; the outcome of a turn that died without result is unknown, so the server ends it interrupted (09 5.5, AG-10). |
| D-M2-35-12 |
2026-10-24 |
SDK run loop: a handler that overrides AgentHandler.on_cancel(run, reason) gets the stop of a started run and finishes it itself (the adapter cancels nothing and sends no finishRun); a run that has not started is never started and is finished cancelled with the request's reason; a second request for the same run changes nothing. The FakeServer answers finishRun of a run it already ended with the repeated final state. |
11 11.2 module runs "cancel callback"; 09 8 point 3; M2-09 accepts cancelled from queued and answers 200 for a run already cancelled. |
| D-M2-35-13 |
2026-10-24 |
The crash scenarios run in tests/scenarios/test_crash.py with the runner as a process of its own (python -m claude_code_runner run) and a FakeServer clock that follows the real time; the switch test is tests/real_cli/test_pinned_cli.py behind CLAUDE_CODE_RUNNER_REAL_CLI, the switch of M2-34. |
The brief names tests/cli/ and test_error_paths.py; M2-34's layout and switch win (Boundaries), and a killed runner process needs a clock both runners agree on. |
| D-M2-35-14 |
2026-10-24 |
The scenario tests read log lines through a handler of their own on the root logger; caplog stayed empty for the async scenarios, so the log check of the M2-34 secret scenario read no line. |
A log check that sees nothing proves nothing; it now asserts that lines were seen. |
| D-M2-35-15 |
2026-10-24 |
Fixtures of this ticket are constructed (marked in their fixture_meta), with the error texts from the files of the pinned CLI; a recording needs a paid run behind level two, which this ticket does not make. |
RK-14; no model call without the owner's decision. |
| D-M2-35-16 |
2026-10-24 |
The turn runner moved from app.py into runturn.py (RunState, RunTurn); turn.terminate uses stop.stop_group, so the end of a turn, a stop and the stop of an adopted turn share one escalation. |
One path for the escalation and its log code; app.py keeps the handler. |
| D-M2-35-17 |
2026-10-24 |
A stop that arrives while a further process of a recovery is being prepared (its pre-check) or started ends the run cancelled without starting that process, or stops the process as soon as it runs; no answer is posted after a stop request, also not for a result that raced it; an adopted compaction process is never mapped into the run document; the identity of a new process is read off the event loop. |
Review finding over the first build: a stop during the restart's pre-check was lost and the compaction and retry still ran (note 6 d). |
| D-M2-35-18 |
2026-10-24 |
The channel assistant of ERROR_TEXTS is only the text of an API error the CLI writes as an assistant message with the stream-json member is_api_error_message and no parent_tool_use_id (failures.api_error_text), never the model's own text. |
Red team: a model that quoted "Prompt is too long" or "Not logged in" triggered a compaction and a second run of the prompt, or a false runtime_not_authenticated. |
| D-M2-35-19 |
2026-10-24 |
A process of a recovery gets the inputs of the failed turn process that no result answered (which inputs a result answers is D-M2-35-28 rule 3 since 2026-10-25; before: a success result answers the inputs it names in user_message_uuids or user_message_uuid and every input written before the last named one, so a follow-up the CLI folded into the running turn counts as answered; a result without them answers no input once a result of the run named one, and the first unanswered input only while none did (D-M2-35-26); followups.answered_inputs): the prompt line only while it was not answered, then the follow-ups after it, each with its uuid; when every input was answered, the run completes after the compaction without a retry. |
Red team: a full context while a follow-up ran sent the answered prompt again and dropped the follow-up; note 8 b's "first input line of the failed turn" read as the input that failed. |
| D-M2-35-20 |
2026-10-24 |
The follow of an adopted turn ends with its leader, not with its group; afterwards a job of the group is stopped as in D-M2-35-5 when the group matched its record after the restart (AdoptedGroup.verified: a process group ID is not reused while the group exists), and never otherwise; finishing from the log never waits for leftover jobs; an orderly stop of the runner keeps the records of adopted turns. |
Red team: a leftover job kept an adopted run from being stopped or finished and blocked the start of the runner; a deploy lost the record of an adopted turn. |
| D-M2-35-21 |
2026-10-24 |
The SDK decides "running here" and "not started" for a follow-up from one look at the run before it closes the entry; stop_group closes stdin in a worker thread while the escalation goes on. |
Red team: a follow-up during startRun was acknowledged and lost; a stop during a blocked stdin write froze the event loop and skipped the escalation. |
| D-M2-35-22 |
2026-10-24 |
The cost baseline of a bound session (M8-06) is kept with the reference of its row: it applies only while the run works on that reference, so a session that replaced it after session_missing books its full amounts, and the turn record carries it, so an adopted run and the runs after it subtract it once. |
Red team: an adopted first run after an import booked the old amount again, and a replacing session inherited the import's baseline and booked nothing. |
| D-M2-35-23 |
2026-10-24 |
The turn record keeps the live box's map of step ids (tool calls by tool_use_id, tasks by task_id, ended steps) and its counters, written whenever a step is added or ended and before its patch goes out; an adopting runner restores it, so a line read again reuses its step id and a result ends the step its call opened. |
Review: after a crash between a tool call and its result, the adopted run left the call's step running and added a second step for the same call. |
| D-M2-35-24 |
2026-10-24 |
An adopting runner restores from the turn record only the step ids the server's run has (from getRun) and only the ends the server shows as final; the stored offset moves only after the flush of the patches before it succeeded. A step or end whose patch never went out is then sent again when its line is read again. |
Own test run: the record kept a tool call's step id before its patch went out; after a crash in that window the adopted run skipped the call as known and the server never got the step. |
| D-M2-35-25 |
2026-10-24 |
A compaction process counts as successful only when it wrote system/compact_boundary; a success result without one ends the run runtime_error like any failed compaction. |
Red team: the pinned CLI 2.1.259 ends a failed /compact with a success result (num_turns 0, text "Compaction failed"); the retry then reused the input's uuid, which the CLI skipped as a duplicate, and the run hung. |
| D-M2-35-26 |
2026-10-24 |
(Replaced by D-M2-35-28 rule 3.) Once a result of the run named one of its inputs (user_message_uuids, user_message_uuid), a success result that names none answers no input (a synthetic turn such as a background task's notification); the positional rule stays only for a CLI that never names inputs. |
Red team: a notification turn's result marked a queued follow-up as answered, and a full context in that follow-up's own turn then completed the run without it. |
| D-M2-35-27 |
2026-10-24 |
A session the runner creates itself (session_missing) gets a usage.json with a remaining baseline of 0 before its reference is reported, so a bound row's cost_baseline_micros that the server keeps across the change of reference never applies to it, also when its first run ended without a result. The adopted path moves the stored offset only after a successful flush, like an own turn. |
Review and red team: the next run of such a session booked nothing up to the import's baseline; a second crash of an adopting runner lost a tool step whose patch had not gone out. |
| D-M2-35-28 |
2026-10-25 |
Recovery invariants of a turn (brief design note 12 f), each held at one place, the turn ledger (ledger.TurnLedger) whose state is part of the turn record, for an own and an adopted turn alike: (1) the stored offset moves only after the patches of the lines before it went out and never past the run's end evidence (the last result read and an API error message before it) until the run is finished; (2) each result posts at most one answer, with a client_msg_id stored before the reply goes to the outbox and sent again unchanged when the line is read again, and the costs are booked only by the finish from the run's last result (its usage kept in the record across the processes of a recovery), with the decided end stored before the costs go into the usage file and repeated by a runner that finds it; (3) an input line counts as answered when a success result names its uuid or when its echo came before a success result (no positional rule; this replaces the result-only rules of D-M2-35-19 and D-M2-35-26), a success result ends a process well only while no input taken after it is without an answer (else runtime_error), and an input enters the ledger and a follow-up the record before its line is written, so an adopting runner patches its echo absorbed; (4) a process keeps the kind it started with, also when adopted. Outbox.send_message takes a client_msg_id; an entry with that key already stored is returned (sent again while pending) instead of added twice. |
Owner ruling 2026-10-25 (no more spot fixes): three delta red teams found crash regressions of the same class (an offset past a result already read, a folded follow-up answered twice or lost, an adopted compaction rewritten as a turn, a reply posted twice after a crash); a crash-point test enumerates every first and second crash point over the scenarios and found three more holes of the class (a follow-up stuck delivered, the costs of an earlier process lost, a run left with its costs consumed). |