mirror of
https://github.com/TianyiDataScience/openclaw-control-center.git
synced 2026-08-14 08:52:27 +00:00
Reduce session preview cold-start cost
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user