From 18869acf469065f6547ab8694319c4a35c60f004 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 22 Apr 2026 15:11:35 +0530 Subject: [PATCH] fix(cli): keep provider-owned sessions through implicit expiry --- docs/concepts/session.md | 4 + docs/gateway/cli-backends.md | 3 + src/auto-reply/reply/session.test.ts | 106 +++++++++++++++++++++++++++ src/auto-reply/reply/session.ts | 9 ++- src/config/sessions/reset-policy.ts | 4 +- 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/docs/concepts/session.md b/docs/concepts/session.md index fd01c5762fe..d05010fcb2b 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -69,6 +69,10 @@ Sessions are reused until they expire: When both daily and idle resets are configured, whichever expires first wins. +Sessions with an active provider-owned CLI session are not cut by the implicit +daily default. Use `/reset` or configure `session.reset` explicitly when those +sessions should expire on a timer. + ## Where state lives All session state is owned by the **gateway**. UI clients query the gateway for diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 646074f3308..fa1672082ae 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -185,6 +185,9 @@ child process environment for the run. follow-up turns reuse the live Claude process while it is active. If the Gateway restarts or the idle process exits, OpenClaw resumes from the stored Claude session id. +- Stored CLI sessions are provider-owned continuity. The implicit daily session + reset does not cut them; `/reset` and explicit `session.reset` policies still + do. Serialization notes: diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index d3ef69e83ca..bad125b0cc4 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2319,6 +2319,112 @@ describe("initSessionState preserves behavior overrides across /new and /reset", } }); + it("keeps provider-owned CLI sessions on implicit daily reset boundaries", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + const storePath = await createStorePath("openclaw-cli-implicit-reset-"); + const sessionKey = "agent:main:telegram:dm:claude-cli-user"; + const existingSessionId = "provider-owned-session"; + const transcriptPath = path.join(path.dirname(storePath), `${existingSessionId}.jsonl`); + const cliBinding = { + sessionId: "claude-session-1", + authProfileId: "anthropic:claude-cli", + mcpResumeHash: "mcp-resume-hash", + }; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + modelProvider: "claude-cli", + model: "claude-opus-4-6", + cliSessionBindings: { + "claude-cli": cliBinding, + }, + cliSessionIds: { + "claude-cli": cliBinding.sessionId, + }, + claudeCliSessionId: cliBinding.sessionId, + }, + }); + await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf8"); + + const cfg = { session: { store: storePath } } as OpenClawConfig; + const result = await initSessionState({ + ctx: { + Body: "hello", + RawBody: "hello", + CommandBody: "hello", + From: "claude-cli-user", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + expect(result.sessionEntry.cliSessionBindings?.["claude-cli"]).toEqual(cliBinding); + expect(await fs.stat(transcriptPath).catch(() => null)).not.toBeNull(); + const archived = (await fs.readdir(path.dirname(storePath))).filter((entry) => + entry.startsWith(`${existingSessionId}.jsonl.reset.`), + ); + expect(archived).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it("honors explicit reset policies for provider-owned CLI sessions", async () => { + const storePath = await createStorePath("openclaw-cli-explicit-reset-"); + const sessionKey = "agent:main:telegram:dm:claude-cli-explicit-user"; + const existingSessionId = "provider-owned-explicit-session"; + const cfg = { + session: { + store: storePath, + reset: { mode: "idle", idleMinutes: 1 }, + }, + } as OpenClawConfig; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now() - 5 * 60_000, + modelProvider: "claude-cli", + cliSessionBindings: { + "claude-cli": { + sessionId: "claude-session-explicit", + }, + }, + }, + }); + + const result = await initSessionState({ + ctx: { + Body: "hello", + RawBody: "hello", + CommandBody: "hello", + From: "claude-cli-explicit-user", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.cliSessionBindings).toBeUndefined(); + }); + it("disposes the previous bundle MCP runtime on session rollover", async () => { const storePath = await createStorePath("openclaw-stale-runtime-dispose-"); const sessionKey = "agent:main:telegram:dm:runtime-stale-user"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 964bfe39e47..6adf268b2af 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; +import { getCliSessionBinding } from "../../agents/cli-session.js"; import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/registry.js"; import { disposeSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; import { normalizeChatType } from "../../channels/chat-type.js"; @@ -139,6 +140,11 @@ function resolveStaleSessionEndReason(params: { return undefined; } +function hasProviderOwnedSession(entry: SessionEntry | undefined): boolean { + const provider = normalizeOptionalString(entry?.providerOverride ?? entry?.modelProvider); + return Boolean(provider && getCliSessionBinding(entry, provider)); +} + export type SessionInitResult = { sessionCtx: TemplateContext; sessionEntry: SessionEntry; @@ -447,8 +453,9 @@ export async function initSessionState(params: { typeof entry?.updatedAt === "number" && Number.isFinite(entry.updatedAt); // Forcing freshEntry=true prevents accidental data loss on automated system events. + const skipImplicitExpiry = hasProviderOwnedSession(entry) && resetPolicy.configured !== true; const entryFreshness = entry - ? isSystemEvent + ? isSystemEvent || skipImplicitExpiry ? ({ fresh: true } satisfies SessionFreshness) : evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }) : undefined; diff --git a/src/config/sessions/reset-policy.ts b/src/config/sessions/reset-policy.ts index ebf1aea5133..66df22d5512 100644 --- a/src/config/sessions/reset-policy.ts +++ b/src/config/sessions/reset-policy.ts @@ -8,6 +8,7 @@ export type SessionResetPolicy = { mode: SessionResetMode; atHour: number; idleMinutes?: number; + configured?: boolean; }; export type SessionFreshness = { @@ -45,6 +46,7 @@ export function resolveSessionResetPolicy(params: { : undefined)); const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes; + const configured = Boolean(baseReset || typeReset || legacyIdleMinutes != null); const mode = typeReset?.mode ?? baseReset?.mode ?? @@ -64,7 +66,7 @@ export function resolveSessionResetPolicy(params: { idleMinutes = DEFAULT_IDLE_MINUTES; } - return { mode, atHour, idleMinutes }; + return { mode, atHour, idleMinutes, configured }; } export function evaluateSessionFreshness(params: {