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.
This commit is contained in:
HFConsultant
2026-04-22 12:01:35 -07:00
committed by GitHub
parent e3fc1a237b
commit 647f4ee8ce
5 changed files with 119 additions and 21 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- 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.
- 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.

View File

@@ -112,14 +112,14 @@ export function clearCliSession(entry: SessionEntry, provider: string): void {
entry.cliSessionIds = Object.keys(next).length > 0 ? next : undefined;
}
if (normalized === CLAUDE_CLI_BACKEND_ID) {
delete entry.claudeCliSessionId;
entry.claudeCliSessionId = undefined;
}
}
export function clearAllCliSessions(entry: SessionEntry): void {
delete entry.cliSessionBindings;
delete entry.cliSessionIds;
delete entry.claudeCliSessionId;
entry.cliSessionBindings = undefined;
entry.cliSessionIds = undefined;
entry.claudeCliSessionId = undefined;
}
export function resolveCliSessionReuse(params: {

View File

@@ -12,7 +12,7 @@ import { sanitizeForLog } from "../../terminal/ansi.js";
import { resolveMessageChannel } from "../../utils/message-channel.js";
import { resolveBootstrapWarningSignaturesSeen } from "../bootstrap-budget.js";
import { runCliAgent } from "../cli-runner.js";
import { clearCliSession, getCliSessionBinding, setCliSessionBinding } from "../cli-session.js";
import { getCliSessionBinding, setCliSessionBinding } from "../cli-session.js";
import { FailoverError } from "../failover-error.js";
import { isCliProvider } from "../model-selection.js";
import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js";
@@ -22,6 +22,7 @@ import { buildUsageWithNoCost } from "../stream-message-shared.js";
import { resolveFallbackRetryPrompt } from "./attempt-execution.helpers.js";
import { persistSessionEntry } from "./attempt-execution.shared.js";
import { resolveAgentRunContext } from "./run-context.js";
import { clearCliSessionInStore } from "./session-store.js";
import type { AgentCommandOpts } from "./types.js";
export {
@@ -301,22 +302,13 @@ export function runAgentAttempt(params: {
`CLI session expired, clearing from session store: provider=${sanitizeForLog(params.providerOverride)} sessionKey=${params.sessionKey}`,
);
const entry = params.sessionStore[params.sessionKey];
if (entry) {
const updatedEntry = { ...entry };
clearCliSession(updatedEntry, params.providerOverride);
updatedEntry.updatedAt = Date.now();
await persistSessionEntry({
sessionStore: params.sessionStore,
params.sessionEntry =
(await clearCliSessionInStore({
provider: params.providerOverride,
sessionKey: params.sessionKey,
sessionStore: params.sessionStore,
storePath: params.storePath,
entry: updatedEntry,
clearedFields: ["cliSessionBindings", "cliSessionIds", "claudeCliSessionId"],
});
params.sessionEntry = updatedEntry;
}
})) ?? params.sessionEntry;
return runCliWithSession(undefined).then(async (result) => {
if (

View File

@@ -6,7 +6,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionStore } from "../../config/sessions.js";
import type { EmbeddedPiRunResult } from "../pi-embedded.js";
import { updateSessionStoreAfterAgentRun } from "./session-store.js";
import { clearCliSessionInStore, updateSessionStoreAfterAgentRun } from "./session-store.js";
import { resolveSession } from "./session.js";
vi.mock("../model-selection.js", () => ({
@@ -515,3 +515,83 @@ describe("updateSessionStoreAfterAgentRun", () => {
});
});
});
describe("clearCliSessionInStore", () => {
it("persists cleared Claude CLI bindings through session-store merge", async () => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:explicit:test-clear-claude-cli";
const entry: SessionEntry = {
sessionId: "openclaw-session-1",
updatedAt: 1,
cliSessionBindings: {
"claude-cli": {
sessionId: "claude-session-1",
authEpoch: "epoch-1",
},
"codex-cli": {
sessionId: "codex-session-1",
},
},
cliSessionIds: {
"claude-cli": "claude-session-1",
"codex-cli": "codex-session-1",
},
claudeCliSessionId: "claude-session-1",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8");
const cleared = await clearCliSessionInStore({
provider: "claude-cli",
sessionKey,
sessionStore,
storePath,
});
expect(cleared?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
expect(cleared?.cliSessionBindings?.["codex-cli"]).toEqual({
sessionId: "codex-session-1",
});
expect(cleared?.cliSessionIds?.["claude-cli"]).toBeUndefined();
expect(cleared?.cliSessionIds?.["codex-cli"]).toBe("codex-session-1");
expect(cleared?.claudeCliSessionId).toBeUndefined();
expect(sessionStore[sessionKey]).toEqual(cleared);
const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
expect(persisted?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
expect(persisted?.cliSessionBindings?.["codex-cli"]).toEqual({
sessionId: "codex-session-1",
});
expect(persisted?.cliSessionIds?.["claude-cli"]).toBeUndefined();
expect(persisted?.cliSessionIds?.["codex-cli"]).toBe("codex-session-1");
expect(persisted?.claudeCliSessionId).toBeUndefined();
});
});
it("leaves the caller snapshot intact when the session entry is missing", async () => {
await withTempSessionStore(async ({ storePath }) => {
const existingKey = "agent:main:explicit:existing";
const sessionStore: Record<string, SessionEntry> = {
[existingKey]: {
sessionId: "openclaw-session-1",
updatedAt: 1,
claudeCliSessionId: "claude-session-1",
},
};
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8");
const cleared = await clearCliSessionInStore({
provider: "claude-cli",
sessionKey: "agent:main:explicit:missing",
sessionStore,
storePath,
});
expect(cleared).toBeUndefined();
expect(sessionStore[existingKey]?.claudeCliSessionId).toBe("claude-session-1");
expect(
loadSessionStore(storePath, { skipCache: true })[existingKey]?.claudeCliSessionId,
).toBe("claude-session-1");
});
});
});

View File

@@ -5,7 +5,7 @@ import {
updateSessionStore,
} from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { setCliSessionBinding, setCliSessionId } from "../cli-session.js";
import { clearCliSession, setCliSessionBinding, setCliSessionId } from "../cli-session.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { isCliProvider } from "../model-selection.js";
import { deriveSessionTotalTokens, hasNonzeroUsage } from "../usage.js";
@@ -154,3 +154,28 @@ export async function updateSessionStoreAfterAgentRun(params: {
});
sessionStore[sessionKey] = persisted;
}
export async function clearCliSessionInStore(params: {
provider: string;
sessionKey: string;
sessionStore: Record<string, SessionEntry>;
storePath: string;
}): Promise<SessionEntry | undefined> {
const { provider, sessionKey, sessionStore, storePath } = params;
const entry = sessionStore[sessionKey];
if (!entry) {
return undefined;
}
const next = { ...entry };
clearCliSession(next, provider);
next.updatedAt = Date.now();
const persisted = await updateSessionStore(storePath, (store) => {
const merged = mergeSessionEntry(store[sessionKey], next);
store[sessionKey] = merged;
return merged;
});
sessionStore[sessionKey] = persisted;
return persisted;
}