From 678a3c18a94188093e260807b44f4ac9e9bc6391 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 5 Aug 2026 17:24:12 -0700 Subject: [PATCH] fix(gateway): finish successful sessions after timestamp races (#119752) * fix(gateway): correlate session lifecycle by run * test(qa): expect settled handoffs to survive restart quietly * test(agents): split embedded run persistence coverage --- .../src/scenario-catalog-channels.test.ts | 2 + .../subagent-completion-direct-fallback.yaml | 46 +++++----- src/agents/command/session.ts | 3 +- .../runs.persistence.test.ts | 42 +++++++++ .../runs.test-support.ts | 7 ++ src/agents/embedded-agent-runner/runs.ts | 6 +- .../main-session-recovery-state.test.ts | 6 ++ src/agents/main-session-recovery-state.ts | 5 ++ ...ain-session-restart-recovery-checkpoint.ts | 2 + .../main-session-restart-recovery-failure.ts | 1 + .../reply/agent-runner-session-reset.test.ts | 4 +- .../reply/agent-runner-session-reset.ts | 3 +- ...dispatch-from-config.pending-final.test.ts | 45 +++++----- .../dispatch-from-config.pending-final.ts | 3 +- .../reply/restart-recovery-claim.test.ts | 51 +++++++++++ .../reply/restart-recovery-claim.ts | 2 + src/auto-reply/reply/session-fork.test.ts | 9 +- .../reply/session-parent-fork-prepare.ts | 6 +- .../session-accessor.conformance.test.ts | 9 +- .../session-accessor.sqlite-checkpoint.ts | 3 +- ...ession-accessor.sqlite-message-cut.test.ts | 8 +- .../session-accessor.sqlite-message-cut.ts | 3 +- .../session-accessor.sqlite-parent-session.ts | 8 +- ...session-transcript-turn-lifecycle.types.ts | 1 + src/config/sessions/terminal-status.ts | 3 +- src/config/sessions/types.ts | 2 + src/gateway/server-chat.agent-events.test.ts | 60 ++++++++++++- ...r-chat.load-gateway-session-row.runtime.ts | 2 +- src/gateway/server-chat.ts | 17 ++-- .../agent-session-patch.test.ts | 9 +- .../server-methods/agent-session-patch.ts | 3 +- .../server-methods/chat-restart-recovery.ts | 2 + src/gateway/session-create-fork-entry.test.ts | 4 +- src/gateway/session-create-fork-entry.ts | 3 +- src/gateway/session-lifecycle-state.test.ts | 85 +++++++++++++++++-- src/gateway/session-lifecycle-state.ts | 60 ++++++++++--- src/gateway/session-utils-search.ts | 65 ++++++++------ src/gateway/session-utils.ts | 1 + src/plugins/session-entry-slot-keys.ts | 1 + 39 files changed, 472 insertions(+), 120 deletions(-) create mode 100644 src/agents/embedded-agent-runner/runs.persistence.test.ts diff --git a/extensions/qa-lab/src/scenario-catalog-channels.test.ts b/extensions/qa-lab/src/scenario-catalog-channels.test.ts index 3a39eff097d7..5136eb531729 100644 --- a/extensions/qa-lab/src/scenario-catalog-channels.test.ts +++ b/extensions/qa-lab/src/scenario-catalog-channels.test.ts @@ -170,6 +170,8 @@ describe("qa scenario catalog channel contracts", () => { expect(flow).toContain("task.deliveryStatus === 'delivered'"); expect(flow).toContain("readSettledTerminalTask('restart')"); expect(flow).toContain("readSettledTerminalTask('empty')"); + expect(flow).toContain("postRestartUnexpectedPayloads.length === 0"); + expect(flow).not.toContain("interrupted by a gateway restart"); expect(flow).toContain("verdicts.length === 5"); expect(flow).not.toContain('"call":"sleep"'); }); diff --git a/qa/scenarios/agents/subagent-completion-direct-fallback.yaml b/qa/scenarios/agents/subagent-completion-direct-fallback.yaml index 8cbbf191d2c0..04735f2bdd5c 100644 --- a/qa/scenarios/agents/subagent-completion-direct-fallback.yaml +++ b/qa/scenarios/agents/subagent-completion-direct-fallback.yaml @@ -24,7 +24,7 @@ scenario: - Visible completion output reaches the originating QA DM exactly once. - Exact NO_REPLY completion output produces no channel delivery. - Genuinely empty completion output is represented intentionally once. - - Gateway restart does not replay prior terminal payloads, represents the interrupted handoff explicitly, and a post-restart completion is delivered exactly once. + - Gateway restart does not replay prior terminal payloads or synthesize interruption notices for already-settled handoffs, and a post-restart completion is delivered exactly once. - Direct fallback strips protected internal metadata before one channel delivery. docsRefs: - docs/tools/subagents.md @@ -179,29 +179,6 @@ flow: args: - ref: env - 180000 - - call: waitForCondition - args: - - lambda: - expr: "state.getSnapshot().messages.find((message) => message.direction === 'outbound' && verdicts.some((verdict) => verdict.conversationId === message.conversation.id) && !preRestartOutbound.some((before) => before.id === message.id) && String(message.text ?? '').includes('interrupted by a gateway restart'))" - - 60000 - - 250 - - set: postRestartOutbound - value: - expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && verdicts.some((verdict) => verdict.conversationId === message.conversation.id)).map((message) => ({ id: message.id, conversationId: message.conversation.id, text: String(message.text ?? '') }))" - - set: postRestartTerminalPayloads - value: - expr: "postRestartOutbound.filter((message) => verdicts.some((verdict) => verdict.capturedTerminalPayloads.includes(message.text)))" - - set: restartInterruptionPayloads - value: - expr: "postRestartOutbound.filter((message) => !preRestartOutbound.some((before) => before.id === message.id))" - - assert: - expr: "JSON.stringify(postRestartTerminalPayloads) === JSON.stringify(preRestartTerminalPayloads)" - message: - expr: "`Gateway restart replayed or lost a prior terminal payload: before=${JSON.stringify(preRestartTerminalPayloads)} after=${JSON.stringify(postRestartTerminalPayloads)}`" - - assert: - expr: "restartInterruptionPayloads.length === 1 && restartInterruptionPayloads.every((message) => message.text.includes('interrupted by a gateway restart'))" - message: - expr: "`Gateway restart did not represent the interrupted completion handoff exactly once: ${JSON.stringify(restartInterruptionPayloads)}`" - set: restartStartIndex value: expr: state.getSnapshot().messages.length @@ -241,9 +218,28 @@ flow: expr: "restartTask.title === 'qa-terminal-restart' && restartTask.status === 'completed' && restartTask.deliveryStatus === 'delivered'" message: expr: "`restart completion task lifecycle did not settle authoritatively; task=${JSON.stringify(restartTask)}`" + # Use a new durably delivered task as the observation boundary. By then, + # restart recovery must not have replayed or invented output for settled tasks. + - set: postRestartOutbound + value: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && verdicts.some((verdict) => verdict.conversationId === message.conversation.id)).map((message) => ({ id: message.id, conversationId: message.conversation.id, text: String(message.text ?? '') }))" + - set: postRestartTerminalPayloads + value: + expr: "postRestartOutbound.filter((message) => verdicts.some((verdict) => verdict.capturedTerminalPayloads.includes(message.text)))" + - set: postRestartUnexpectedPayloads + value: + expr: "postRestartOutbound.filter((message) => !preRestartOutbound.some((before) => before.id === message.id))" + - assert: + expr: "JSON.stringify(postRestartTerminalPayloads) === JSON.stringify(preRestartTerminalPayloads)" + message: + expr: "`Gateway restart replayed or lost a prior terminal payload: before=${JSON.stringify(preRestartTerminalPayloads)} after=${JSON.stringify(postRestartTerminalPayloads)}`" + - assert: + expr: "postRestartUnexpectedPayloads.length === 0" + message: + expr: "`Gateway restart synthesized output for already-settled handoffs: ${JSON.stringify(postRestartUnexpectedPayloads)}`" - set: appendRestartVerdict value: - expr: "verdicts.push({ case: 'restart', conversationId: restartConversationId, taskId: restartTask.taskId, taskDeliveryStatus: restartTask.deliveryStatus, inputDisposition: 'visible', restart: true, fallback: true, preRestartTerminalMessageCount: preRestartTerminalPayloads.length, postRestartTerminalPayloadCount: postRestartTerminalPayloads.length, priorTerminalPayloadReplayCount: postRestartTerminalPayloads.length - preRestartTerminalPayloads.length, interruptedHandoffRepresentationCount: restartInterruptionPayloads.length, interruptedHandoffPayloads: restartInterruptionPayloads.map((message) => message.text), expectedTerminalSendCount: 1, actualTerminalSendCount: restartMatches.length, capturedTerminalPayloads: restartMatches.map((message) => String(message.text ?? '')), silenceTokenLeaked: false, internalMetadataLeak: false, pass: true })" + expr: "verdicts.push({ case: 'restart', conversationId: restartConversationId, taskId: restartTask.taskId, taskDeliveryStatus: restartTask.deliveryStatus, inputDisposition: 'visible', restart: true, fallback: true, preRestartTerminalMessageCount: preRestartTerminalPayloads.length, postRestartTerminalPayloadCount: postRestartTerminalPayloads.length, priorTerminalPayloadReplayCount: postRestartTerminalPayloads.length - preRestartTerminalPayloads.length, unexpectedPostRestartPayloadCount: postRestartUnexpectedPayloads.length, unexpectedPostRestartPayloads: postRestartUnexpectedPayloads.map((message) => message.text), expectedTerminalSendCount: 1, actualTerminalSendCount: restartMatches.length, capturedTerminalPayloads: restartMatches.map((message) => String(message.text ?? '')), silenceTokenLeaked: false, internalMetadataLeak: false, pass: true })" - set: emptyStartIndex value: expr: state.getSnapshot().messages.length diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index 4051b0d8322d..a0bcc6100c73 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -27,7 +27,7 @@ import { import { resolveChannelResetConfig, resolveSessionResetType } from "../../config/sessions/reset.js"; import { listSessionEntries } from "../../config/sessions/session-accessor.js"; import { resolveSessionKey } from "../../config/sessions/session-key.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; +import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { classifySessionKeyShape, @@ -67,6 +67,7 @@ export function clearRotatedSessionMetadata(entry: SessionEntry): SessionEntry { ...entry, sessionFile: undefined, status: undefined, + lifecycleRunId: undefined, startedAt: undefined, endedAt: undefined, runtimeMs: undefined, diff --git a/src/agents/embedded-agent-runner/runs.persistence.test.ts b/src/agents/embedded-agent-runner/runs.persistence.test.ts new file mode 100644 index 000000000000..3422bacda82e --- /dev/null +++ b/src/agents/embedded-agent-runner/runs.persistence.test.ts @@ -0,0 +1,42 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js"; +import type { InternalSessionEntry } from "../../config/sessions/types.js"; +import { testing } from "./runs.test-support.js"; + +describe("embedded-agent runner persistence", () => { + afterEach(() => { + testing.resetActiveEmbeddedRuns(); + }); + + it("clears lifecycle ownership when a forced run clear persists killed state", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-force-clear-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:main"; + const entry: InternalSessionEntry = { + lifecycleRunId: "stuck-run", + sessionId: "session-stuck", + startedAt: 10, + status: "running", + updatedAt: 20, + }; + try { + await replaceSessionEntry({ storePath, sessionKey }, entry); + await testing.persistForceClearedEmbeddedRunTerminalState({ + sessionId: entry.sessionId, + sessionKey, + startedAt: entry.startedAt, + storePath, + updatedAt: entry.updatedAt, + }); + + const persisted = loadSessionEntry({ storePath, sessionKey }) as InternalSessionEntry; + expect(persisted.status).toBe("killed"); + expect(persisted.lifecycleRunId).toBeUndefined(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/embedded-agent-runner/runs.test-support.ts b/src/agents/embedded-agent-runner/runs.test-support.ts index 87a4ec773366..e14e6ce14bac 100644 --- a/src/agents/embedded-agent-runner/runs.test-support.ts +++ b/src/agents/embedded-agent-runner/runs.test-support.ts @@ -1,6 +1,13 @@ import "./runs.js"; type EmbeddedRunsTestApi = { + persistForceClearedEmbeddedRunTerminalState(params: { + sessionId: string; + sessionKey: string; + startedAt?: number; + storePath: string; + updatedAt: number; + }): Promise; resetActiveEmbeddedRuns(): void; }; diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts index 2de193c90e5e..b029a7416f70 100644 --- a/src/agents/embedded-agent-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -25,6 +25,7 @@ import { import { getRuntimeConfig } from "../../config/io.js"; import { resolveStorePath } from "../../config/sessions/paths.js"; import { loadSessionEntry, updateSessionEntry } from "../../config/sessions/session-accessor.js"; +import type { InternalSessionEntry } from "../../config/sessions/types.js"; import { getAgentEventLifecycleGeneration, isAgentEventLifecycleGenerationCurrent, @@ -958,7 +959,8 @@ async function persistForceClearedEmbeddedRunTerminalState(params: { try { await updateSessionEntry( { sessionKey: params.sessionKey, storePath: params.storePath }, - (entry) => { + (storedEntry) => { + const entry = storedEntry as InternalSessionEntry; // A replacement can reuse the session id; bind this patch to both owners' exact snapshot. if ( ACTIVE_EMBEDDED_RUNS.has(params.sessionId) || @@ -976,6 +978,7 @@ async function persistForceClearedEmbeddedRunTerminalState(params: { return { status: "killed", abortedLastRun: true, + lifecycleRunId: undefined, endedAt, updatedAt: endedAt, }; @@ -1130,6 +1133,7 @@ function forceClearEmbeddedAgentRun( } const testing = { + persistForceClearedEmbeddedRunTerminalState, resetActiveEmbeddedRuns() { for (const waiters of EMBEDDED_RUN_WAITERS.values()) { for (const waiter of waiters) { diff --git a/src/agents/main-session-recovery-state.test.ts b/src/agents/main-session-recovery-state.test.ts index a9b02eb4ed58..8e420b3e440d 100644 --- a/src/agents/main-session-recovery-state.test.ts +++ b/src/agents/main-session-recovery-state.test.ts @@ -130,6 +130,7 @@ describe("main session recovery state", () => { it("marks without charging and replaces an older lifecycle owner for the same run", () => { const entry = interruptedEntry({ + lifecycleRunId: "dead-run", restartRecoveryRuns: [ { runId: "older-run", lifecycleGeneration: "generation-old" }, { runId: "shared-run", lifecycleGeneration: "generation-1" }, @@ -161,6 +162,7 @@ describe("main session recovery state", () => { { runId: "older-run", lifecycleGeneration: "generation-old" }, { runId: "shared-run", lifecycleGeneration: "generation-2" }, ]); + expect(entry.lifecycleRunId).toBeUndefined(); }); it("rejects foreground work after the automatic recovery budget is exhausted", () => { @@ -343,6 +345,7 @@ describe("main session recovery state", () => { }, }); expect(entry.mainRestartRecovery?.reservation).toBeUndefined(); + expect(entry.lifecycleRunId).toBe("recovery-1"); expect( transitionMainSessionRecovery(entry, { @@ -358,6 +361,7 @@ describe("main session recovery state", () => { expect(entry.abortedLastRun).toBe(true); expect(entry.restartRecoveryDeliveryRunId).toBeUndefined(); expect(entry.restartRecoveryDeliverySourceRunId).toBe("source-1"); + expect(entry.lifecycleRunId).toBeUndefined(); }); it("rejects a reservation created by an older lifecycle generation", () => { @@ -565,6 +569,7 @@ describe("main session recovery state", () => { it("tombstones an exhausted cycle and exposes only the Doctor repair action", () => { const entry = interruptedEntry({ + lifecycleRunId: "exhausted-run", mainRestartRecovery: recoveryState({ chargedAttempts: 3, }), @@ -584,6 +589,7 @@ describe("main session recovery state", () => { }), ).toEqual({ kind: "tombstoned" }); expect(entry.mainRestartRecovery?.tombstone?.reason).toBe(view.reason); + expect(entry.lifecycleRunId).toBeUndefined(); expect(observe(entry, "generation-1")).toEqual({ status: "tombstoned" }); entry.abortedLastRun = true; diff --git a/src/agents/main-session-recovery-state.ts b/src/agents/main-session-recovery-state.ts index 2fffe9672be9..67a1efb26633 100644 --- a/src/agents/main-session-recovery-state.ts +++ b/src/agents/main-session-recovery-state.ts @@ -266,6 +266,7 @@ export function transitionMainSessionRecovery( entry.mainRestartRecovery = createCycle(command.cycleId); } entry.status = "running"; + entry.lifecycleRunId = undefined; entry.abortedLastRun = true; if (command.resetRuntime) { entry.startedAt = undefined; @@ -419,6 +420,7 @@ export function transitionMainSessionRecovery( foregroundClaims: undefined, }); entry.abortedLastRun = false; + entry.lifecycleRunId = command.runId; recordLifecycleFence(entry, { runId: command.runId, lifecycleGeneration: command.lifecycleGeneration, @@ -449,6 +451,7 @@ export function transitionMainSessionRecovery( return { kind: "rejected", reason: "stale_reservation" }; } entry.status = "running"; + entry.lifecycleRunId = undefined; entry.abortedLastRun = true; entry.startedAt = undefined; entry.endedAt = undefined; @@ -599,6 +602,7 @@ export function transitionMainSessionRecovery( }); entry.abortedLastRun = false; entry.status = "failed"; + entry.lifecycleRunId = undefined; entry.endedAt = command.now; entry.runtimeMs = Math.max(0, command.now - (entry.startedAt ?? command.now)); entry.updatedAt = command.now; @@ -611,6 +615,7 @@ export function transitionMainSessionRecovery( } const noticeEntry = structuredClone(entry); entry.status = "failed"; + entry.lifecycleRunId = undefined; entry.abortedLastRun = true; entry.endedAt = command.now; entry.updatedAt = command.now; diff --git a/src/agents/main-session-restart-recovery-checkpoint.ts b/src/agents/main-session-restart-recovery-checkpoint.ts index 5718cd6c2794..458a6dba8f51 100644 --- a/src/agents/main-session-restart-recovery-checkpoint.ts +++ b/src/agents/main-session-restart-recovery-checkpoint.ts @@ -66,6 +66,7 @@ export async function reconcileInterruptedCompletionReport(params: { ...buildRestartRecoveryClaimCleanupPatch({ entry, recordTerminalSource: false }), ...buildMainSessionRecoveryClearPatch(entry), status: "killed", + lifecycleRunId: undefined, abortedLastRun: false, endedAt, lastRunError: undefined, @@ -315,6 +316,7 @@ export async function markSessionCompletedAfterRecoveryCheckpoint(params: { terminalSourceRunId: expectedRecoverySourceRunId, }), abortedLastRun: false, + lifecycleRunId: undefined, endedAt, pendingFinalDelivery: undefined, restartRecoveryForceSafeTools: undefined, diff --git a/src/agents/main-session-restart-recovery-failure.ts b/src/agents/main-session-restart-recovery-failure.ts index 95db9104f29b..589d18eb1213 100644 --- a/src/agents/main-session-restart-recovery-failure.ts +++ b/src/agents/main-session-restart-recovery-failure.ts @@ -81,6 +81,7 @@ export async function tombstoneMainRestartRecoveryWithNotice(params: { sessionLifecyclePatch: { abortedLastRun: false, endedAt: now, + lifecycleRunId: undefined, mainRestartRecovery: { ...recoveryState, revision: recoveryState.revision + 1, diff --git a/src/auto-reply/reply/agent-runner-session-reset.test.ts b/src/auto-reply/reply/agent-runner-session-reset.test.ts index 14e8525aa356..a950cb11bf7b 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.test.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { SessionEntry } from "../../config/sessions.js"; +import type { InternalSessionEntry as SessionEntry } from "../../config/sessions.js"; import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptMessage, @@ -78,6 +78,7 @@ describe("resetReplyRunSession", () => { sessionId: "session", updatedAt: 1, sessionFile: path.join(rootDir, "session.jsonl"), + lifecycleRunId: "run-before-reset", agentHarnessId: "codex", claudeCliSessionId: "native-before-boundary", modelProvider: "qwencode", @@ -150,6 +151,7 @@ describe("resetReplyRunSession", () => { expect(isNewSession).toBe(true); expect(activeSessionEntry?.sessionId).toBe("session"); expect(activeSessionEntry?.lifecycleRevision).toBe("00000000-0000-0000-0000-000000000123"); + expect(activeSessionEntry?.lifecycleRunId).toBeUndefined(); expect(followupRun.run.sessionId).toBe(activeSessionEntry?.sessionId); expect(activeSessionEntry?.modelProvider).toBeUndefined(); expect(activeSessionEntry?.agentHarnessId).toBeUndefined(); diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index 9587db3b0be4..4c439e9cbafc 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -3,7 +3,7 @@ import { clearAllCliSessions } from "../../agents/cli-session.js"; import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/registry.js"; // Handles session reset requests produced during agent runner execution. import { transitionMainSessionRecovery } from "../../agents/main-session-recovery-state.js"; -import type { SessionEntry } from "../../config/sessions.js"; +import type { InternalSessionEntry as SessionEntry } from "../../config/sessions.js"; import { persistSessionResetLifecycle } from "../../config/sessions/session-accessor.js"; import { generateSecureUuid } from "../../infra/secure-random.js"; import { defaultRuntime } from "../../runtime.js"; @@ -79,6 +79,7 @@ export async function resetReplyRunSession(params: { lastInteractionAt: now, systemSent: false, abortedLastRun: false, + lifecycleRunId: undefined, modelProvider: undefined, model: undefined, inputTokens: undefined, diff --git a/src/auto-reply/reply/dispatch-from-config.pending-final.test.ts b/src/auto-reply/reply/dispatch-from-config.pending-final.test.ts index 452621c828fd..ed7e84b938bb 100644 --- a/src/auto-reply/reply/dispatch-from-config.pending-final.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.pending-final.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; +import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js"; import type { ReplyPayload } from "../reply-payload.js"; import { capturePendingFinalDeliveryIdentity, @@ -29,10 +29,11 @@ describe("pending final delivery restart proof", () => { async function writePendingFinal( beforeAgentReplyState: "continue" | "handled-reply", ): Promise { - await replaceSessionEntry({ storePath, sessionKey }, { + const entry: SessionEntry = { sessionId: "session", status: "running", startedAt: 10, + lifecycleRunId: "active-run", updatedAt: Date.now(), pendingFinalDelivery: { kind: "replayable", @@ -43,7 +44,8 @@ describe("pending final delivery restart proof", () => { restartRecoveryBeforeAgentReplyState: beforeAgentReplyState, restartRecoveryForceSafeTools: beforeAgentReplyState === "handled-reply" ? true : undefined, restartRecoverySourceIngress: "channel", - } satisfies SessionEntry); + }; + await replaceSessionEntry({ storePath, sessionKey }, entry); } it.each(["continue", "handled-reply"] as const)( @@ -58,12 +60,15 @@ describe("pending final delivery restart proof", () => { await clearPendingFinalDeliveryAfterSuccess({ identity, sessionKey, storePath }); - const entry = loadSessionEntry({ sessionKey, storePath }); + const entry = loadSessionEntry({ sessionKey, storePath }) as SessionEntry | undefined; expect(entry?.pendingFinalDelivery).toBeUndefined(); expect(entry?.restartRecoveryBeforeAgentReplyState).toBeUndefined(); expect(entry?.restartRecoveryForceSafeTools).toBeUndefined(); expect(entry?.restartRecoverySourceIngress).toBeUndefined(); expect(entry?.status).toBe(beforeAgentReplyState === "handled-reply" ? "done" : "running"); + expect(entry?.lifecycleRunId).toBe( + beforeAgentReplyState === "handled-reply" ? undefined : "active-run", + ); if (beforeAgentReplyState === "handled-reply") { expect(entry?.endedAt).toBeTypeOf("number"); expect(entry?.runtimeMs).toBeGreaterThanOrEqual(0); @@ -72,22 +77,21 @@ describe("pending final delivery restart proof", () => { ); it("finalizes a media-only hook turn after its exact transport intent succeeds", async () => { - await replaceSessionEntry( - { storePath, sessionKey }, - { - sessionId: "session", - status: "running", - startedAt: 10, - updatedAt: Date.now(), - pendingFinalDelivery: { - kind: "transport-only", - createdAt: Date.now(), - intentId: "intent-media", - }, - restartRecoveryBeforeAgentReplyState: "handled-unrecoverable", - restartRecoverySourceIngress: "channel", + const entry: SessionEntry = { + sessionId: "session", + status: "running", + startedAt: 10, + lifecycleRunId: "media-run", + updatedAt: Date.now(), + pendingFinalDelivery: { + kind: "transport-only", + createdAt: Date.now(), + intentId: "intent-media", }, - ); + restartRecoveryBeforeAgentReplyState: "handled-unrecoverable", + restartRecoverySourceIngress: "channel", + }; + await replaceSessionEntry({ storePath, sessionKey }, entry); const identity = capturePendingFinalDeliveryIdentity({ intentId: "intent-media", sessionKey, @@ -100,6 +104,9 @@ describe("pending final delivery restart proof", () => { status: "done", abortedLastRun: false, }); + expect( + (loadSessionEntry({ sessionKey, storePath }) as SessionEntry | undefined)?.lifecycleRunId, + ).toBeUndefined(); }); it("keeps normal-turn provenance when transport fails before delivery", async () => { diff --git a/src/auto-reply/reply/dispatch-from-config.pending-final.ts b/src/auto-reply/reply/dispatch-from-config.pending-final.ts index 19703e2e4302..f0098431f190 100644 --- a/src/auto-reply/reply/dispatch-from-config.pending-final.ts +++ b/src/auto-reply/reply/dispatch-from-config.pending-final.ts @@ -3,7 +3,7 @@ import { loadSessionEntryReadOnly, updateSessionEntry, } from "../../config/sessions/session-accessor.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; +import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js"; import type { ReplyPayload } from "../reply-payload.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; import { @@ -47,6 +47,7 @@ function buildPendingFinalDeliveryCleanupPatch(entry: SessionEntry): Partial { + it.each([ + { receiptState: undefined, expectedStatus: "done" }, + { receiptState: "terminal-pending" as const, expectedStatus: "failed" }, + ])( + "clears lifecycle ownership when claim cleanup settles $expectedStatus", + async ({ receiptState, expectedStatus }) => { + const root = tempDirs.make(`openclaw-reply-claim-${expectedStatus}-`); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:main"; + const sessionId = "session"; + let entry: InternalSessionEntry = { + abortedLastRun: false, + lifecycleRunId: "recovery-run", + restartRecoveryBeforeAgentReplyState: "admitted", + restartRecoveryDeliveryRunId: "recovery-run", + sessionId, + startedAt: 1, + status: "running", + updatedAt: 1, + }; + await replaceSessionEntry({ storePath, sessionKey }, entry); + const controller = createReplyRestartRecoveryClaimController({ + admissionRunId: "recovery-run", + getEntry: () => entry, + getSessionId: () => sessionId, + isRestartAbort: () => false, + resolveDeliveryContext: () => undefined, + sessionKey, + setEntry: (next) => { + entry = next; + }, + storePath, + }); + + await expect(controller.admitUserTurn()).resolves.toBe("admitted"); + if (receiptState) { + entry = (await updateSessionEntry({ storePath, sessionKey }, () => ({ + restartRecoveryDeliveryReceiptState: receiptState, + }))) as InternalSessionEntry; + } else { + await expect(controller.beginBeforeAgentReply()).resolves.toBe(true); + await controller.checkpointBeforeAgentReply({ state: "handled-silent" }); + } + await controller.clear(); + + const persisted = loadSessionEntry({ storePath, sessionKey }) as InternalSessionEntry; + expect(persisted.status).toBe(expectedStatus); + expect(persisted.lifecycleRunId).toBeUndefined(); + }, + ); + it("retargets durable user-turn admission to the prepared reply session", async () => { const root = tempDirs.make("openclaw-reply-admission-"); const storePath = path.join(root, "sessions.json"); diff --git a/src/auto-reply/reply/restart-recovery-claim.ts b/src/auto-reply/reply/restart-recovery-claim.ts index 6d27bafb5d94..eba72c441c6d 100644 --- a/src/auto-reply/reply/restart-recovery-claim.ts +++ b/src/auto-reply/reply/restart-recovery-claim.ts @@ -499,6 +499,7 @@ export function createReplyRestartRecoveryClaimController(params: { }), abortedLastRun: true, endedAt, + lifecycleRunId: undefined, pendingFinalDelivery: undefined, runtimeMs: typeof current.startedAt === "number" @@ -532,6 +533,7 @@ export function createReplyRestartRecoveryClaimController(params: { ? { abortedLastRun: false, endedAt, + lifecycleRunId: undefined, runtimeMs: typeof current.startedAt === "number" ? Math.max(0, endedAt - current.startedAt) diff --git a/src/auto-reply/reply/session-fork.test.ts b/src/auto-reply/reply/session-fork.test.ts index dcfa7b0adfcb..4c32093ee03f 100644 --- a/src/auto-reply/reply/session-fork.test.ts +++ b/src/auto-reply/reply/session-fork.test.ts @@ -9,6 +9,7 @@ import { replaceSessionEntry, } from "../../config/sessions/session-accessor.js"; import { replaceSqliteTranscriptEvents } from "../../config/sessions/session-accessor.sqlite.js"; +import type { InternalSessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { forkSessionEntryFromParent, @@ -165,10 +166,15 @@ describe("forkSessionEntryFromParent", () => { ], ); + const fallbackEntry: InternalSessionEntry = { + lifecycleRunId: "pre-fork-run", + sessionId: "", + updatedAt: 2, + }; const result = await forkSessionEntryFromParent({ agentId: "main", config: { session: { store: configStorePath } } as OpenClawConfig, - fallbackEntry: { sessionId: "", updatedAt: 2 }, + fallbackEntry, parentSessionKey, parentStoreKeys: [parentSessionKey], sessionKey, @@ -196,6 +202,7 @@ describe("forkSessionEntryFromParent", () => { sessionId: result.fork.sessionId, updatedAt: expect.any(Number), }); + expect((stored as InternalSessionEntry | undefined)?.lifecycleRunId).toBeUndefined(); expect(loadSessionEntry({ agentId: "main", sessionKey: staleSessionKey, storePath })).toBe( undefined, ); diff --git a/src/auto-reply/reply/session-parent-fork-prepare.ts b/src/auto-reply/reply/session-parent-fork-prepare.ts index ee58510ee167..3357e23a792e 100644 --- a/src/auto-reply/reply/session-parent-fork-prepare.ts +++ b/src/auto-reply/reply/session-parent-fork-prepare.ts @@ -1,6 +1,6 @@ // Prepares parent-context fork metadata for guarded reply session initialization. import { buildMainSessionRecoveryClearPatch } from "../../agents/main-session-recovery-clear.js"; -import type { SessionEntry } from "../../config/sessions.js"; +import type { InternalSessionEntry, SessionEntry } from "../../config/sessions.js"; import { forkSessionFromParent, resolveParentForkDecision } from "./session-fork.js"; export async function prepareReplySessionParentFork(params: { @@ -54,10 +54,11 @@ export async function prepareReplySessionParentFork(params: { ); // The fork replaces this thread's transcript identity; recovery state from // the preseed row must not govern a later interruption of the fork. - return { + const forkedEntry: InternalSessionEntry = { ...params.sessionEntry, ...buildMainSessionRecoveryClearPatch(params.sessionEntry), sessionId: fork.sessionId, + lifecycleRunId: undefined, forkSource: { sessionKey: params.parentSessionKey, sessionId: parentEntry.sessionId, @@ -66,4 +67,5 @@ export async function prepareReplySessionParentFork(params: { totalTokens: undefined, totalTokensFresh: false, }; + return forkedEntry; } diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index 9216efdeb0c0..d2f04695634b 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -66,7 +66,7 @@ import { upsertSqliteSessionEntry, } from "./session-accessor.sqlite.js"; import { setCanonicalSqliteSessionMainKey } from "./session-canonical-key.js"; -import type { SessionCompactionCheckpoint, SessionEntry } from "./types.js"; +import type { InternalSessionEntry, SessionCompactionCheckpoint, SessionEntry } from "./types.js"; // Keep accessor conformance independent of any real openclaw.json on the machine. vi.mock("../config.js", async () => ({ @@ -2091,12 +2091,14 @@ describe("sqlite session normalization", () => { message: { content: "post-two" }, }, ]); - await upsertSqliteSessionEntry(sourceEntryScope, { + const sourceEntry: InternalSessionEntry = { label: "Source", + lifecycleRunId: "source-run", sessionId: "source-session", updatedAt: 10, compactionCheckpoints: [checkpoint], - }); + }; + await upsertSqliteSessionEntry(sourceEntryScope, sourceEntry); const notify = vi.fn(); const unsubscribe = onSessionIdentityMutation(notify); @@ -2134,6 +2136,7 @@ describe("sqlite session normalization", () => { totalTokensFresh: true, }), ); + expect((result.entry as InternalSessionEntry).lifecycleRunId).toBeUndefined(); await expect(loadSqliteTranscriptEvents(branchScope)).resolves.toEqual([ expect.objectContaining({ type: "session", id: result.entry.sessionId }), expect.objectContaining({ id: "pre-msg", type: "message" }), diff --git a/src/config/sessions/session-accessor.sqlite-checkpoint.ts b/src/config/sessions/session-accessor.sqlite-checkpoint.ts index e3bcf6ce9af2..885853b07dc2 100644 --- a/src/config/sessions/session-accessor.sqlite-checkpoint.ts +++ b/src/config/sessions/session-accessor.sqlite-checkpoint.ts @@ -27,7 +27,7 @@ import { readTranscriptIdentityByEventId, } from "./session-accessor.sqlite-transcript-store.js"; import { createSessionTranscriptHeader } from "./transcript-header.js"; -import type { SessionCompactionCheckpoint, SessionEntry } from "./types.js"; +import type { InternalSessionEntry as SessionEntry, SessionCompactionCheckpoint } from "./types.js"; // Compaction checkpoint branch/restore owner. @@ -428,6 +428,7 @@ function cloneSqliteCheckpointSessionEntry(params: { updatedAt: Date.now(), systemSent: false, abortedLastRun: false, + lifecycleRunId: undefined, startedAt: undefined, endedAt: undefined, runtimeMs: undefined, diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts index 342522a4c4ee..7bc043a9a059 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts @@ -24,6 +24,7 @@ import { upsertSessionEntry, } from "./session-accessor.js"; import { listSqliteSessionBranches } from "./session-accessor.sqlite.js"; +import type { InternalSessionEntry } from "./types.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); const agentId = "main"; @@ -74,7 +75,7 @@ async function createSession(options: { activeLeafTarget?: string } = {}) { const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const sessionId = "message-cut-source"; const scope = { agentId, env, sessionId, sessionKey }; - await upsertSessionEntry(scope, { + const entry: InternalSessionEntry = { agentHarnessId: "embedded", claudeCliSessionId: "claude-conversation", cliSessionBindings: { "claude-cli": { sessionId: "claude-conversation" } }, @@ -89,12 +90,14 @@ async function createSession(options: { activeLeafTarget?: string } = {}) { }), forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" }, lifecycleRevision: "source-lifecycle-revision", + lifecycleRunId: "source-run", modelOverride: "gpt-5", modelOverrideSource: "user", providerOverride: "openai", sessionId, updatedAt: Date.now(), - }); + }; + await upsertSessionEntry(scope, entry); for (const event of [ { type: "session", id: sessionId, version: 3, timestamp: "2026-07-18T00:00:00.000Z" }, { @@ -495,6 +498,7 @@ describe("SQLite session message cuts", () => { ).toEqual([result.entry.sessionId, "user-1", "assistant-1"]); expect(loadSessionEntry(scope)?.sessionId).toBe(scope.sessionId); expect(result.entry.lifecycleRevision).not.toBe("source-lifecycle-revision"); + expect((result.entry as InternalSessionEntry).lifecycleRunId).toBeUndefined(); expect(result.entry.cliSessionBindings).toBeUndefined(); expect(deliveryContextFromSession(result.entry)).toBeUndefined(); expect(result.entry.parentSessionKey).toBe(canonicalSourceKey); diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.ts b/src/config/sessions/session-accessor.sqlite-message-cut.ts index e16f72edf370..9a429f299299 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.ts @@ -46,7 +46,7 @@ import { selectSessionTranscriptTreePathNodes, type SessionTranscriptTree, } from "./transcript-tree.js"; -import type { SessionEntry } from "./types.js"; +import type { InternalSessionEntry as SessionEntry } from "./types.js"; type MessageCut = { editorText?: string; @@ -475,6 +475,7 @@ function cloneMessageCutSessionEntry(params: { updatedAt: Date.now(), systemSent: false, abortedLastRun: false, + lifecycleRunId: undefined, startedAt: undefined, endedAt: undefined, runtimeMs: undefined, diff --git a/src/config/sessions/session-accessor.sqlite-parent-session.ts b/src/config/sessions/session-accessor.sqlite-parent-session.ts index 3d86aee772eb..0fa6cd8a4d49 100644 --- a/src/config/sessions/session-accessor.sqlite-parent-session.ts +++ b/src/config/sessions/session-accessor.sqlite-parent-session.ts @@ -48,7 +48,7 @@ import { } from "./session-accessor.sqlite-scope.js"; import { appendTranscriptEventsInTransaction } from "./session-accessor.sqlite-transcript-store.js"; import { preserveSqliteSameKeySessionRolloverLineage } from "./session-entry-lineage.js"; -import type { SessionEntry } from "./types.js"; +import type { InternalSessionEntry, SessionEntry } from "./types.js"; import { mergeSessionEntry, resolveFreshSessionTotalTokens } from "./types.js"; // Parent-session fork owner: decision, transcript copy, and child entry commit. @@ -218,15 +218,17 @@ export async function forkSqliteSessionEntryFromParentTarget( fork: fork.transcript, parentEntry: cloneSessionEntry(freshParent), }); - const next = mergeSessionEntry(freshBase, { + const forkIdentityPatch: Partial = { ...patch, forkSource: { sessionKey: parentTarget.canonicalKey, sessionId: freshParent.sessionId, }, forkedFromParent: true, + lifecycleRunId: undefined, sessionId: fork.transcript.sessionId, - }); + }; + const next = mergeSessionEntry(freshBase, forkIdentityPatch); previousIdentity = readSqliteSessionIdentitySnapshot(writeDatabase, sessionTarget.storeKeys); writeSessionEntry(writeDatabase, sessionTarget.canonicalKey, next, { previousEntry: freshBase, diff --git a/src/config/sessions/session-transcript-turn-lifecycle.types.ts b/src/config/sessions/session-transcript-turn-lifecycle.types.ts index 69540679b6e7..cf5213f9d2be 100644 --- a/src/config/sessions/session-transcript-turn-lifecycle.types.ts +++ b/src/config/sessions/session-transcript-turn-lifecycle.types.ts @@ -28,6 +28,7 @@ export type SessionTranscriptTurnExpectedState = { export type SessionTranscriptTurnLifecyclePatch = { abortedLastRun?: boolean; endedAt?: number; + lifecycleRunId?: SessionEntry["lifecycleRunId"]; pendingFinalDelivery?: SessionEntry["pendingFinalDelivery"]; mainRestartRecovery?: SessionEntry["mainRestartRecovery"]; restartRecoveryBeforeAgentReplyState?: SessionRestartRecoveryState["restartRecoveryBeforeAgentReplyState"]; diff --git a/src/config/sessions/terminal-status.ts b/src/config/sessions/terminal-status.ts index e25cdfcbb7d1..98ca48994701 100644 --- a/src/config/sessions/terminal-status.ts +++ b/src/config/sessions/terminal-status.ts @@ -1,4 +1,4 @@ -import type { SessionEntry } from "./types.js"; +import type { InternalSessionEntry as SessionEntry } from "./types.js"; /** Returns true for terminal statuses that a later visible turn may recover in place. */ export function isRecoverableTerminalSessionStatus( @@ -12,6 +12,7 @@ export function recoverTerminalSessionEntryForVisibleTurn(entry: SessionEntry): return { ...entry, status: undefined, + lifecycleRunId: undefined, startedAt: undefined, endedAt: undefined, runtimeMs: undefined, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index c6d70361f127..216f13ceca4d 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -597,6 +597,8 @@ export interface SessionEntry extends SessionEntryCore {} /** Internal durable fields excluded from public/plugin session projections. */ export type InternalSessionEntryCore = SessionEntryCore & { + /** Run that owns the current non-terminal Gateway lifecycle projection. */ + lifecycleRunId?: string; mainRestartRecovery?: MainRestartRecoveryState; }; diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 844e5984b75a..591d5ab150b9 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -24,6 +24,7 @@ import { subscribePluginSessionsChanged } from "../plugins/gateway-events.js"; const persistGatewaySessionLifecycleEventMock = vi.fn(); const logErrorMock = vi.fn(); const normalizeLiveAssistantBufferedTextMock = vi.hoisted(() => vi.fn()); +const loadGatewaySessionRow = vi.hoisted(() => vi.fn()); vi.mock("./server-chat.persist-session-lifecycle.runtime.js", () => ({ persistGatewaySessionLifecycleEvent: (...args: unknown[]) => @@ -58,7 +59,7 @@ vi.mock("../infra/heartbeat-visibility.js", () => ({ })); vi.mock("./server-chat.load-gateway-session-row.runtime.js", () => ({ - loadGatewaySessionRow: vi.fn(), + loadGatewaySessionLifecycleSnapshot: vi.fn(), })); vi.mock("./session-utils.js", () => { @@ -96,7 +97,7 @@ import { resolveChatErrorKindFromError, type AgentEventHandlerOptions, } from "./server-chat.js"; -import { loadGatewaySessionRow } from "./server-chat.load-gateway-session-row.runtime.js"; +import { loadGatewaySessionLifecycleSnapshot } from "./server-chat.load-gateway-session-row.runtime.js"; import { loadSessionEntry } from "./session-utils.js"; function waitForFast( @@ -127,6 +128,13 @@ describe("agent event handler", () => { legacyKey: undefined, }); vi.mocked(loadGatewaySessionRow).mockReset().mockReturnValue(null); + vi.mocked(loadGatewaySessionLifecycleSnapshot) + .mockReset() + .mockImplementation((sessionKey, options) => ({ + row: options + ? loadGatewaySessionRow(sessionKey, options) + : loadGatewaySessionRow(sessionKey), + })); persistGatewaySessionLifecycleEventMock.mockReset().mockResolvedValue(undefined); logErrorMock.mockReset(); normalizeLiveAssistantBufferedTextMock.mockReset(); @@ -174,7 +182,7 @@ describe("agent event handler", () => { toolEventRecipients, sessionEventSubscribers, sessionMessageSubscribers, - loadGatewaySessionRowForSnapshot: loadGatewaySessionRow, + loadGatewaySessionLifecycleSnapshotForEvent: loadGatewaySessionLifecycleSnapshot, lifecycleErrorRetryGraceMs: params?.lifecycleErrorRetryGraceMs, isChatSendRunActive: params?.isChatSendRunActive, clearTrackedActiveRun: params?.clearTrackedActiveRun ?? clearTrackedActiveRun, @@ -2212,6 +2220,52 @@ describe("agent event handler", () => { } }); + it.each([ + { eventRunId: "run-current", expectedStartedAt: 1_900 }, + { eventRunId: "run-older", expectedStartedAt: 2_000 }, + ])( + "projects older lifecycle timestamps only for the owning run ($eventRunId)", + async ({ eventRunId, expectedStartedAt }) => { + vi.mocked(loadGatewaySessionLifecycleSnapshot).mockReturnValue({ + lifecycleRunId: "run-current", + row: { + key: "session-owned", + kind: "direct", + sessionId: "session-id", + updatedAt: 2_000, + status: "running", + startedAt: 2_000, + }, + }); + const { broadcastToConnIds, sessionEventSubscribers, handler } = createHarness({ + lifecycleErrorRetryGraceMs: 0, + }); + sessionEventSubscribers.subscribe("conn-session"); + + emitAgentEvent( + handler, + eventRunId, + "lifecycle", + { phase: "start", startedAt: 1_900 }, + { sessionKey: "session-owned", sessionId: "session-id", ts: 2_200 }, + ); + + await waitForFast(() => { + expect( + broadcastToConnIds.mock.calls.filter(([event]) => event === "sessions.changed"), + ).toHaveLength(1); + }); + const payload = broadcastToConnIds.mock.calls.find( + ([event]) => event === "sessions.changed", + )?.[1]; + expectPayloadFields(payload, { + sessionKey: "session-owned", + status: "running", + startedAt: expectedStartedAt, + }); + }, + ); + it("suppresses late interrupted pre-restart lifecycle events from live projections", () => { mockSessionEntry( { diff --git a/src/gateway/server-chat.load-gateway-session-row.runtime.ts b/src/gateway/server-chat.load-gateway-session-row.runtime.ts index b22979b924c7..071bbb64e313 100644 --- a/src/gateway/server-chat.load-gateway-session-row.runtime.ts +++ b/src/gateway/server-chat.load-gateway-session-row.runtime.ts @@ -1,3 +1,3 @@ // Runtime barrel for loading Gateway session rows from chat paths without // pulling the rest of session-utils into static startup imports. -export { loadGatewaySessionRow } from "./session-utils.js"; +export { loadGatewaySessionLifecycleSnapshot } from "./session-utils.js"; diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 87b968cf44f8..b858f1713869 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -52,7 +52,7 @@ import type { SessionMessageSubscriberRegistry, ToolEventRecipientRegistry, } from "./server-chat-state.js"; -import { loadGatewaySessionRow } from "./server-chat.load-gateway-session-row.runtime.js"; +import { loadGatewaySessionLifecycleSnapshot } from "./server-chat.load-gateway-session-row.runtime.js"; import { persistGatewaySessionLifecycleEvent } from "./server-chat.persist-session-lifecycle.runtime.js"; import { hasSessionChangeReceivers } from "./session-change-receivers.js"; import { @@ -320,7 +320,7 @@ export type AgentEventHandlerOptions = { toolEventRecipients: ToolEventRecipientRegistry; sessionEventSubscribers: SessionEventSubscriberRegistry; sessionMessageSubscribers: SessionMessageSubscriberRegistry; - loadGatewaySessionRowForSnapshot?: typeof loadGatewaySessionRow; + loadGatewaySessionLifecycleSnapshotForEvent?: typeof loadGatewaySessionLifecycleSnapshot; lifecycleErrorRetryGraceMs?: number; isChatSendRunActive?: (runId: string) => boolean; clearTrackedActiveRun?: (params: { @@ -374,7 +374,7 @@ export function createAgentEventHandler({ toolEventRecipients, sessionEventSubscribers, sessionMessageSubscribers, - loadGatewaySessionRowForSnapshot = loadGatewaySessionRow, + loadGatewaySessionLifecycleSnapshotForEvent = loadGatewaySessionLifecycleSnapshot, lifecycleErrorRetryGraceMs = AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS, isChatSendRunActive = () => false, clearTrackedActiveRun, @@ -476,7 +476,7 @@ export function createAgentEventHandler({ } let result: string | null = null; try { - result = loadGatewaySessionRow(sessionKey)?.spawnedBy ?? null; + result = loadGatewaySessionLifecycleSnapshotForEvent(sessionKey).row?.spawnedBy ?? null; } catch { // result stays null } @@ -490,13 +490,20 @@ export function createAgentEventHandler({ agentId?: string, includeActiveRunState = false, ) => { - const row = loadGatewaySessionRowForSnapshot(sessionKey, agentId ? { agentId } : undefined); + const snapshotOptions = agentId ? { agentId } : undefined; + const lifecycleSnapshot = loadGatewaySessionLifecycleSnapshotForEvent( + sessionKey, + snapshotOptions, + ); + const { lifecycleRunId, row } = lifecycleSnapshot; const omitUnscopedGlobalGoal = sessionKey === "global" && !agentId; const lifecyclePatch = evt && !isStaleLifecycleEventForSession({ owningSessionId: evt.sessionId, currentSessionId: row?.sessionId, + eventRunId: evt.runId, + currentRunId: lifecycleRunId, eventStartedAt: evt.data?.startedAt, currentStartedAt: row?.startedAt, }) diff --git a/src/gateway/server-methods/agent-session-patch.test.ts b/src/gateway/server-methods/agent-session-patch.test.ts index dec0e706d042..f40925f1b7ef 100644 --- a/src/gateway/server-methods/agent-session-patch.test.ts +++ b/src/gateway/server-methods/agent-session-patch.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveSessionResetPolicy, type SessionEntry } from "../../config/sessions.js"; +import { + resolveSessionResetPolicy, + type InternalSessionEntry as SessionEntry, +} from "../../config/sessions.js"; import { buildAgentSessionPatch } from "./agent-session-patch.js"; function buildPatch(touchInteraction: boolean, opts?: { requestLabel?: string; label?: string }) { @@ -7,6 +10,8 @@ function buildPatch(touchInteraction: boolean, opts?: { requestLabel?: string; l const entry: SessionEntry = { sessionId: "session", updatedAt: now, + lifecycleRunId: "completed-run", + status: "failed", agentStatus: { note: "Need a password", attention: "key", expiresAt: now + 60_000 }, ...(opts?.label ? { label: opts.label } : {}), }; @@ -37,6 +42,8 @@ describe("agent session patch", () => { const patch = buildPatch(true); expect(Object.hasOwn(patch, "agentStatus")).toBe(true); expect(patch.agentStatus).toBeUndefined(); + expect(Object.hasOwn(patch, "lifecycleRunId")).toBe(true); + expect(patch.lifecycleRunId).toBeUndefined(); }); it("does not clear agent status for lifecycle-only patches", () => { diff --git a/src/gateway/server-methods/agent-session-patch.ts b/src/gateway/server-methods/agent-session-patch.ts index 9af0b4c9d42b..1f8bf678765d 100644 --- a/src/gateway/server-methods/agent-session-patch.ts +++ b/src/gateway/server-methods/agent-session-patch.ts @@ -6,12 +6,12 @@ import { evaluateSessionFreshness, hasTerminalMainSessionTranscriptNewerThanRegistrySync, resolveSessionLifecycleTimestamps, - type SessionEntry, type SessionFreshness, } from "../../config/sessions.js"; import { hasProviderOwnedSession } from "../../config/sessions/entry-freshness.js"; import { resolveSessionEntryAccessTarget } from "../../config/sessions/session-accessor.js"; import { isRecoverableTerminalSessionStatus } from "../../config/sessions/terminal-status.js"; +import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { deliveryContextFromSession, @@ -243,6 +243,7 @@ export function buildAgentSessionPatch(params: { ...(shouldClearRotatedState || shouldClearTerminalState ? { status: undefined, + lifecycleRunId: undefined, startedAt: undefined, endedAt: undefined, runtimeMs: undefined, diff --git a/src/gateway/server-methods/chat-restart-recovery.ts b/src/gateway/server-methods/chat-restart-recovery.ts index 89a833bf1305..8c50aef3f06b 100644 --- a/src/gateway/server-methods/chat-restart-recovery.ts +++ b/src/gateway/server-methods/chat-restart-recovery.ts @@ -356,6 +356,7 @@ export function buildRestartSafeChatTranscriptState(params: { restartRecoveryDeliveryReceiptState: undefined, restartRecoveryDeliveryToolCallId: undefined, status: "running", + lifecycleRunId: params.clientRunId, startedAt: params.startedAt, endedAt: undefined, restartRecoveryDeliveryContext: undefined, @@ -402,6 +403,7 @@ export async function terminalizeRestartSafeChatAdmission(params: { terminalized = true; return { abortedLastRun: params.retryable ? false : params.status === "killed", + lifecycleRunId: undefined, endedAt, ...(params.retryable ? {} diff --git a/src/gateway/session-create-fork-entry.test.ts b/src/gateway/session-create-fork-entry.test.ts index d9f648a53970..b2ff7e2e5d78 100644 --- a/src/gateway/session-create-fork-entry.test.ts +++ b/src/gateway/session-create-fork-entry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { SessionEntry } from "../config/sessions.js"; +import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js"; describe("buildForkedGatewaySessionEntry", () => { @@ -7,6 +7,7 @@ describe("buildForkedGatewaySessionEntry", () => { const previous: SessionEntry = { sessionId: "adopted-generation", updatedAt: 1, + lifecycleRunId: "adopted-run", forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" }, }; @@ -22,6 +23,7 @@ describe("buildForkedGatewaySessionEntry", () => { previousSessionId: "adopted-generation", forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" }, }); + expect(forked.lifecycleRunId).toBeUndefined(); }); it("uses the requested ancestry for a genuinely new node", () => { diff --git a/src/gateway/session-create-fork-entry.ts b/src/gateway/session-create-fork-entry.ts index 5a285eb3870f..b2a2e402ff69 100644 --- a/src/gateway/session-create-fork-entry.ts +++ b/src/gateway/session-create-fork-entry.ts @@ -1,5 +1,5 @@ import { buildMainSessionRecoveryClearPatch } from "../agents/main-session-recovery-clear.js"; -import type { SessionEntry } from "../config/sessions.js"; +import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; export function buildForkedGatewaySessionEntry( entry: SessionEntry, @@ -12,6 +12,7 @@ export function buildForkedGatewaySessionEntry( ...entry, ...buildMainSessionRecoveryClearPatch(entry), sessionId: fork.sessionId, + lifecycleRunId: undefined, forkSource: previousEntry?.forkSource ?? forkSource, ...(previousEntry?.sessionId && previousEntry.sessionId !== fork.sessionId ? { previousSessionId: previousEntry.sessionId } diff --git a/src/gateway/session-lifecycle-state.test.ts b/src/gateway/session-lifecycle-state.test.ts index adbbea6f964c..e80e7fe0f759 100644 --- a/src/gateway/session-lifecycle-state.test.ts +++ b/src/gateway/session-lifecycle-state.test.ts @@ -105,7 +105,12 @@ async function persistLifecycle(entry: SessionEntry, event: LifecycleEvent): Pro describe("session lifecycle state", () => { it("treats a pre-reset run's lifecycle event as stale once the row's sessionId rotated (#88538)", () => { expect( - isStaleLifecycleEventForSession({ owningSessionId: "old-id", currentSessionId: "new-id" }), + isStaleLifecycleEventForSession({ + owningSessionId: "old-id", + currentSessionId: "new-id", + eventRunId: "same-run", + currentRunId: "same-run", + }), ).toBe(true); }); @@ -122,20 +127,26 @@ describe("session lifecycle state", () => { }); it.each([ - { eventStartedAt: 100, currentStartedAt: 200, stale: true }, - { eventStartedAt: 200, currentStartedAt: 200, stale: false }, - { eventStartedAt: 300, currentStartedAt: 200, stale: false }, - { eventStartedAt: undefined, currentStartedAt: 200, stale: false }, - { eventStartedAt: Number.NaN, currentStartedAt: 200, stale: false }, + { eventRunId: undefined, currentRunId: undefined, eventStartedAt: 100, stale: true }, + { eventRunId: "run-a", currentRunId: "run-a", eventStartedAt: 100, stale: false }, + { eventRunId: "run-a", currentRunId: "run-b", eventStartedAt: 100, stale: true }, + { eventRunId: "run-a", currentRunId: undefined, eventStartedAt: 100, stale: true }, + { eventRunId: undefined, currentRunId: "run-a", eventStartedAt: 100, stale: true }, + { eventRunId: undefined, currentRunId: undefined, eventStartedAt: 200, stale: false }, + { eventRunId: undefined, currentRunId: undefined, eventStartedAt: 300, stale: false }, + { eventRunId: undefined, currentRunId: undefined, eventStartedAt: undefined, stale: false }, + { eventRunId: undefined, currentRunId: undefined, eventStartedAt: Number.NaN, stale: false }, ])( "correlates explicit same-session run start times", - ({ eventStartedAt, currentStartedAt, stale }) => { + ({ eventRunId, currentRunId, eventStartedAt, stale }) => { expect( isStaleLifecycleEventForSession({ owningSessionId: "session-id", currentSessionId: "session-id", + eventRunId, + currentRunId, eventStartedAt, - currentStartedAt, + currentStartedAt: 200, }), ).toBe(stale); }, @@ -159,6 +170,7 @@ describe("session lifecycle state", () => { runId: "run-b", data: { phase: "start", startedAt: 2_000 }, }); + expect(second.lifecycleRunId).toBe("run-b"); const afterOlderTerminal = await persistLifecycle(second, { ts: 3_000, sessionId: "session-id", @@ -172,6 +184,7 @@ describe("session lifecycle state", () => { }); expect(afterOlderTerminal).toMatchObject({ status: "running", startedAt: 2_000 }); + expect(afterOlderTerminal.lifecycleRunId).toBe("run-b"); expect(afterOlderTerminal.endedAt).toBeUndefined(); expect(afterOlderTerminal.lastRunError).toBeUndefined(); @@ -187,9 +200,55 @@ describe("session lifecycle state", () => { endedAt: 4_000, runtimeMs: 2_000, }); + expect(completed.lifecycleRunId).toBeUndefined(); }, ); + it("settles a same-run terminal event whose outer start time predates its embedded start", async () => { + const started = await persistLifecycle( + { sessionId: "session-id", updatedAt: 900 }, + { + ts: 2_000, + sessionId: "session-id", + runId: "run-a", + data: { phase: "start", startedAt: 2_000 }, + }, + ); + const completed = await persistLifecycle(started, { + ts: 3_000, + sessionId: "session-id", + runId: "run-a", + data: { phase: "end", startedAt: 1_900, endedAt: 3_000 }, + }); + + expect(completed).toMatchObject({ + status: "done", + startedAt: 1_900, + endedAt: 3_000, + runtimeMs: 1_100, + }); + expect(completed.lifecycleRunId).toBeUndefined(); + }); + + it("clears inherited run ownership when a start event has no run id", async () => { + const started = await persistLifecycle( + { + sessionId: "session-id", + updatedAt: 900, + status: "running", + startedAt: 900, + lifecycleRunId: "old-run", + }, + { + ts: 2_000, + sessionId: "session-id", + data: { phase: "start", startedAt: 2_000 }, + }, + ); + + expect(started.lifecycleRunId).toBeUndefined(); + }); + it.each([ { name: "aborted", @@ -399,6 +458,7 @@ describe("session lifecycle state", () => { updatedAt: 1_000, startedAt: 1_050, status: "running", + lifecycleRunId: "foreground-run", abortedLastRun: true, restartRecoveryRuns: [{ runId: "restart-run", lifecycleGeneration: "pre-restart" }], mainRestartRecovery: { @@ -464,6 +524,7 @@ describe("session lifecycle state", () => { }); expect(persisted.restartRecoveryRuns).toBeUndefined(); expect(persisted.mainRestartRecovery).toBeUndefined(); + expect(persisted.lifecycleRunId).toBeUndefined(); }); it("clears every generation of a resumed run when its current owner completes", async () => { @@ -474,6 +535,7 @@ describe("session lifecycle state", () => { updatedAt: 1_000, startedAt: 1_050, status: "running", + lifecycleRunId: "recovery-run", abortedLastRun: false, restartRecoveryRuns: [ { runId: "recovery-run", lifecycleGeneration: "pre-restart" }, @@ -501,6 +563,7 @@ describe("session lifecycle state", () => { }); expect(persisted.restartRecoveryRuns).toBeUndefined(); expect(persisted.mainRestartRecovery).toBeUndefined(); + expect(persisted.lifecycleRunId).toBeUndefined(); }); it("keeps an active recovery when an older same-run terminal arrives", async () => { @@ -511,6 +574,7 @@ describe("session lifecycle state", () => { updatedAt: 1_000, startedAt: 1_050, status: "running", + lifecycleRunId: "recovery-run", abortedLastRun: false, restartRecoveryDeliveryRunId: "recovery-run", restartRecoveryRuns: [ @@ -540,6 +604,7 @@ describe("session lifecycle state", () => { mainRestartRecovery: { cycleId: "cycle-1" }, }); expect(persisted.restartRecoveryTerminalRunIds).toBeUndefined(); + expect(persisted.lifecycleRunId).toBe("recovery-run"); }); it("does not settle a foreground owner from a stale lifecycle generation", async () => { @@ -549,6 +614,7 @@ describe("session lifecycle state", () => { updatedAt: 1_000, startedAt: 1_050, status: "running", + lifecycleRunId: "foreground-run", abortedLastRun: true, restartRecoveryRuns: [ { runId: "interrupted-run", lifecycleGeneration: "pre-restart" }, @@ -582,6 +648,7 @@ describe("session lifecycle state", () => { foregroundClaims: { tokens: ["owner-claim"] }, }, }); + expect(persisted.lifecycleRunId).toBe("foreground-run"); }); it("clears only the completed recovery marker", async () => { @@ -591,6 +658,7 @@ describe("session lifecycle state", () => { updatedAt: 1_000, startedAt: 1_050, status: "running", + lifecycleRunId: "interrupted-run", abortedLastRun: true, restartRecoveryRuns: [ { runId: "completed-run", lifecycleGeneration: "pre-restart" }, @@ -610,6 +678,7 @@ describe("session lifecycle state", () => { { runId: "interrupted-run", lifecycleGeneration: "pre-restart" }, ]); expect(persisted.status).toBe("running"); + expect(persisted.lifecycleRunId).toBe("interrupted-run"); }); it.each([ diff --git a/src/gateway/session-lifecycle-state.ts b/src/gateway/session-lifecycle-state.ts index bd15445a2b39..31821dbaf841 100644 --- a/src/gateway/session-lifecycle-state.ts +++ b/src/gateway/session-lifecycle-state.ts @@ -55,6 +55,7 @@ type PersistedLifecycleSessionShape = Pick< | "abortedLastRun" | "restartRecoveryRuns" | "mainRestartRecovery" + | "lifecycleRunId" >; type GatewaySessionLifecycleSnapshot = Partial; @@ -214,18 +215,39 @@ function derivePersistedSessionLifecyclePatch(params: { event: params.event, snapshotPatch, }); - return projection.action === "suppress" ? {} : projection.patch; + if (projection.action === "suppress") { + return {}; + } + const phase = resolveLifecyclePhase(params.event); + const runId = normalizeLifecycleRunId(params.event.runId); + // Run ownership follows the durable running projection. Terminal settlement + // releases it; yielded parents retain it for their continuation lifecycle. + return { + ...projection.patch, + ...(phase === "start" + ? { lifecycleRunId: runId } + : projection.patch.status && projection.patch.status !== "running" + ? { lifecycleRunId: undefined } + : {}), + }; } export function deriveGatewaySessionLifecycleProjectionPatch(params: { entry?: Partial | null; event: LifecycleEventLike; }): GatewaySessionLifecycleSnapshot { - const { restartRecoveryRuns: _restartRecoveryRuns, ...patch } = - derivePersistedSessionLifecyclePatch(params); + const { + restartRecoveryRuns: _restartRecoveryRuns, + lifecycleRunId: _lifecycleRunId, + ...patch + } = derivePersistedSessionLifecyclePatch(params); return patch; } +function normalizeLifecycleRunId(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + export function isRestartRecoveryLifecycleEvent(params: { entry?: Pick | null; event: Pick; @@ -240,18 +262,29 @@ export function isRestartRecoveryLifecycleEvent(params: { export function isStaleLifecycleEventForSession(params: { owningSessionId?: string; currentSessionId?: string; + eventRunId?: unknown; + currentRunId?: unknown; eventStartedAt?: unknown; currentStartedAt?: number; }): boolean { + if ( + params.owningSessionId && + params.currentSessionId && + params.owningSessionId !== params.currentSessionId + ) { + return true; + } + const eventRunId = normalizeLifecycleRunId(params.eventRunId); + const currentRunId = normalizeLifecycleRunId(params.currentRunId); + // Matching ownership is stronger than producer timestamps. Missing or + // different identities retain the legacy timestamp fence. + if (eventRunId && currentRunId && eventRunId === currentRunId) { + return false; + } return ( - Boolean( - params.owningSessionId && - params.currentSessionId && - params.owningSessionId !== params.currentSessionId, - ) || - (isFiniteTimestamp(params.eventStartedAt) && - isFiniteTimestamp(params.currentStartedAt) && - params.eventStartedAt < params.currentStartedAt) + isFiniteTimestamp(params.eventStartedAt) && + isFiniteTimestamp(params.currentStartedAt) && + params.eventStartedAt < params.currentStartedAt ); } @@ -295,7 +328,8 @@ export async function persistGatewaySessionLifecycleEvent(params: { storePath: sessionEntry.storePath, sessionKey: sessionEntry.canonicalKey, }, - async (entry) => { + async (storedEntry) => { + const entry = storedEntry as SessionEntry; if ( exactCronRun && !acceptsCronRunContinuationLifecycleEvent({ entry, event: params.event }) @@ -308,6 +342,8 @@ export async function persistGatewaySessionLifecycleEvent(params: { isStaleLifecycleEventForSession({ owningSessionId, currentSessionId: entry.sessionId, + eventRunId: params.event.runId, + currentRunId: entry.lifecycleRunId, eventStartedAt: params.event.data?.startedAt, currentStartedAt: entry.startedAt, }) diff --git a/src/gateway/session-utils-search.ts b/src/gateway/session-utils-search.ts index 9b68b7968a9d..d667e503e37a 100644 --- a/src/gateway/session-utils-search.ts +++ b/src/gateway/session-utils-search.ts @@ -7,7 +7,11 @@ import { DEFAULT_MODEL } from "../agents/defaults.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveSessionModelIdentityRef } from "../agents/session-model-ref.js"; import { getSessionDisplaySubagentRunByChildSessionKey } from "../agents/subagent-registry-read.js"; -import { buildGroupDisplayName, type SessionEntry } from "../config/sessions.js"; +import { + buildGroupDisplayName, + type InternalSessionEntry, + type SessionEntry, +} from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { sessionDeliveryChannel, sessionDeliveryOrigin } from "../utils/delivery-context.shared.js"; @@ -147,16 +151,18 @@ export function resolveSessionListSearchModelFields(params: { return fields; } -export function loadGatewaySessionRow( +type LoadGatewaySessionRowOptions = { + agentId?: string; + includeDerivedTitles?: boolean; + includeLastMessage?: boolean; + now?: number; + transcriptUsageMaxBytes?: number; +}; + +export function loadGatewaySessionLifecycleSnapshot( sessionKey: string, - options?: { - agentId?: string; - includeDerivedTitles?: boolean; - includeLastMessage?: boolean; - now?: number; - transcriptUsageMaxBytes?: number; - }, -): GatewaySessionRow | null { + options?: LoadGatewaySessionRowOptions, +): { lifecycleRunId?: string; row: GatewaySessionRow | null } { const now = options?.now ?? Date.now(); const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntryReadOnly(sessionKey, { clone: false, @@ -164,7 +170,7 @@ export function loadGatewaySessionRow( ...(options?.agentId ? { agentId: options.agentId } : {}), }); if (!entry) { - return null; + return { row: null }; } const storeChildSessionsByKey = buildSingleRowStoreChildSessionsByKey({ storePath, @@ -172,19 +178,30 @@ export function loadGatewaySessionRow( key: canonicalKey, now, }); - return buildGatewaySessionRow({ - cfg, - storePath, - store, - key: canonicalKey, - entry, - now, - includeDerivedTitles: options?.includeDerivedTitles, - includeLastMessage: options?.includeLastMessage, - transcriptUsageMaxBytes: options?.transcriptUsageMaxBytes, - storeChildSessionsByKey, - ...(options?.agentId ? { agentId: options.agentId } : {}), - }); + const lifecycleRunId = (entry as InternalSessionEntry).lifecycleRunId; + return { + ...(lifecycleRunId === undefined ? {} : { lifecycleRunId }), + row: buildGatewaySessionRow({ + cfg, + storePath, + store, + key: canonicalKey, + entry, + now, + includeDerivedTitles: options?.includeDerivedTitles, + includeLastMessage: options?.includeLastMessage, + transcriptUsageMaxBytes: options?.transcriptUsageMaxBytes, + storeChildSessionsByKey, + ...(options?.agentId ? { agentId: options.agentId } : {}), + }), + }; +} + +export function loadGatewaySessionRow( + sessionKey: string, + options?: LoadGatewaySessionRowOptions, +): GatewaySessionRow | null { + return loadGatewaySessionLifecycleSnapshot(sessionKey, options).row; } export function buildGatewaySessionInfo(params: { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 4ee3e967806b..4a1e4005b090 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -28,6 +28,7 @@ export { resolveGatewayModelSupportsImages } from "./session-utils-model.js"; export { resolveSessionDisplayModelIdentityRef } from "./session-utils-model.js"; export { buildGatewaySessionRow } from "./session-utils-row.js"; export { loadGatewaySessionRow } from "./session-utils-search.js"; +export { loadGatewaySessionLifecycleSnapshot } from "./session-utils-search.js"; export { buildGatewaySessionInfo } from "./session-utils-search.js"; export { filterAndSortSessionEntries } from "./session-utils-list.js"; export { listSessionsFromStore } from "./session-utils-list.js"; diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 4513a2d2c020..7e97c6b520be 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -50,6 +50,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "inheritedToolPolicyVersion", "inheritedToolDeny", "inheritedToolAllow", + "lifecycleRunId", "mainRestartRecovery", "subagentRecovery", "pluginOwnerId",