Skip to content

Commit c230d1d

Browse files
jwchmodxclaude
andcommitted
fix(sessions): preserve terminal status after agent run completes
persistGatewaySessionLifecycleEvent writes the terminal status to disk as a fire-and-forget write. updateSessionStoreAfterAgentRun runs after it, but reads the in-memory sessionStore (loaded before the run) which still carries status: "running". The stale status was being spread into the merge patch, clobbering the "done"/"failed"/"killed" status on disk. Result: sessions could remain stuck as "running" even though endedAt and runtimeMs were correctly set, blocking new inbound messages and stop/abort commands until the store row was edited manually. Fix: omit the status field from the patch in updateSessionStoreAfterAgentRun so that the lifecycle-managed terminal status on disk is always preserved. Closes #60250 Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 8a67d6a commit c230d1d

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

src/agents/command/session-store.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,15 @@ export async function updateSessionStoreAfterAgentRun(params: {
126126
next.compactionCount = (entry.compactionCount ?? 0) + compactionsThisRun;
127127
}
128128
const persisted = await updateSessionStore(storePath, (store) => {
129-
const merged = mergeSessionEntry(store[sessionKey], next);
129+
// Omit status from next so that the terminal status already written to disk
130+
// by persistGatewaySessionLifecycleEvent is not clobbered by a stale
131+
// in-memory "running" value. The in-memory sessionStore is loaded during
132+
// session initialisation (before the run) and never updated by the lifecycle
133+
// handler, so it can still carry status: "running" by the time this
134+
// post-run write executes — even though the lifecycle end event has already
135+
// persisted a terminal status (#60250).
136+
const { status: _status, ...patch } = next;
137+
const merged = mergeSessionEntry(store[sessionKey], patch);
130138
store[sessionKey] = merged;
131139
return merged;
132140
});

src/commands/agent/session-store.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,70 @@ describe("updateSessionStoreAfterAgentRun", () => {
6464
expect(staleInMemory[sessionKey]?.acp).toBeDefined();
6565
});
6666

67+
it("does not clobber terminal status already written to disk by lifecycle handler", async () => {
68+
// Regression test for #60250.
69+
// persistGatewaySessionLifecycleEvent (fire-and-forget) writes the terminal
70+
// status to disk before updateSessionStoreAfterAgentRun runs. The in-memory
71+
// sessionStore carries a stale status: "running" because it was loaded before
72+
// the lifecycle end event fired. This test asserts that the stale in-memory
73+
// status does not overwrite the terminal status on disk.
74+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-"));
75+
const storePath = path.join(dir, "sessions.json");
76+
const sessionKey = `agent:default:lifecycle:${randomUUID()}`;
77+
const sessionId = randomUUID();
78+
const endedAt = Date.now();
79+
80+
// Disk already has the terminal state written by the lifecycle end handler.
81+
const diskState: SessionEntry = {
82+
sessionId,
83+
updatedAt: endedAt,
84+
status: "done",
85+
endedAt,
86+
runtimeMs: 1234,
87+
};
88+
await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: diskState }, null, 2), "utf8");
89+
90+
// In-memory sessionStore has a stale status: "running" (loaded before the run ended).
91+
const staleInMemory: Record<string, SessionEntry> = {
92+
[sessionKey]: {
93+
sessionId,
94+
updatedAt: endedAt - 2000,
95+
status: "running",
96+
},
97+
};
98+
99+
await updateSessionStoreAfterAgentRun({
100+
cfg: {} as never,
101+
sessionId,
102+
sessionKey,
103+
storePath,
104+
sessionStore: staleInMemory,
105+
defaultProvider: "openai-codex",
106+
defaultModel: "gpt-5.4",
107+
result: {
108+
payloads: [],
109+
meta: {
110+
aborted: false,
111+
agentMeta: {
112+
provider: "openai-codex",
113+
model: "gpt-5.4",
114+
usage: { input: 100, output: 50 },
115+
},
116+
},
117+
} as never,
118+
});
119+
120+
const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
121+
// Terminal status must be preserved, not overwritten with stale "running".
122+
expect(persisted?.status).toBe("done");
123+
// endedAt and runtimeMs written by the lifecycle handler must be preserved.
124+
expect(persisted?.endedAt).toBe(endedAt);
125+
expect(persisted?.runtimeMs).toBe(1234);
126+
// Token fields from this run should have been merged in.
127+
expect(persisted?.inputTokens).toBe(100);
128+
expect(persisted?.outputTokens).toBe(50);
129+
});
130+
67131
it("persists latest systemPromptReport for downstream warning dedupe", async () => {
68132
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-"));
69133
const storePath = path.join(dir, "sessions.json");

0 commit comments

Comments
 (0)