mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Claude Opus 4.8
Peter Steinberger
parent
de1bac6bc0
commit
e580ed2f71
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user