mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 23:51:14 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
23
src/infra/file-read.ts
Normal 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;
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user