mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:50:44 +00:00
fix(session): clean up rollover resources
This commit is contained in:
@@ -16,6 +16,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai.
|
||||
- Browser/Linux: detect Chromium-based installs under `/opt/google`, `/opt/brave.com`, `/usr/lib/chromium`, and `/usr/lib/chromium-browser` before asking users to set `browser.executablePath`. (#48563) Thanks @lupuletic.
|
||||
- Sessions/browser: close tracked browser tabs when idle, daily, `/new`, or `/reset` session rollover archives the previous transcript, preventing tabs from leaking past the old session. Thanks @jakozloski.
|
||||
- Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. Thanks @jalehman.
|
||||
- MCP/CLI: retire bundled MCP runtimes at the end of one-shot `openclaw agent` and `openclaw infer model run` gateway/local executions, so repeated scripted runs do not accumulate stdio MCP child processes. Fixes #71457.
|
||||
- OpenAI/Codex image generation: canonicalize legacy `openai-codex.baseUrl` values such as `https://chatgpt.com/backend-api` to the Codex Responses backend before calling `gpt-image-2`, matching the chat transport. Fixes #71460.
|
||||
- Control UI: make `/usage` use the fresh context snapshot for context percentage, and include cache-write tokens in the Usage overview cache-hit denominator. Fixes #47885. Thanks @imwyvern and @Ante042.
|
||||
|
||||
73
src/auto-reply/reply/session-fork.runtime.test.ts
Normal file
73
src/auto-reply/reply/session-fork.runtime.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import { resolveParentForkTokenCountRuntime } from "./session-fork.runtime.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
async function makeRoot(prefix: string): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("resolveParentForkTokenCountRuntime", () => {
|
||||
it("falls back to transcript-estimated tokens when cached totals are stale", async () => {
|
||||
const root = await makeRoot("openclaw-parent-fork-token-estimate-");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
await fs.mkdir(sessionsDir);
|
||||
|
||||
const sessionId = "parent-overflow-transcript";
|
||||
const sessionFile = path.join(sessionsDir, "parent.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
];
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const body = `turn-${index} ${"x".repeat(12_000)}`;
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: `u${index}`,
|
||||
parentId: index === 0 ? null : `a${index - 1}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: "user", content: body },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: `a${index}`,
|
||||
parentId: `u${index}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: "assistant", content: body },
|
||||
}),
|
||||
);
|
||||
}
|
||||
await fs.writeFile(sessionFile, `${lines.join("\n")}\n`, "utf-8");
|
||||
|
||||
const entry: SessionEntry = {
|
||||
sessionId,
|
||||
sessionFile,
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 1,
|
||||
totalTokensFresh: false,
|
||||
};
|
||||
|
||||
const tokens = resolveParentForkTokenCountRuntime({
|
||||
parentEntry: entry,
|
||||
storePath: path.join(root, "sessions.json"),
|
||||
});
|
||||
|
||||
expect(tokens).toBeGreaterThan(100_000);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,49 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { estimateMessagesTokens } from "../../agents/compaction.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import { resolveFreshSessionTotalTokens, type SessionEntry } from "../../config/sessions/types.js";
|
||||
import { readSessionMessages } from "../../gateway/session-utils.fs.js";
|
||||
|
||||
function resolvePositiveTokenCount(value: number | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveParentForkTokenCountRuntime(params: {
|
||||
parentEntry: SessionEntry;
|
||||
storePath: string;
|
||||
}): number | undefined {
|
||||
const freshPersistedTokens = resolveFreshSessionTotalTokens(params.parentEntry);
|
||||
if (typeof freshPersistedTokens === "number") {
|
||||
return freshPersistedTokens;
|
||||
}
|
||||
|
||||
try {
|
||||
const transcriptMessages = readSessionMessages(
|
||||
params.parentEntry.sessionId,
|
||||
params.storePath,
|
||||
params.parentEntry.sessionFile,
|
||||
) as AgentMessage[];
|
||||
if (transcriptMessages.length > 0) {
|
||||
const estimatedTokens = estimateMessagesTokens(transcriptMessages);
|
||||
const transcriptTokens = resolvePositiveTokenCount(
|
||||
Number.isFinite(estimatedTokens) ? Math.ceil(estimatedTokens) : undefined,
|
||||
);
|
||||
if (typeof transcriptTokens === "number") {
|
||||
return transcriptTokens;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to cached totals when the parent transcript cannot be read.
|
||||
}
|
||||
|
||||
return resolvePositiveTokenCount(params.parentEntry.totalTokens);
|
||||
}
|
||||
|
||||
export function forkSessionFromParentRuntime(params: {
|
||||
parentEntry: SessionEntry;
|
||||
|
||||
@@ -30,3 +30,11 @@ export async function forkSessionFromParent(params: {
|
||||
const runtime = await loadSessionForkRuntime();
|
||||
return runtime.forkSessionFromParentRuntime(params);
|
||||
}
|
||||
|
||||
export async function resolveParentForkTokenCount(params: {
|
||||
parentEntry: SessionEntry;
|
||||
storePath: string;
|
||||
}): Promise<number | undefined> {
|
||||
const runtime = await loadSessionForkRuntime();
|
||||
return runtime.resolveParentForkTokenCountRuntime(params);
|
||||
}
|
||||
|
||||
@@ -28,11 +28,15 @@ import { initSessionState } from "./session.js";
|
||||
|
||||
const sessionForkMocks = vi.hoisted(() => ({
|
||||
forkSessionFromParent: vi.fn(),
|
||||
resolveParentForkTokenCount: vi.fn(),
|
||||
nextSessionId: 0,
|
||||
}));
|
||||
const channelSummaryMocks = vi.hoisted(() => ({
|
||||
buildChannelSummary: vi.fn(async () => [] as string[]),
|
||||
}));
|
||||
const browserMaintenanceMocks = vi.hoisted(() => ({
|
||||
closeTrackedBrowserTabsForSessions: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
type ForkSessionParamsForTest = {
|
||||
parentEntry: SessionEntry;
|
||||
@@ -42,6 +46,8 @@ type ForkSessionParamsForTest = {
|
||||
vi.mock("./session-fork.js", () => ({
|
||||
forkSessionFromParent: (...args: [ForkSessionParamsForTest]) =>
|
||||
sessionForkMocks.forkSessionFromParent(...args),
|
||||
resolveParentForkTokenCount: (...args: [{ parentEntry: SessionEntry; storePath: string }]) =>
|
||||
sessionForkMocks.resolveParentForkTokenCount(...args),
|
||||
resolveParentForkMaxTokens: (cfg: { session?: { parentForkMaxTokens?: unknown } }) => {
|
||||
const configured = cfg.session?.parentForkMaxTokens;
|
||||
return typeof configured === "number" && Number.isFinite(configured) && configured >= 0
|
||||
@@ -50,6 +56,10 @@ vi.mock("./session-fork.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({
|
||||
closeTrackedBrowserTabsForSessions: browserMaintenanceMocks.closeTrackedBrowserTabsForSessions,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => null,
|
||||
}));
|
||||
@@ -248,8 +258,15 @@ function registerCurrentConversationBindingAdapterForTest(params: {
|
||||
|
||||
beforeEach(() => {
|
||||
channelSummaryMocks.buildChannelSummary.mockReset().mockResolvedValue([]);
|
||||
browserMaintenanceMocks.closeTrackedBrowserTabsForSessions.mockReset().mockResolvedValue(0);
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
sessionForkMocks.nextSessionId = 0;
|
||||
sessionForkMocks.resolveParentForkTokenCount.mockReset().mockImplementation(({ parentEntry }) => {
|
||||
const tokens = parentEntry.totalTokens;
|
||||
return typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0
|
||||
? Math.floor(tokens)
|
||||
: undefined;
|
||||
});
|
||||
sessionForkMocks.forkSessionFromParent
|
||||
.mockReset()
|
||||
.mockImplementation(async ({ parentEntry, sessionsDir }: ForkSessionParamsForTest) => {
|
||||
@@ -517,6 +534,66 @@ describe("initSessionState thread forking", () => {
|
||||
expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
|
||||
});
|
||||
|
||||
it("skips fork when resolved parent token estimate exceeds threshold", async () => {
|
||||
const root = await makeCaseDir("openclaw-thread-session-overflow-estimated-");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
await fs.mkdir(sessionsDir);
|
||||
|
||||
const parentSessionId = "parent-overflow-estimated";
|
||||
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
|
||||
await fs.writeFile(
|
||||
parentSessionFile,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: parentSessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const parentSessionKey = "agent:main:slack:channel:c1";
|
||||
await writeSessionStoreFast(storePath, {
|
||||
[parentSessionKey]: {
|
||||
sessionId: parentSessionId,
|
||||
sessionFile: parentSessionFile,
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 1,
|
||||
totalTokensFresh: false,
|
||||
},
|
||||
});
|
||||
sessionForkMocks.resolveParentForkTokenCount.mockReturnValueOnce(170_000);
|
||||
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const threadSessionKey = "agent:main:slack:channel:c1:thread:estimated";
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "Thread reply",
|
||||
SessionKey: threadSessionKey,
|
||||
ParentSessionKey: parentSessionKey,
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(sessionForkMocks.resolveParentForkTokenCount).toHaveBeenCalledWith({
|
||||
parentEntry: expect.objectContaining({
|
||||
sessionId: parentSessionId,
|
||||
totalTokensFresh: false,
|
||||
}),
|
||||
storePath,
|
||||
});
|
||||
expect(result.sessionEntry.forkedFromParent).toBe(true);
|
||||
expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
|
||||
expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
|
||||
expect(sessionForkMocks.forkSessionFromParent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects session.parentForkMaxTokens override", async () => {
|
||||
const root = await makeCaseDir("openclaw-thread-session-overflow-override-");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
@@ -1616,6 +1693,95 @@ describe("initSessionState reset policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSessionState browser tab cleanup", () => {
|
||||
it("closes tracked browser tabs when idle session expires", async () => {
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
|
||||
const storePath = await createStorePath("openclaw-tab-cleanup-idle-");
|
||||
const sessionKey = "agent:main:whatsapp:dm:tab-idle";
|
||||
const existingSessionId = "tab-idle-session-id";
|
||||
|
||||
await writeSessionStoreFast(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKeys: expect.arrayContaining([existingSessionId, sessionKey]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("closes tracked browser tabs on explicit /new reset", async () => {
|
||||
const storePath = await createStorePath("openclaw-tab-cleanup-reset-");
|
||||
const sessionKey = "agent:main:telegram:dm:tab-reset";
|
||||
const existingSessionId = "tab-reset-session-id";
|
||||
|
||||
await writeSessionStoreFast(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: { store: storePath, idleMinutes: 999 },
|
||||
} as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "/new",
|
||||
RawBody: "/new",
|
||||
CommandBody: "/new",
|
||||
SessionKey: sessionKey,
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKeys: expect.arrayContaining([existingSessionId, sessionKey]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not close browser tabs for a fresh session without previous state", async () => {
|
||||
const storePath = await createStorePath("openclaw-tab-cleanup-fresh-");
|
||||
const sessionKey = "agent:main:telegram:dm:tab-fresh";
|
||||
|
||||
const cfg = {
|
||||
session: { store: storePath, idleMinutes: 999 },
|
||||
} as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "hello",
|
||||
SessionKey: sessionKey,
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSessionState channel reset overrides", () => {
|
||||
it("uses channel-specific reset policy when configured", async () => {
|
||||
const root = await makeCaseDir("openclaw-channel-idle-");
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { TtsAutoMode } from "../../config/types.tts.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { closeTrackedBrowserTabsForSessions } from "../../plugin-sdk/browser-maintenance.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import type { PluginHookSessionEndReason } from "../../plugins/hook-types.js";
|
||||
import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js";
|
||||
@@ -58,7 +59,11 @@ import {
|
||||
resolveLastChannelRaw,
|
||||
resolveLastToRaw,
|
||||
} from "./session-delivery.js";
|
||||
import { forkSessionFromParent, resolveParentForkMaxTokens } from "./session-fork.js";
|
||||
import {
|
||||
forkSessionFromParent,
|
||||
resolveParentForkMaxTokens,
|
||||
resolveParentForkTokenCount,
|
||||
} from "./session-fork.js";
|
||||
import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js";
|
||||
|
||||
const log = createSubsystemLogger("session-init");
|
||||
@@ -678,8 +683,19 @@ export async function initSessionState(params: {
|
||||
sessionStore[parentSessionKey] &&
|
||||
!alreadyForked
|
||||
) {
|
||||
const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0;
|
||||
if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) {
|
||||
const parentEntry = sessionStore[parentSessionKey];
|
||||
const parentTokens =
|
||||
parentForkMaxTokens > 0
|
||||
? await resolveParentForkTokenCount({
|
||||
parentEntry,
|
||||
storePath,
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
parentForkMaxTokens > 0 &&
|
||||
typeof parentTokens === "number" &&
|
||||
parentTokens > parentForkMaxTokens
|
||||
) {
|
||||
// Parent context is too large — forking would create a thread session
|
||||
// that immediately overflows the model's context window. Start fresh
|
||||
// instead and mark as forked to prevent re-attempts. See #26905.
|
||||
@@ -691,10 +707,10 @@ export async function initSessionState(params: {
|
||||
} else {
|
||||
log.warn(
|
||||
`forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
|
||||
`parentTokens=${parentTokens}`,
|
||||
`parentTokens=${parentTokens ?? "unknown"}`,
|
||||
);
|
||||
const forked = await forkSessionFromParent({
|
||||
parentEntry: sessionStore[parentSessionKey],
|
||||
parentEntry,
|
||||
agentId,
|
||||
sessionsDir: path.dirname(storePath),
|
||||
});
|
||||
@@ -806,6 +822,12 @@ export async function initSessionState(params: {
|
||||
sessionFile: previousSessionEntry.sessionFile,
|
||||
reason: previousSessionEndReason ?? "unknown",
|
||||
});
|
||||
void closeTrackedBrowserTabsForSessions({
|
||||
sessionKeys: [previousSessionEntry.sessionId, sessionKey],
|
||||
onWarn: (message) => log.warn(message),
|
||||
}).catch((error) => {
|
||||
log.warn(`browser tab cleanup failed: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
|
||||
Reference in New Issue
Block a user