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 <yang.jiajun1@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
sunlit-deng
2026-07-16 06:50:59 +08:00
committed by GitHub
parent 8aa2392003
commit 8cc8feee4d
5 changed files with 64 additions and 28 deletions

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearInternalHooks,
registerInternalHook,
@@ -495,6 +495,7 @@ describe("hasCompletedBootstrapTurn", () => {
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(tmpDir, { recursive: true, force: true });
});
@@ -640,6 +641,37 @@ describe("hasCompletedBootstrapTurn", () => {
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
});
it("finds a recent full bootstrap marker when the tail read returns short", async () => {
const sessionFile = path.join(tmpDir, "short-read-tail.jsonl");
const lines = [
JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }),
JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }),
JSON.stringify({
type: "custom",
customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
data: { timestamp: 1 },
}),
];
await fs.writeFile(sessionFile, `${lines.join("\n")}\n`, "utf8");
const realOpen = fs.open.bind(fs);
vi.spyOn(fs, "open").mockImplementation(async (...args) => {
const handle = await realOpen(...args);
const realRead = handle.read.bind(handle);
return new Proxy(handle, {
get(target, prop, receiver) {
if (prop === "read") {
return (buffer: Buffer, offset: number, length: number, position: number) =>
realRead(buffer, offset, Math.min(length, 16), position);
}
return Reflect.get(target, prop, receiver);
},
});
});
expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true);
});
it("returns false for symbolic links", async () => {
const realFile = path.join(tmpDir, "real.jsonl");
const linkFile = path.join(tmpDir, "link.jsonl");

View File

@@ -8,6 +8,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readFileWindowFully } from "../infra/file-read.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js";
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
@@ -87,7 +88,7 @@ export async function hasCompletedBootstrapTurn(sessionFile: string): Promise<bo
}
const start = stat.size - bytesToRead;
const buffer = Buffer.allocUnsafe(bytesToRead);
const { bytesRead } = await fh.read(buffer, 0, bytesToRead, start);
const bytesRead = await readFileWindowFully(fh, buffer, start);
let text = buffer.toString("utf-8", 0, bytesRead);
if (start > 0) {
const firstNewline = text.indexOf("\n");

View File

@@ -3,9 +3,10 @@ import fs from "node:fs/promises";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { normalizeChannelId as normalizeBundledChannelId } from "../../channels/registry.js";
import { readFileWindowFully } from "../../infra/file-read.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { readLogWindowFully, resolveLogFile } from "../../logging/log-tail.js";
import { resolveLogFile } from "../../logging/log-tail.js";
import { parseLogLine } from "../../logging/parse-log-line.js";
import { listManifestChannelContributionIds } from "../../plugins/manifest-contribution-ids.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
@@ -87,7 +88,7 @@ async function readTailLines(file: string, limit: number): Promise<string[]> {
return [];
}
const buffer = Buffer.alloc(length);
const bytesRead = await readLogWindowFully(handle, buffer, start);
const bytesRead = await readFileWindowFully(handle, buffer, start);
const text = buffer.toString("utf8", 0, bytesRead);
let lines = text.split("\n");
if (start > 0 && prefix !== "\n") {

23
src/infra/file-read.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { FileHandle } from "node:fs/promises";
/** Fills a bounded positional-read buffer unless the file reaches EOF. */
export async function readFileWindowFully(
handle: FileHandle,
buffer: Buffer,
position: number,
): Promise<number> {
let bytesRead = 0;
while (bytesRead < buffer.length) {
const result = await handle.read(
buffer,
bytesRead,
buffer.length - bytesRead,
position + bytesRead,
);
if (result.bytesRead === 0) {
break;
}
bytesRead += result.bytesRead;
}
return bytesRead;
}

View File

@@ -1,6 +1,7 @@
// Log tail helpers read recent log lines with optional parsing and redaction.
import fs, { type FileHandle } from "node:fs/promises";
import fs from "node:fs/promises";
import path from "node:path";
import { readFileWindowFully } from "../infra/file-read.js";
import { getResolvedLoggerSettings } from "../logging.js";
import { clamp } from "../utils.js";
import { redactSensitiveLines, resolveRedactOptions } from "./redact.js";
@@ -26,28 +27,6 @@ function isRollingLogFile(file: string): boolean {
return ROLLING_LOG_RE.test(path.basename(file));
}
/** Fills a bounded positional-read buffer unless the file reaches EOF. */
export async function readLogWindowFully(
handle: FileHandle,
buffer: Buffer,
position: number,
): Promise<number> {
let bytesRead = 0;
while (bytesRead < buffer.length) {
const result = await handle.read(
buffer,
bytesRead,
buffer.length - bytesRead,
position + bytesRead,
);
if (result.bytesRead === 0) {
break;
}
bytesRead += result.bytesRead;
}
return bytesRead;
}
/** Resolves a rolling daily log path to the newest existing rolling log when needed. */
export async function resolveLogFile(file: string): Promise<string> {
const stat = await fs.stat(file).catch(() => null);
@@ -148,7 +127,7 @@ async function readLogSlice(params: {
const length = Math.max(0, size - start);
const buffer = Buffer.alloc(length);
const bytesRead = await readLogWindowFully(handle, buffer, start);
const bytesRead = await readFileWindowFully(handle, buffer, start);
const text = buffer.toString("utf8", 0, bytesRead);
let lines = text.split("\n");
if (start > 0 && prefix !== "\n") {