From 8cc8feee4d35e8df338ed73b11202b8422cdb06a Mon Sep 17 00:00:00 2001 From: sunlit-deng Date: Thu, 16 Jul 2026 06:50:59 +0800 Subject: [PATCH] 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 --------- Co-authored-by: Peter Steinberger --- src/agents/bootstrap-files.test.ts | 34 +++++++++++++++++++++++++++++- src/agents/bootstrap-files.ts | 3 ++- src/commands/channels/logs.ts | 5 +++-- src/infra/file-read.ts | 23 ++++++++++++++++++++ src/logging/log-tail.ts | 27 +++--------------------- 5 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 src/infra/file-read.ts diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index a6292098b27a..4908d27b837c 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -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"); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 8dc24ca9d174..ad3865720f8d 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -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 0) { const firstNewline = text.indexOf("\n"); diff --git a/src/commands/channels/logs.ts b/src/commands/channels/logs.ts index 02ab7f8728a3..723592fc2e7b 100644 --- a/src/commands/channels/logs.ts +++ b/src/commands/channels/logs.ts @@ -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 { 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") { diff --git a/src/infra/file-read.ts b/src/infra/file-read.ts new file mode 100644 index 000000000000..f59065e172be --- /dev/null +++ b/src/infra/file-read.ts @@ -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 { + 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; +} diff --git a/src/logging/log-tail.ts b/src/logging/log-tail.ts index d99f9e0acf0a..7bcf2ab6c09b 100644 --- a/src/logging/log-tail.ts +++ b/src/logging/log-tail.ts @@ -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 { - 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 { 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") {