Expand description
PersistentSubprocessController: manages agent CLI as a long-running process with bidirectional NDJSON streaming via stdin/stdout.
Architecture: A single CLI process is spawned on first message and kept alive for the entire session. User messages are written as NDJSON lines to stdin without closing it. This enables mid-turn input (redirecting the agent while it is still processing).
State machine: INIT ─(first message)─> RUNNING ─(idle between turns)─> RUNNING RUNNING ─(kill/stop)─> DONE RUNNING ─(process crash)─> DONE (auto-restart possible via session_id)
I/O model (3 async tasks per session):
- stdin_writer: mpsc channel → process stdin (NDJSON lines)
- stdout_reader: process stdout → .jsonl persistence + MPS blockfile events
- process_waiter: wait for exit, update status
Module layout (split 2026-09-22; one impl PersistentSubprocessController
block per file, all private methods pub(super)):
mod.rs— types, shared helpers, construction,Controllerimplstatus.rs— runtime status snapshot/publication and heartbeateager_resume.rs— spawn-on-construction when a session id is on filequeue.rs—send_message, spawn claim, queue drain/replayresume_retry.rs— stale---resumerecovery effects and retry batchinput.rs— user messages, question answers, tool decisions, stdinspawn.rs—spawn_process: builds the command, spawns, wires the tasksstderr_reader.rs,stdout_reader.rs,process_waiter.rs— the bodies of those per-session I/O taskslifecycle.rs— stop/restart,session_id,needs_spawntests/— one file per former inline test module
Modules§
- eager_
resume 🔒 - Eager resume: spawning the CLI on controller construction (before the first message) when a prior session id is on file, so the first turn is not charged the full cold-start.
- input 🔒
- User-facing input surface: user messages, AskUserQuestion answers/denials, tool-permission decisions, raw stdin, and inbound control frames.
- lifecycle 🔒
- Stop/restart and session-identity queries:
stop_process, the kill request path,session_id,needs_spawn, and the deferred-restart flag. - process_
waiter 🔒 - The persistent CLI’s process waiter task, started by
spawn_process(spawn.rs): thechild.wait()arm (the process exited on its own) and the kill arm (a stop or restart asked for it). - queue 🔒
- Message intake and the spawn-claim/queue-drain protocol:
send_messageand everything that decides whether a message is written to stdin now, queued behind an in-flight spawn, or replayed after one. - resume_
retry 🔒 - Stale-
--resumerecovery: executing the effectspersistent_resumedecides, locating a recoverable session id, and the retry-batch rewrite. - segments 🔒
- This controller’s side of the segment index
(
backend::continuity_segments): each spawn is one segment of its agent’s conversation. Best-effort throughout: a failed write is logged and never affects the process. - spawn 🔒
spawn_process: builds the CLI argv/env, spawns the child, and starts the per-session I/O tasks (stdin writer, stdout reader, stderr reader, waiter). The stdin writer runs inline; the other three bodies live instderr_reader.rs,stdout_reader.rsandprocess_waiter.rs.- status 🔒
- Runtime status: the
BlockControllerRuntimeStatussnapshot, its publication to the frontend, the status heartbeat, and turn-activity marking. - stderr_
reader 🔒 - The persistent CLI’s stderr reader task, started by
spawn_process(spawn.rs): logs every line and reacts to a stale--resume. - stdout_
reader 🔒 - The persistent CLI’s stdout reader task, started by
spawn_process(spawn.rs): control frames, session-id capture, resume-state events, failure classification, the task feed and the output append.
Structs§
- Deferred
Drop 🔒Report - The last line of defence for §4.5: whatever is still deferred when the controller goes away is reported, not silently discarded with it.
- Persistent
Inner 🔒 - Inner state protected by mutex.
- Persistent
Spawn Config - Configuration for spawning the persistent process.
PartialEqis derived forpersistent_resume::RetryPayload’s own derive and its exhaustive unit tests — not used elsewhere in this file itself. - Persistent
Subprocess Controller - PersistentSubprocessController keeps a long-running CLI process alive, sending user messages as NDJSON lines on stdin.
- Queued
Message 🔒 - A single entry in
pending_send_messages: the formatted stdin JSON payload plus whether it’s already been persisted to the blockfile. codex P2 on PR #2360 (sixth review pass, round 7): a stale-resume retry’s redelivery has already been correctly persisted on its original (failed) attempt — seeretry_after_resume_failure’s own doc comment — but the drain used to persist EVERY message it delivers unconditionally, double-persisting a replayed retry batch into the blockfile transcript.
Enums§
- Eager
Resume 🔒Outcome - Result of
try_eager_resume— seestart()’s doc comment.DeclinedTocarries a short, non-exhaustive reason for the log line; it is not meant to be pattern-matched on by callers, just read. - Kill
Request 🔒 - What the process-waiter task’s kill arm is asked to do.
- Queue
Drain 🔒Claim - Which exclusivity claim a queue-drain task runs under — the spawn
claim (
spawning_in_progress, the post-spawn drain) or the retry flush claim (drain_claim, issue #2367). The task must release exactly the claim its initiator took, and nothing else. - Retry
Batch 🔒Action - What
decide_retry_batch_actiondetermined a stale-resume retry batch’s fate should be (issue #2367; spec §4). Split fromSendActionbecause the batch’s live-process outcome is not a direct delivery: the batch goes through the queue under the drain claim, never through a caller’s owntry_send. - Send
Action 🔒 - What
decide_send_actiondetermined a message’s fate should be — see its own doc comment andPersistentInner::spawning_in_progress. - Watchdog
Step 🔒 - Whether the deferred-delivery watchdog keeps running after a tick.
Constants§
- ANSWER_
RESUME_ 🔒FALLBACK_ MS - How long to wait after delivering an AskUserQuestion answer before assuming
the turn did not resume and re-delivering the answer as a follow-up message.
See
answer_questionand SPEC_ASK_USER_QUESTION_2026_06_15.md §10.1. - ASK_
USER_ 🔒QUESTION_ DENY_ MESSAGE - Fixed, server-owned decline message for a cancelled AskUserQuestion (the
Cancel button / Escape in
AgentQuestionPanel.tsx) — not client-suppliable, since Cancel and Escape both mean exactly one thing and there is no user- authored content to carry. Seedeny_questionand docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md. - BLOCK_
CONTROLLER_ PERSISTENT - CONTINUATION_
TAIL_ 🔒BYTES - How much of a transcript’s tail
find_continuation_session_idreads. The provider stamps its session id on every stream event, so the last few events suffice; a long-lived agent’s transcript runs to many MB. - DEFERRED_
ORPHAN_ 🔒GRACE_ TICKS - Consecutive watchdog ticks with no process and no spawn in flight before
the queue is declared stranded and reported (spec §4.5). Long enough to
ride out the gap between a process exit and the respawn that follows it
(a stale-
--resumeretry, a fallback respawn); short enough that a dead agent’s backlog is surfaced rather than held forever. - DEFERRED_
WATCHDOG_ 🔒TICK - How often the deferred-delivery watchdog re-checks a non-empty queue. Messages are queued only while the process cannot take a write (starting up, restarting, stopping), and the watchdog is what writes them once it can, so this bounds how long after the process comes up they arrive.
- MAX_
DEFERRED_ 🔒DELIVERIES - Bound on
PersistentInner::deferred_deliveries. Past this, enqueueing returns an error instead of a false success, so a caller is never told a message was delivered and then has it silently dropped (SPEC_NO_MIDTURN_DELIVERY_2026_09_23.md§4.5). Senders already handle transient delivery failure — the reactive handler, muxbus and the bridges all have to cope with an unreachable target instance — so surfacing a full queue as one more retryable failure needs no new machinery on their side. - PERSISTENT_
OUTPUT_ SUBJECT - MPS file subject name for persistent subprocess output.
Functions§
- build_
answer_ 🔒resume_ message - Compose the directive follow-up message used by the AskUserQuestion dead-air
fallback.
answersmaps each question’s text to the selected label(s) or free text (the same object delivered in the control_response). The message is deliberately directive so the model resumes the task instead of treating it as a no-op (the “user sent an empty message” failure mode). - build_
deny_ 🔒resume_ message - Compose the directive follow-up message used by the AskUserQuestion dead-air
fallback when the answer was a DECLINE rather than a real answer. Mirrors
build_answer_resume_message’s directive tone (resume the task, don’t wait for further input) but tells the model the outcome was a decline so it doesn’t hallucinate a value the user never provided. - build_
tool_ 🔒decision_ resume_ message - Compose the directive follow-up message used by the tool-permission
dead-air fallback (mirrors
build_answer_resume_message/build_deny_resume_message’s directive tone — resume the task, don’t wait for further input — but for an ordinary tool-permission decision rather than an AskUserQuestion answer). - classify_
exit_ 🔒line - Classify a flushed error line, if it’s confident enough to surface to
the pane’s failure-recovery UI.
lineis usually the same result-frame JSON the mid-stream error-result path handles, butpersistent_resume::ResumeEffect::FlushErrorLinecan also carry an earlier held-back turn’s line — falls back to raw-text keyword matching (the sameclassify()uses for a stderr tail) if it doesn’t parse as JSON.exit_codeisSomeat the process-exit arm andNoneat the mid-generation flush sites (a superseded spawn generation, or aSessionCapturedresolution), where no fresh exit code exists for the line being flushed. ReturnsNonefor an unrecognized, non-retryableFailureClass::UnknownNonZeroto avoid a low-confidence banner from noisy flushed text — every other recognized class is surfaced, not just the retryable ones, since the recovery banner has value for e.g.Authtoo (seeSPEC_AGENT_FAILURE_RECOVERY_UI_2026_06_16.md’s per-class action matrix), just without auto-retry. SeeSPEC_PERSISTENT_CONTROLLER_FAILURE_CLASSIFICATION_2026_08_04.md. - fresh_
start_ 🔒needs_ disclosure - Should a spawn that attached NO
--resumedisclose itself as a fresh start (subject to the caller also confirming prior history actually exists — seehas_prior_transcript)? - held_
elsewhere_ 🔒error - The one spawn refusal that is a decision, not a failure:
--resume <sid>was asked for while another pane still holds (or is still closing) that same conversation —session_held_elsewhere. Built here so thatis_held_elsewhere_erroris checked against the same textspawn_processactually returns, rather than a second copy that could drift;tests/reopen_guard.rspins the wording users see. - interrupt_
control_ 🔒request_ line - The Claude control-protocol interrupt
(docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md). Ends the turn in
progress — measured at ~2s, with
result: error_during_execution— while leaving the process and session alive (pane-close spec §5.1). - is_
held_ 🔒elsewhere_ error - Did
spawn_processrefuse because ofsession_held_elsewhere? The answer decides whether a caller may fall back to a FRESH spawn: for any other failure a blank session is a reasonable recovery, but for this one it silently hands the user’s accepted prompt to a new conversation while the real one is open next door — exactly what the guard exists to prevent (codex P1 on PR #3551). - last_
session_ 🔒id_ in_ stream - The last non-empty
fieldon a complete JSON line oftail.starts_mid_line:tailwas cut from a longer file, so its first line is a fragment and is dropped. - muxbus_
agent_ 🔒id_ from_ env - Resolve the muxbus address (the agent’s display name) from a spawn env map.
AGENTMUX_AGENT_ID(=agent.name, set at block creation) is canonical;WAVEMUX_AGENT_IDis the legacy fallback. ReturnsNone— i.e. not muxbus-addressable — when neither is present (a non-agent persistent block). - next_
registration_ 🔒nonce - Draws the next process-wide registration nonce (≥ 1) for a persistent
spawn’s muxbus/registry registrations — see the doc comment at the
my_registration_noncebinding inspawn_processfor why this is a srv-wide counter rather than the controller-local spawn generation (codex P1 on PR #2500: generations restart per controller instance and can collide across aresync_controllerreplacement). - park_
tool_ 🔒permission_ request - Park an ordinary (non-AskUserQuestion) tool-permission
can_use_toolcontrol_request intoPersistentInner::pending_permissions, mirroring how AskUserQuestion is parked intopending_questionstwo branches up inhandle_control_frame— same in-memory-only, per-controller-instance lifetime, and the same “process respawned” failure mode on decide (PersistentSubprocessController::decide_tool_permission’s error text explains it the same wayanswer_question’s does). - publish_
resume_ 🔒retry_ status - Publish a
mps::EVENT_AGENT_RESUME_RETRYstatus ping — a free function (not a method) for the same reasonsession_outcome_lineabove is one: callable from the stdout-reader/process-waiter match arms, which only hold_read/_wait-suffixed clones, not&self.statusis"retrying"(set when a stale--resumeis detected and a retry/recovery attempt is about to fire) or"resolved"(set the moment the retry’s outcome — Fresh or Resumed — is actually known). Seedocs/status/STATUS_STALE_RESUME_LIVE_REPRO_AND_FIX_PLAN_2026_08_23.md§6.2. No-ops ifbrokerisNone(tests / non-wired controllers), same posture as every other best-effort MPS publish in this file. - session_
outcome_ 🔒line - Builds the NDJSON line for a
persistent_resume::ResumeEffect:: EmitSessionOutcome— a free function (not a method) so it’s callable directly from the stdout-reader, process-waiter, and stop-path match arms below, which only hold_read/_wait-suffixed clones, not&self. See SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md §2.1. - session_
outcome_ 🔒line_ with session_outcome_line, plus whether the fresh session was given AgentMux’s record of the conversation (continued, SPEC_DURABLE_ CONVERSATION_MEMORY_2026_09_23.md §4.8). The outcome staysfresh: the provider session is new, and every consumer that scopes scrollback onfreshkeeps doing so. Only the pane’s label changes.- should_
route_ 🔒to_ decision_ panel - Whether a
can_use_toolcontrol_request fortool_name(anything except AskUserQuestion, which is always parked separately — seehandle_control_frame) should be routed to the frontend decision panel instead of auto-allowed. - surface_
error_ 🔒line - Classify a final error line with
classify_exit_lineand, if it is a failure,surface_failureit. Returns whether it was surfaced. - surface_
failure 🔒 - Record
failureas the block’sagent:last_failure, then publish it asagent-failureonblock:<id>(persisted for replay) when there is a broker.