Skip to content

Commit 647f4ee

Browse files
authored
fix: persist CLI session clearing atomically (#70298)
Persist stale CLI session clearing through the session-store merge path and add regression coverage for Claude binding removal.\n\nThanks @HFConsultant.
1 parent e3fc1a2 commit 647f4ee

5 files changed

Lines changed: 119 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
1919

2020
### Fixes
2121

22+
- CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant.
2223
- ACP/sessions_spawn: honor explicit `model` overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao.
2324
- CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.
2425
- Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.

src/agents/cli-session.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,14 @@ export function clearCliSession(entry: SessionEntry, provider: string): void {
112112
entry.cliSessionIds = Object.keys(next).length > 0 ? next : undefined;
113113
}
114114
if (normalized === CLAUDE_CLI_BACKEND_ID) {
115-
delete entry.claudeCliSessionId;
115+
entry.claudeCliSessionId = undefined;
116116
}
117117
}
118118

119119
export function clearAllCliSessions(entry: SessionEntry): void {
120-
delete entry.cliSessionBindings;
121-
delete entry.cliSessionIds;
122-
delete entry.claudeCliSessionId;
120+
entry.cliSessionBindings = undefined;
121+
entry.cliSessionIds = undefined;
122+
entry.claudeCliSessionId = undefined;
123123
}
124124

125125
export function resolveCliSessionReuse(params: {

src/agents/command/attempt-execution.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { sanitizeForLog } from "../../terminal/ansi.js";
1212
import { resolveMessageChannel } from "../../utils/message-channel.js";
1313
import { resolveBootstrapWarningSignaturesSeen } from "../bootstrap-budget.js";
1414
import { runCliAgent } from "../cli-runner.js";
15-
import { clearCliSession, getCliSessionBinding, setCliSessionBinding } from "../cli-session.js";
15+
import { getCliSessionBinding, setCliSessionBinding } from "../cli-session.js";
1616
import { FailoverError } from "../failover-error.js";
1717
import { isCliProvider } from "../model-selection.js";
1818
import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js";
@@ -22,6 +22,7 @@ import { buildUsageWithNoCost } from "../stream-message-shared.js";
2222
import { resolveFallbackRetryPrompt } from "./attempt-execution.helpers.js";
2323
import { persistSessionEntry } from "./attempt-execution.shared.js";
2424
import { resolveAgentRunContext } from "./run-context.js";
25+
import { clearCliSessionInStore } from "./session-store.js";
2526
import type { AgentCommandOpts } from "./types.js";
2627

2728
export {
@@ -301,22 +302,13 @@ export function runAgentAttempt(params: {
301302
`CLI session expired, clearing from session store: provider=${sanitizeForLog(params.providerOverride)} sessionKey=${params.sessionKey}`,
302303
);
303304

304-
const entry = params.sessionStore[params.sessionKey];
305-
if (entry) {
306-
const updatedEntry = { ...entry };
307-
clearCliSession(updatedEntry, params.providerOverride);
308-
updatedEntry.updatedAt = Date.now();
309-
310-
await persistSessionEntry({
311-
sessionStore: params.sessionStore,
305+
params.sessionEntry =
306+
(await clearCliSessionInStore({
307+
provider: params.providerOverride,
312308
sessionKey: params.sessionKey,
309+
sessionStore: params.sessionStore,
313310
storePath: params.storePath,
314-
entry: updatedEntry,
315-
clearedFields: ["cliSessionBindings", "cliSessionIds", "claudeCliSessionId"],
316-
});
317-
318-
params.sessionEntry = updatedEntry;
319-
}
311+
})) ?? params.sessionEntry;
320312

321313
return runCliWithSession(undefined).then(async (result) => {
322314
if (

src/agents/command/session-store.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { OpenClawConfig } from "../../config/config.js";
66
import type { SessionEntry } from "../../config/sessions.js";
77
import { loadSessionStore } from "../../config/sessions.js";
88
import type { EmbeddedPiRunResult } from "../pi-embedded.js";
9-
import { updateSessionStoreAfterAgentRun } from "./session-store.js";
9+
import { clearCliSessionInStore, updateSessionStoreAfterAgentRun } from "./session-store.js";
1010
import { resolveSession } from "./session.js";
1111

1212
vi.mock("../model-selection.js", () => ({
@@ -515,3 +515,83 @@ describe("updateSessionStoreAfterAgentRun", () => {
515515
});
516516
});
517517
});
518+
519+
describe("clearCliSessionInStore", () => {
520+
it("persists cleared Claude CLI bindings through session-store merge", async () => {
521+
await withTempSessionStore(async ({ storePath }) => {
522+
const sessionKey = "agent:main:explicit:test-clear-claude-cli";
523+
const entry: SessionEntry = {
524+
sessionId: "openclaw-session-1",
525+
updatedAt: 1,
526+
cliSessionBindings: {
527+
"claude-cli": {
528+
sessionId: "claude-session-1",
529+
authEpoch: "epoch-1",
530+
},
531+
"codex-cli": {
532+
sessionId: "codex-session-1",
533+
},
534+
},
535+
cliSessionIds: {
536+
"claude-cli": "claude-session-1",
537+
"codex-cli": "codex-session-1",
538+
},
539+
claudeCliSessionId: "claude-session-1",
540+
};
541+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
542+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8");
543+
544+
const cleared = await clearCliSessionInStore({
545+
provider: "claude-cli",
546+
sessionKey,
547+
sessionStore,
548+
storePath,
549+
});
550+
551+
expect(cleared?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
552+
expect(cleared?.cliSessionBindings?.["codex-cli"]).toEqual({
553+
sessionId: "codex-session-1",
554+
});
555+
expect(cleared?.cliSessionIds?.["claude-cli"]).toBeUndefined();
556+
expect(cleared?.cliSessionIds?.["codex-cli"]).toBe("codex-session-1");
557+
expect(cleared?.claudeCliSessionId).toBeUndefined();
558+
expect(sessionStore[sessionKey]).toEqual(cleared);
559+
560+
const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
561+
expect(persisted?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
562+
expect(persisted?.cliSessionBindings?.["codex-cli"]).toEqual({
563+
sessionId: "codex-session-1",
564+
});
565+
expect(persisted?.cliSessionIds?.["claude-cli"]).toBeUndefined();
566+
expect(persisted?.cliSessionIds?.["codex-cli"]).toBe("codex-session-1");
567+
expect(persisted?.claudeCliSessionId).toBeUndefined();
568+
});
569+
});
570+
571+
it("leaves the caller snapshot intact when the session entry is missing", async () => {
572+
await withTempSessionStore(async ({ storePath }) => {
573+
const existingKey = "agent:main:explicit:existing";
574+
const sessionStore: Record<string, SessionEntry> = {
575+
[existingKey]: {
576+
sessionId: "openclaw-session-1",
577+
updatedAt: 1,
578+
claudeCliSessionId: "claude-session-1",
579+
},
580+
};
581+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8");
582+
583+
const cleared = await clearCliSessionInStore({
584+
provider: "claude-cli",
585+
sessionKey: "agent:main:explicit:missing",
586+
sessionStore,
587+
storePath,
588+
});
589+
590+
expect(cleared).toBeUndefined();
591+
expect(sessionStore[existingKey]?.claudeCliSessionId).toBe("claude-session-1");
592+
expect(
593+
loadSessionStore(storePath, { skipCache: true })[existingKey]?.claudeCliSessionId,
594+
).toBe("claude-session-1");
595+
});
596+
});
597+
});

src/agents/command/session-store.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
updateSessionStore,
66
} from "../../config/sessions.js";
77
import type { OpenClawConfig } from "../../config/types.openclaw.js";
8-
import { setCliSessionBinding, setCliSessionId } from "../cli-session.js";
8+
import { clearCliSession, setCliSessionBinding, setCliSessionId } from "../cli-session.js";
99
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
1010
import { isCliProvider } from "../model-selection.js";
1111
import { deriveSessionTotalTokens, hasNonzeroUsage } from "../usage.js";
@@ -154,3 +154,28 @@ export async function updateSessionStoreAfterAgentRun(params: {
154154
});
155155
sessionStore[sessionKey] = persisted;
156156
}
157+
158+
export async function clearCliSessionInStore(params: {
159+
provider: string;
160+
sessionKey: string;
161+
sessionStore: Record<string, SessionEntry>;
162+
storePath: string;
163+
}): Promise<SessionEntry | undefined> {
164+
const { provider, sessionKey, sessionStore, storePath } = params;
165+
const entry = sessionStore[sessionKey];
166+
if (!entry) {
167+
return undefined;
168+
}
169+
170+
const next = { ...entry };
171+
clearCliSession(next, provider);
172+
next.updatedAt = Date.now();
173+
174+
const persisted = await updateSessionStore(storePath, (store) => {
175+
const merged = mergeSessionEntry(store[sessionKey], next);
176+
store[sessionKey] = merged;
177+
return merged;
178+
});
179+
sessionStore[sessionKey] = persisted;
180+
return persisted;
181+
}

0 commit comments

Comments
 (0)