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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* review: separate failed/done rationale in comment

* fix(sessions): reuse completed main transcripts

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
SunnyShu
2026-07-06 17:09:55 +01:00
committed by GitHub
co-authored by Claude Opus 4.8 Peter Steinberger
parent de1bac6bc0
commit e580ed2f71
4 changed files with 106 additions and 64 deletions
+10 -1
View File
@@ -2203,11 +2203,20 @@ describe("initSessionState reset policy", () => {
expectNewSession: false,
},
{
name: "main terminal rows rotate when transcript is newer than updatedAt",
name: "main status-done terminal rows reuse when transcript is newer than updatedAt",
sessionKey: "agent:main:main",
updatedAtOffsetMs: -10_000,
endedAtOffsetMs: -11_000,
transcriptMtimeOffsetMs: 0,
expectNewSession: false,
},
{
name: "main killed terminal rows rotate when transcript is newer than updatedAt",
sessionKey: "agent:main:main",
status: "killed" as const,
updatedAtOffsetMs: -10_000,
endedAtOffsetMs: -11_000,
transcriptMtimeOffsetMs: 0,
expectNewSession: true,
},
{
+32 -7
View File
@@ -149,23 +149,44 @@ describe("agent session resolution", () => {
});
});
it("rotates stale terminal main sessions whose transcript is newer than the registry", async () => {
it("handles terminal main sessions whose transcript is newer than the registry", async () => {
const scenarios = [
{
label: "canonical main",
label: "canonical done main",
mainKey: "main",
sessionKey: "agent:main:main",
status: "done" as const,
expectNewSession: false,
},
{ label: "raw main alias", mainKey: "main", sessionKey: "main", status: "done" as const },
{
label: "custom main alias",
label: "raw done main alias",
mainKey: "main",
sessionKey: "main",
status: "done" as const,
expectNewSession: false,
},
{
label: "custom done main alias",
mainKey: "work",
sessionKey: "agent:main:main",
status: "done" as const,
expectNewSession: false,
},
{ label: "endedAt-only main", mainKey: "main", sessionKey: "agent:main:main" },
];
{
label: "killed main",
mainKey: "main",
sessionKey: "agent:main:main",
status: "killed" as const,
expectNewSession: true,
},
{
label: "endedAt-only main",
mainKey: "main",
sessionKey: "agent:main:main",
status: undefined,
expectNewSession: true,
},
] as const;
for (const scenario of scenarios) {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
@@ -205,7 +226,11 @@ describe("agent session resolution", () => {
const resolution = resolveSession({ cfg, sessionKey: scenario.sessionKey });
expect(resolution.isNewSession).toBe(true);
expect(resolution.isNewSession).toBe(scenario.expectNewSession);
if (!scenario.expectNewSession) {
expect(resolution.sessionId).toBe(sessionId);
return;
}
expect(resolution.sessionId).not.toBe(sessionId);
expect(resolution.sessionEntry?.sessionFile).toBeUndefined();
expect(resolution.sessionEntry?.status).toBeUndefined();
+5
View File
@@ -213,6 +213,11 @@ export function resolveTerminalMainSessionTranscriptRegistryCheck(
if (!hasTerminalLifecycle) {
return undefined;
}
if (params.entry.status === "done") {
// Successful rows stay reusable: transcript writes can land after registry
// updates without making the session stale.
return undefined;
}
if (params.entry.status === "failed") {
// Failed rows with a present transcript stay reusable for retry/recovery.
// Callers already rotate failed rows when the transcript is missing.
+59 -56
View File
@@ -1286,72 +1286,75 @@ describe("gateway agent handler", () => {
});
it.each([
{ name: "status terminal row", status: "done" as const },
{ name: "endedAt-only terminal row" },
{ name: "status-done row", status: "done" as const, expectReuse: true },
{ name: "status-killed row", status: "killed" as const, expectReuse: false },
{ name: "endedAt-only row", status: undefined, expectReuse: false },
])(
"rotates a terminal main session from a $name when its transcript is newer",
"handles a terminal main session from a $name when its transcript is newer",
async (scenario) => {
const now = Date.parse("2026-05-18T09:47:00.000Z");
vi.useFakeTimers({ toFake: ["Date"] });
dateOnlyFakeClockActive = true;
vi.setSystemTime(now);
await withTempDir(
{ prefix: "openclaw-gateway-terminal-main-newer-transcript-" },
async (root) => {
const sessionsDir = `${root}/sessions`;
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = "terminal-main-session.jsonl";
const transcriptPath = `${sessionsDir}/${sessionFile}`;
await fs.writeFile(
transcriptPath,
`${JSON.stringify({ type: "session", id: "terminal-main-session" })}\n`,
"utf8",
);
await fs.utimes(transcriptPath, new Date(now - 1_000), new Date(now - 1_000));
mocks.loadSessionEntry.mockReturnValue({
cfg: {},
storePath: `${sessionsDir}/sessions.json`,
entry: {
sessionId: "terminal-main-session",
sessionFile,
...(scenario.status ? { status: scenario.status } : {}),
updatedAt: now - 10_000,
sessionStartedAt: now - 60_000,
lastInteractionAt: now - 10_000,
startedAt: now - 20_000,
endedAt: now - 15_000,
runtimeMs: 5_000,
cliSessionBindings: {
"claude-cli": { sessionId: "old-claude-cli-session" },
"codex-cli": { sessionId: "old-codex-cli-session" },
},
cliSessionIds: {
"claude-cli": "old-claude-cli-session",
"codex-cli": "old-codex-cli-session",
},
claudeCliSessionId: "old-claude-cli-session",
await withTempDir({ prefix: "openclaw-gateway-terminal-main-newer-" }, async (root) => {
const sessionsDir = `${root}/sessions`;
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = "terminal-main-session.jsonl";
const transcriptPath = `${sessionsDir}/${sessionFile}`;
await fs.writeFile(
transcriptPath,
`${JSON.stringify({ type: "session", id: "terminal-main-session" })}\n`,
"utf8",
);
await fs.utimes(transcriptPath, new Date(now - 1_000), new Date(now - 1_000));
mocks.loadSessionEntry.mockReturnValue({
cfg: {},
storePath: `${sessionsDir}/sessions.json`,
entry: {
sessionId: "terminal-main-session",
sessionFile,
...(scenario.status ? { status: scenario.status } : {}),
updatedAt: now - 10_000,
sessionStartedAt: now - 60_000,
lastInteractionAt: now - 10_000,
startedAt: now - 20_000,
endedAt: now - 15_000,
runtimeMs: 5_000,
cliSessionBindings: {
"claude-cli": { sessionId: "old-claude-cli-session" },
"codex-cli": { sessionId: "old-codex-cli-session" },
},
canonicalKey: "agent:main:main",
});
cliSessionIds: {
"claude-cli": "old-claude-cli-session",
"codex-cli": "old-codex-cli-session",
},
claudeCliSessionId: "old-claude-cli-session",
},
canonicalKey: "agent:main:main",
});
const capturedEntry = await runMainAgentAndCaptureEntry(
"test-idem-terminal-main-newer-transcript",
);
const capturedEntry = await runMainAgentAndCaptureEntry(
"test-idem-terminal-main-newer-transcript",
);
const call = await waitForAgentCommandCall<{ sessionId?: string }>();
expect(call.sessionId).not.toBe("terminal-main-session");
expect(capturedEntry?.sessionId).not.toBe("terminal-main-session");
expect(capturedEntry?.status).toBeUndefined();
expect(capturedEntry?.startedAt).toBeUndefined();
expect(capturedEntry?.endedAt).toBeUndefined();
expect(capturedEntry?.runtimeMs).toBeUndefined();
expect(capturedEntry?.sessionFile).toBeUndefined();
expect(capturedEntry?.cliSessionBindings).toBeUndefined();
expect(capturedEntry?.cliSessionIds).toBeUndefined();
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
},
);
const call = await waitForAgentCommandCall<{ sessionId?: string }>();
if (scenario.expectReuse) {
expect(call.sessionId).toBe("terminal-main-session");
expect(capturedEntry?.sessionId).toBe("terminal-main-session");
return;
}
expect(call.sessionId).not.toBe("terminal-main-session");
expect(capturedEntry?.sessionId).not.toBe("terminal-main-session");
expect(capturedEntry?.status).toBeUndefined();
expect(capturedEntry?.startedAt).toBeUndefined();
expect(capturedEntry?.endedAt).toBeUndefined();
expect(capturedEntry?.runtimeMs).toBeUndefined();
expect(capturedEntry?.sessionFile).toBeUndefined();
expect(capturedEntry?.cliSessionBindings).toBeUndefined();
expect(capturedEntry?.cliSessionIds).toBeUndefined();
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
});
},
);