openclaw/scripts/bench-session-manager-hydration-memory.ts
Peter Steinberger 03440e1545
perf: prepare native session lineage off the Gateway thread (#171364)
Prepare native session lineage through the existing worker and retain owner-published authority through terminal cleanup. Migrate bundled Codex and Agents API consumers, retain released synchronous APIs behind deprecated-only guards, move the bounded title kernel into its worker, and use async QA/history reads. Revocation still denies before real effects; conversation authority remains held for complete publication.

Correct failed-publication cleanup so captured lineage survives lease settlement and claim release. External QA readers use the existing latest-read mode; subprocess regressions cover stale reads, background settlement, retry, and sibling ownership.

Two Codex review rounds are complete. Required CI/static gates and the applicable published-driver update cell are verified before merge. The integrated L2 harness pair has unchanged warm causal SQL/requests: 124 [124–129] statements and 34 [34–36] requests, MAIN 0. Participant-triggered MAIN admission falls 27 to 0; remaining first-turn MAIN 27 belongs to heartbeat. Original L2 static T1 remains 76; no whole-sink retirement is claimed.
2026-10-13 15:50:28 -07:00

216 lines
7.1 KiB
TypeScript

import { spawnSync } from "node:child_process";
import type { HeapProfiler } from "node:inspector";
import { Session as InspectorSession } from "node:inspector/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { SessionManager } from "../src/agents/sessions/session-manager.js";
import {
persistSessionTranscriptTurn,
upsertSessionEntryCore,
} from "../src/config/sessions/session-accessor.js";
import { withOpenClawTestState } from "../src/test-utils/openclaw-test-state.js";
const scriptPath = fileURLToPath(import.meta.url);
const readerSentinel = "OPENCLAW_SESSION_HYDRATION_MEMORY:";
type ReaderResult = {
mode: "bounded" | "full";
entries: number;
heapUsedDeltaBytes: number;
rssDeltaBytes: number;
peakRssBytes: number;
topAllocationSites: ReturnType<typeof summarizeHeapProfile>;
};
function forceGc(): void {
const gc = globalThis.gc;
if (!gc) {
throw new Error("reader requires --expose-gc");
}
gc();
gc();
}
function readArg(name: string): string {
const index = process.argv.indexOf(name);
const value = index >= 0 ? process.argv[index + 1] : undefined;
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
function summarizeHeapProfile(profile: HeapProfiler.SamplingHeapProfile) {
type ProfileNode = HeapProfiler.SamplingHeapProfileNode;
const samples: Array<{ node: ProfileNode; stack: ProfileNode[] }> = [];
const visit = (node: ProfileNode, stack: ProfileNode[]): void => {
const nextStack = [...stack, node];
if (node.selfSize > 0) {
samples.push({ node, stack: nextStack });
}
for (const child of node.children ?? []) {
visit(child, nextStack);
}
};
const formatFrame = (node: ProfileNode): { functionName: string; line: number; url: string } => {
const rawUrl = node.callFrame.url;
const filePath = rawUrl.startsWith("file://") ? fileURLToPath(rawUrl) : rawUrl;
const relativeUrl = path.isAbsolute(filePath)
? path.relative(process.cwd(), filePath)
: filePath;
return {
functionName: node.callFrame.functionName || "(anonymous)",
line: node.callFrame.lineNumber + 1,
url: relativeUrl.startsWith("..") ? path.basename(filePath) : relativeUrl,
};
};
visit(profile.head, []);
return samples
.toSorted((left, right) => right.node.selfSize - left.node.selfSize)
.slice(0, 12)
.map(({ node, stack }) => {
const frame = formatFrame(node);
return {
bytes: node.selfSize,
functionName: frame.functionName,
line: frame.line,
stack: stack
.slice(-6)
.map(formatFrame)
.map(({ functionName, line, url }) => `${functionName} (${url}:${String(line)})`),
url: frame.url,
};
});
}
async function runReader(): Promise<void> {
const mode = readArg("--mode");
if (mode !== "full" && mode !== "bounded") {
throw new Error("--mode must be full or bounded");
}
const target = {
agentId: "main",
sessionId: readArg("--session-id"),
sessionKey: readArg("--session-key"),
storePath: readArg("--store-path"),
};
forceGc();
const before = process.memoryUsage();
const inspector = new InspectorSession();
inspector.connect();
await inspector.post("HeapProfiler.startSampling", { samplingInterval: 32 * 1024 });
const manager =
mode === "bounded"
? await SessionManager.openBoundedAsync(target, {
maxBytes: 4 * 1024 * 1024,
maxEvents: 10_000,
})
: await SessionManager.openAsync(target);
forceGc();
const after = process.memoryUsage();
const { profile } = await inspector.post("HeapProfiler.stopSampling");
inspector.disconnect();
const result: ReaderResult = {
mode,
entries: manager.getEntries().length,
heapUsedDeltaBytes: after.heapUsed - before.heapUsed,
rssDeltaBytes: after.rss - before.rss,
peakRssBytes: Math.round(process.resourceUsage().maxRSS * 1024),
topAllocationSites: summarizeHeapProfile(profile),
};
process.stdout.write(`${readerSentinel}${JSON.stringify(result)}\n`);
}
function runChild(params: {
mode: ReaderResult["mode"];
sessionId: string;
sessionKey: string;
storePath: string;
}): ReaderResult {
const result = spawnSync(
process.execPath,
[
"--expose-gc",
"--import",
"tsx",
scriptPath,
"--reader",
"--mode",
params.mode,
"--session-id",
params.sessionId,
"--session-key",
params.sessionKey,
"--store-path",
params.storePath,
],
{ encoding: "utf8", env: process.env },
);
if (result.error) {
throw new Error(`${params.mode} reader failed to start: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`${params.mode} reader failed: ${result.stderr || result.stdout}`);
}
const line = result.stdout
.split(/\r?\n/u)
.find((candidate) => candidate.startsWith(readerSentinel));
if (!line) {
throw new Error(
`${params.mode} reader omitted its result: ${result.stderr || result.stdout || "no output"}`,
);
}
return JSON.parse(line.slice(readerSentinel.length)) as ReaderResult;
}
async function main(): Promise<void> {
if (process.argv.includes("--reader")) {
await runReader();
return;
}
const eventCount = Number.parseInt(process.env.OPENCLAW_BENCH_EVENTS ?? "2048", 10);
const eventBytes = Number.parseInt(process.env.OPENCLAW_BENCH_EVENT_BYTES ?? "65536", 10);
if (!Number.isInteger(eventCount) || eventCount < 1 || eventCount > 10_000) {
throw new Error("OPENCLAW_BENCH_EVENTS must be between 1 and 10000");
}
if (!Number.isInteger(eventBytes) || eventBytes < 1024 || eventBytes > 1024 * 1024) {
throw new Error("OPENCLAW_BENCH_EVENT_BYTES must be between 1024 and 1048576");
}
await withOpenClawTestState({ label: "session-hydration-memory" }, async (state) => {
const sessionId = "hydration-memory";
const sessionKey = "agent:main:hydration-memory";
const storePath = path.join(state.sessionsDir("main"), "sessions.json");
await upsertSessionEntryCore(
{ agentId: "main", sessionKey, storePath },
{ sessionId, updatedAt: 1 },
);
const content = "x".repeat(eventBytes);
await persistSessionTranscriptTurn(
{ agentId: "main", sessionId, sessionKey, storePath },
{
messages: Array.from({ length: eventCount }, (_, index) => ({
eventId: `message-${index}`,
parentId: index === 0 ? null : `message-${index - 1}`,
message: { role: index % 2 === 0 ? "user" : "assistant", content },
})),
touchSessionEntry: false,
},
);
const full = runChild({ mode: "full", sessionId, sessionKey, storePath });
const bounded = runChild({ mode: "bounded", sessionId, sessionKey, storePath });
const output = {
eventCount,
eventBytes,
serializedPayloadBytes: eventCount * eventBytes,
full,
bounded,
heapReductionBytes: full.heapUsedDeltaBytes - bounded.heapUsedDeltaBytes,
rssReductionBytes: full.rssDeltaBytes - bounded.rssDeltaBytes,
};
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
});
}
await main();