Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 126 additions & 61 deletions src/agent-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1555,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))
Expand All @@ -1577,9 +1598,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);
}
Expand Down Expand Up @@ -2552,74 +2585,92 @@ 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;
}

this.stateMgr.updateRecord(agent.agent_id, {
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);
const awaitingManagedBootPrompt =
agent.boot_prompt_pending === true &&
agent.prompt_delivered === false &&
!evidence.activeCodex;

if (promptStillPending || awaitingManagedBootPrompt) {
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;
}

Expand All @@ -2629,7 +2680,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,
Expand Down Expand Up @@ -4683,6 +4744,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,
Expand Down
2 changes: 2 additions & 0 deletions src/agent-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
};
}
Expand Down
29 changes: 21 additions & 8 deletions src/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/agent-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions src/screen-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -1152,6 +1157,10 @@ function inferStatus(
return "frozen";
}

if (CONTEXT_LIMIT_BANNER_RE.test(joined)) {
return "idle";
}

if (THINKING_RE.test(text)) {
return "thinking";
}
Expand Down
18 changes: 16 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -8897,7 +8905,13 @@ 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,
});
registry.set(agentId, updated);
result.agent_id = updated.agent_id;
Expand Down
Loading