Skip to content

Commit e580ed2

Browse files
SunnyShu0925claudesteipete
authored
fix(sessions): exclude done sessions from transcript freshness rollover guard (#99985)
* [AI] fix(sessions): exclude done sessions from transcript freshness rollover guard Exclude status: "done" from resolveTerminalMainSessionTranscriptRegistryCheck alongside the already-excluded status: "failed". Successful main sessions should stay reusable for the next user message even when transcript mtime slightly exceeds registry updatedAt. Updated test expectations in: - src/gateway/server-methods/agent.test.ts (split it.each into done-reuse + endedAt-only-rotate) - src/commands/agent.session.test.ts (split into done-reuse + endedAt-only-rotate) - src/auto-reply/reply/session.test.ts (default-done case now expects reuse) Related to #99964 Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(ts): fix TypeScript error in endedAt-only test scenario type Remove the conditional status spread that TS cannot resolve when status is absent from the scenario type, and add 'as const' for narrowed literal inference. * [AI] fix(comment): correct mtime direction in done exclusion comment Co-Authored-By: Claude Opus 4.8 <[email protected]> * review: separate failed/done rationale in comment * fix(sessions): reuse completed main transcripts --------- Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent de1bac6 commit e580ed2

4 files changed

Lines changed: 106 additions & 64 deletions

File tree

src/auto-reply/reply/session.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2203,11 +2203,20 @@ describe("initSessionState reset policy", () => {
22032203
expectNewSession: false,
22042204
},
22052205
{
2206-
name: "main terminal rows rotate when transcript is newer than updatedAt",
2206+
name: "main status-done terminal rows reuse when transcript is newer than updatedAt",
22072207
sessionKey: "agent:main:main",
22082208
updatedAtOffsetMs: -10_000,
22092209
endedAtOffsetMs: -11_000,
22102210
transcriptMtimeOffsetMs: 0,
2211+
expectNewSession: false,
2212+
},
2213+
{
2214+
name: "main killed terminal rows rotate when transcript is newer than updatedAt",
2215+
sessionKey: "agent:main:main",
2216+
status: "killed" as const,
2217+
updatedAtOffsetMs: -10_000,
2218+
endedAtOffsetMs: -11_000,
2219+
transcriptMtimeOffsetMs: 0,
22112220
expectNewSession: true,
22122221
},
22132222
{

src/commands/agent.session.test.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,23 +149,44 @@ describe("agent session resolution", () => {
149149
});
150150
});
151151

152-
it("rotates stale terminal main sessions whose transcript is newer than the registry", async () => {
152+
it("handles terminal main sessions whose transcript is newer than the registry", async () => {
153153
const scenarios = [
154154
{
155-
label: "canonical main",
155+
label: "canonical done main",
156156
mainKey: "main",
157157
sessionKey: "agent:main:main",
158158
status: "done" as const,
159+
expectNewSession: false,
159160
},
160-
{ label: "raw main alias", mainKey: "main", sessionKey: "main", status: "done" as const },
161161
{
162-
label: "custom main alias",
162+
label: "raw done main alias",
163+
mainKey: "main",
164+
sessionKey: "main",
165+
status: "done" as const,
166+
expectNewSession: false,
167+
},
168+
{
169+
label: "custom done main alias",
163170
mainKey: "work",
164171
sessionKey: "agent:main:main",
165172
status: "done" as const,
173+
expectNewSession: false,
174+
},
175+
{
176+
label: "killed main",
177+
mainKey: "main",
178+
sessionKey: "agent:main:main",
179+
status: "killed" as const,
180+
expectNewSession: true,
181+
},
182+
{
183+
label: "endedAt-only main",
184+
mainKey: "main",
185+
sessionKey: "agent:main:main",
186+
status: undefined,
187+
expectNewSession: true,
166188
},
167-
{ label: "endedAt-only main", mainKey: "main", sessionKey: "agent:main:main" },
168-
];
189+
] as const;
169190
for (const scenario of scenarios) {
170191
await withTempHome(async (home) => {
171192
const store = path.join(home, "sessions.json");
@@ -205,7 +226,11 @@ describe("agent session resolution", () => {
205226

206227
const resolution = resolveSession({ cfg, sessionKey: scenario.sessionKey });
207228

208-
expect(resolution.isNewSession).toBe(true);
229+
expect(resolution.isNewSession).toBe(scenario.expectNewSession);
230+
if (!scenario.expectNewSession) {
231+
expect(resolution.sessionId).toBe(sessionId);
232+
return;
233+
}
209234
expect(resolution.sessionId).not.toBe(sessionId);
210235
expect(resolution.sessionEntry?.sessionFile).toBeUndefined();
211236
expect(resolution.sessionEntry?.status).toBeUndefined();

src/config/sessions/lifecycle.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ export function resolveTerminalMainSessionTranscriptRegistryCheck(
213213
if (!hasTerminalLifecycle) {
214214
return undefined;
215215
}
216+
if (params.entry.status === "done") {
217+
// Successful rows stay reusable: transcript writes can land after registry
218+
// updates without making the session stale.
219+
return undefined;
220+
}
216221
if (params.entry.status === "failed") {
217222
// Failed rows with a present transcript stay reusable for retry/recovery.
218223
// Callers already rotate failed rows when the transcript is missing.

src/gateway/server-methods/agent.test.ts

Lines changed: 59 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,72 +1286,75 @@ describe("gateway agent handler", () => {
12861286
});
12871287

12881288
it.each([
1289-
{ name: "status terminal row", status: "done" as const },
1290-
{ name: "endedAt-only terminal row" },
1289+
{ name: "status-done row", status: "done" as const, expectReuse: true },
1290+
{ name: "status-killed row", status: "killed" as const, expectReuse: false },
1291+
{ name: "endedAt-only row", status: undefined, expectReuse: false },
12911292
])(
1292-
"rotates a terminal main session from a $name when its transcript is newer",
1293+
"handles a terminal main session from a $name when its transcript is newer",
12931294
async (scenario) => {
12941295
const now = Date.parse("2026-05-18T09:47:00.000Z");
12951296
vi.useFakeTimers({ toFake: ["Date"] });
12961297
dateOnlyFakeClockActive = true;
12971298
vi.setSystemTime(now);
12981299

1299-
await withTempDir(
1300-
{ prefix: "openclaw-gateway-terminal-main-newer-transcript-" },
1301-
async (root) => {
1302-
const sessionsDir = `${root}/sessions`;
1303-
await fs.mkdir(sessionsDir, { recursive: true });
1304-
const sessionFile = "terminal-main-session.jsonl";
1305-
const transcriptPath = `${sessionsDir}/${sessionFile}`;
1306-
await fs.writeFile(
1307-
transcriptPath,
1308-
`${JSON.stringify({ type: "session", id: "terminal-main-session" })}\n`,
1309-
"utf8",
1310-
);
1311-
await fs.utimes(transcriptPath, new Date(now - 1_000), new Date(now - 1_000));
1312-
mocks.loadSessionEntry.mockReturnValue({
1313-
cfg: {},
1314-
storePath: `${sessionsDir}/sessions.json`,
1315-
entry: {
1316-
sessionId: "terminal-main-session",
1317-
sessionFile,
1318-
...(scenario.status ? { status: scenario.status } : {}),
1319-
updatedAt: now - 10_000,
1320-
sessionStartedAt: now - 60_000,
1321-
lastInteractionAt: now - 10_000,
1322-
startedAt: now - 20_000,
1323-
endedAt: now - 15_000,
1324-
runtimeMs: 5_000,
1325-
cliSessionBindings: {
1326-
"claude-cli": { sessionId: "old-claude-cli-session" },
1327-
"codex-cli": { sessionId: "old-codex-cli-session" },
1328-
},
1329-
cliSessionIds: {
1330-
"claude-cli": "old-claude-cli-session",
1331-
"codex-cli": "old-codex-cli-session",
1332-
},
1333-
claudeCliSessionId: "old-claude-cli-session",
1300+
await withTempDir({ prefix: "openclaw-gateway-terminal-main-newer-" }, async (root) => {
1301+
const sessionsDir = `${root}/sessions`;
1302+
await fs.mkdir(sessionsDir, { recursive: true });
1303+
const sessionFile = "terminal-main-session.jsonl";
1304+
const transcriptPath = `${sessionsDir}/${sessionFile}`;
1305+
await fs.writeFile(
1306+
transcriptPath,
1307+
`${JSON.stringify({ type: "session", id: "terminal-main-session" })}\n`,
1308+
"utf8",
1309+
);
1310+
await fs.utimes(transcriptPath, new Date(now - 1_000), new Date(now - 1_000));
1311+
mocks.loadSessionEntry.mockReturnValue({
1312+
cfg: {},
1313+
storePath: `${sessionsDir}/sessions.json`,
1314+
entry: {
1315+
sessionId: "terminal-main-session",
1316+
sessionFile,
1317+
...(scenario.status ? { status: scenario.status } : {}),
1318+
updatedAt: now - 10_000,
1319+
sessionStartedAt: now - 60_000,
1320+
lastInteractionAt: now - 10_000,
1321+
startedAt: now - 20_000,
1322+
endedAt: now - 15_000,
1323+
runtimeMs: 5_000,
1324+
cliSessionBindings: {
1325+
"claude-cli": { sessionId: "old-claude-cli-session" },
1326+
"codex-cli": { sessionId: "old-codex-cli-session" },
13341327
},
1335-
canonicalKey: "agent:main:main",
1336-
});
1328+
cliSessionIds: {
1329+
"claude-cli": "old-claude-cli-session",
1330+
"codex-cli": "old-codex-cli-session",
1331+
},
1332+
claudeCliSessionId: "old-claude-cli-session",
1333+
},
1334+
canonicalKey: "agent:main:main",
1335+
});
13371336

1338-
const capturedEntry = await runMainAgentAndCaptureEntry(
1339-
"test-idem-terminal-main-newer-transcript",
1340-
);
1337+
const capturedEntry = await runMainAgentAndCaptureEntry(
1338+
"test-idem-terminal-main-newer-transcript",
1339+
);
13411340

1342-
const call = await waitForAgentCommandCall<{ sessionId?: string }>();
1343-
expect(call.sessionId).not.toBe("terminal-main-session");
1344-
expect(capturedEntry?.sessionId).not.toBe("terminal-main-session");
1345-
expect(capturedEntry?.status).toBeUndefined();
1346-
expect(capturedEntry?.startedAt).toBeUndefined();
1347-
expect(capturedEntry?.endedAt).toBeUndefined();
1348-
expect(capturedEntry?.runtimeMs).toBeUndefined();
1349-
expect(capturedEntry?.sessionFile).toBeUndefined();
1350-
expect(capturedEntry?.cliSessionBindings).toBeUndefined();
1351-
expect(capturedEntry?.cliSessionIds).toBeUndefined();
1352-
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
1353-
},
1354-
);
1341+
const call = await waitForAgentCommandCall<{ sessionId?: string }>();
1342+
if (scenario.expectReuse) {
1343+
expect(call.sessionId).toBe("terminal-main-session");
1344+
expect(capturedEntry?.sessionId).toBe("terminal-main-session");
1345+
return;
1346+
}
1347+
expect(call.sessionId).not.toBe("terminal-main-session");
1348+
expect(capturedEntry?.sessionId).not.toBe("terminal-main-session");
1349+
expect(capturedEntry?.status).toBeUndefined();
1350+
expect(capturedEntry?.startedAt).toBeUndefined();
1351+
expect(capturedEntry?.endedAt).toBeUndefined();
1352+
expect(capturedEntry?.runtimeMs).toBeUndefined();
1353+
expect(capturedEntry?.sessionFile).toBeUndefined();
1354+
expect(capturedEntry?.cliSessionBindings).toBeUndefined();
1355+
expect(capturedEntry?.cliSessionIds).toBeUndefined();
1356+
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
1357+
});
13551358
},
13561359
);
13571360

0 commit comments

Comments
 (0)