Skip to content

Commit 9caec80

Browse files
authored
Normalize agent resume fallback policy (#5269)
1 parent b9b7bb8 commit 9caec80

3 files changed

Lines changed: 221 additions & 21 deletions

File tree

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,87 @@ export interface AgentResumeContext {
4141

4242
export type CapturedAgentSessionResult = 'stored' | 'cleared' | 'skipped';
4343

44+
export type AgentResumeTranscriptMode = 'resume-session' | 'full-transcript';
45+
46+
export interface AgentResumePromptPolicy {
47+
mode: AgentResumeTranscriptMode;
48+
/** Stored upstream handle to continue this turn, or null when reseeding. */
49+
resumeSessionId: string | null;
50+
/** True only when the daemon also asks the agent to resume native state. */
51+
skipTranscript: boolean;
52+
/** True for every guard miss, missing handle, unsupported adapter, or create turn. */
53+
requiresFullTranscript: boolean;
54+
invalidationReason: ResumeInvalidationReason | null;
55+
}
56+
57+
export interface AgentResumeFailurePolicy {
58+
resumeFailed: boolean;
59+
/** Clear the persisted handle before the next attempt. */
60+
clearStaleSession: boolean;
61+
/** Re-run this turn fresh so the daemon sends the full transcript. */
62+
autoReseedFullTranscript: boolean;
63+
reason: 'resume_failed' | null;
64+
}
65+
66+
/**
67+
* Shared transcript policy for resumable adapters. Native continuation and
68+
* transcript skipping are coupled: if the daemon cannot prove it is resuming a
69+
* valid upstream session this turn, the prompt path must recompose the full
70+
* transcript.
71+
*/
72+
export function resolveAgentResumePromptPolicy(
73+
ctx: Pick<AgentResumeContext, 'isResuming' | 'resumeSessionId' | 'invalidationReason'>,
74+
): AgentResumePromptPolicy {
75+
const canResume =
76+
ctx.isResuming === true
77+
&& typeof ctx.resumeSessionId === 'string'
78+
&& ctx.resumeSessionId.length > 0
79+
&& ctx.invalidationReason == null;
80+
if (canResume) {
81+
return {
82+
mode: 'resume-session',
83+
resumeSessionId: ctx.resumeSessionId,
84+
skipTranscript: true,
85+
requiresFullTranscript: false,
86+
invalidationReason: null,
87+
};
88+
}
89+
return {
90+
mode: 'full-transcript',
91+
resumeSessionId: null,
92+
skipTranscript: false,
93+
requiresFullTranscript: true,
94+
invalidationReason: ctx.invalidationReason ?? null,
95+
};
96+
}
97+
98+
/**
99+
* Shared fallback policy for a resume target that no longer exists upstream.
100+
* Only a run that actually attempted native resume may clear the stored handle
101+
* and auto-reseed; fresh/create turns must ignore matching prose in stdout.
102+
*/
103+
export function resolveAgentResumeFailurePolicy(input: {
104+
agentId: string;
105+
stderr: string;
106+
stdout?: string;
107+
isResuming: boolean;
108+
resumeSessionId: string | null | undefined;
109+
}): AgentResumeFailurePolicy {
110+
const attemptedResume =
111+
input.isResuming === true
112+
&& typeof input.resumeSessionId === 'string'
113+
&& input.resumeSessionId.length > 0;
114+
const resumeFailed =
115+
attemptedResume &&
116+
isAgentResumeFailure(input.agentId, input.stderr, input.stdout ?? '');
117+
return {
118+
resumeFailed,
119+
clearStaleSession: resumeFailed,
120+
autoReseedFullTranscript: resumeFailed,
121+
reason: resumeFailed ? 'resume_failed' : null,
122+
};
123+
}
124+
44125
/**
45126
* Resume identity guard. A stored upstream session is only safe to continue
46127
* (and to `skipTranscript` for) when the conversation has not changed shape

apps/daemon/src/server.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -684,9 +684,10 @@ import {
684684
import {
685685
computeIncludeStable,
686686
hashStableInstructions,
687-
isAgentResumeFailure,
688687
persistCapturedAgentSession,
689688
resolveAgentResumeContext,
689+
resolveAgentResumeFailurePolicy,
690+
resolveAgentResumePromptPolicy,
690691
} from './agent-session-resume.js';
691692
import {
692693
initialNativeSessionRecoveryMetadata,
@@ -10505,14 +10506,15 @@ export async function startServer({
1050510506
invalidationReason: agentResumeCtx.invalidationReason,
1050610507
});
1050710508
publishNativeSessionRecoveryMetadata();
10509+
const agentResumePromptPolicy = resolveAgentResumePromptPolicy(agentResumeCtx);
1050810510
const userRequestPrompt = composeChatUserRequestForAgent(
1050910511
message,
1051010512
currentPrompt,
1051110513
// Only trim to the latest turn when we are actually resuming an
1051210514
// existing session. A create turn still sends the full transcript so
1051310515
// a brand-new session (incl. first turn after another agent)
1051410516
// is seeded with prior context.
10515-
{ skipTranscript: agentResumeCtx.isResuming },
10517+
{ skipTranscript: agentResumePromptPolicy.skipTranscript },
1051610518
);
1051710519
// The stable instruction slice (daemon prompt + tool contract + system
1051810520
// prompt = design system / skills / memory) is identical across turns of
@@ -10538,12 +10540,12 @@ export async function startServer({
1053810540
// tool-token grant's presence flips between turns (rare cwd/projectId edge
1053910541
// cases); any such change correctly forces a full re-send that turn.
1054010542
const includeStableInstructions = computeIncludeStable(
10541-
agentResumeCtx.isResuming,
10543+
agentResumePromptPolicy.skipTranscript,
1054210544
agentResumeCtx.storedStablePromptHash,
1054310545
currentStableHash,
1054410546
);
1054510547
run.promptCache = describeStablePromptCache({
10546-
isResuming: agentResumeCtx.isResuming,
10548+
isResuming: agentResumePromptPolicy.skipTranscript,
1054710549
storedStablePromptHash: agentResumeCtx.storedStablePromptHash,
1054810550
currentStableHash,
1054910551
storedStableSections: agentResumeCtx.storedStableSections,
@@ -11694,7 +11696,7 @@ export async function startServer({
1169411696
hasPriorAssistantTurn,
1169511697
agentLogFilePath,
1169611698
promptFilePath: promptFile?.path,
11697-
resumeSessionId: agentResumeCtx.resumeSessionId,
11699+
resumeSessionId: agentResumePromptPolicy.resumeSessionId,
1169811700
newSessionId: agentResumeCtx.newSessionId,
1169911701
disablePlugins:
1170011702
def.id === 'codex'
@@ -13049,15 +13051,20 @@ export async function startServer({
1304913051
// authority on how a resume failure ends.
1305013052
if (
1305113053
(runtimeResumesSessionById(def) || def.resumesSessionViaAcpLoad === true) &&
13052-
agentResumeCtx.isResuming &&
1305313054
!run.resumeAutoReseeded &&
13054-
isAgentResumeFailure(def.id, agentStderrTail, agentStdoutTail)
13055+
resolveAgentResumeFailurePolicy({
13056+
agentId: def.id,
13057+
stderr: agentStderrTail,
13058+
stdout: agentStdoutTail,
13059+
isResuming: agentResumePromptPolicy.skipTranscript,
13060+
resumeSessionId: agentResumePromptPolicy.resumeSessionId,
13061+
}).resumeFailed
1305513062
) {
1305613063
design.runs.emit(run, 'diagnostic', {
1305713064
type: 'agent_resume_failed_suppressed',
1305813065
agent_id: def.id,
1305913066
reason: 'resume_failed',
13060-
previous_session_id: agentResumeCtx.resumeSessionId ?? null,
13067+
previous_session_id: agentResumePromptPolicy.resumeSessionId ?? null,
1306113068
});
1306213069
return;
1306313070
}
@@ -13199,8 +13206,8 @@ export async function startServer({
1319913206
prompt: composed,
1320013207
cwd: effectiveCwd,
1320113208
model: safeModel,
13202-
parentSession: agentResumeCtx.isResuming && agentResumeCtx.resumeSessionId
13203-
? agentResumeCtx.resumeSessionId
13209+
parentSession: agentResumePromptPolicy.resumeSessionId
13210+
? agentResumePromptPolicy.resumeSessionId
1320413211
: undefined,
1320513212
send: (channel, payload) => {
1320613213
if (channel === 'agent') {
@@ -13249,8 +13256,8 @@ export async function startServer({
1324913256
...(def.id === 'amr' ? { modelUnavailableErrorCode: 'AMR_MODEL_UNAVAILABLE' } : {}),
1325013257
// Resume the prior upstream session (drives `session/load`) when the
1325113258
// resume-identity guard says it is safe; otherwise a fresh session/new.
13252-
...(def.resumesSessionViaAcpLoad === true && agentResumeCtx.isResuming && agentResumeCtx.resumeSessionId
13253-
? { resumeSessionId: agentResumeCtx.resumeSessionId }
13259+
...(def.resumesSessionViaAcpLoad === true && agentResumePromptPolicy.resumeSessionId
13260+
? { resumeSessionId: agentResumePromptPolicy.resumeSessionId }
1325413261
: {}),
1325513262
onCliReady: () => noteCliReadyAt(),
1325613263
onSessionInit: () => noteSessionInitDoneAt(),
@@ -13308,16 +13315,20 @@ export async function startServer({
1330813315
if (
1330913316
event === 'error' &&
1331013317
def.resumesSessionViaAcpLoad === true &&
13311-
agentResumeCtx.isResuming &&
13312-
agentResumeCtx.resumeSessionId &&
1331313318
!run.resumeAutoReseeded &&
13314-
isAgentResumeFailure(def.id, agentStderrTail, agentStdoutTail)
13319+
resolveAgentResumeFailurePolicy({
13320+
agentId: def.id,
13321+
stderr: agentStderrTail,
13322+
stdout: agentStdoutTail,
13323+
isResuming: agentResumePromptPolicy.skipTranscript,
13324+
resumeSessionId: agentResumePromptPolicy.resumeSessionId,
13325+
}).resumeFailed
1331513326
) {
1331613327
design.runs.emit(run, 'diagnostic', {
1331713328
type: 'agent_resume_failed_suppressed',
1331813329
agent_id: def.id,
1331913330
reason: 'resume_failed',
13320-
previous_session_id: agentResumeCtx.resumeSessionId ?? null,
13331+
previous_session_id: agentResumePromptPolicy.resumeSessionId ?? null,
1332113332
});
1332213333
return;
1332313334
}
@@ -13523,9 +13534,14 @@ export async function startServer({
1352313534
if (
1352413535
!run.cancelRequested &&
1352513536
(runtimeResumesSessionById(def) || def.resumesSessionViaAcpLoad === true) &&
13526-
agentResumeCtx.isResuming &&
1352713537
run.conversationId &&
13528-
isAgentResumeFailure(def.id, agentStderrTail, agentStdoutTail)
13538+
resolveAgentResumeFailurePolicy({
13539+
agentId: def.id,
13540+
stderr: agentStderrTail,
13541+
stdout: agentStdoutTail,
13542+
isResuming: agentResumePromptPolicy.skipTranscript,
13543+
resumeSessionId: agentResumePromptPolicy.resumeSessionId,
13544+
}).autoReseedFullTranscript
1352913545
) {
1353013546
// The resumed upstream session is gone (expired / pruned). Clear the dead
1353113547
// handle and TRANSPARENTLY re-run this same turn with a fresh session +
@@ -13538,11 +13554,11 @@ export async function startServer({
1353813554
clearAgentSession(db, run.conversationId, def.id);
1353913555
if (!run.resumeAutoReseeded) {
1354013556
run.resumeAutoReseeded = true;
13541-
run.resumeAutoReseededFrom = agentResumeCtx.resumeSessionId ?? null;
13557+
run.resumeAutoReseededFrom = agentResumePromptPolicy.resumeSessionId ?? null;
1354213558
run.nativeSessionRecovery = markNativeSessionAutoReseeded({
1354313559
previous: run.nativeSessionRecovery,
1354413560
agentId: def.id,
13545-
previousSessionId: agentResumeCtx.resumeSessionId,
13561+
previousSessionId: agentResumePromptPolicy.resumeSessionId,
1354613562
});
1354713563
publishNativeSessionRecoveryMetadata();
1354813564
// Persisted to the per-run events.jsonl that the help → diagnostics
@@ -13552,7 +13568,7 @@ export async function startServer({
1355213568
type: 'agent_resume_auto_reseed',
1355313569
agent_id: def.id,
1355413570
reason: 'resume_failed',
13555-
previous_session_id: agentResumeCtx.resumeSessionId ?? null,
13571+
previous_session_id: agentResumePromptPolicy.resumeSessionId ?? null,
1355613572
stale_session_cleared: true,
1355713573
nativeSessionRecovery: run.nativeSessionRecovery,
1355813574
});

apps/daemon/tests/agent-session-resume.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
isOpencodeResumeFailure,
2424
persistCapturedAgentSession,
2525
resolveAgentResumeContext,
26+
resolveAgentResumeFailurePolicy,
27+
resolveAgentResumePromptPolicy,
2628
} from '../src/agent-session-resume.js';
2729

2830
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -258,6 +260,71 @@ describe('computeIncludeStable', () => {
258260
});
259261
});
260262

263+
describe('resolveAgentResumePromptPolicy', () => {
264+
it('allows transcript skipping only when a valid native resume handle is selected', () => {
265+
expect(
266+
resolveAgentResumePromptPolicy({
267+
isResuming: true,
268+
resumeSessionId: 'sess-A',
269+
invalidationReason: null,
270+
}),
271+
).toEqual({
272+
mode: 'resume-session',
273+
resumeSessionId: 'sess-A',
274+
skipTranscript: true,
275+
requiresFullTranscript: false,
276+
invalidationReason: null,
277+
});
278+
});
279+
280+
it('requires the full transcript for a fresh create turn with no stored session', () => {
281+
expect(
282+
resolveAgentResumePromptPolicy({
283+
isResuming: false,
284+
resumeSessionId: null,
285+
invalidationReason: null,
286+
}),
287+
).toMatchObject({
288+
mode: 'full-transcript',
289+
resumeSessionId: null,
290+
skipTranscript: false,
291+
requiresFullTranscript: true,
292+
invalidationReason: null,
293+
});
294+
});
295+
296+
it('requires the full transcript for every guard failure', () => {
297+
expect(
298+
resolveAgentResumePromptPolicy({
299+
isResuming: false,
300+
resumeSessionId: null,
301+
invalidationReason: 'conversation_advanced',
302+
}),
303+
).toMatchObject({
304+
mode: 'full-transcript',
305+
resumeSessionId: null,
306+
skipTranscript: false,
307+
requiresFullTranscript: true,
308+
invalidationReason: 'conversation_advanced',
309+
});
310+
});
311+
312+
it('treats inconsistent resume state as full-transcript reseed instead of skipping history', () => {
313+
expect(
314+
resolveAgentResumePromptPolicy({
315+
isResuming: true,
316+
resumeSessionId: null,
317+
invalidationReason: null,
318+
}),
319+
).toMatchObject({
320+
mode: 'full-transcript',
321+
resumeSessionId: null,
322+
skipTranscript: false,
323+
requiresFullTranscript: true,
324+
});
325+
});
326+
});
327+
261328
describe('persistCapturedAgentSession', () => {
262329
let tempDir: string;
263330

@@ -577,3 +644,39 @@ describe('isAgentResumeFailure dispatch', () => {
577644
expect(isAgentResumeFailure('claude', '')).toBe(false);
578645
});
579646
});
647+
648+
describe('resolveAgentResumeFailurePolicy', () => {
649+
it('clears stale state and auto-reseeds only for a failed attempted resume', () => {
650+
expect(
651+
resolveAgentResumeFailurePolicy({
652+
agentId: 'opencode',
653+
stderr: 'Error: Session not found',
654+
stdout: '',
655+
isResuming: true,
656+
resumeSessionId: 'ses-old',
657+
}),
658+
).toEqual({
659+
resumeFailed: true,
660+
clearStaleSession: true,
661+
autoReseedFullTranscript: true,
662+
reason: 'resume_failed',
663+
});
664+
});
665+
666+
it('does not clear state on a create turn even if output contains a resume-like phrase', () => {
667+
expect(
668+
resolveAgentResumeFailurePolicy({
669+
agentId: 'opencode',
670+
stderr: 'Error: Session not found',
671+
stdout: '',
672+
isResuming: false,
673+
resumeSessionId: null,
674+
}),
675+
).toEqual({
676+
resumeFailed: false,
677+
clearStaleSession: false,
678+
autoReseedFullTranscript: false,
679+
reason: null,
680+
});
681+
});
682+
});

0 commit comments

Comments
 (0)