fix(session): clean up rollover resources

This commit is contained in:
Vincent Koc
2026-04-25 01:27:16 -07:00
committed by GitHub
parent b0c55eb659
commit bf0d2d70be
6 changed files with 317 additions and 6 deletions

View File

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

View 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);
});
});

View File

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

View File

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

View File

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

View File

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