Skip to content

Commit 8cc8fee

Browse files
fix(agents): keep continuation bootstrap marker across short reads (#108253)
* fix(agents): keep bootstrap markers across short reads * refactor(infra): share bounded file window reads Co-authored-by: sunlit-deng <[email protected]> --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 8aa2392 commit 8cc8fee

5 files changed

Lines changed: 64 additions & 28 deletions

File tree

src/agents/bootstrap-files.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
33
import fs from "node:fs/promises";
44
import path from "node:path";
55
import { expectDefined } from "@openclaw/normalization-core";
6-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
77
import {
88
clearInternalHooks,
99
registerInternalHook,
@@ -495,6 +495,7 @@ describe("hasCompletedBootstrapTurn", () => {
495495
});
496496

497497
afterEach(async () => {
498+
vi.restoreAllMocks();
498499
await fs.rm(tmpDir, { recursive: true, force: true });
499500
});
500501

@@ -640,6 +641,37 @@ describe("hasCompletedBootstrapTurn", () => {
640641
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
641642
});
642643

644+
it("finds a recent full bootstrap marker when the tail read returns short", async () => {
645+
const sessionFile = path.join(tmpDir, "short-read-tail.jsonl");
646+
const lines = [
647+
JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }),
648+
JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }),
649+
JSON.stringify({
650+
type: "custom",
651+
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
652+
data: { timestamp: 1 },
653+
}),
654+
];
655+
await fs.writeFile(sessionFile, `${lines.join("\n")}\n`, "utf8");
656+
657+
const realOpen = fs.open.bind(fs);
658+
vi.spyOn(fs, "open").mockImplementation(async (...args) => {
659+
const handle = await realOpen(...args);
660+
const realRead = handle.read.bind(handle);
661+
return new Proxy(handle, {
662+
get(target, prop, receiver) {
663+
if (prop === "read") {
664+
return (buffer: Buffer, offset: number, length: number, position: number) =>
665+
realRead(buffer, offset, Math.min(length, 16), position);
666+
}
667+
return Reflect.get(target, prop, receiver);
668+
},
669+
});
670+
});
671+
672+
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
673+
});
674+
643675
it("returns false for symbolic links", async () => {
644676
const realFile = path.join(tmpDir, "real.jsonl");
645677
const linkFile = path.join(tmpDir, "link.jsonl");

src/agents/bootstrap-files.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
88
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
99
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
1010
import type { OpenClawConfig } from "../config/types.openclaw.js";
11+
import { readFileWindowFully } from "../infra/file-read.js";
1112
import { resolveUserPath } from "../utils.js";
1213
import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js";
1314
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
@@ -87,7 +88,7 @@ export async function hasCompletedBootstrapTurn(sessionFile: string): Promise<bo
8788
}
8889
const start = stat.size - bytesToRead;
8990
const buffer = Buffer.allocUnsafe(bytesToRead);
90-
const { bytesRead } = await fh.read(buffer, 0, bytesToRead, start);
91+
const bytesRead = await readFileWindowFully(fh, buffer, start);
9192
let text = buffer.toString("utf-8", 0, bytesRead);
9293
if (start > 0) {
9394
const firstNewline = text.indexOf("\n");

src/commands/channels/logs.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import fs from "node:fs/promises";
33
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
44
import { theme } from "../../../packages/terminal-core/src/theme.js";
55
import { normalizeChannelId as normalizeBundledChannelId } from "../../channels/registry.js";
6+
import { readFileWindowFully } from "../../infra/file-read.js";
67
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
78
import { getResolvedLoggerSettings } from "../../logging.js";
8-
import { readLogWindowFully, resolveLogFile } from "../../logging/log-tail.js";
9+
import { resolveLogFile } from "../../logging/log-tail.js";
910
import { parseLogLine } from "../../logging/parse-log-line.js";
1011
import { listManifestChannelContributionIds } from "../../plugins/manifest-contribution-ids.js";
1112
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
@@ -87,7 +88,7 @@ async function readTailLines(file: string, limit: number): Promise<string[]> {
8788
return [];
8889
}
8990
const buffer = Buffer.alloc(length);
90-
const bytesRead = await readLogWindowFully(handle, buffer, start);
91+
const bytesRead = await readFileWindowFully(handle, buffer, start);
9192
const text = buffer.toString("utf8", 0, bytesRead);
9293
let lines = text.split("\n");
9394
if (start > 0 && prefix !== "\n") {

src/infra/file-read.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { FileHandle } from "node:fs/promises";
2+
3+
/** Fills a bounded positional-read buffer unless the file reaches EOF. */
4+
export async function readFileWindowFully(
5+
handle: FileHandle,
6+
buffer: Buffer,
7+
position: number,
8+
): Promise<number> {
9+
let bytesRead = 0;
10+
while (bytesRead < buffer.length) {
11+
const result = await handle.read(
12+
buffer,
13+
bytesRead,
14+
buffer.length - bytesRead,
15+
position + bytesRead,
16+
);
17+
if (result.bytesRead === 0) {
18+
break;
19+
}
20+
bytesRead += result.bytesRead;
21+
}
22+
return bytesRead;
23+
}

src/logging/log-tail.ts

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Log tail helpers read recent log lines with optional parsing and redaction.
2-
import fs, { type FileHandle } from "node:fs/promises";
2+
import fs from "node:fs/promises";
33
import path from "node:path";
4+
import { readFileWindowFully } from "../infra/file-read.js";
45
import { getResolvedLoggerSettings } from "../logging.js";
56
import { clamp } from "../utils.js";
67
import { redactSensitiveLines, resolveRedactOptions } from "./redact.js";
@@ -26,28 +27,6 @@ function isRollingLogFile(file: string): boolean {
2627
return ROLLING_LOG_RE.test(path.basename(file));
2728
}
2829

29-
/** Fills a bounded positional-read buffer unless the file reaches EOF. */
30-
export async function readLogWindowFully(
31-
handle: FileHandle,
32-
buffer: Buffer,
33-
position: number,
34-
): Promise<number> {
35-
let bytesRead = 0;
36-
while (bytesRead < buffer.length) {
37-
const result = await handle.read(
38-
buffer,
39-
bytesRead,
40-
buffer.length - bytesRead,
41-
position + bytesRead,
42-
);
43-
if (result.bytesRead === 0) {
44-
break;
45-
}
46-
bytesRead += result.bytesRead;
47-
}
48-
return bytesRead;
49-
}
50-
5130
/** Resolves a rolling daily log path to the newest existing rolling log when needed. */
5231
export async function resolveLogFile(file: string): Promise<string> {
5332
const stat = await fs.stat(file).catch(() => null);
@@ -148,7 +127,7 @@ async function readLogSlice(params: {
148127

149128
const length = Math.max(0, size - start);
150129
const buffer = Buffer.alloc(length);
151-
const bytesRead = await readLogWindowFully(handle, buffer, start);
130+
const bytesRead = await readFileWindowFully(handle, buffer, start);
152131
const text = buffer.toString("utf8", 0, bytesRead);
153132
let lines = text.split("\n");
154133
if (start > 0 && prefix !== "\n") {

0 commit comments

Comments
 (0)