Skip to content

Commit 904438b

Browse files
committed
fix(session): repair event-log tail, backfill, and flush
1 parent ef26b9b commit 904438b

2 files changed

Lines changed: 138 additions & 37 deletions

File tree

‎src/session/event-log.test.ts‎

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,40 @@ describe("createEventStreamWriter", () => {
174174
expect(records.map((r) => r.seq)).toEqual([0, 1]);
175175
});
176176

177+
test("a non-ASCII line plus a torn tail stays two parseable records with distinct seqs", async () => {
178+
const dir = tempDir();
179+
const writer = createEventStreamWriter(dir);
180+
await writer.append({
181+
id: "evt-1",
182+
type: "session.started",
183+
payload: { text: "é" },
184+
});
185+
await writer.flush();
186+
187+
fs.appendFileSync(
188+
path.join(dir, EVENT_LOG_FILE),
189+
'{"id":"torn","type":"eve',
190+
);
191+
192+
const reopened = createEventStreamWriter(dir);
193+
const next = await reopened.append({ id: "evt-2", type: "turn.done" });
194+
expect(next.seq).toBe(1);
195+
196+
const lines = rawLines(dir);
197+
expect(lines).toHaveLength(2);
198+
const parsed = lines.map(
199+
(line) => JSON.parse(line) as { id: string; seq: number },
200+
);
201+
expect(parsed.map((record) => record.id)).toEqual(["evt-1", "evt-2"]);
202+
expect(parsed.map((record) => record.seq)).toEqual([0, 1]);
203+
204+
const records = await reopened.readFrom(-1);
205+
expect(records.map((r) => r.id)).toEqual(["evt-1", "evt-2"]);
206+
expect(records.map((r) => r.seq)).toEqual([0, 1]);
207+
const again = await reopened.append({ id: "evt-3", type: "turn.done" });
208+
expect(again.seq).toBe(2);
209+
});
210+
177211
test("readFrom returns only events past the cursor", async () => {
178212
const dir = tempDir();
179213
const writer = createEventStreamWriter(dir);
@@ -253,10 +287,7 @@ describe("createEventStreamWriter", () => {
253287
}
254288
expect(first.parentId).toBeUndefined();
255289
expect(second.parentId).toBe(first.id);
256-
expect((first.payload as { record: unknown }).record).toEqual({
257-
role: "user",
258-
text: "one",
259-
});
290+
expect(first.payload).toMatchObject({ role: "user", text: "one" });
260291
});
261292

262293
test("backfillFromLegacy returns 0 when turns.jsonl is absent", async () => {
@@ -298,6 +329,59 @@ describe("createEventStreamWriter", () => {
298329
const records = await writer.readFrom(-1);
299330
expect(records).toHaveLength(3);
300331
});
332+
333+
test("backfillFromLegacy keeps identical lines and does not duplicate them on re-run", async () => {
334+
const dir = tempDir();
335+
const line = '{"role":"user","text":"same"}\n';
336+
fs.writeFileSync(path.join(dir, "turns.jsonl"), line + line);
337+
const writer = createEventStreamWriter(dir);
338+
expect(await writer.backfillFromLegacy()).toBe(2);
339+
expect(await writer.backfillFromLegacy()).toBe(0);
340+
expect(await writer.readFrom(-1)).toHaveLength(2);
341+
});
342+
343+
test("backfillFromLegacy includes rolled turn segments in order", async () => {
344+
const dir = tempDir();
345+
fs.writeFileSync(
346+
path.join(dir, "turns.jsonl"),
347+
'{"role":"user","text":"one"}\n',
348+
);
349+
fs.writeFileSync(
350+
path.join(dir, "turns-0001.jsonl"),
351+
'{"role":"user","text":"two"}\n',
352+
);
353+
const writer = createEventStreamWriter(dir);
354+
expect(await writer.backfillFromLegacy()).toBe(2);
355+
const records = await writer.readFrom(-1);
356+
expect(
357+
records.map((record) => (record.payload as { text?: string }).text),
358+
).toEqual(["one", "two"]);
359+
});
360+
361+
test("backfillFromLegacy stores user text where projectPrompt reads it", async () => {
362+
const dir = tempDir();
363+
fs.writeFileSync(
364+
path.join(dir, "turns.jsonl"),
365+
'{"role":"user","text":"one"}\n',
366+
);
367+
const writer = createEventStreamWriter(dir);
368+
expect(await writer.backfillFromLegacy()).toBe(1);
369+
expect(await writer.projectPrompt()).toBe("one");
370+
});
371+
372+
test("flush rejects when the append it covers rejects", async () => {
373+
const dir = tempDir();
374+
const writer = createEventStreamWriter(dir);
375+
const payload: { self?: unknown } = {};
376+
payload.self = payload;
377+
const appending = writer.append({ type: "event", payload });
378+
const flushing = writer.flush();
379+
const settled = await Promise.allSettled([appending, flushing]);
380+
expect(settled[0]?.status).toBe("rejected");
381+
expect(settled[1]?.status).toBe("rejected");
382+
await expect(appending).rejects.toThrow(TypeError);
383+
await expect(flushing).rejects.toThrow(TypeError);
384+
});
301385
});
302386

303387
describe("event stream flag gating", () => {

‎src/session/event-log.ts‎

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
22
import { join } from "node:path";
33

44
import { generateSessionId } from "./index.js";
5+
import { listSegmentFiles } from "./incremental-jsonl.js";
56
import { withResolvedDirLock } from "./session-dir-lock.js";
67

78
export const EVENT_LOG_FILE = "event-log.jsonl";
@@ -86,6 +87,16 @@ function completeLines(raw: string): string[] {
8687
return split.filter((line) => line.length > 0);
8788
}
8889

90+
function legacyPayload(
91+
parsed: unknown,
92+
legacyKey: string,
93+
): Record<string, unknown> {
94+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
95+
return { ...(parsed as Record<string, unknown>), legacyKey };
96+
}
97+
return { record: parsed, legacyKey };
98+
}
99+
89100
function parseRecordLine(line: string): EventStreamRecord {
90101
return JSON.parse(line) as EventStreamRecord;
91102
}
@@ -103,8 +114,9 @@ async function readRawOrEmpty(filePath: string): Promise<string> {
103114
async function dropTornTail(filePath: string, raw: string): Promise<string> {
104115
if (raw.length === 0 || raw.endsWith("\n")) return raw;
105116
const lastNl = raw.lastIndexOf("\n");
106-
await truncate(filePath, lastNl + 1);
107-
return raw.slice(0, lastNl + 1);
117+
const kept = raw.slice(0, lastNl + 1);
118+
await truncate(filePath, Buffer.byteLength(kept));
119+
return kept;
108120
}
109121

110122
export function createEventStreamWriter(
@@ -147,15 +159,19 @@ export function createEventStreamWriter(
147159

148160
const append = (event: AppendEventInput): Promise<EventStreamRecord> => {
149161
const run = withResolvedDirLock(dir, () => appendLocked(event));
150-
pending = run.then(
151-
() => undefined,
152-
() => undefined,
153-
);
162+
pending = run.then(() => undefined);
163+
void pending.catch(() => undefined);
154164
return run;
155165
};
156166

157167
const flush = async (): Promise<void> => {
158-
await pending;
168+
const current = pending;
169+
try {
170+
await current;
171+
} catch (err) {
172+
if (pending === current) pending = Promise.resolve();
173+
throw err;
174+
}
159175
};
160176

161177
const readAll = async (): Promise<EventStreamRecord[]> => {
@@ -195,14 +211,8 @@ export function createEventStreamWriter(
195211

196212
const backfillFromLegacy = (): Promise<number> =>
197213
withResolvedDirLock(dir, async () => {
198-
let raw: string;
199-
try {
200-
raw = await readFile(join(dir, "turns.jsonl"), "utf8");
201-
} catch (err) {
202-
if (isENOENT(err)) return 0;
203-
throw err;
204-
}
205-
// Content hash, not position, so a partial re-run cannot duplicate lines.
214+
const names = await listSegmentFiles(dir, "turns.jsonl");
215+
if (names.length === 0) return 0;
206216
const records = await readAll();
207217
const existingKeys = new Set<string>();
208218
for (const record of records) {
@@ -213,25 +223,32 @@ export function createEventStreamWriter(
213223
}
214224
let lastId = records[records.length - 1]?.id;
215225
let count = 0;
216-
for (const line of raw.split("\n")) {
217-
if (line.length === 0) continue;
218-
const key = hashLine(line);
219-
if (existingKeys.has(key)) continue;
220-
let record: unknown;
221-
try {
222-
record = JSON.parse(line);
223-
} catch {
224-
continue;
226+
const occurrences = new Map<string, number>();
227+
for (const name of names) {
228+
const raw = await readFile(join(dir, name), "utf8");
229+
for (const line of raw.split("\n")) {
230+
if (line.length === 0) continue;
231+
const hash = hashLine(line);
232+
const index = occurrences.get(hash) ?? 0;
233+
occurrences.set(hash, index + 1);
234+
const key = `${hash}:${index}`;
235+
if (existingKeys.has(key)) continue;
236+
let parsed: unknown;
237+
try {
238+
parsed = JSON.parse(line);
239+
} catch {
240+
continue;
241+
}
242+
const event: AppendEventInput = {
243+
type: "turn.legacy",
244+
payload: legacyPayload(parsed, key),
245+
};
246+
if (lastId !== undefined) event.parentId = lastId;
247+
const appended = await appendLocked(event);
248+
existingKeys.add(key);
249+
lastId = appended.id;
250+
count += 1;
225251
}
226-
const event: AppendEventInput = {
227-
type: "turn.legacy",
228-
payload: { record, legacyKey: key },
229-
};
230-
if (lastId !== undefined) event.parentId = lastId;
231-
const appended = await appendLocked(event);
232-
existingKeys.add(key);
233-
lastId = appended.id;
234-
count += 1;
235252
}
236253
return count;
237254
});

0 commit comments

Comments
 (0)