From a0fef3b3984a0fdbc63ed2e6d53792503ac63c2b Mon Sep 17 00:00:00 2001 From: Jared Loman Date: Tue, 14 Jul 2026 07:02:22 -1000 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20spawn=5Fagent=20boot=20hardening=20?= =?UTF-8?q?=E2=80=94=20verify=20prompt=20delivery,=20report=20model=20mism?= =?UTF-8?q?atch,=20truthful=20ready/error=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - screen-parser.ts: stop misclassifying idle Claude sessions as "working" (the persistent "bypass permissions on" footer was wrongly used as a workingMarker); extract isSubmitVerifiedStatus/screenShowsPendingInput as shared exports for delivery verification. - agent-engine.ts: new maybeAdvanceBootingAgent(), run from the sweep alongside maybeCaptureBootSessionId(). Promotes booting -> ready once the CLI screen is actually interactive (previously never happened for engine-spawned agents); delivers the stored task_summary exactly once on that transition and verifies submission landed; computes parsed_model/model_mismatch against the requested model; times out stuck boots into a truthful "error" state instead of hanging in "booting" forever. Guards against touching auto-discovered ("auto-") records, which are synced by a different path. - agent-types.ts/agent-facade.ts/state-manager.ts: plumb the new submit_verified/prompt_delivered/parsed_model/model_mismatch fields through AgentRecord, PublicAgent projection, and ensureAutoRecord's explicit "already settled" defaults for auto-discovered agents. - Tests: regression test for the idle-vs-working misclassification, and 5 new tests covering ready-promotion + prompt delivery, model-mismatch detection, stuck-boot timeout, in-window no-op, and the auto-discovered skip guard. package-lock.json: incidental npm/bun sync — package.json already declared cmuxlayer-app-server/transform-tty, lockfile was stale before this session's install/build/test runs corrected it. --- src/agent-engine.ts | 177 ++++++++++++++++++++----------- src/agent-facade.ts | 2 + src/agent-types.ts | 9 ++ src/screen-parser.ts | 9 ++ src/server.ts | 13 ++- src/state-manager.ts | 4 + tests/agent-engine.test.ts | 153 +++++++++++++++++++++++++- tests/agent-facade.test.ts | 18 ++++ tests/screen-parser.test.ts | 26 +++++ tests/server-agent-tools.test.ts | 2 + 10 files changed, 348 insertions(+), 65 deletions(-) diff --git a/src/agent-engine.ts b/src/agent-engine.ts index fc607a4f..ea74d25a 100644 --- a/src/agent-engine.ts +++ b/src/agent-engine.ts @@ -403,6 +403,7 @@ const DEFAULT_STOP_POST_CONDITION_TIMEOUT_MS = 1_000; const STOP_POST_CONDITION_POLL_MS = 50; const BOOT_SESSION_CAPTURE_LINES = 80; const MAX_DEFERRED_TRANSCRIPT_CAPTURE_ATTEMPTS = 3; +const BOOT_READY_TIMEOUT_MS = 45_000; const BOOT_PROMPT_PENDING_STALE_MS = 5 * 60_000; const TASK_DONE_CONFIRMATION_MS = 5_000; const DONE_QUIESCENCE_MS = 1_500; @@ -441,6 +442,20 @@ function toParsedScreenStatus( } } +/** + * Loosely compare the requested model with the model reported by the live CLI. + * A missing side is unknown rather than a match. + */ +export function computeModelMismatch( + requestedModel: string, + parsedModel: string | null, +): boolean | null { + const requested = requestedModel.toLowerCase().trim(); + const parsed = parsedModel?.toLowerCase().trim(); + if (!requested || !parsed) return null; + return !parsed.includes(requested) && !requested.includes(parsed); +} + export { buildResumeCommand } from "./agent-command.js"; interface SidebarStatusSnapshot { @@ -1577,9 +1592,21 @@ export class AgentEngine { screen: Promise.resolve(screen), }) : agent; - if (targetState === "ready" && transitionAgent.boot_prompt_pending) { + if (targetState === "ready") { + const parsedModel = parseScreen(screen.text).model; transitionAgent = this.stateMgr.updateRecord(transitionAgent.agent_id, { - boot_prompt_pending: false, + parsed_model: parsedModel, + model_mismatch: computeModelMismatch( + transitionAgent.model, + parsedModel, + ), + ...(transitionAgent.boot_prompt_pending + ? { + boot_prompt_pending: false, + prompt_delivered: true, + submit_verified: true, + } + : {}), }); this.registry.set(transitionAgent.agent_id, transitionAgent); } @@ -2552,74 +2579,88 @@ export class AgentEngine { this.readyPatternMatches.delete(agent.agent_id); return agent; } - if (agent.boot_prompt_pending) { - try { - const screen = await this.readSweepScreen(agent, ctx); - const evidence = this.readReadyEvidence(agent, screen.text); - if ( - (evidence.ready || evidence.activeCodex) && - (evidence.activeCodex || - !this.screenShowsPendingBootPrompt(agent, screen.text)) - ) { - const count = (this.readyPatternMatches.get(agent.agent_id) ?? 0) + 1; - this.readyPatternMatches.set(agent.agent_id, count); - if (count < Math.max(1, evidence.consecutive)) { - return agent; - } + if (agent.agent_id.startsWith("auto-")) { + return agent; + } + + try { + const screen = await this.readSweepScreen(agent, ctx); + const parsed = parseScreen(screen.text); + const settlement = { + parsed_model: parsed.model, + model_mismatch: computeModelMismatch(agent.model, parsed.model), + }; + const evidence = this.readReadyEvidence(agent, screen.text); + const promptStillPending = + agent.boot_prompt_pending === true && + !evidence.activeCodex && + this.screenShowsPendingBootPrompt(agent, screen.text); - this.stateMgr.updateRecord(agent.agent_id, { + if (agent.boot_prompt_pending && promptStillPending) { + this.readyPatternMatches.delete(agent.agent_id); + if (this.isBootPromptPendingStale(agent)) { + const failedSettlement = this.stateMgr.updateRecord(agent.agent_id, { + ...settlement, boot_prompt_pending: false, + prompt_delivered: false, + submit_verified: false, }); - let ready = this.stateMgr.transition(agent.agent_id, "ready", { - error: null, + const failed = this.stateMgr.transition( + failedSettlement.agent_id, + "error", + { + error: + "Boot prompt delivery was not verified before the pending-input timeout", + }, + ); + this.registry.set(agent.agent_id, failed); + return failed; + } + if ( + agent.submit_verified !== false || + agent.prompt_delivered !== false || + agent.parsed_model !== settlement.parsed_model || + agent.model_mismatch !== settlement.model_mismatch + ) { + const pending = this.stateMgr.updateRecord(agent.agent_id, { + ...settlement, + prompt_delivered: false, + submit_verified: false, }); - if ( - ready.quality === "degraded" && - agent.error?.startsWith("Post-spawn liveness failed:") - ) { - ready = this.stateMgr.updateRecord(agent.agent_id, { - quality: "unknown", - }); - } - this.registry.set(agent.agent_id, ready); - this.readyPatternMatches.delete(agent.agent_id); - return ready; + this.registry.set(agent.agent_id, pending); + return pending; } - this.readyPatternMatches.delete(agent.agent_id); - } catch { - // Fall through to the explicit interrupted-delivery error below. - } - - const since = Date.parse(agent.updated_at); - if ( - !Number.isNaN(since) && - Date.now() - since < BOOT_PROMPT_PENDING_STALE_MS - ) { return agent; } - try { - this.stateMgr.updateRecord(agent.agent_id, { - boot_prompt_pending: false, - }); - const surfaceAlive = await this.registry.isSurfaceAlive(agent); - const reconciled = surfaceAlive - ? this.stateMgr.transition(agent.agent_id, "ready", { error: null }) - : this.stateMgr.transition(agent.agent_id, "error", { - error: "Boot prompt delivery interrupted before completion", - }); - this.registry.set(agent.agent_id, reconciled); - return reconciled; - } catch { - return agent; - } - } - - try { - const screen = await this.readSweepScreen(agent, ctx); - const evidence = this.readReadyEvidence(agent, screen.text); if (!evidence.ready && !evidence.activeCodex) { this.readyPatternMatches.delete(agent.agent_id); + const since = Date.parse(agent.updated_at); + if ( + !Number.isNaN(since) && + Date.now() - since >= BOOT_READY_TIMEOUT_MS + ) { + const failedSettlement = this.stateMgr.updateRecord(agent.agent_id, { + ...settlement, + ...(agent.boot_prompt_pending + ? { + boot_prompt_pending: false, + prompt_delivered: false, + submit_verified: false, + } + : {}), + }); + const failed = this.stateMgr.transition( + failedSettlement.agent_id, + "error", + { + error: + "Stuck booting — CLI never became interactive within the boot timeout", + }, + ); + this.registry.set(agent.agent_id, failed); + return failed; + } return agent; } @@ -2629,7 +2670,17 @@ export class AgentEngine { return agent; } - let updated = this.stateMgr.transition(agent.agent_id, "ready", { + const settled = this.stateMgr.updateRecord(agent.agent_id, { + ...settlement, + ...(agent.boot_prompt_pending + ? { + boot_prompt_pending: false, + prompt_delivered: true, + submit_verified: true, + } + : {}), + }); + let updated = this.stateMgr.transition(settled.agent_id, "ready", { error: agent.error?.startsWith("Post-spawn liveness failed:") ? null : agent.error, @@ -4683,6 +4734,10 @@ export class AgentEngine { respawn_attempts: 0, user_killed: false, boot_prompt_pending: spawnParams.boot_prompt_pending ?? false, + submit_verified: null, + prompt_delivered: false, + parsed_model: null, + model_mismatch: null, launch_cwd: spawnParams.cwd ?? null, mcp_profile: spawnParams.mcp_profile_label ?? null, worktree_path: spawnParams.cwd ?? null, diff --git a/src/agent-facade.ts b/src/agent-facade.ts index 7480189c..27603602 100644 --- a/src/agent-facade.ts +++ b/src/agent-facade.ts @@ -29,6 +29,8 @@ export function toPublicAgent(record: AgentRecord): PublicAgent { state: record.state, session_id: record.cli_session_id, resumable, + submit_verified: record.submit_verified ?? null, + model_mismatch: record.model_mismatch ?? null, ...(resumeCommand ? { resume_command: resumeCommand } : {}), }; } diff --git a/src/agent-types.ts b/src/agent-types.ts index 530f9dd1..8dabab91 100644 --- a/src/agent-types.ts +++ b/src/agent-types.ts @@ -74,6 +74,13 @@ export interface AgentRecord { user_killed?: boolean; // Boot prompt delivery guard boot_prompt_pending?: boolean; + // Spawn settlement evidence (PR #326): a managed agent must not report + // ready without retaining what was actually observed about prompt delivery + // and the model shown by the CLI. + submit_verified?: boolean | null; + prompt_delivered?: boolean; + parsed_model?: string | null; + model_mismatch?: boolean | null; // File-backed goal contract for superseded/long-running collab tasks goal_file?: string | null; // Launch context for worktree/profile-aware spawns @@ -96,6 +103,8 @@ export interface PublicAgent { session_id: string | null; resumable: boolean; resume_command?: string; + submit_verified?: boolean | null; + model_mismatch?: boolean | null; } export interface AgentRoute { diff --git a/src/screen-parser.ts b/src/screen-parser.ts index 4f89df86..2d8c9702 100644 --- a/src/screen-parser.ts +++ b/src/screen-parser.ts @@ -236,6 +236,11 @@ const GEMINI_WORKING_RE = /^\s*(?:✦\s*)?Working(?:\.\.\.|…)?\s*$/im; const CLAUDE_DONE_LINE_RE = /^\s*[⏺●]\s+Completed(?: successfully)?\s*$/im; const CLAUDE_WORKING_LINE_RE = /^\s*(?:[✻✢✳✶]|[⏺●])\s+(?:Thinking|Working|Running|Receiving|Preparing|Updating|Sending|Reading|Analyzing)\b/im; +// Claude's context-limit/auto-compact banner wording is not stable. A pane +// sitting at one of these blockers must not become "working" merely because +// the same line also contains a busy-looking marker. +const CONTEXT_LIMIT_BANNER_RE = + /\bcontext\s+(?:low|window\s+is\s+almost\s+full|limit\s+reached)\b|\bauto-compact(?:ing)?\b|\bcompacting\s+conversation\b/i; const THINKING_RE = /(?:^|\n)\s*(?:(?:[✻✢✳✶]\s*)?thinking(?:\s+with\s+[a-z-]+\s+effort)?(?:\s*(?:\.{3,}|…))?|(?:Reticulating splines|Perambulating|Cooked|Crunched|Razzmatazzing|Schlepping|Nucleating|Seasoning)(?:\s*(?:\.{3,}|…))?|(?:⬡\s*)?(?:Running|Generating)(?:\s*(?:\.{3,}|…))?\s+[0-9][0-9,]*(?:\.[0-9]+)?[km]?\s+tokens)\s*$/im; @@ -1152,6 +1157,10 @@ function inferStatus( return "frozen"; } + if (CONTEXT_LIMIT_BANNER_RE.test(joined)) { + return "idle"; + } + if (THINKING_RE.test(text)) { return "thinking"; } diff --git a/src/server.ts b/src/server.ts index 2711d85d..d6ff5e5d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8870,17 +8870,25 @@ export function createServer(opts?: CreateServerOptions): McpServer { const updated = stateMgr.updateRecord(result.agent_id, { task_summary: bootPromptDelivery.prompt_text, boot_prompt_pending: false, + prompt_delivered: + bootPromptDelivery.submit_verified === true, + submit_verified: bootPromptDelivery.submit_verified, }); registry.set(result.agent_id, updated); } else { const updated = stateMgr.updateRecord(result.agent_id, { boot_prompt_pending: false, + prompt_delivered: false, + submit_verified: null, }); registry.set(result.agent_id, updated); } const current = engine.getAgentState(result.agent_id); - if (current?.state === "booting") { + if ( + current?.state === "booting" && + bootPromptDelivery.submit_verified === true + ) { const ready = stateMgr.transition(result.agent_id, "ready"); registry.set(result.agent_id, ready); result.state = "ready"; @@ -8898,6 +8906,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { const agentId = record?.agent_id ?? result.agent_id; const updated = stateMgr.updateRecord(agentId, { boot_prompt_pending: false, + prompt_delivered: false, + submit_verified: + e instanceof BootPromptDeliveryError ? false : null, }); registry.set(agentId, updated); result.agent_id = updated.agent_id; diff --git a/src/state-manager.ts b/src/state-manager.ts index 6c6aec9c..905ce639 100644 --- a/src/state-manager.ts +++ b/src/state-manager.ts @@ -632,6 +632,10 @@ export class StateManager { crash_recover: false, respawn_attempts: 0, user_killed: false, + submit_verified: null, + prompt_delivered: true, + parsed_model: discovered.model ?? null, + model_mismatch: null, }; this.writeState(record); return record; diff --git a/tests/agent-engine.test.ts b/tests/agent-engine.test.ts index 33ba18ef..5128d3a3 100644 --- a/tests/agent-engine.test.ts +++ b/tests/agent-engine.test.ts @@ -7482,6 +7482,71 @@ To continue this session, run codex resume ${sessionId}`, expect(result.agent?.state).toBe("booting"); }); + it("does not promote a stale boot prompt that is still sitting in the composer", async () => { + const prompt = "Read and follow docs.local/phase-3.md"; + stateMgr.writeState( + makeRecord({ + agent_id: "agent-stale-unsent-prompt", + state: "booting", + surface_id: "surface:stale-unsent-prompt", + cli: "codex", + boot_prompt_pending: true, + task_summary: prompt, + updated_at: new Date(Date.now() - 6 * 60_000).toISOString(), + }), + ); + liveSurfaces = [makeSurface("surface:stale-unsent-prompt")]; + (mockClient.readScreen as ReturnType).mockResolvedValue({ + surface: "surface:stale-unsent-prompt", + text: [ + "OpenAI Codex", + "Model: gpt-5.5", + "", + `› ${prompt}`, + "gpt-5.5 xhigh · ~/Gits/cmuxlayer", + ].join("\n"), + lines: 80, + scrollback_used: false, + }); + await engine.getRegistry().reconstitute(); + + await engine.runSweep(); + + expect(engine.getAgentState("agent-stale-unsent-prompt")).toMatchObject({ + state: "error", + boot_prompt_pending: false, + submit_verified: false, + prompt_delivered: false, + error: expect.stringMatching(/prompt.*not.*verif/i), + }); + }); + + it("errors a stale booting agent whose CLI never becomes interactive", async () => { + stateMgr.writeState( + makeRecord({ + agent_id: "agent-stale-shell", + state: "booting", + surface_id: "surface:stale-shell", + updated_at: "2020-01-01T00:00:00.000Z", + }), + ); + liveSurfaces = [makeSurface("surface:stale-shell")]; + (mockClient.readScreen as ReturnType).mockResolvedValue({ + surface: "surface:stale-shell", + text: "$ ", + lines: 80, + scrollback_used: false, + }); + await engine.getRegistry().reconstitute(); + + await engine.runSweep(); + + expect(engine.getAgentState("agent-stale-shell")).toMatchObject({ + state: "error", + error: expect.stringMatching(/stuck booting|never became interactive/i), + }); + }); + it("detects state change via sweep", async () => { vi.useFakeTimers(); try { @@ -7540,6 +7605,85 @@ To continue this session, run codex resume ${sessionId}`, }); }); + it("records a model mismatch when the live CLI banner disagrees with the requested model", async () => { + stateMgr.writeState( + makeRecord({ + agent_id: "agent-model-mismatch", + state: "booting", + surface_id: "surface:model-mismatch", + cli: "claude", + model: "opus", + }), + ); + liveSurfaces = [makeSurface("surface:model-mismatch")]; + (mockClient.readScreen as ReturnType).mockResolvedValue({ + surface: "surface:model-mismatch", + text: [ + "Claude Code", + "🤖 Sonnet 4.6", + "❯", + "⏵⏵ bypass permissions on", + ].join("\n"), + lines: 80, + scrollback_used: false, + }); + await engine.getRegistry().reconstitute(); + + await engine.runSweep(); + + expect(engine.getAgentState("agent-model-mismatch")).toMatchObject({ + state: "ready", + parsed_model: "Sonnet 4.6", + model_mismatch: true, + }); + }); + + it("leaves a fresh noninteractive boot record inside its boot window", async () => { + stateMgr.writeState( + makeRecord({ + agent_id: "agent-fresh-shell", + state: "booting", + surface_id: "surface:fresh-shell", + updated_at: new Date().toISOString(), + }), + ); + liveSurfaces = [makeSurface("surface:fresh-shell")]; + (mockClient.readScreen as ReturnType).mockResolvedValue({ + surface: "surface:fresh-shell", + text: "$ ", + lines: 80, + scrollback_used: false, + }); + await engine.getRegistry().reconstitute(); + + await engine.runSweep(); + + expect(engine.getAgentState("agent-fresh-shell")?.state).toBe("booting"); + }); + + it("does not advance an auto-discovered boot record", async () => { + stateMgr.writeState( + makeRecord({ + agent_id: "auto-brainlayer-1", + state: "booting", + surface_id: "surface:auto-boot", + cli: "claude", + }), + ); + liveSurfaces = [makeSurface("surface:auto-boot")]; + (mockClient.readScreen as ReturnType).mockResolvedValue({ + surface: "surface:auto-boot", + text: "Claude Code\n🤖 Sonnet 4.6\n❯", + lines: 80, + scrollback_used: false, + }); + await engine.getRegistry().reconstitute(); + + await engine.runSweep(); + + expect(engine.getAgentState("auto-brainlayer-1")?.state).toBe("booting"); + }); + it("clears stale post-spawn liveness errors when a booting agent reaches ready", async () => { stateMgr.writeState( makeRecord({ @@ -8089,7 +8233,7 @@ To continue this session, run codex resume ${sessionId}`, ); }); - it("RC5: keeps a stale pending boot prompt agent reachable while its surface is alive", async () => { + it("RC5: errors a stale pending boot prompt without readiness evidence even when its surface is alive", async () => { stateMgr.writeState( makeRecord({ agent_id: "agent-boot", @@ -8106,9 +8250,11 @@ To continue this session, run codex resume ${sessionId}`, await engine.runSweep(); expect(engine.getAgentState("agent-boot")).toMatchObject({ - state: "ready", + state: "error", boot_prompt_pending: false, - error: null, + prompt_delivered: false, + submit_verified: false, + error: expect.stringMatching(/stuck booting|never became interactive/i), }); }); @@ -8233,6 +8379,7 @@ To continue this session, run codex resume ${sessionId}`, surface_id: "surface:42", cli: "gemini", task_summary: "", + updated_at: new Date().toISOString(), }), ); liveSurfaces = [makeSurface("surface:42")]; diff --git a/tests/agent-facade.test.ts b/tests/agent-facade.test.ts index 8f6d6546..6f5b4143 100644 --- a/tests/agent-facade.test.ts +++ b/tests/agent-facade.test.ts @@ -45,6 +45,8 @@ describe("agent facade projections", () => { state: "ready", session_id: "session-1", resumable: true, + submit_verified: null, + model_mismatch: null, resume_command: "brainlayerClaude -s --resume session-1", }); expect((projected as any).surface_id).toBeUndefined(); @@ -60,6 +62,22 @@ describe("agent facade projections", () => { state: "ready", session_id: null, resumable: false, + submit_verified: null, + model_mismatch: null, + }); + }); + + it("preserves boot submit verification and model mismatch in the public projection", () => { + const projected = toPublicAgent( + makeRecord({ + submit_verified: false, + model_mismatch: true, + }), + ); + + expect(projected).toMatchObject({ + submit_verified: false, + model_mismatch: true, }); }); }); diff --git a/tests/screen-parser.test.ts b/tests/screen-parser.test.ts index 27cdc696..dd640352 100644 --- a/tests/screen-parser.test.ts +++ b/tests/screen-parser.test.ts @@ -1279,6 +1279,32 @@ I only have 42 tokens expect(parsed.model).toBeNull(); }); + it("does not report working for a session sitting at a context-limit / auto-compact banner (AC4)", () => { + const parsed = parseScreen(` + Context low · Run /compact to compact & continue + +────────────────────────────────────────────────────────────────────────────────────────── +❯ +────────────────────────────────────────────────────────────────────────────────────────── + ⎇ master | +1273,-196 | 🔧 11 418310 tokens + 🤖 Sonnet 4.6 | 💰 $0.10 current: 2.1.81 · latest… + ⏵⏵ bypass permissions on (shift+tab to cycle) +`); + + expect(parsed.agent_type).toBe("claude"); + expect(parsed.status).toBe("idle"); + }); + + it("does not report working for an auto-compacting banner even when it co-occurs with a busy marker", () => { + const parsed = parseScreen(` +⏺ Auto-compacting conversation… (esc to interrupt) + ⏵⏵ bypass permissions on (shift+tab to cycle) +`); + + expect(parsed.agent_type).toBe("claude"); + expect(parsed.status).toBe("idle"); + }); + // --- context_pct and context_window tests --- describe("context_pct computation", () => { diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index 95262684..4ac00c2f 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -2616,6 +2616,8 @@ describe("agent lifecycle tool handlers", () => { const persisted = stateResult.structuredContent ?? JSON.parse(stateResult.content[0].text); expect(persisted.boot_prompt_pending).toBe(false); + expect(persisted.prompt_delivered).toBe(true); + expect(persisted.submit_verified).toBe(true); expect(persisted.task_summary).toBe("probe renamed state"); }); From b038e84d2a93391d30dcabf3187266001010b8ef Mon Sep 17 00:00:00 2001 From: Jared Loman Date: Mon, 3 Aug 2026 19:02:46 +0300 Subject: [PATCH 2/5] fix: persist prompt-delivery/model fields before flipping booting->ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review (SDLC-87) flagged that maybeAdvanceBootingAgent() persisted the "ready" state transition before attempting prompt delivery. Any throw during client.send/sendKey/recheck left the on-disk record "ready" while returning a stale in-memory "booting" snapshot to the caller — the next sweep's "state !== booting" guard would then skip the record forever, silently losing prompt delivery (undermining the whole point of this work item). Reordered so prompt_delivered/submit_verified/parsed_model/model_mismatch are computed and persisted via updateRecord first, while the record is still "booting"; the transition to "ready" is now the final write, only reached once delivery has actually been attempted and recorded. A failure anywhere in the delivery block now leaves the record retry-safely in "booting" instead of stranding it as ready-but-never-prompted. Adds a regression test that forces client.send to reject once and asserts the record stays booting/prompt_delivered-falsy after the failed sweep, then recovers to ready/prompt_delivered:true on the next sweep. Codex's other (low-severity) finding — stuck-boot timeout window widening via updated_at refreshes from maybeCaptureBootSessionId() — is deferred; see .agent/progress.md SDLC-87 session log for rationale. # Conflicts: # src/agent-engine.ts # tests/sidebar-sync.test.ts From 4a659be6e7842949fbcc2c9703420cfb96d961c3 Mon Sep 17 00:00:00 2001 From: Jared Loman Date: Mon, 3 Aug 2026 19:02:50 +0300 Subject: [PATCH 3/5] chore: keep package-lock.json aligned with base (drop unrelated drift) # Conflicts: # package-lock.json From d658ebe9307dd90e4bd3bdff8280de198cda17d8 Mon Sep 17 00:00:00 2001 From: Jared Loman Date: Mon, 3 Aug 2026 19:02:55 +0300 Subject: [PATCH 4/5] fix: AC4 context-limit-banner detection + AC1 submit_verified:false test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-QA (Codex Desktop, independent evaluator) returned FAIL with two gaps: 1. AC4 incomplete: screen-parser.ts handled the idle-prompt case but not a context-limit / auto-compact banner — such a screen still parsed as "working". Added CONTEXT_LIMIT_BANNER_RE (matches several known Claude Code phrasings: "Context low", "Context window is almost full", "auto-compact(ing)", "compacting conversation" — there's no single stable banner string) and check it in inferStatus() ahead of every working/thinking signal, so it can't be shadowed by a co-occurring busy-looking marker like "esc to interrupt" during an in-progress compact. Maps to "idle" (no dedicated "blocked" status exists in ParsedScreenStatus). Two new screen-parser tests: a full banner screen, and a banner co-occurring with "esc to interrupt" to prove priority ordering. 2. AC1 test gap: existing tests only exercised submit_verified: true. Added a sidebar-sync test (STUCK_INPUT_SCREEN fixture: typed text still visible at the prompt post-Enter, so screenShowsPendingInput() is true and status isn't working/thinking/done) asserting submit_verified is surfaced as false — not swallowed to null or coerced to true — on both the persisted AgentRecord and its toPublicAgent() projection, which is what callers like get_agent_state/wait_for actually see. bun run typecheck / build / test all green: 27 files, 462/462 tests (459 prior + 3 new). package-lock.json untouched. # Conflicts: # src/screen-parser.ts # tests/screen-parser.test.ts # tests/sidebar-sync.test.ts From 376e54f8583e357cfdd68fa40d3b01c713e454ae Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 3 Aug 2026 19:08:31 +0300 Subject: [PATCH 5/5] fix: keep undelivered boot prompts pending --- src/agent-engine.ts | 12 +++++- src/agent-registry.ts | 29 ++++++++++---- src/server.ts | 5 ++- tests/agent-engine.test.ts | 69 ++++++++++++++++++++++++++++++++ tests/server-agent-tools.test.ts | 3 ++ tests/sidebar-sync.test.ts | 2 + 6 files changed, 110 insertions(+), 10 deletions(-) diff --git a/src/agent-engine.ts b/src/agent-engine.ts index ea74d25a..f72966f9 100644 --- a/src/agent-engine.ts +++ b/src/agent-engine.ts @@ -1570,8 +1570,14 @@ export class AgentEngine { const evidence = this.readReadyEvidence(agent, screen.text); const hasTargetEvidence = evidence.ready || (targetState === "ready" && evidence.activeCodex); + const awaitingManagedBootPrompt = + targetState === "ready" && + agent.boot_prompt_pending === true && + agent.prompt_delivered === false && + !evidence.activeCodex; if ( !hasTargetEvidence || + awaitingManagedBootPrompt || (targetState === "ready" && !evidence.activeCodex && this.screenShowsPendingBootPrompt(agent, screen.text)) @@ -2595,8 +2601,12 @@ export class AgentEngine { agent.boot_prompt_pending === true && !evidence.activeCodex && this.screenShowsPendingBootPrompt(agent, screen.text); + const awaitingManagedBootPrompt = + agent.boot_prompt_pending === true && + agent.prompt_delivered === false && + !evidence.activeCodex; - if (agent.boot_prompt_pending && promptStillPending) { + if (promptStillPending || awaitingManagedBootPrompt) { this.readyPatternMatches.delete(agent.agent_id); if (this.isBootPromptPendingStale(agent)) { const failedSettlement = this.stateMgr.updateRecord(agent.agent_id, { diff --git a/src/agent-registry.ts b/src/agent-registry.ts index ccfc07ab..f15da29b 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -2162,13 +2162,23 @@ export class AgentRegistry { const bySurface = new Map(discovered.map((entry) => [entry.surface_id, entry])); for (const [id, agent] of [...this.agents.entries()]) { - if (agent.state !== "booting") { + const failedDuringBootReadiness = + agent.state === "error" && + agent.error?.startsWith( + "Stuck booting — CLI never became interactive", + ); + if (agent.state !== "booting" && !failedDuringBootReadiness) { continue; } if (!this.canMutateForObservedAbsence(agent)) { continue; } - const lastUpdated = Date.parse(agent.updated_at); + // The readiness transition stamps updated_at. Keep the original boot + // age when deciding whether an explicit resync may evict that exact + // no-CLI error, otherwise the transition would restart the ghost clock. + const lastUpdated = Date.parse( + failedDuringBootReadiness ? agent.created_at : agent.updated_at, + ); if (Number.isNaN(lastUpdated)) { continue; } @@ -2185,12 +2195,15 @@ export class AgentRegistry { continue; } - try { - this.stateMgr.transition(id, "error", { - error: "Launch failed — no agent detected in surface after boot timeout", - }); - } catch { - // Best-effort transition before eviction. + if (agent.state === "booting") { + try { + this.stateMgr.transition(id, "error", { + error: + "Launch failed — no agent detected in surface after boot timeout", + }); + } catch { + // Best-effort transition before eviction. + } } const removedAgentId = this.deleteAgentAndAliases(id); diff --git a/src/server.ts b/src/server.ts index d6ff5e5d..b0282a90 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8905,7 +8905,10 @@ export function createServer(opts?: CreateServerOptions): McpServer { ); const agentId = record?.agent_id ?? result.agent_id; const updated = stateMgr.updateRecord(agentId, { - boot_prompt_pending: false, + // A readiness timeout happens before delivery. Preserve the + // pending marker so a later idle CLI cannot be mistaken for a + // successfully tasked agent by the lifecycle sweep. + boot_prompt_pending: e instanceof BootPromptTimeoutError, prompt_delivered: false, submit_verified: e instanceof BootPromptDeliveryError ? false : null, diff --git a/tests/agent-engine.test.ts b/tests/agent-engine.test.ts index 5128d3a3..a96c560f 100644 --- a/tests/agent-engine.test.ts +++ b/tests/agent-engine.test.ts @@ -8233,6 +8233,75 @@ To continue this session, run codex resume ${sessionId}`, ); }); + it("does not promote an idle managed pane when its boot prompt was never delivered", async () => { + stateMgr.writeState( + makeRecord({ + agent_id: "agent-undelivered-prompt", + state: "booting", + surface_id: "surface:undelivered-prompt", + cli: "codex", + boot_prompt_pending: true, + task_summary: "Read and follow docs.local/phase-3.md", + prompt_delivered: false, + submit_verified: null, + updated_at: new Date().toISOString(), + }), + ); + liveSurfaces = [makeSurface("surface:undelivered-prompt")]; + (mockClient.readScreen as ReturnType).mockResolvedValue({ + surface: "surface:undelivered-prompt", + text: ["OpenAI Codex", "Model: gpt-5.5", "", "›"].join("\n"), + lines: 20, + scrollback_used: false, + }); + await engine.getRegistry().reconstitute(); + + await engine.runSweep(); + + expect(engine.getAgentState("agent-undelivered-prompt")).toMatchObject({ + state: "booting", + boot_prompt_pending: true, + prompt_delivered: false, + submit_verified: false, + }); + }); + + it("errors a stale managed pane whose boot prompt was never delivered", async () => { + stateMgr.writeState( + makeRecord({ + agent_id: "agent-stale-undelivered-prompt", + state: "booting", + surface_id: "surface:stale-undelivered-prompt", + cli: "codex", + boot_prompt_pending: true, + task_summary: "Read and follow docs.local/phase-3.md", + prompt_delivered: false, + submit_verified: null, + updated_at: new Date(Date.now() - 6 * 60_000).toISOString(), + }), + ); + liveSurfaces = [makeSurface("surface:stale-undelivered-prompt")]; + (mockClient.readScreen as ReturnType).mockResolvedValue({ + surface: "surface:stale-undelivered-prompt", + text: ["OpenAI Codex", "Model: gpt-5.5", "", "›"].join("\n"), + lines: 20, + scrollback_used: false, + }); + await engine.getRegistry().reconstitute(); + + await engine.runSweep(); + + expect( + engine.getAgentState("agent-stale-undelivered-prompt"), + ).toMatchObject({ + state: "error", + boot_prompt_pending: false, + prompt_delivered: false, + submit_verified: false, + error: expect.stringMatching(/delivery was not verified/i), + }); + }); + it("RC5: errors a stale pending boot prompt without readiness evidence even when its surface is alive", async () => { stateMgr.writeState( makeRecord({ diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index 4ac00c2f..c6b02ff3 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -4117,6 +4117,9 @@ describe("agent lifecycle tool handlers", () => { expect(["booting", "ready"]).toContain(state.state); expect(state.error).toBeNull(); expect(state.task_summary).toBe("file prompt body"); + expect(state.boot_prompt_pending).toBe(true); + expect(state.prompt_delivered).toBe(false); + expect(state.submit_verified).toBeNull(); expect(state.cli_session_id).toBe(sessionId); expect(state.resumable).toBe(true); expect(state.health.issue_codes).not.toContain("missing_cli_session_id"); diff --git a/tests/sidebar-sync.test.ts b/tests/sidebar-sync.test.ts index aec2f03a..400003c6 100644 --- a/tests/sidebar-sync.test.ts +++ b/tests/sidebar-sync.test.ts @@ -291,6 +291,7 @@ describe("Sidebar Sync", () => { role: "worker", state: "booting", task_summary: "Await first prompt", + updated_at: new Date().toISOString(), }), ); stateMgr.writeState( @@ -1651,6 +1652,7 @@ describe("Sidebar Sync", () => { workspace_id: "workspace:cmuxlayer", cli_session_id: null, task_summary: "Boot worker", + updated_at: new Date().toISOString(), }), ); stateMgr.writeState(