fix(cli): keep provider-owned sessions through implicit expiry

This commit is contained in:
Ayaan Zaidi
2026-04-22 15:11:35 +05:30
parent e36e0e8ad2
commit 18869acf46
5 changed files with 124 additions and 2 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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";

View File

@@ -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;

View File

@@ -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: {