fix(core): flush JSONL appends to disk (#4095 Tier 3b, closes #3681)

#3656 fixed the read side of glued '}{' JSONL records — when a process
was killed mid-appendFile, the trailing '\n' was lost and the next
record was concatenated. The write side was left for a follow-up
(#3681).

This adds flush:true (fsync) to every per-line append:
- jsonl-utils.ts writeLine / writeLineSync (session transcripts,
  auto-titles, prompt history)
- debugLogger.ts appendFile (per-session debug log)

jsonl-utils.ts write() (full-file replace) now goes through
atomicWriteFileSync so a crash during overwrite cannot corrupt the
session transcript either.

Trade-off: fsync on every append adds disk-sync latency (single-digit
ms on SSD, more on spinning disk / network FS). Acceptable for a few
writes per turn; the alternative is silently losing the last record
of every interrupted session, which #3681 explicitly flagged.

Refs: #4095 Phase 2 Tier 3b
Closes: #3681
This commit is contained in:
doudouOUC 2026-05-19 20:49:26 +08:00
parent 2a6ded1fd0
commit b7badc7775
3 changed files with 19 additions and 11 deletions

View file

@ -96,7 +96,7 @@ describe('debugLogger', () => {
expect(fs.appendFile).toHaveBeenCalledWith(
Storage.getDebugLogPath('test-session-123'),
'2026-01-24T10:30:00.000Z [DEBUG] Hello world\n',
'utf8',
{ encoding: 'utf8', flush: true },
);
});
@ -109,7 +109,7 @@ describe('debugLogger', () => {
expect(fs.appendFile).toHaveBeenCalledWith(
Storage.getDebugLogPath('test-session-123'),
'2026-01-24T10:30:00.000Z [INFO] [STARTUP] Server started\n',
'utf8',
{ encoding: 'utf8', flush: true },
);
});
@ -149,7 +149,7 @@ describe('debugLogger', () => {
expect.stringContaining(
'[trace_id=realtraceidddddddddddddddddddddd span_id=realspanid111111]',
),
'utf8',
{ encoding: 'utf8', flush: true },
);
});
@ -170,7 +170,7 @@ describe('debugLogger', () => {
expect(fs.appendFile).toHaveBeenCalledWith(
expect.any(String),
expect.not.stringContaining('trace_id='),
'utf8',
{ encoding: 'utf8', flush: true },
);
});
@ -187,7 +187,7 @@ describe('debugLogger', () => {
expect(fs.appendFile).toHaveBeenCalledWith(
expect.any(String),
expect.not.stringContaining('trace_id='),
'utf8',
{ encoding: 'utf8', flush: true },
);
});
@ -230,7 +230,7 @@ describe('debugLogger', () => {
expect.stringContaining(
'[trace_id=cccccccccccccccccccccccccccccccc span_id=dddddddddddddddd]',
),
'utf8',
{ encoding: 'utf8', flush: true },
);
});
@ -264,7 +264,7 @@ describe('debugLogger', () => {
expect(fs.appendFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Count: 42 items'),
'utf8',
{ encoding: 'utf8', flush: true },
);
});

View file

@ -148,7 +148,9 @@ function writeLog(
const line = buildLogLine(level, message, tag, traceCtx);
void ensureDebugDirExists()
.then(() => fs.appendFile(logFilePath, line, 'utf8'))
.then(() =>
fs.appendFile(logFilePath, line, { encoding: 'utf8', flush: true }),
)
.catch(() => {
hasWriteFailure = true;
});

View file

@ -23,6 +23,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { atomicWriteFileSync } from './atomicFileWrite.js';
import readline from 'node:readline';
import { finished } from 'node:stream/promises';
import { Mutex } from 'async-mutex';
@ -267,7 +268,12 @@ export async function writeLine(
await fs.promises.mkdir(dir, { recursive: true });
ensuredDirs.add(dir);
}
await fs.promises.appendFile(filePath, line, 'utf8');
// flush:true fsyncs after each line so a process killed mid-write
// doesn't leave a glued `}{` record on disk (closes #3681).
await fs.promises.appendFile(filePath, line, {
encoding: 'utf8',
flush: true,
});
});
}
@ -282,7 +288,7 @@ export function writeLineSync(filePath: string, data: unknown): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.appendFileSync(filePath, line, 'utf8');
fs.appendFileSync(filePath, line, { encoding: 'utf8', flush: true });
}
/**
@ -296,7 +302,7 @@ export function write(filePath: string, data: unknown[]): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, `${lines}\n`, 'utf8');
atomicWriteFileSync(filePath, `${lines}\n`, { encoding: 'utf8' });
}
/**