Skip to content

Commit f7fe6ad

Browse files
committed
perf: avoid session manager opens for transcript maintenance
1 parent d4bdd40 commit f7fe6ad

12 files changed

Lines changed: 712 additions & 99 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424
### Fixes
2525

2626
- Feishu: preserve Feishu/Lark HTTP error bodies for message sends, media sends, and chat member lookups, so HTTP 400 failures include vendor code, message, log id, and troubleshooter details. Fixes #73860. Thanks @desksk.
27+
- Agents/transcripts: avoid reopening large Pi transcript files through the synchronous session manager for maintenance rewrites, persisted tool-result truncation, manual compaction boundary hardening, and queued compaction rotation. Thanks @mariozechner.
2728
- Telegram: inherit the process DNS result order for Bot API transport and downgrade recovered sticky IPv4 fallback promotions to debug logs, while keeping pinned-IP escalation warnings visible. Fixes #75904. Thanks @highfly-hi and @neeravmakwana.
2829
- Web search/MiniMax: allow `MINIMAX_OAUTH_TOKEN` to satisfy MiniMax Search credentials, so OAuth-authorized MiniMax Token Plan setups do not need a separate web-search key. Fixes #65768. Thanks @kikibrian and @zhouhe-xydt.
2930
- Providers/MiniMax: derive Coding Plan usage polling from the configured MiniMax base URL, so global setups no longer query the CN usage host. Fixes #65054. Thanks @sixone74 and @Yanhu007.

src/agents/pi-embedded-runner/compact.queued.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { SessionManager } from "@mariozechner/pi-coding-agent";
21
import { ensureContextEnginesInitialized } from "../../context-engine/init.js";
32
import { resolveContextEngine } from "../../context-engine/registry.js";
43
import type { ContextEngineRuntimeContext } from "../../context-engine/types.js";
@@ -28,7 +27,7 @@ import {
2827
resolveEmbeddedCompactionTarget,
2928
} from "./compaction-runtime-context.js";
3029
import {
31-
rotateTranscriptAfterCompaction,
30+
rotateTranscriptFileAfterCompaction,
3231
shouldRotateCompactionTranscript,
3332
} from "./compaction-successor-transcript.js";
3433
import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
@@ -177,8 +176,7 @@ export async function compactEmbeddedPiSession(
177176
if (result.ok && result.compacted) {
178177
if (shouldRotateCompactionTranscript(params.config) && !delegatedRotatedTranscript) {
179178
try {
180-
const rotation = await rotateTranscriptAfterCompaction({
181-
sessionManager: SessionManager.open(params.sessionFile),
179+
const rotation = await rotateTranscriptFileAfterCompaction({
182180
sessionFile: params.sessionFile,
183181
});
184182
if (rotation.rotated) {

src/agents/pi-embedded-runner/compact.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ import {
153153
toSessionToolAllowlist,
154154
} from "./tool-name-allowlist.js";
155155
import { splitSdkTools } from "./tool-split.js";
156+
import { readTranscriptFileState } from "./transcript-file-state.js";
156157
import type { EmbeddedPiCompactResult } from "./types.js";
157158
import { mapThinkingLevel } from "./utils.js";
158159
import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js";
@@ -1172,7 +1173,9 @@ async function compactEmbeddedPiSessionDirectOnce(
11721173
typeof sessionManager.getLeafId === "function"
11731174
? (sessionManager.getLeafId() ?? undefined)
11741175
: undefined;
1175-
let transcriptRotationSessionManager = sessionManager;
1176+
let transcriptRotationSessionManager: Parameters<
1177+
typeof rotateTranscriptAfterCompaction
1178+
>[0]["sessionManager"] = sessionManager;
11761179
if (params.trigger === "manual") {
11771180
try {
11781181
const hardenedBoundary = await hardenManualCompactionBoundary({
@@ -1185,7 +1188,9 @@ async function compactEmbeddedPiSessionDirectOnce(
11851188
hardenedBoundary.firstKeptEntryId ?? effectiveFirstKeptEntryId;
11861189
postCompactionLeafId = hardenedBoundary.leafId ?? postCompactionLeafId;
11871190
session.agent.state.messages = hardenedBoundary.messages;
1188-
transcriptRotationSessionManager = SessionManager.open(params.sessionFile);
1191+
transcriptRotationSessionManager = await readTranscriptFileState(
1192+
params.sessionFile,
1193+
);
11891194
}
11901195
} catch (err) {
11911196
log.warn("[compaction] failed to harden manual compaction boundary", {

src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { SessionManager } from "@mariozechner/pi-coding-agent";
5-
import { afterEach, describe, expect, it } from "vitest";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
66
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
77
import {
88
rotateTranscriptAfterCompaction,
9+
rotateTranscriptFileAfterCompaction,
910
shouldRotateCompactionTranscript,
1011
} from "./compaction-successor-transcript.js";
1112
import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js";
@@ -54,6 +55,30 @@ function createCompactedSession(sessionDir: string): {
5455
}
5556

5657
describe("rotateTranscriptAfterCompaction", () => {
58+
it("can rotate a persisted transcript without opening a manager", async () => {
59+
const dir = await createTmpDir();
60+
const { sessionFile } = createCompactedSession(dir);
61+
62+
const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => {
63+
throw new Error("SessionManager.open should not be used for file rotation");
64+
});
65+
const result = await rotateTranscriptFileAfterCompaction({
66+
sessionFile,
67+
now: () => new Date("2026-04-27T12:00:00.000Z"),
68+
});
69+
openSpy.mockRestore();
70+
71+
expect(result.rotated).toBe(true);
72+
expect(result.sessionFile).toBeTruthy();
73+
74+
const successor = SessionManager.open(result.sessionFile!);
75+
expect(successor.getHeader()).toMatchObject({
76+
parentSession: sessionFile,
77+
cwd: dir,
78+
});
79+
expect(successor.buildSessionContext().messages.length).toBeGreaterThan(0);
80+
});
81+
5782
it("creates a compacted successor transcript and leaves the archive untouched", async () => {
5883
const dir = await createTmpDir();
5984
const { manager, sessionFile, firstKeptId, oldUserId } = createCompactedSession(dir);

src/agents/pi-embedded-runner/compaction-successor-transcript.ts

Lines changed: 20 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { randomUUID } from "node:crypto";
2-
import fs from "node:fs/promises";
32
import path from "node:path";
43
import {
54
CURRENT_SESSION_VERSION,
6-
SessionManager,
75
type CompactionEntry,
86
type SessionEntry,
97
type SessionHeader,
108
} from "@mariozechner/pi-coding-agent";
119
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1210
import { collectDuplicateUserMessageEntryIdsForCompaction } from "./compaction-duplicate-user-messages.js";
11+
import {
12+
readTranscriptFileState,
13+
TranscriptFileState,
14+
writeTranscriptFileAtomic,
15+
} from "./transcript-file-state.js";
1316

1417
type ReadonlySessionManagerForRotation = Pick<
15-
SessionManager,
18+
TranscriptFileState,
1619
"buildSessionContext" | "getBranch" | "getCwd" | "getEntries" | "getHeader"
1720
>;
1821

@@ -70,14 +73,8 @@ export async function rotateTranscriptAfterCompaction(params: {
7073
cwd: params.sessionManager.getCwd(),
7174
parentSession: sessionFile,
7275
});
73-
await writeSessionFileAtomic(successorFile, [header, ...successorEntries]);
74-
75-
try {
76-
SessionManager.open(successorFile).buildSessionContext();
77-
} catch (err) {
78-
await fs.unlink(successorFile).catch(() => undefined);
79-
throw err;
80-
}
76+
await writeTranscriptFileAtomic(successorFile, [header, ...successorEntries]);
77+
new TranscriptFileState({ header, entries: successorEntries }).buildSessionContext();
8178

8279
return {
8380
rotated: true,
@@ -89,6 +86,18 @@ export async function rotateTranscriptAfterCompaction(params: {
8986
};
9087
}
9188

89+
export async function rotateTranscriptFileAfterCompaction(params: {
90+
sessionFile: string;
91+
now?: () => Date;
92+
}): Promise<CompactionTranscriptRotation> {
93+
const state = await readTranscriptFileState(params.sessionFile);
94+
return rotateTranscriptAfterCompaction({
95+
sessionManager: state,
96+
sessionFile: params.sessionFile,
97+
...(params.now ? { now: params.now } : {}),
98+
});
99+
}
100+
92101
function findLatestCompactionIndex(entries: SessionEntry[]): number {
93102
for (let index = entries.length - 1; index >= 0; index -= 1) {
94103
if (entries[index]?.type === "compaction") {
@@ -263,20 +272,3 @@ function resolveSuccessorSessionFile(params: {
263272
const fileTimestamp = params.timestamp.replace(/[:.]/g, "-");
264273
return path.join(path.dirname(params.sessionFile), `${fileTimestamp}_${params.sessionId}.jsonl`);
265274
}
266-
267-
async function writeSessionFileAtomic(
268-
filePath: string,
269-
entries: Array<SessionHeader | SessionEntry>,
270-
) {
271-
const dir = path.dirname(filePath);
272-
await fs.mkdir(dir, { recursive: true });
273-
const tmpFile = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
274-
const content = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
275-
try {
276-
await fs.writeFile(tmpFile, content, { encoding: "utf8", flag: "wx" });
277-
await fs.rename(tmpFile, filePath);
278-
} catch (err) {
279-
await fs.unlink(tmpFile).catch(() => undefined);
280-
throw err;
281-
}
282-
}

src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import type { AgentMessage } from "@mariozechner/pi-agent-core";
55
import type { AssistantMessage } from "@mariozechner/pi-ai";
66
import { SessionManager } from "@mariozechner/pi-coding-agent";
7-
import { afterEach, describe, expect, it } from "vitest";
7+
import { afterEach, describe, expect, it, vi } from "vitest";
88
import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js";
99

1010
let tmpDir = "";
@@ -95,7 +95,11 @@ describe("hardenManualCompactionBoundary", () => {
9595
.messages.map((message) => messageText(message));
9696
expect(beforeTexts.join("\n")).toContain("detailed new answer");
9797

98+
const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => {
99+
throw new Error("SessionManager.open should not be used for boundary hardening");
100+
});
98101
const hardened = await hardenManualCompactionBoundary({ sessionFile: sessionFile! });
102+
openSpy.mockRestore();
99103
expect(hardened.applied).toBe(true);
100104
expect(hardened.firstKeptEntryId).toBe(latestCompactionId);
101105
expect(hardened.messages.map((message) => message.role)).toEqual(["compactionSummary"]);
Lines changed: 27 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import fs from "node:fs/promises";
21
import type { AgentMessage } from "@mariozechner/pi-agent-core";
3-
import { SessionManager } from "@mariozechner/pi-coding-agent";
2+
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
3+
import {
4+
readTranscriptFileState,
5+
TranscriptFileState,
6+
writeTranscriptFileAtomic,
7+
} from "./transcript-file-state.js";
48

5-
type SessionManagerLike = ReturnType<typeof SessionManager.open>;
6-
type SessionEntry = ReturnType<SessionManagerLike["getEntries"]>[number];
7-
type SessionHeader = NonNullable<ReturnType<SessionManagerLike["getHeader"]>>;
89
type CompactionEntry = Extract<SessionEntry, { type: "compaction" }>;
910

1011
export type HardenedManualCompactionBoundary = {
@@ -14,12 +15,6 @@ export type HardenedManualCompactionBoundary = {
1415
messages: AgentMessage[];
1516
};
1617

17-
function serializeSessionFile(header: SessionHeader, entries: SessionEntry[]): string {
18-
return (
19-
[JSON.stringify(header), ...entries.map((entry) => JSON.stringify(entry))].join("\n") + "\n"
20-
);
21-
}
22-
2318
function replaceLatestCompactionBoundary(params: {
2419
entries: SessionEntry[];
2520
compactionEntryId: string;
@@ -42,76 +37,60 @@ export async function hardenManualCompactionBoundary(params: {
4237
sessionFile: string;
4338
preserveRecentTail?: boolean;
4439
}): Promise<HardenedManualCompactionBoundary> {
45-
const sessionManager = SessionManager.open(params.sessionFile) as Partial<SessionManagerLike>;
46-
if (
47-
typeof sessionManager.getHeader !== "function" ||
48-
typeof sessionManager.getLeafEntry !== "function" ||
49-
typeof sessionManager.buildSessionContext !== "function" ||
50-
typeof sessionManager.getEntries !== "function"
51-
) {
40+
const state = await readTranscriptFileState(params.sessionFile);
41+
const header = state.getHeader();
42+
if (!header) {
5243
return {
5344
applied: false,
5445
messages: [],
5546
};
5647
}
5748

58-
const header = sessionManager.getHeader();
59-
const leaf = sessionManager.getLeafEntry();
60-
if (!header || leaf?.type !== "compaction") {
61-
const sessionContext = sessionManager.buildSessionContext();
49+
const leaf = state.getLeafEntry();
50+
if (leaf?.type !== "compaction") {
51+
const sessionContext = state.buildSessionContext();
6252
return {
6353
applied: false,
64-
leafId:
65-
typeof sessionManager.getLeafId === "function"
66-
? (sessionManager.getLeafId() ?? undefined)
67-
: undefined,
54+
leafId: state.getLeafId() ?? undefined,
6855
messages: sessionContext.messages,
6956
};
7057
}
7158

7259
if (params.preserveRecentTail) {
73-
const sessionContext = sessionManager.buildSessionContext();
60+
const sessionContext = state.buildSessionContext();
7461
return {
7562
applied: false,
7663
firstKeptEntryId: leaf.firstKeptEntryId,
77-
leafId:
78-
typeof sessionManager.getLeafId === "function"
79-
? (sessionManager.getLeafId() ?? undefined)
80-
: undefined,
64+
leafId: state.getLeafId() ?? undefined,
8165
messages: sessionContext.messages,
8266
};
8367
}
8468

8569
if (leaf.firstKeptEntryId === leaf.id) {
86-
const sessionContext = sessionManager.buildSessionContext();
70+
const sessionContext = state.buildSessionContext();
8771
return {
8872
applied: false,
8973
firstKeptEntryId: leaf.id,
90-
leafId:
91-
typeof sessionManager.getLeafId === "function"
92-
? (sessionManager.getLeafId() ?? undefined)
93-
: undefined,
74+
leafId: state.getLeafId() ?? undefined,
9475
messages: sessionContext.messages,
9576
};
9677
}
9778

98-
const content = serializeSessionFile(
79+
const replacedEntries = replaceLatestCompactionBoundary({
80+
entries: state.getEntries(),
81+
compactionEntryId: leaf.id,
82+
});
83+
const replacedState = new TranscriptFileState({
9984
header,
100-
replaceLatestCompactionBoundary({
101-
entries: sessionManager.getEntries(),
102-
compactionEntryId: leaf.id,
103-
}),
104-
);
105-
const tmpFile = `${params.sessionFile}.manual-compaction-tmp`;
106-
await fs.writeFile(tmpFile, content, "utf-8");
107-
await fs.rename(tmpFile, params.sessionFile);
85+
entries: replacedEntries,
86+
});
87+
await writeTranscriptFileAtomic(params.sessionFile, [header, ...replacedEntries]);
10888

109-
const refreshed = SessionManager.open(params.sessionFile);
110-
const sessionContext = refreshed.buildSessionContext();
89+
const sessionContext = replacedState.buildSessionContext();
11190
return {
11291
applied: true,
11392
firstKeptEntryId: leaf.id,
114-
leafId: refreshed.getLeafId() ?? undefined,
93+
leafId: replacedState.getLeafId() ?? undefined,
11594
messages: sessionContext.messages,
11695
};
11796
}

src/agents/pi-embedded-runner/tool-result-truncation.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import type { AgentMessage } from "@mariozechner/pi-agent-core";
55
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
66
import { SessionManager } from "@mariozechner/pi-coding-agent";
7-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
7+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
88
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
99

1010
let truncateToolResultText: typeof import("./tool-result-truncation.js").truncateToolResultText;
@@ -441,10 +441,14 @@ describe("truncateOversizedToolResultsInSession", () => {
441441
)
442442
.filter((length) => length > 0);
443443

444+
const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => {
445+
throw new Error("SessionManager.open should not be used for persisted truncation");
446+
});
444447
const result = await truncateOversizedToolResultsInSession({
445448
sessionFile,
446449
contextWindowTokens: 100,
447450
});
451+
openSpy.mockRestore();
448452

449453
expect(result.truncated).toBe(true);
450454
expect(result.truncatedCount).toBeGreaterThan(0);

0 commit comments

Comments
 (0)