Skip to content

Commit 474bea1

Browse files
authored
fix: bound trajectory runtime flush (#77154)
* fix: bound trajectory runtime flush * fix: keep trajectory export cap compatible * test: keep followup delivery test pure
1 parent be41b8c commit 474bea1

9 files changed

Lines changed: 255 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
8585
- Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting.
8686
- iOS/mobile pairing: reject non-loopback `ws://` setup URLs before QR/setup-code issuance and let the iOS Gateway settings screen scan QR codes or paste full setup-code messages. Thanks @BunsDev.
8787
- Control UI: keep Gateway Access inputs and locale picker contained inside the card at narrow and tablet widths.
88+
- Agents/trajectory: bound runtime trajectory capture and yield queued sidecar writes so oversized traces stop recording instead of monopolizing Gateway cleanup. Fixes #77124. Thanks @loyur.
8889
- Telegram/streaming: sanitize tool-progress draft preview backticks before shared compaction, so long backtick-heavy progress text still renders inside the safe code-formatted preview instead of collapsing to an ellipsis.
8990
- UI/chat: remove the unsupported `line-clamp` declaration from the chat queue text rule to eliminate Firefox console noise without changing visible truncation behavior. Thanks @ZanderH-code.
9091
- Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc.

docs/tools/trajectory.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ OpenClaw redacts sensitive values before writing export files:
181181

182182
The exporter also bounds input size:
183183

184-
- runtime sidecar files: 50 MiB
184+
- runtime sidecar files: live capture stops at 10 MiB and records a truncation event when space remains; export accepts existing runtime sidecars up to 50 MiB
185185
- session files: 50 MiB
186186
- runtime events: 200,000
187187
- total exported events: 250,000

src/agents/queued-file-writer.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,16 @@ describe("getQueuedFileWriter", () => {
8080

8181
expect(fs.readFileSync(filePath, "utf8")).toBe("12345\n");
8282
});
83+
84+
it("drops writes that would exceed the pending queue cap", async () => {
85+
const tmpDir = makeTempDir();
86+
const filePath = path.join(tmpDir, "trace.jsonl");
87+
const writer = getQueuedFileWriter(new Map(), filePath, { maxQueuedBytes: 6 });
88+
89+
expect(writer.write("12345\n")).toBe("queued");
90+
expect(writer.write("after\n")).toBe("dropped");
91+
await writer.flush();
92+
93+
expect(fs.readFileSync(filePath, "utf8")).toBe("12345\n");
94+
});
8395
});

src/agents/queued-file-writer.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ import nodeFs from "node:fs";
22
import fs from "node:fs/promises";
33
import path from "node:path";
44

5+
export type QueuedFileWriteResult = "queued" | "dropped";
6+
57
export type QueuedFileWriter = {
68
filePath: string;
7-
write: (line: string) => void;
9+
write: (line: string) => unknown;
810
flush: () => Promise<void>;
911
};
1012

1113
type QueuedFileWriterOptions = {
1214
maxFileBytes?: number;
15+
maxQueuedBytes?: number;
16+
yieldBeforeWrite?: boolean;
1317
};
1418

1519
type QueuedFileAppendFlagConstants = Pick<
@@ -111,6 +115,12 @@ async function safeAppendFile(
111115
}
112116
}
113117

118+
function waitForImmediate(): Promise<void> {
119+
return new Promise((resolve) => {
120+
setImmediate(resolve);
121+
});
122+
}
123+
114124
export function getQueuedFileWriter(
115125
writers: Map<string, QueuedFileWriter>,
116126
filePath: string,
@@ -123,15 +133,29 @@ export function getQueuedFileWriter(
123133

124134
const dir = path.dirname(filePath);
125135
const ready = fs.mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => undefined);
126-
let queue = Promise.resolve();
136+
let queue: Promise<unknown> = Promise.resolve();
137+
let queuedBytes = 0;
127138

128139
const writer: QueuedFileWriter = {
129140
filePath,
130141
write: (line: string) => {
142+
const lineBytes = Buffer.byteLength(line, "utf8");
143+
if (
144+
options.maxQueuedBytes !== undefined &&
145+
queuedBytes + lineBytes > options.maxQueuedBytes
146+
) {
147+
return "dropped";
148+
}
149+
queuedBytes += lineBytes;
131150
queue = queue
132151
.then(() => ready)
152+
.then(() => (options.yieldBeforeWrite ? waitForImmediate() : undefined))
133153
.then(() => safeAppendFile(filePath, line, options))
134-
.catch(() => undefined);
154+
.catch(() => undefined)
155+
.finally(() => {
156+
queuedBytes = Math.max(0, queuedBytes - lineBytes);
157+
});
158+
return "queued";
135159
},
136160
flush: async () => {
137161
await queue;

src/auto-reply/reply/followup-delivery.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../../config/config.js";
33
import { resolveFollowupDeliveryPayloads } from "./followup-delivery.js";
44

5+
vi.mock("../../channels/plugins/index.js", () => ({
6+
getChannelPlugin: () => undefined,
7+
}));
8+
59
const baseConfig = {} as OpenClawConfig;
610

711
describe("resolveFollowupDeliveryPayloads", () => {

src/trajectory/export.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import type { Message, Usage } from "@mariozechner/pi-ai";
55
import { afterAll, describe, expect, it } from "vitest";
66
import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js";
7-
import { resolveTrajectoryPointerFilePath } from "./paths.js";
7+
import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, resolveTrajectoryPointerFilePath } from "./paths.js";
88
import type { TrajectoryEvent } from "./types.js";
99

1010
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trajectory-"));
@@ -272,7 +272,7 @@ describe("exportTrajectoryBundle", () => {
272272
const outputDir = path.join(tmpDir, "bundle");
273273
writeSimpleSessionFile(sessionFile);
274274
fs.closeSync(fs.openSync(runtimeFile, "w"));
275-
fs.truncateSync(runtimeFile, 50 * 1024 * 1024 + 1);
275+
fs.truncateSync(runtimeFile, TRAJECTORY_RUNTIME_FILE_MAX_BYTES + 1);
276276

277277
await expect(
278278
exportTrajectoryBundle({

src/trajectory/paths.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs";
22
import path from "node:path";
33
import { resolveHomeRelativePath } from "../infra/home-dir.js";
44

5+
export const TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES = 10 * 1024 * 1024;
56
export const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024;
67
export const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
78

src/trajectory/runtime.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ describe("trajectory runtime", () => {
8888
expect(JSON.stringify(parsed.data)).not.toContain("sk-other-secret-token");
8989
});
9090

91-
it("truncates events that exceed the runtime event byte limit", () => {
91+
it("bounds large runtime event fields before serialization", () => {
9292
const writes: string[] = [];
9393
const recorder = createTrajectoryRuntimeRecorder({
9494
sessionId: "session-1",
@@ -108,15 +108,53 @@ describe("trajectory runtime", () => {
108108

109109
expect(writes).toHaveLength(1);
110110
const parsed = JSON.parse(writes[0]);
111-
expect(parsed.data).toMatchObject({
111+
expect(parsed.data.prompt).toMatchObject({
112112
truncated: true,
113-
reason: "trajectory-event-size-limit",
113+
reason: "trajectory-field-size-limit",
114114
});
115115
expect(Buffer.byteLength(writes[0], "utf8")).toBeLessThanOrEqual(
116116
TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1,
117117
);
118118
});
119119

120+
it("stops runtime capture at the file budget and records a truncation event", async () => {
121+
const writes: string[] = [];
122+
const recorder = createTrajectoryRuntimeRecorder({
123+
sessionId: "session-1",
124+
sessionFile: "/tmp/session.jsonl",
125+
maxRuntimeFileBytes: 900,
126+
writer: {
127+
filePath: "/tmp/session.trajectory.jsonl",
128+
write: (line) => {
129+
writes.push(line);
130+
},
131+
flush: async () => undefined,
132+
},
133+
});
134+
135+
recorder?.recordEvent("context.compiled", {
136+
prompt: "x".repeat(180),
137+
});
138+
recorder?.recordEvent("prompt.submitted", {
139+
prompt: "y".repeat(180),
140+
});
141+
recorder?.recordEvent("model.completed", {
142+
get prompt() {
143+
throw new Error("stopped recorder should not read dropped payloads");
144+
},
145+
});
146+
await recorder?.flush();
147+
148+
const parsed = writes.map((line) => JSON.parse(line));
149+
expect(parsed.map((event) => event.type)).toContain("trace.truncated");
150+
const truncated = parsed.find((event) => event.type === "trace.truncated");
151+
expect(truncated?.data).toMatchObject({
152+
reason: "trajectory-runtime-file-size-limit",
153+
limitBytes: 900,
154+
});
155+
expect(truncated?.data.droppedEvents).toBeGreaterThan(0);
156+
});
157+
120158
it("writes a session-adjacent pointer when using an override directory", () => {
121159
const tmpDir = makeTempDir();
122160
const sessionFile = path.join(tmpDir, "session.jsonl");

0 commit comments

Comments
 (0)