Skip to content

Commit e066f4b

Browse files
authored
Merge pull request ceilf6#289 from FrontAgent/improve/runtime-node-async-run-logger
perf(runtime-node): write run log through async WriteStream instead of appendFileSync
2 parents 641bfc8 + fefb86b commit e066f4b

4 files changed

Lines changed: 581 additions & 25 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
# Async Run Logger (Issue #276) Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Stop `FileRunLogger` from blocking the Node event loop by replacing per-entry `appendFileSync` calls with a single append-mode `fs.createWriteStream` per run.
6+
7+
**Architecture (final, amended after repo-guard review on PR #289):** `packages/runtime-node/src/run-logger.ts` changes the public `RunLogger` contract in one place: `close()` returns `Promise<void>`, resolved on the stream `close` event so callers can await the flush before reading `logger.path`; repeated `close()` calls share the first call's promise. Both callers (`runFrontAgentTask`, `planFrontAgentTask` in `run.ts`) now `await runLogger?.close()` in their `finally` blocks. Internally, `FileRunLogger` opens one `WriteStream` with `flags: 'a'` in its constructor and pushes every entry through `stream.write()` (non-blocking; the stream buffers and flushes on the libuv thread pool). Backpressure: once `stream.writableLength` exceeds `maxBufferedBytes` (default 4 MB, `RunLoggerOptions.maxBufferedBytes`), only droppable entries (`stream_token` events, `console.log/warn`) are dropped and counted — `result`, `error`, `console.error`, non-stream events, and the close marker are always written, and a dropped-entries summary line is emitted on recovery/close. Stream `error` events disable further logging instead of crashing the agent run.
8+
9+
> Note: the task snippets below are the original pre-review execution plan and predate the amendments above (async `close()`, shared close promise, droppable-only backpressure). Where they conflict, the paragraph above and the merged code are authoritative.
10+
11+
**Tech Stack:** Node `fs.createWriteStream`, TypeScript, Vitest.
12+
13+
**GitNexus blast radius (recorded before editing):** `gitnexus_impact` on `FileRunLogger` and `createRunLogger` returns **HIGH** risk — direct callers `runFrontAgentTask` and `planFrontAgentTask` (`packages/runtime-node/src/run.ts`), re-export via `packages/runtime-node/src/index.ts`, indirect impact on `createFrontAgentMcpServer` / `startFrontAgentMcpServer` flows. Mitigation: the `RunLogger` interface and entry format are preserved byte-for-byte; only the write mechanism changes. Pending stream writes hold the event loop open, so the CLI's natural exit still flushes the log (no `process.exit` calls exist in `apps/cli/src`).
14+
15+
**Out of scope:** `apps/cli/src/run-logger.ts` is a legacy copy imported only by its own test file (no production import). Issue #276 targets `packages/runtime-node` only; do not touch the CLI copy.
16+
17+
---
18+
19+
### Task 1: Behavior tests for FileRunLogger file output
20+
21+
**Files:**
22+
- Modify: `packages/runtime-node/src/run-logger.test.ts`
23+
24+
- [ ] **Step 1: Write the failing tests**
25+
26+
Append to `packages/runtime-node/src/run-logger.test.ts`. Update the imports at the top of the file:
27+
28+
```ts
29+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
30+
import { tmpdir } from 'node:os';
31+
import { join, resolve } from 'node:path';
32+
import type { AgentEvent } from '@frontagent/core';
33+
import { afterEach, describe, expect, it, vi } from 'vitest';
34+
import { createRunLogger, redactForLog, resolveRunLogPath } from './run-logger.js';
35+
```
36+
37+
Append this suite at the end of the file:
38+
39+
```ts
40+
describe('createRunLogger (file logger)', () => {
41+
const tempDirs: string[] = [];
42+
43+
function makeLogger() {
44+
const dir = mkdtempSync(join(tmpdir(), 'frontagent-runlog-'));
45+
tempDirs.push(dir);
46+
const logger = createRunLogger({
47+
projectRoot: dir,
48+
enabled: true,
49+
logFile: 'run.log',
50+
task: 'test task',
51+
provider: 'test-provider',
52+
model: 'test-model',
53+
options: {},
54+
});
55+
if (!logger) throw new Error('expected logger');
56+
return logger;
57+
}
58+
59+
async function readWhenClosed(path: string): Promise<string> {
60+
await vi.waitFor(() => {
61+
expect(readFileSync(path, 'utf8')).toContain('closed');
62+
});
63+
return readFileSync(path, 'utf8');
64+
}
65+
66+
afterEach(() => {
67+
for (const dir of tempDirs.splice(0)) {
68+
rmSync(dir, { recursive: true, force: true });
69+
}
70+
});
71+
72+
it('returns null when disabled', () => {
73+
expect(
74+
createRunLogger({
75+
projectRoot: '/tmp',
76+
enabled: false,
77+
task: 't',
78+
provider: 'p',
79+
model: 'm',
80+
options: {},
81+
}),
82+
).toBeNull();
83+
});
84+
85+
it('flushes header, entries, and close marker to the log file in order', async () => {
86+
const logger = makeLogger();
87+
logger.event({ type: 'stream_token', stepId: 's1', token: 'hello' } as AgentEvent);
88+
logger.console('warn', ['warned', { apiKey: 'sk-secret' }]);
89+
logger.result({ success: true } as never);
90+
logger.error(new Error('boom'));
91+
logger.close();
92+
93+
const content = await readWhenClosed(logger.path);
94+
expect(content).toContain('# FrontAgent Run Log');
95+
expect(content).toContain('event.stream_token');
96+
expect(content).toContain('"tokenLength": 5');
97+
expect(content).toContain('console.warn');
98+
expect(content).toContain('[REDACTED]');
99+
expect(content).not.toContain('sk-secret');
100+
expect(content).toContain('result');
101+
expect(content).toContain('boom');
102+
expect(content.indexOf('# FrontAgent Run Log')).toBeLessThan(content.indexOf('event.stream_token'));
103+
expect(content.indexOf('event.stream_token')).toBeLessThan(content.indexOf('console.warn'));
104+
expect(content.trimEnd().endsWith('closed')).toBe(true);
105+
});
106+
107+
it('ignores writes after close and is idempotent on close', async () => {
108+
const logger = makeLogger();
109+
logger.event({ type: 'status_update', label: 'before' } as AgentEvent);
110+
logger.close();
111+
logger.close();
112+
logger.event({ type: 'status_update', label: 'after-close' } as AgentEvent);
113+
logger.console('log', ['after-close-console']);
114+
115+
const content = await readWhenClosed(logger.path);
116+
expect(content).toContain('before');
117+
expect(content).not.toContain('after-close');
118+
expect(content.match(/closed/g)).toHaveLength(1);
119+
});
120+
121+
it('appends to an existing file instead of truncating', async () => {
122+
const dir = mkdtempSync(join(tmpdir(), 'frontagent-runlog-'));
123+
tempDirs.push(dir);
124+
const path = resolve(dir, 'run.log');
125+
const first = createRunLogger({
126+
projectRoot: dir,
127+
enabled: true,
128+
logFile: 'run.log',
129+
task: 'first',
130+
provider: 'p',
131+
model: 'm',
132+
options: {},
133+
});
134+
if (!first) throw new Error('expected logger');
135+
first.close();
136+
await vi.waitFor(() => {
137+
expect(readFileSync(path, 'utf8')).toContain('closed');
138+
});
139+
140+
const second = createRunLogger({
141+
projectRoot: dir,
142+
enabled: true,
143+
logFile: 'run.log',
144+
task: 'second',
145+
provider: 'p',
146+
model: 'm',
147+
options: {},
148+
});
149+
if (!second) throw new Error('expected logger');
150+
second.close();
151+
await vi.waitFor(() => {
152+
expect((readFileSync(path, 'utf8').match(/closed/g) ?? []).length).toBe(2);
153+
});
154+
155+
const content = readFileSync(path, 'utf8');
156+
expect(content).toContain('task: first');
157+
expect(content).toContain('task: second');
158+
});
159+
});
160+
```
161+
162+
Note on `task: first` / `task: second`: the header is `stringify(header)` of an object containing `task`, which JSON-stringifies as `"task": "first"`. Use these assertions instead:
163+
164+
```ts
165+
expect(content).toContain('"task": "first"');
166+
expect(content).toContain('"task": "second"');
167+
```
168+
169+
- [ ] **Step 2: Run tests — new ordering/append tests must pass against current sync implementation too (they assert behavior, not mechanism), so verify they pass, then proceed; the refactor in Task 2 must keep them green**
170+
171+
Run: `pnpm vitest run packages/runtime-node/src/run-logger.test.ts`
172+
Expected: PASS (these tests pin the existing observable contract before the internal change).
173+
174+
- [ ] **Step 3: Commit**
175+
176+
```bash
177+
git add packages/runtime-node/src/run-logger.test.ts
178+
git commit -m "test(runtime-node): pin FileRunLogger file output contract before async refactor"
179+
```
180+
181+
### Task 2: Replace appendFileSync with a single append-mode WriteStream
182+
183+
**Files:**
184+
- Modify: `packages/runtime-node/src/run-logger.ts`
185+
186+
- [ ] **Step 1: Update imports**
187+
188+
Replace line 2:
189+
190+
```ts
191+
import { appendFileSync, mkdirSync } from 'node:fs';
192+
```
193+
194+
with:
195+
196+
```ts
197+
import { createWriteStream, mkdirSync, type WriteStream } from 'node:fs';
198+
```
199+
200+
- [ ] **Step 2: Rewrite FileRunLogger to write through one stream**
201+
202+
Replace the whole `FileRunLogger` class (lines 119–162) with:
203+
204+
```ts
205+
class FileRunLogger implements RunLogger {
206+
private closed = false;
207+
private readonly stream: WriteStream;
208+
209+
constructor(
210+
readonly path: string,
211+
header: Record<string, unknown>,
212+
) {
213+
mkdirSync(dirname(path), { recursive: true });
214+
this.stream = createWriteStream(path, { flags: 'a', encoding: 'utf8' });
215+
// Disk failures (ENOSPC, EACCES, ...) must not crash the agent run; an
216+
// unhandled 'error' event on the stream would. Disable further logging instead.
217+
this.stream.on('error', () => {
218+
this.closed = true;
219+
});
220+
this.stream.write(
221+
['# FrontAgent Run Log', `startedAt: ${timestampForLine()}`, stringify(header), ''].join(
222+
'\n',
223+
),
224+
);
225+
}
226+
227+
private write(kind: string, payload: unknown): void {
228+
if (this.closed) return;
229+
this.stream.write(`[${timestampForLine()}] ${kind}\n${stringify(payload)}\n\n`);
230+
}
231+
232+
console(level: 'log' | 'warn' | 'error', args: unknown[]): void {
233+
this.write(`console.${level}`, formatConsoleArgs(args));
234+
}
235+
236+
event(event: AgentEvent): void {
237+
this.write(`event.${event.type}`, summarizeEvent(event));
238+
}
239+
240+
result(result: AgentExecutionResult): void {
241+
this.write('result', result);
242+
}
243+
244+
error(error: unknown): void {
245+
this.write('error', error);
246+
}
247+
248+
close(): void {
249+
if (this.closed) return;
250+
this.closed = true;
251+
// end() writes the final marker after all buffered entries, preserving
252+
// order, then closes the fd. Pending writes keep the event loop alive,
253+
// so a normal CLI exit still flushes the full log.
254+
this.stream.end(`[${timestampForLine()}] closed\n`);
255+
}
256+
}
257+
```
258+
259+
- [ ] **Step 3: Run the focused tests**
260+
261+
Run: `pnpm vitest run packages/runtime-node/src/run-logger.test.ts`
262+
Expected: PASS (all redaction, path, and new file-logger tests).
263+
264+
- [ ] **Step 4: Run the package tests and type check**
265+
266+
Run: `pnpm vitest run packages/runtime-node && pnpm typecheck`
267+
Expected: PASS.
268+
269+
- [ ] **Step 5: Commit**
270+
271+
```bash
272+
git add packages/runtime-node/src/run-logger.ts
273+
git commit -m "perf(runtime-node): write run log through async WriteStream instead of appendFileSync"
274+
```
275+
276+
### Task 3: Optional benchmark evidence for the PR (not committed)
277+
278+
- [ ] **Step 1: Run an ad-hoc latency comparison**
279+
280+
Write `/tmp/runlog-bench.mjs` (do NOT commit):
281+
282+
```js
283+
import { appendFileSync, createWriteStream, rmSync } from 'node:fs';
284+
import { performance } from 'node:perf_hooks';
285+
286+
const N = 5000;
287+
const entry = `[2026-06-11T00:00:00.000Z] event.stream_token\n${JSON.stringify({ type: 'stream_token', stepId: 's1', tokenLength: 4 }, null, 2)}\n\n`;
288+
289+
function pctl(samples, p) {
290+
const s = [...samples].sort((a, b) => a - b);
291+
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
292+
}
293+
294+
// sync
295+
{
296+
const path = '/tmp/bench-sync.log';
297+
rmSync(path, { force: true });
298+
const samples = [];
299+
const t0 = performance.now();
300+
for (let i = 0; i < N; i++) {
301+
const a = performance.now();
302+
appendFileSync(path, entry, 'utf8');
303+
samples.push(performance.now() - a);
304+
}
305+
const total = performance.now() - t0;
306+
console.log(`appendFileSync: total=${total.toFixed(1)}ms p50=${pctl(samples, 50).toFixed(3)}ms p99=${pctl(samples, 99).toFixed(3)}ms max=${Math.max(...samples).toFixed(3)}ms (per-call event-loop block)`);
307+
}
308+
309+
// stream
310+
{
311+
const path = '/tmp/bench-stream.log';
312+
rmSync(path, { force: true });
313+
const stream = createWriteStream(path, { flags: 'a', encoding: 'utf8' });
314+
const samples = [];
315+
const t0 = performance.now();
316+
for (let i = 0; i < N; i++) {
317+
const a = performance.now();
318+
stream.write(entry);
319+
samples.push(performance.now() - a);
320+
}
321+
const submitted = performance.now() - t0;
322+
await new Promise((r) => stream.end(r));
323+
const total = performance.now() - t0;
324+
console.log(`WriteStream: submit=${submitted.toFixed(1)}ms flushTotal=${total.toFixed(1)}ms p50=${pctl(samples, 50).toFixed(3)}ms p99=${pctl(samples, 99).toFixed(3)}ms max=${Math.max(...samples).toFixed(3)}ms (per-call event-loop block)`);
325+
}
326+
```
327+
328+
Run: `node /tmp/runlog-bench.mjs`
329+
Record the output for the PR description.
330+
331+
### Task 4: Gates and PR
332+
333+
- [ ] **Step 1: Run precommit gate**
334+
335+
Run: `pnpm quality:precommit`
336+
Expected: PASS.
337+
338+
- [ ] **Step 2: GitNexus detect_changes**
339+
340+
Run `gitnexus_detect_changes()` and confirm only `run-logger.ts` symbols (`FileRunLogger`, its methods) and the test file are affected; affected flows limited to `runFrontAgentTask` / `planFrontAgentTask` logging paths.
341+
342+
- [ ] **Step 3: Open PR to develop**
343+
344+
Use the repo PR template, reference `Closes #276`, include the GitNexus impact summary (HIGH risk, interface preserved) and the benchmark numbers from Task 3.

0 commit comments

Comments
 (0)