Reduce session preview cold-start cost

This commit is contained in:
OpenClaw Local
2026-03-13 14:16:28 +01:00
parent f3327e3f95
commit 1ba2ad0322
3 changed files with 140 additions and 6 deletions
+21
View File
@@ -1,5 +1,26 @@
# Progress
## Phase 152 (Session-preview cold-start optimization without UI changes) — Completed
- Scope:
- Reduce the overview cold-start stall caused by session preview history reads without changing page structure, copy, or data semantics.
- Changed files:
- `src/clients/openclaw-live-client.ts`
- `test/openclaw-live-client-history.test.ts`
- `docs/PROGRESS.md`
- Implementation:
- Replaced the per-session external `tail` subprocess path with an in-process reverse file reader that scans recent JSONL history directly from the end of the file.
- Kept the same `sessionsHistory` API shape and the same history normalization logic so execution chains, previews, and UI copy stay unchanged.
- Added regression coverage for large cached history files and files without a trailing newline.
- Verification:
- `npm run build`
- `npm test`
- `npm run smoke:ui`
- Local route timing after restart on `UI_PORT=4522`:
- `overview` first open: about `5.38s` -> about `0.84s`
- `usage-cost` first open: about `1.18s` -> about `0.95s`
- `projects-tasks` first open: about `0.38s` -> about `0.57s`
- repeated `overview` opens: about `0.82s`, `0.62s`, `1.13s`, `0.25s`, `0.21s`
## Phase 151 (Route-latency optimization without UI changes) — Completed
- Scope:
- Reduce slow first-navigation and post-cache-expiry stalls without changing page structure, copy, or data surfaces.
+50 -6
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { open, readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import type {
@@ -58,6 +58,9 @@ const INACTIVE_SESSION_STATES = new Set([
"canceled",
]);
const FALLBACK_ACTIVE_RECENCY_WINDOW_MS = 45 * 60 * 1000;
const SESSION_HISTORY_TAIL_MIN_LINES = 80;
const SESSION_HISTORY_TAIL_LINE_MULTIPLIER = 8;
const SESSION_HISTORY_TAIL_CHUNK_BYTES = 64 * 1024;
/**
* Live read client using official OpenClaw CLI JSON outputs.
@@ -429,12 +432,10 @@ async function readSessionHistoryFile(
sessionFile: string,
limit: number,
): Promise<SessionsHistoryResponse | undefined> {
const targetLineCount = Math.max(limit * SESSION_HISTORY_TAIL_LINE_MULTIPLIER, SESSION_HISTORY_TAIL_MIN_LINES);
try {
const { stdout } = await execFileAsync("tail", ["-n", String(Math.max(limit * 8, 80)), sessionFile], {
timeout: 5_000,
maxBuffer: 512 * 1024,
});
return normalizeSessionHistoryChunk(stdout, limit);
const raw = await readRecentSessionHistoryChunk(sessionFile, targetLineCount);
return normalizeSessionHistoryChunk(raw, limit);
} catch {
try {
const raw = await readFile(sessionFile, "utf8");
@@ -445,6 +446,49 @@ async function readSessionHistoryFile(
}
}
async function readRecentSessionHistoryChunk(sessionFile: string, targetLineCount: number): Promise<string> {
const handle = await open(sessionFile, "r");
try {
const { size } = await handle.stat();
if (size <= 0) return "";
let position = size;
let newlineCount = 0;
const chunks: Buffer[] = [];
while (position > 0 && newlineCount < targetLineCount) {
const bytesToRead = Math.min(SESSION_HISTORY_TAIL_CHUNK_BYTES, position);
position -= bytesToRead;
const buffer = Buffer.allocUnsafe(bytesToRead);
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, position);
if (bytesRead <= 0) break;
const chunk = bytesRead === bytesToRead ? buffer : buffer.subarray(0, bytesRead);
chunks.push(chunk);
newlineCount += countLineFeeds(chunk);
}
if (chunks.length === 0) return "";
const raw = Buffer.concat(chunks.reverse()).toString("utf8");
if (position <= 0) return raw;
const firstLineBreak = raw.indexOf("\n");
return firstLineBreak >= 0 ? raw.slice(firstLineBreak + 1) : raw;
} finally {
await handle.close();
}
}
function countLineFeeds(buffer: Uint8Array): number {
let count = 0;
for (const byte of buffer) {
if (byte === 0x0a) count += 1;
}
return count;
}
function normalizeSessionHistoryChunk(raw: string, limit: number): SessionsHistoryResponse {
const lines = raw
.split(/\r?\n/)
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { OpenClawLiveClient } from "../src/clients/openclaw-live-client";
function attachSessionFile(client: OpenClawLiveClient, sessionKey: string, sessionFile: string): void {
const internalClient = client as OpenClawLiveClient & {
sessionCache: Map<string, { sessionFile?: string }>;
};
internalClient.sessionCache.set(sessionKey, { sessionFile });
}
test("sessionsHistory reads recent history from large cached session files", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "openclaw-history-"));
try {
const sessionKey = "agent:main:cron:demo:run:child";
const sessionFile = join(tempDir, "session.jsonl");
const payload = "x".repeat(2048);
const lines = Array.from({ length: 120 }, (_, index) =>
JSON.stringify({ seq: index + 1, message: `entry-${index + 1}`, payload }),
);
await writeFile(sessionFile, `${lines.join("\n")}\n`, "utf8");
const client = new OpenClawLiveClient();
attachSessionFile(client, sessionKey, sessionFile);
const response = await client.sessionsHistory({ sessionKey, limit: 3 });
const history = Array.isArray(response.json?.history) ? response.json.history : [];
assert.deepEqual(
history.map((item) => (typeof item === "string" ? item : item.seq)),
[118, 119, 120],
);
assert.match(response.rawText, /"seq":118/);
assert.match(response.rawText, /"seq":120/);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test("sessionsHistory keeps the last line when the history file has no trailing newline", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "openclaw-history-"));
try {
const sessionKey = "agent:coq:main";
const sessionFile = join(tempDir, "session.jsonl");
const lines = [
JSON.stringify({ seq: 1, message: "first" }),
JSON.stringify({ seq: 2, message: "second" }),
JSON.stringify({ seq: 3, message: "third" }),
];
await writeFile(sessionFile, lines.join("\n"), "utf8");
const client = new OpenClawLiveClient();
attachSessionFile(client, sessionKey, sessionFile);
const response = await client.sessionsHistory({ sessionKey, limit: 2 });
const history = Array.isArray(response.json?.history) ? response.json.history : [];
assert.deepEqual(
history.map((item) => (typeof item === "string" ? item : item.seq)),
[2, 3],
);
assert.equal(response.rawText.trim().split("\n").length, 2);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});