mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:00:44 +00:00
fix(cli): keep provider-owned sessions through implicit expiry
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user