Skip to content

Commit ce063c8

Browse files
lefarcenopen-design-bot[bot]
authored andcommitted
feat(daemon): native session resume across turns (codex / opencode / pi / AMR) (#4629)
* feat(daemon): codex native session resume (capture-style) Codex follow-up turns re-pay the whole conversation as cache-cold input: the daemon flattens history into a fresh user message every turn and starts `codex exec` from scratch, so codex rebuilds its own structure and the upstream prefix cache from the previous turn no longer matches. Measured on real codex (consecutive turns): resume reuses 96% of the prefix vs 39% for flattened-resend — ~15x fewer recomputed tokens. Generalize the claude-style `resumesSessionViaCli` path to codex, which is "capture-style": codex mints its OWN thread id and reports it on the first stream event `thread.started`, so the daemon must capture that id and replay it rather than specifying one (claude `--session-id`). - types: add `capturesSessionIdFromStream` to distinguish capture-style from specify-style resume adapters. - codex def: `resumesSessionViaCli` + `capturesSessionIdFromStream`; buildArgs emits `exec resume <thread_id>` when a stored handle exists, else `exec`. `exec resume` rejects `--sandbox`, so the sandbox is passed as `-c sandbox_mode=...` so the per-turn context block byte-matches the create turn and does not break the prefix. - json-event-stream: capture `thread.started.thread_id` onto the status event's `sessionId`. - server: capture the reported id and persist THAT (not the unused minted `newSessionId`) on a create turn; skip the transcript resend on resume. - resume-missing fallback: detect codex `no rollout found for thread id` (generalized `isAgentResumeFailure` dispatch), clear the stale handle, and surface a retryable error so the next turn re-seeds the full transcript — one cold turn, never a broken conversation. The check runs BEFORE the generic stream-error short-circuit so a stale handle is always cleared. Tests: codex buildArgs create/resume shapes; thread.started capture (+ null case); codex resume-failure detection + dispatch; and an end-to-end real- daemon spec (capture -> resume without history resend; dead-thread fallback -> fresh exec next turn). * feat(daemon): opencode native session resume (capture-style) Extend the capture-style resume infra (codex) to OpenCode direct (~16% of users). Same shape: OpenCode mints its own session id and stamps it on every stream event as `sessionID` (e.g. `ses_...`); the daemon captures it and continues the session with `opencode run -s <id>` on the next turn instead of re-flattening the transcript, so the first upstream call reuses the warm prefix cache. Measured on real OpenCode: turn-2 resume = input 162 + cache_read 8192 (~98% reused) vs cache_read 0 cold. - opencode def: thread `runtimeContext` through buildArgs; emit `-s <id>` on a resume turn, plain `run` on create; `resumesSessionViaCli` + `capturesSessionIdFromStream`. - json-event-stream: capture `step_start.sessionID` onto the status event's `sessionId` (OpenCode stamps it on every event; step_start is the opener). - resume-missing fallback: `isOpencodeResumeFailure` (`Session not found` / `NotFoundError`) wired into `isAgentResumeFailure`. Verified against the real CLI: `run -s <missing>` prints `Error: Session not found` to stderr and exits 0 — the hoisted fallback clears the stale handle regardless of exit code, so the next turn re-seeds the full transcript (one cold turn, never broken). Tests: opencode buildArgs create/resume shapes; step_start session capture (+ null case); session-not-found detection + dispatch; and an end-to-end real- daemon spec (capture -> resume without history resend; dead-session fallback -> fresh run next turn, exercising the exit-0 failure path). * fix(daemon): gate codex `-C`/`--add-dir` to create turns (rejected by exec resume) `codex exec resume` rejects the create-only `-C <cwd>` and `--add-dir <dir>` flags (`error: unexpected argument '-C' found`), so the resume branch falling through the shared appends would make every follow-up Codex turn die before the first event. The daemon already spawns the child with `cwd: effectiveCwd`, and resuming by explicit SESSION_ID does not use codex's cwd-based session filtering (verified: resume by UUID succeeds from any cwd), so the resumed turn runs in the right workspace without `-C`; the extra writable dirs were granted when the session was created and ride along with the resumed session. - codex def: gate `-C` / `--add-dir` to `!resumeSessionId`. - unit: resume argv excludes `-C` / `--add-dir` even when cwd + extra dirs are supplied; create argv still includes both. - e2e: the fake codex now rejects `-C` / `--add-dir` on resume exactly like the real binary, so the regression is caught end-to-end; chat-turn filtering keys off the spawn cwd (no `-C` to match on resume turns). Reported by @nettee on #4629. * fix(daemon): resume identity guard — reseed on intervening turns / model / cwd Setting `resumesSessionViaCli` for codex/opencode widened the transcript-skip path, but `agent_sessions` only keyed on (conversation_id, agent_id). A conversation like `Codex -> other agent -> Codex` would resume the stale codex session and skip the intervening turns, so the resumed agent silently lost visible conversation state (reported by @nettee on #4629). The keying also did not encode model/cwd, so a model/cwd switch could keep resuming a session built under different parameters (raised by @PerishCode on #4627). Persist a resume identity with each session row and only resume when it still matches: - `model` / `cwd` — the runtime identity the upstream session was created with; a change forces a fresh session. - `last_message_id` — the assistant message the session last produced. At the next turn's resolve time the latest completed assistant message (excluding the current run's in-flight placeholder) must still be that id; otherwise another agent ran in between (or it was edited away) and the session is behind. On any mismatch `resolveAgentResumeContext` returns `isResuming: false` (with an `invalidationReason`), so the daemon starts a fresh session reseeded with the full transcript — worst case one cold turn, never amnesia. A null cursor (legacy row) is treated as unverifiable and reseeded once. The cursor is advanced on every successful create AND resume turn so back-to-back same-agent turns keep resuming. Tests: resolveAgentResumeContext invalidation matrix (in-sync resume, current placeholder excluded, model_changed, cwd_changed, conversation_advanced, missing_cursor); agent_sessions identity round-trip; and an end-to-end `codex -> claude -> codex` spec asserting codex reseeds (fresh `exec`, no resume) after the intervening turn. * fix(daemon): persist resume identity on the pi capture path too The identity guard routes pi-rpc through resolveAgentResumeContext, but pi persists via persistCapturedAgentSession() which only wrote session_id + stable_prompt_hash. After one successful pi turn the row came back with model/cwd/last_message_id = null, so the guard returned `missing_cursor` and reseeded every turn — silently disabling pi's existing follow-up-session path (reported by @nettee on #4629). Extend persistCapturedAgentSession to store model/cwd/last_message_id and pass them from the pi-rpc call site (run.model / effectiveCwd / run.assistantMessageId), so a successful pi turn keeps a verifiable identity and resumes next turn. Regression test: a stored pi session with the identity resumes; the existing clear-on-no-capture path is unchanged. * fix(daemon): scan only the CLI failure channel for resume-miss, not assistant stdout The resume-miss fallback fed both agentStderrTail AND agentStdoutTail into isAgentResumeFailure(). agentStdoutTail holds ordinary assistant text, so the OpenCode matcher (generic /session not found/i) could fire on a SUCCESSFUL turn whose model output merely mentioned the phrase — clearing the stored session and failing the turn (reported by @nettee on #4629). Detect resume-miss only from each CLI's failure channel: codex/opencode match stderr only; Claude matches prose on stderr and its structured stream-json result event on stdout (never the prose against stdout). Regression test: a generic phrase in successful assistant stdout does not trigger a resume miss. * fix(daemon): resume cursor counts only succeeded assistant turns latestCompletedAssistantMessageId is documented and consumed as the latest COMPLETED assistant turn for the resume-identity guard, but the query matched any assistant message regardless of run_status. A sequence like `codex success -> other agent fails/cancels before output -> codex again` read the failed/canceled placeholder as conversation advancement, returned `conversation_advanced`, and forced a needless cold reseed — silently disabling the resume perf path even though no completed turn was added after the stored session. Filter the cursor to `run_status = 'succeeded'` (a run stamps its terminal status on finish; in-flight placeholders are null and were already excluded). Adds a red-spec (db-agent-sessions: intervening failed/canceled run does not advance the cursor) and makes the resolveAgentResumeContext fixtures faithful by stamping completed assistant turns 'succeeded' and placeholders null. Reported by @nettee on #4629. * fix(daemon): resume cursor admits the session own failed turn (resume-on-failure) The run_status='succeeded' cursor filter regressed the resume-on-failure path: a transiently-failed-but-resumable turn persists a session pointing at its own FAILED assistant message, but that message was then excluded from the cursor, so resolveAgentResumeContext returned conversation_advanced and the next turn cold- restarted instead of --resume (broke run-resume-on-failure.test.ts). Admit the stored session's own lastMessageId through the filter via a new `resumableMessageId` arg: the session it owns still matches its cursor, while a DIFFERENT later failed/canceled turn stays excluded and a later succeeded turn is still detected as genuine advancement. Adds two guard tests: a stored failed cursor still resumes, and a later succeeded turn after the stored failed turn still reseeds. Reported by @nettee on #4629. * feat(daemon): AMR (ACP) session resume — capture durable handle + session/load + resume_failed Wire the AMR/vela runtime into session resume, mirroring the pi-rpc capture pattern (the handle comes from the ACP result, not a --session-id flag or a stream status event): - acp.ts: attachAcpSession accepts resumeSessionId and drives `session/load` (instead of session/new) to resume the prior upstream session; captures the durable `openCodeSessionId` from the result and exposes getDurableSessionId(). - new def flag resumesSessionViaAcpLoad (set on amr.ts) opts AMR into resume without colliding with resumesSessionViaCli / capturesSessionIdFromStream. - server.ts: agentSupportsSessionResume includes it; pass the stored handle as resumeSessionId on a resume turn (guarded by the resume-identity check); persist the captured durable handle on success via persistCapturedAgentSession (with model/cwd/lastMessageId); extend the resume-failure reseed gate. - agent-session-resume.ts: isAmrResumeFailure matches vela's structured {"kind":"resume_failed"} on stdout (the ACP channel), and isAgentResumeFailure dispatches 'amr' to it. skipTranscript already follows isResuming. Tests: session/load drive + durable-handle capture (acp.test); isAmrResumeFailure structured match (stdout only, not bare prose). Daemon typecheck + guard + existing AMR integration / resume suites green. * test(daemon): AMR resume integration — session/load drive, durable handle, resume_failed Extend fake-vela.mjs with openCodeSessionId on session/new, a session/load handler that echoes the durable handle, and FAKE_VELA_RESUME_FAILED to emit the structured resume_failed on prompt. Integration tests: a resume turn drives session/load and captures the handle; a missing resumed session surfaces resume_failed and does not complete. * fix(daemon): clear AMR resume handle on resume_failed before fatal short-circuit vela reports a missing/expired upstream OpenCode session as a structured `resume_failed` JSON-RPC error on session/prompt, which the ACP bridge turns into a fatal. The server close handler's `hasFatalError()` short-circuit ran BEFORE the resume-failure reseed block, so for AMR (resumesSessionViaAcpLoad) the dead durable handle was never cleared — every later turn re-issued session/load against the same dead session and failed forever (#4275 class). Hoist the resume-failure recovery (clear stale handle + retryable reseed error) above the fatal/stream-error short-circuits so it fires for both the codex stream-error shape and the AMR structured-fatal shape. Add server-level coverage (amr-session-resume.test.ts) driving the FULL close handler through startServer + fake-vela: happy session/load resume, the dead-resume -> reseed cycle (red without this fix), null durable handle -> fresh session, and model-change -> fresh session. fake-vela gains a didLoad gate (resume_failed fires only on a resumed turn), an invocation log, a --version handler, and an omit-handle knob. * fix(daemon): resume guard keys off the concrete launched model The resume-identity guard compared the raw request model (`run.model`, often `default`/null) and persisted that into `agent_sessions.model`, but the model actually launched is `safeModel` — for AMR the preflight rewrites a `default` request to the live catalog's first entry. So a conversation that ran under an implicit default stored `null`/`default`; changing the effective default between turns would still pass the guard and resume the old upstream session under the wrong model (skipping transcript reseeding). Hoist model resolution above the resume guard so it (and every `agent_sessions.model` write) keys off the concrete launched model. The AMR default->live-catalog rewrite is mirrored before the guard (read-only, cached); the existing preflight stays authoritative for auth/availability. Adds a red-spec: a default turn followed by an equivalent explicit model now resumes (was a needless cold reseed). Reported by @nettee on #4704. * chore(pack): bump bundled @powerformer/vela-cli to 0.0.18-test.0 Pulls the AMR OpenCode session-reuse changes (powerformer/vela#434, built from its feature branch via the vela-cli test channel) into the packaged bundle so a beta build can validate AMR session resume end-to-end. Reverts to a stable @powerformer/vela-cli pin once #434 ships to vela main. * chore(nix): refresh pnpm deps hash * feat(daemon): transparently auto-reseed when a resumed session is gone When a resume-capable agent (claude/codex/opencode/AMR) tries to resume an upstream CLI session that has expired/pruned, the daemon previously surfaced a retryable "previous session could not be resumed — resend" error and made the user re-send. That is a visible blemish on what is meant to be an invisible optimization. Now the daemon recovers transparently: it clears the dead handle and re-runs the SAME turn once with a fresh session + the full transcript rebuilt from the DB — exactly the pre-session-reuse path. The user sees one (slightly slower) turn, never an error. Reuses the existing same-run retry machinery (scheduleRetryRestart); the re-spawn resolves isResuming=false so it cannot resume-fail again, and a `resumeAutoReseeded` guard is belt-and-suspenders against any loop. Observability: emits an `agent_resume_auto_reseed` diagnostic into the per-run events.jsonl (bundled by the help → diagnostics export, so the full resume → fail → reseed chain is visible in a support bundle with no user-facing signal), and adds a `resume_auto_reseeded` field to the run_finished analytics so the fallback frequency can be monitored (should be rare). Updates the AMR/codex/opencode dead-session server tests to assert the turn now succeeds transparently, and adds run-diagnostics coverage for the new flag. * chore: re-trigger CI on updated main — needs-validation gate moved to merge_group (#4714) * chore(pack): bump bundled @powerformer/vela-cli to 0.0.18-test.1 Pulls the OpenCode interactive-question-tool fix (powerformer/vela#434: OPENCODE_CLIENT=acp withholds the question tool over the ACP bridge, which was crashing AMR turns with an empty-output failure) into the packaged bundle for the QA beta. * chore(nix): refresh pnpm deps hash * fix(daemon): keep AMR same-turn resume reseed invisible (suppress resume_failed error) When an AMR (vela) run resumes an upstream session via session/load and that session is gone, the ACP bridge calls fail() -> send('error') for the failed load BEFORE the child-close handler clears the stale handle and re-runs the turn fresh. The forwarded error flashed a client-visible execution failure — and tripped any client treating an SSE `error` as terminal — a beat before the recovery that is supposed to be invisible. Hold the error back in the ACP send wrapper when this run is resuming via session/load and the agent reports resume_failed: emit a non-user-visible `agent_resume_failed_suppressed` diagnostic instead of forwarding the error. The close handler stays the sole authority on whether the turn ends in an error or a transparent reseed; the resumeAutoReseeded guard still lets a second resume failure fall through to the explicit "resend your message" affordance. Red spec: amr-session-resume.test.ts asserts the run event log carries no client-visible `error` event during the transparent reseed (only the suppression diagnostic), going red on the pre-fix code (an `error` event with kind=resume_failed) and green after. * test(daemon): cover resume reseed event invariants --------- Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
1 parent 58baf18 commit ce063c8

22 files changed

Lines changed: 2817 additions & 140 deletions

apps/daemon/src/acp.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ interface AttachAcpSessionOptions {
9393
stageTimeoutMs?: number;
9494
executionProfile?: ExecutionProfile;
9595
modelUnavailableErrorCode?: 'AMR_MODEL_UNAVAILABLE';
96+
// When set, resume an existing upstream session instead of creating a new
97+
// one: the handshake sends `session/load { sessionId }` (the durable handle
98+
// captured from a prior run via `getDurableSessionId()`) rather than
99+
// `session/new`. The agent verifies the session and, if it is gone, returns a
100+
// structured `resume_failed` error the caller maps to its reseed path.
101+
resumeSessionId?: string | null;
96102
// Subsegment timing markers for spawn->first-token attribution (#3408 §4).
97103
// `onCliReady` fires once on the first well-formed ACP JSON-RPC message
98104
// (the CLI is up and speaking the protocol); `onSessionInit` fires once when
@@ -917,6 +923,7 @@ export function attachAcpSession({
917923
stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
918924
executionProfile = 'filesystem',
919925
modelUnavailableErrorCode,
926+
resumeSessionId,
920927
onCliReady,
921928
onSessionInit,
922929
}: AttachAcpSessionOptions) {
@@ -932,6 +939,11 @@ export function attachAcpSession({
932939
let promptRequestId: JsonRpcId | null = null;
933940
let setModelRequestId: JsonRpcId | null = null;
934941
let sessionId: string | null = null;
942+
// The durable upstream session handle reported by the agent on session/new or
943+
// session/load (vela's `openCodeSessionId`). The caller stores it per
944+
// conversation to resume next turn. Distinct from `sessionId`, which is the
945+
// ACP wrapper id ("vela-opencode-1").
946+
let durableSessionId: string | null = null;
935947
let activeModel: string | null = null;
936948
let modelConfigId: string | null = null;
937949
let emittedThinkingStart = false;
@@ -1477,20 +1489,33 @@ export function attachAcpSession({
14771489
}
14781490
if (expectedId === 1) {
14791491
expectedId = nextId;
1480-
writeRpc(
1481-
nextId,
1482-
'session/new',
1483-
buildAcpSessionNewParams(
1484-
effectiveCwd,
1485-
mcpServers ? { mcpServers, envFormat } : { envFormat },
1486-
),
1487-
'session/new',
1488-
);
1492+
if (resumeSessionId) {
1493+
// Resume the prior upstream session instead of creating a fresh one.
1494+
writeRpc(
1495+
nextId,
1496+
'session/load',
1497+
{ sessionId: resumeSessionId, cwd: effectiveCwd },
1498+
'session/load',
1499+
);
1500+
} else {
1501+
writeRpc(
1502+
nextId,
1503+
'session/new',
1504+
buildAcpSessionNewParams(
1505+
effectiveCwd,
1506+
mcpServers ? { mcpServers, envFormat } : { envFormat },
1507+
),
1508+
'session/new',
1509+
);
1510+
}
14891511
nextId += 1;
14901512
return;
14911513
}
14921514
if (expectedId === 2) {
14931515
sessionId = typeof result.sessionId === 'string' ? result.sessionId : null;
1516+
// The durable handle for resuming this session on the next turn.
1517+
durableSessionId =
1518+
typeof result.openCodeSessionId === 'string' ? result.openCodeSessionId : null;
14941519
// session/new acknowledged with a session id = handshake done (#3408 §4).
14951520
if (sessionId) onSessionInit?.();
14961521
const modelConfig = findModelConfigOption(result.configOptions);
@@ -1579,6 +1604,12 @@ export function attachAcpSession({
15791604
hasFatalError() {
15801605
return fatal;
15811606
},
1607+
// The durable upstream session handle to persist for resume, or null when
1608+
// none was reported (older agents, or a handshake that never established a
1609+
// session). Mirrors pi-rpc's getLastSessionPath().
1610+
getDurableSessionId() {
1611+
return durableSessionId;
1612+
},
15821613
completedSuccessfully() {
15831614
// Returns true when the prompt request resolved without a fatal error
15841615
// and was not aborted. The chat consumer treats this as a successful

apps/daemon/src/agent-session-resume.ts

Lines changed: 176 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,119 @@ import type Database from 'better-sqlite3';
55
import {
66
clearAgentSession,
77
getAgentSessionRecord,
8+
latestCompletedAssistantMessageId,
89
upsertAgentSession,
910
} from './db.js';
1011

1112
type SqliteDb = Database.Database;
1213

14+
/**
15+
* Why a stored session was NOT resumed this turn. `null` means it WAS resumed
16+
* (or there was no stored session to begin with). Surfaced for tests and
17+
* analytics; the daemon reseeds the full transcript for every non-null reason.
18+
*/
19+
export type ResumeInvalidationReason =
20+
| 'model_changed'
21+
| 'cwd_changed'
22+
| 'conversation_advanced'
23+
| 'missing_cursor';
24+
1325
export interface AgentResumeContext {
1426
/** Stored CLI session id to resume, or null when starting fresh. */
1527
resumeSessionId: string | null;
1628
/** Freshly minted UUID to open a new session with when not resuming. */
1729
newSessionId: string;
18-
/** True when a prior session id exists for this (conversation, agent). */
30+
/** True when a prior session id exists AND it is still safe to resume. */
1931
isResuming: boolean;
2032
/** Hash of the stable instruction block last sent on this session, or null. */
2133
storedStablePromptHash: string | null;
34+
/** Set when a stored session existed but was rejected; see the type. */
35+
invalidationReason: ResumeInvalidationReason | null;
2236
}
2337

2438
export type CapturedAgentSessionResult = 'stored' | 'cleared' | 'skipped';
2539

40+
/**
41+
* Resume identity guard. A stored upstream session is only safe to continue
42+
* (and to `skipTranscript` for) when the conversation has not changed shape
43+
* under it. We reject the resume — forcing a fresh session reseeded with the
44+
* full transcript — when:
45+
* - the model changed (the session was built under a different model),
46+
* - the cwd changed (different workspace identity), or
47+
* - the conversation advanced under the session: the assistant message the
48+
* session last produced is no longer the latest completed assistant turn
49+
* (another agent ran in between, or the message was edited/removed).
50+
*
51+
* The cursor is the session's own last assistant message id. At the next turn's
52+
* resolve time the latest completed assistant message — excluding the current
53+
* run's in-flight placeholder — must still be that id. A null stored cursor (row
54+
* written before this guard shipped) cannot be verified, so it is treated as
55+
* unsafe and reseeded once.
56+
*/
57+
export function evaluateResumeInvalidation(input: {
58+
storedModel: string | null;
59+
storedCwd: string | null;
60+
storedLastMessageId: string | null;
61+
currentModel: string | null;
62+
currentCwd: string | null;
63+
latestCompletedAssistantId: string | null;
64+
}): ResumeInvalidationReason | null {
65+
if ((input.storedModel ?? null) !== (input.currentModel ?? null)) return 'model_changed';
66+
if ((input.storedCwd ?? null) !== (input.currentCwd ?? null)) return 'cwd_changed';
67+
if (input.storedLastMessageId == null) return 'missing_cursor';
68+
if (input.latestCompletedAssistantId !== input.storedLastMessageId) {
69+
return 'conversation_advanced';
70+
}
71+
return null;
72+
}
73+
2674
/**
2775
* Decide whether a resume-capable adapter should continue its stored CLI
2876
* session or start a new one for this (conversation, agent). Pure read +
29-
* mint; the caller is responsible for persisting `newSessionId` when it
30-
* actually spawns a create turn.
77+
* mint; the caller is responsible for persisting `newSessionId` (and the
78+
* current model/cwd/cursor) when it actually spawns a create turn.
3179
*/
3280
export function resolveAgentResumeContext(
3381
db: SqliteDb,
34-
input: { conversationId: string; agentId: string },
82+
input: {
83+
conversationId: string;
84+
agentId: string;
85+
currentModel?: string | null;
86+
currentCwd?: string | null;
87+
/** The current run's in-flight assistant placeholder id, excluded from the
88+
* "latest completed assistant" cursor lookup. */
89+
currentAssistantMessageId?: string | null;
90+
},
3591
): AgentResumeContext {
3692
const record = getAgentSessionRecord(db, input.conversationId, input.agentId);
37-
const resumeSessionId = record?.sessionId ?? null;
93+
const storedSessionId = record?.sessionId ?? null;
94+
const invalidationReason =
95+
storedSessionId != null
96+
? evaluateResumeInvalidation({
97+
storedModel: record?.model ?? null,
98+
storedCwd: record?.cwd ?? null,
99+
storedLastMessageId: record?.lastMessageId ?? null,
100+
currentModel: input.currentModel ?? null,
101+
currentCwd: input.currentCwd ?? null,
102+
// Admit the stored session's own last message id through the cursor
103+
// filter so a resume-on-failure session (whose last turn FAILED but is
104+
// resumable) still matches its cursor; a different later failed turn
105+
// stays excluded and genuine advancement is still detected.
106+
latestCompletedAssistantId: latestCompletedAssistantMessageId(
107+
db,
108+
input.conversationId,
109+
input.currentAssistantMessageId ?? '',
110+
record?.lastMessageId ?? null,
111+
),
112+
})
113+
: null;
114+
const resumable = storedSessionId != null && invalidationReason == null;
38115
return {
39-
resumeSessionId,
116+
resumeSessionId: resumable ? storedSessionId : null,
40117
newSessionId: randomUUID(),
41-
isResuming: resumeSessionId != null,
42-
storedStablePromptHash: record?.stablePromptHash ?? null,
118+
isResuming: resumable,
119+
storedStablePromptHash: resumable ? (record?.stablePromptHash ?? null) : null,
120+
invalidationReason,
43121
};
44122
}
45123

@@ -58,6 +136,13 @@ export function persistCapturedAgentSession(
58136
agentId: string;
59137
sessionId: string | null;
60138
stablePromptHash?: string | null;
139+
// Resume identity (see resolveAgentResumeContext). Must be stored alongside
140+
// the captured session so the next turn can verify the session is still
141+
// safe to resume; omitting them leaves a null cursor that the guard treats
142+
// as `missing_cursor` and reseeds every turn.
143+
model?: string | null;
144+
cwd?: string | null;
145+
lastMessageId?: string | null;
61146
},
62147
): CapturedAgentSessionResult {
63148
if (!input.conversationId) return 'skipped';
@@ -67,6 +152,9 @@ export function persistCapturedAgentSession(
67152
agentId: input.agentId,
68153
sessionId: input.sessionId,
69154
stablePromptHash: input.stablePromptHash ?? null,
155+
model: input.model ?? null,
156+
cwd: input.cwd ?? null,
157+
lastMessageId: input.lastMessageId ?? null,
70158
});
71159
return 'stored';
72160
}
@@ -147,9 +235,85 @@ export function computeIncludeStable(
147235
return !isResuming || storedStableHash !== currentStableHash;
148236
}
149237

150-
/** True when CLI output indicates a resume target session is missing. */
151-
export function isClaudeResumeFailure(text: string): boolean {
238+
/**
239+
* True when CLI output indicates a resume target session is missing. Prose
240+
* signatures are matched on `stderr` (where Claude prints the failure); the
241+
* version-stable structured `result` event is matched on `stdout` (the
242+
* stream-json channel). We deliberately do NOT scan ordinary assistant stdout
243+
* for the prose phrases — a successful turn whose model text happens to contain
244+
* "session not found" must not be mistaken for a resume failure.
245+
*/
246+
export function isClaudeResumeFailure(stderr: string, stdout = ''): boolean {
247+
if (stderr && CLAUDE_RESUME_FAILURE_PATTERNS.some((re) => re.test(stderr))) return true;
248+
return stdout ? hasClaudeResumeFailureResultEvent(stdout) : false;
249+
}
250+
251+
// Signature codex prints when `exec resume <thread_id>` targets a thread whose
252+
// rollout file is gone (pruned, ~/.codex/sessions cleared, machine moved):
253+
// Error: thread/resume: thread/resume failed: no rollout found for thread id <id>
254+
// Verified against the installed Codex CLI. Like the Claude case this fails
255+
// locally before any model call, so clearing the stale handle and re-seeding
256+
// the transcript next turn is the correct, lossless recovery.
257+
const CODEX_RESUME_FAILURE_PATTERNS: RegExp[] = [
258+
/no rollout found for thread id/i,
259+
/thread\/resume failed/i,
260+
];
261+
262+
/** True when codex CLI output indicates a resume target thread is missing. */
263+
export function isCodexResumeFailure(text: string): boolean {
264+
if (!text) return false;
265+
return CODEX_RESUME_FAILURE_PATTERNS.some((re) => re.test(text));
266+
}
267+
268+
// Signature OpenCode prints when `run -s <id>` targets a session whose store is
269+
// gone (deleted, corrupted, different machine). Verified against the installed
270+
// OpenCode CLI: `run -s <well-formed-but-missing-id>` prints `Error: Session
271+
// not found` to stderr (and the HTTP path returns a `NotFoundError`). Like the
272+
// other CLIs this fails before any model call, so clearing the stale handle and
273+
// re-seeding the transcript next turn is the correct, lossless recovery.
274+
const OPENCODE_RESUME_FAILURE_PATTERNS: RegExp[] = [
275+
/session not found/i,
276+
/NotFoundError/,
277+
];
278+
279+
/** True when OpenCode CLI output indicates a resume target session is missing. */
280+
export function isOpencodeResumeFailure(text: string): boolean {
152281
if (!text) return false;
153-
if (CLAUDE_RESUME_FAILURE_PATTERNS.some((re) => re.test(text))) return true;
154-
return hasClaudeResumeFailureResultEvent(text);
282+
return OPENCODE_RESUME_FAILURE_PATTERNS.some((re) => re.test(text));
283+
}
284+
285+
/**
286+
* Per-agent dispatch for "the session/thread I asked to resume is gone".
287+
* Generalizes the resume-fallback so every `resumesSessionViaCli` adapter
288+
* routes through one decision point in server.ts. Unknown agents return false
289+
* (no fallback) — a new resume-capable adapter must opt in here explicitly.
290+
*
291+
* Detection scans only the CLI's FAILURE channel, never successful assistant
292+
* output: codex/opencode print their resume-miss to `stderr` (a generic phrase
293+
* like OpenCode's "Session not found" must not be matched against the model's
294+
* stdout, or a turn that merely *mentions* it would be falsely failed); Claude's
295+
* prose is on stderr and its structured `result` marker on stdout.
296+
*/
297+
export function isAgentResumeFailure(
298+
agentId: string,
299+
stderr: string,
300+
stdout = '',
301+
): boolean {
302+
if (agentId === 'codex') return isCodexResumeFailure(stderr);
303+
if (agentId === 'opencode') return isOpencodeResumeFailure(stderr);
304+
if (agentId === 'amr') return isAmrResumeFailure(stdout);
305+
// claude + codebuddy share Claude Code's stream-json result shape.
306+
return isClaudeResumeFailure(stderr, stdout);
307+
}
308+
309+
// vela (AMR) reports a missing resumed session as a structured ACP JSON-RPC
310+
// error `{"error":{"data":{"kind":"resume_failed",...}}}` on stdout (the
311+
// protocol channel). Match the structured marker — not a bare word — so a
312+
// model reply that merely mentions "resume_failed" cannot trip it.
313+
const AMR_RESUME_FAILURE_PATTERN = /"kind"\s*:\s*"resume_failed"/;
314+
315+
/** True when vela's ACP output carries a resume_failed signal. */
316+
export function isAmrResumeFailure(stdout: string): boolean {
317+
if (!stdout) return false;
318+
return AMR_RESUME_FAILURE_PATTERN.test(stdout);
155319
}

0 commit comments

Comments
 (0)