Skip to content

Commit 9987884

Browse files
committed
fix(gateway): guard sessions.compact maxLines truncation against active runs
The non-maxLines (LLM) compact branch interrupts an active session run before compacting, but the maxLines truncate branch read the tail, archived, and overwrote the transcript in place without that guard. Exposing `--max-lines` as a documented CLI command (this PR) would make the active-run data-loss mode tracked by #72765 easy to trigger from ordinary CLI usage. Run the same interruptSessionRunIfActive guard in the maxLines branch before reading the tail and truncating, matching the LLM compact path. Add gateway regression coverage over a real in-process Gateway: with no active run, the maxLines branch truncates the on-disk transcript 500 -> 50 and preserves the original 500 lines in the .bak archive; with an active embedded run, the maxLines branch fires the same interrupt (abort + wait-for-end) before archiving and truncating.
1 parent c0f0bc2 commit 9987884

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

src/gateway/server-methods/sessions.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2643,6 +2643,27 @@ export const sessionsHandlers: GatewayRequestHandlers = {
26432643
return;
26442644
}
26452645

2646+
// Active-run safety parity with the LLM-summarize branch above. The
2647+
// maxLines truncate path archives the transcript and overwrites it in
2648+
// place, so an active runner could otherwise keep appending to the file we
2649+
// are about to archive and truncate, losing that output (the data-loss mode
2650+
// tracked by #72765). Interrupt any active run for this session *before*
2651+
// reading the tail and truncating, exactly as the summarize branch does.
2652+
const truncateInterrupt = await interruptSessionRunIfActive({
2653+
req,
2654+
context,
2655+
client,
2656+
isWebchatConnect,
2657+
requestedKey: key,
2658+
canonicalKey: target.canonicalKey,
2659+
agentId: requestedAgentId,
2660+
sessionId,
2661+
});
2662+
if (truncateInterrupt.error) {
2663+
respond(false, undefined, truncateInterrupt.error);
2664+
return;
2665+
}
2666+
26462667
const tail = readRecentSessionTranscriptLines({
26472668
sessionId,
26482669
storePath,

src/gateway/server.sessions.compaction.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,122 @@ test("sessions.compact treats Codex native compaction start as pending, not comp
565565
ws.close();
566566
});
567567

568+
test("sessions.compact maxLines truncates the transcript on disk and archives the original to .bak", async () => {
569+
const { dir } = await createSessionStoreDir();
570+
const transcriptPath = path.join(dir, "sess-main.jsonl");
571+
const originalLines = Array.from({ length: 500 }, (_, index) =>
572+
JSON.stringify({ role: "user", content: `line-${index}` }),
573+
);
574+
await fs.writeFile(transcriptPath, `${originalLines.join("\n")}\n`, "utf-8");
575+
await writeSessionStore({ entries: { main: sessionStoreEntry("sess-main") } });
576+
577+
const { ws } = await openClient();
578+
const compacted = await rpcReq<{
579+
ok: true;
580+
key: string;
581+
compacted: boolean;
582+
kept?: number;
583+
archived?: string;
584+
}>(ws, "sessions.compact", { key: "main", maxLines: 50 });
585+
586+
expect(compacted.ok).toBe(true);
587+
expect(compacted.payload?.compacted).toBe(true);
588+
expect(compacted.payload?.kept).toBe(50);
589+
590+
// Active transcript truncated in place on disk: 500 -> 50 lines, newest kept.
591+
const truncated = (await fs.readFile(transcriptPath, "utf-8")).trim().split("\n");
592+
expect(truncated).toHaveLength(50);
593+
expect(truncated.at(-1)).toBe(JSON.stringify({ role: "user", content: "line-499" }));
594+
595+
// Original 500 lines preserved verbatim in the .bak archive.
596+
const archivedPath = compacted.payload?.archived;
597+
if (!archivedPath) {
598+
throw new Error("expected archived transcript path");
599+
}
600+
const archived = (await fs.readFile(archivedPath, "utf-8")).trim().split("\n");
601+
expect(archived).toHaveLength(500);
602+
expect(archived.at(0)).toBe(JSON.stringify({ role: "user", content: "line-0" }));
603+
604+
// No active run present, so the interrupt guard short-circuits without aborting.
605+
expect(embeddedRunMock.abortCalls).toEqual([]);
606+
expect(embeddedRunMock.waitCalls).toEqual([]);
607+
608+
ws.close();
609+
});
610+
611+
test("sessions.compact maxLines interrupts an active run before truncating, matching the LLM compact path", async () => {
612+
const { dir } = await createSessionStoreDir();
613+
const transcriptPath = path.join(dir, "sess-main.jsonl");
614+
const originalLines = Array.from({ length: 500 }, (_, index) =>
615+
JSON.stringify({ role: "user", content: `line-${index}` }),
616+
);
617+
await fs.writeFile(transcriptPath, `${originalLines.join("\n")}\n`, "utf-8");
618+
await writeSessionStore({ entries: { main: sessionStoreEntry("sess-main") } });
619+
620+
const { ws } = await openClient();
621+
// Simulate an embedded agent run actively appending to this session transcript.
622+
embeddedRunMock.activeIds.add("sess-main");
623+
embeddedRunMock.waitResults.set("sess-main", true);
624+
625+
const compacted = await rpcReq<{
626+
ok: true;
627+
compacted: boolean;
628+
kept?: number;
629+
}>(ws, "sessions.compact", { key: "main", maxLines: 50 });
630+
631+
// Regression for the ClawSweeper finding: the maxLines truncate branch must
632+
// run the same active-run interrupt guard as the LLM-summarize branch *before*
633+
// archiving and overwriting the transcript, so an active runner cannot keep
634+
// appending to the file being truncated (the data-loss mode tracked by #72765).
635+
expect(embeddedRunMock.abortCalls).toEqual(["sess-main"]);
636+
expect(embeddedRunMock.waitCalls).toEqual(["sess-main"]);
637+
638+
// The guard ran first; truncation still completed deterministically afterwards.
639+
expect(compacted.ok).toBe(true);
640+
expect(compacted.payload?.compacted).toBe(true);
641+
expect(compacted.payload?.kept).toBe(50);
642+
const truncated = (await fs.readFile(transcriptPath, "utf-8")).trim().split("\n");
643+
expect(truncated).toHaveLength(50);
644+
645+
ws.close();
646+
});
647+
648+
test("sessions.compact maxLines aborts without truncating when an active run cannot be interrupted", async () => {
649+
const { dir, storePath } = await createSessionStoreDir();
650+
const transcriptPath = path.join(dir, "sess-main.jsonl");
651+
const originalLines = Array.from({ length: 500 }, (_, index) =>
652+
JSON.stringify({ role: "user", content: `line-${index}` }),
653+
);
654+
await fs.writeFile(transcriptPath, `${originalLines.join("\n")}\n`, "utf-8");
655+
await writeSessionStore({ entries: { main: sessionStoreEntry("sess-main") } });
656+
657+
const { ws } = await openClient();
658+
// Active embedded run that fails to end within the interrupt window.
659+
embeddedRunMock.activeIds.add("sess-main");
660+
embeddedRunMock.waitResults.set("sess-main", false);
661+
662+
const compacted = await rpcReq<{ ok: boolean }>(ws, "sessions.compact", {
663+
key: "main",
664+
maxLines: 50,
665+
});
666+
667+
// Order proof: the guard ran first and failed, so the RPC errors out *before*
668+
// any archive/truncate. If the guard ran after truncation, the transcript
669+
// would already be 50 lines here. It is still 500 with no .bak, proving the
670+
// interrupt happens before the destructive tail-read/archive/write.
671+
expect(compacted.ok).toBe(false);
672+
expect(embeddedRunMock.abortCalls).toEqual(["sess-main"]);
673+
expect(embeddedRunMock.waitCalls).toEqual(["sess-main"]);
674+
675+
const untouched = (await fs.readFile(transcriptPath, "utf-8")).trim().split("\n");
676+
expect(untouched).toHaveLength(500);
677+
const dirEntries = await fs.readdir(dir);
678+
expect(dirEntries.some((name) => name.includes(".bak"))).toBe(false);
679+
expect(storePath).toBeTruthy();
680+
681+
ws.close();
682+
});
683+
568684
test("sessions.patch preserves nested model ids under provider overrides", async () => {
569685
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-sessions-nested-"));
570686
const storePath = path.join(dir, "sessions.json");

0 commit comments

Comments
 (0)