Skip to content

Commit e7098aa

Browse files
committed
fix(session): stop the base store warning on every session load
The live-turns read went through the isogit base's load(), which also validates metadata.json against the vendor shape. This store writes token usage to the sidecar instead, so every cold load failed that validation, warned, and re-parsed turns.jsonl from disk. Parse the base segment directly; metadata already has its own lenient reader that still warns on a malformed document. Relaxing the vendored schema would add a ledgered patch for a file the base store no longer owns.
1 parent 9305d2e commit e7098aa

2 files changed

Lines changed: 95 additions & 32 deletions

File tree

‎src/session/optimized-context-store.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defined } from "../../testkit/defined.js";
2+
import { withFileLogSink } from "../../testkit/file-log-sink.js";
23
import { describe, test, expect } from "bun:test";
34
import fs from "node:fs";
45
import os from "node:os";
@@ -13,6 +14,7 @@ import {
1314
} from "./optimized-context-store.js";
1415
import { segmentFileName, listSegmentFiles } from "./incremental-jsonl.js";
1516
import { createPruningCompactor } from "./compactor.js";
17+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
1618

1719
const TURNS_FILE = "turns.jsonl";
1820

@@ -421,6 +423,81 @@ describe("createOptimizedContextStore load", () => {
421423
});
422424
});
423425

426+
describe("createOptimizedContextStore load warnings", () => {
427+
function contextStoreWarnings(logged: string): string[] {
428+
return logged
429+
.split("\n")
430+
.filter((line) => line.length > 0)
431+
.map((line) => JSON.parse(line) as Record<string, unknown>)
432+
.filter(
433+
(record) =>
434+
record.level === "warning" &&
435+
record.category === `${LOG_NAMESPACE_ROOT}.session.context-store`,
436+
)
437+
.map((record) => JSON.stringify(record));
438+
}
439+
440+
const pendingOp = {
441+
correlationId: "corr-1",
442+
kind: "approval" as const,
443+
registeredAt: 1_700_000_000_000,
444+
gateId: "gate-1",
445+
};
446+
const usage = {
447+
input: 7,
448+
output: 3,
449+
cacheRead: 0,
450+
cacheWrite: 0,
451+
thinking: 0,
452+
};
453+
454+
test("a cold load of a store written the current way logs no warning", async () => {
455+
const dir = tempDir();
456+
const writer = await createOptimizedContextStore(dir);
457+
await writer.writeTurns([turn("a"), turn("b")]);
458+
await writer.writeMetadata({
459+
pendingOperations: [pendingOp],
460+
tokenUsage: usage,
461+
});
462+
await writer.commit({ message: "checkpoint: tool-execution" });
463+
464+
let loaded: Awaited<ReturnType<typeof writer.load>> | undefined;
465+
const logged = await withFileLogSink(async () => {
466+
const resumed = await createOptimizedContextStore(dir);
467+
loaded = await resumed.load();
468+
});
469+
470+
expect(contextStoreWarnings(logged)).toEqual([]);
471+
expect(turnTexts(defined(loaded).turns)).toEqual(["a", "b"]);
472+
expect(defined(loaded).pendingOperations).toEqual([pendingOp]);
473+
expect(defined(loaded).tokenUsage).toEqual(usage);
474+
});
475+
476+
test("a malformed metadata.json still warns on load", async () => {
477+
const dir = tempDir();
478+
const writer = await createOptimizedContextStore(dir);
479+
await writer.writeTurns([turn("a")]);
480+
await writer.writeMetadata(EMPTY_CHECKPOINT_METADATA);
481+
await writer.commit({ message: "checkpoint: tool-execution" });
482+
fs.writeFileSync(
483+
path.join(dir, "metadata.json"),
484+
JSON.stringify({ pendingOperations: 42 }),
485+
);
486+
487+
let loaded: Awaited<ReturnType<typeof writer.load>> | undefined;
488+
const logged = await withFileLogSink(async () => {
489+
const resumed = await createOptimizedContextStore(dir);
490+
loaded = await resumed.load();
491+
});
492+
493+
const warnings = contextStoreWarnings(logged);
494+
expect(warnings).toHaveLength(1);
495+
expect(warnings[0]).toContain("metadata.json has unexpected structure");
496+
expect(turnTexts(defined(loaded).turns)).toEqual(["a"]);
497+
expect(defined(loaded).pendingOperations).toEqual([]);
498+
});
499+
});
500+
424501
describe("loadRecentTurns", () => {
425502
test("reads only the tail segments needed to satisfy the window, in order", async () => {
426503
const dir = tempDir();

‎src/session/optimized-context-store.ts‎

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -510,9 +510,8 @@ export async function loadRecentTurns(
510510
}
511511

512512
/**
513-
* Resilient parse of `turns.jsonl` alone (extras merged by the caller).
514-
* Mirrors `load()`'s recovery when the isogit base hard-fails, so a torn or
515-
* poisoned base cannot block the healing write.
513+
* Resilient parse of `turns.jsonl` alone (extras merged by the caller), so a
514+
* torn or poisoned base cannot block the healing write.
516515
*/
517516
async function readBaseTurnsFromDisk(dir: string): Promise<ConversationTurn[]> {
518517
const basePath = path.join(dir, TURNS_FILE);
@@ -1358,9 +1357,7 @@ export async function createSessionStores(
13581357
return count;
13591358
}
13601359

1361-
async function readLiveTurnsCached(
1362-
signal?: AbortSignal,
1363-
): Promise<ConversationTurn[]> {
1360+
async function readLiveTurnsCached(): Promise<ConversationTurn[]> {
13641361
const segments = await turnSegmentStats();
13651362
if (
13661363
turnsCache !== null &&
@@ -1375,23 +1372,14 @@ export async function createSessionStores(
13751372
: sharedSegmentPrefix(turnsCache.segments, segments);
13761373
// Base segment stat unchanged: its parse is still exactly what disk
13771374
// holds, so only the newer segments get re-read and re-parsed.
1378-
let baseTurns: ConversationTurn[];
1379-
if (reused >= 1 && turnsCache !== null) {
1380-
baseTurns = turnsCache.baseTurns;
1381-
} else {
1382-
try {
1383-
baseTurns = (await base.load(signal)).turns;
1384-
} catch (cause) {
1385-
// A torn or poisoned base must not block the healing write; recover
1386-
// the usable turns the same way load() always has. Corrupt
1387-
// metadata.json also slides — callers here only need the turns.
1388-
log.warn(
1389-
"base context store load failed; recovering base segment from disk",
1390-
{ cause: cause instanceof Error ? cause.message : String(cause) },
1391-
);
1392-
baseTurns = await readBaseTurnsFromDisk(dir);
1393-
}
1394-
}
1375+
// Otherwise parse turns.jsonl here rather than through the isogit base's
1376+
// load(): that also validates metadata.json against the vendor shape,
1377+
// which this store no longer writes (tokenUsage lives on the sidecar).
1378+
// Metadata is read separately by readSessionMetadata.
1379+
const baseTurns =
1380+
reused >= 1 && turnsCache !== null
1381+
? turnsCache.baseTurns
1382+
: await readBaseTurnsFromDisk(dir);
13951383
const parsedExtras: ConversationTurn[][] = [];
13961384
for (let index = 1; index < segments.length; index++) {
13971385
const cached =
@@ -1422,10 +1410,8 @@ export async function createSessionStores(
14221410
return merged;
14231411
}
14241412

1425-
async function readLiveTurns(
1426-
signal?: AbortSignal,
1427-
): Promise<ConversationTurn[]> {
1428-
return readLiveTurnsCached(signal);
1413+
async function readLiveTurns(): Promise<ConversationTurn[]> {
1414+
return readLiveTurnsCached();
14291415
}
14301416

14311417
async function resolveFirstWrite(
@@ -1546,15 +1532,15 @@ export async function createSessionStores(
15461532

15471533
const store: ContextStore & AuditStore = {
15481534
// Full-history read: the reactor's initialization contract. Turns come
1549-
// through the shared generation cache (strict base parse with resilient
1550-
// recovery, resilient extras, orphan-tail heal), so a repeated load within
1551-
// one store generation does not re-parse segments whose stat is unchanged.
1535+
// through the shared generation cache (resilient base and extras,
1536+
// orphan-tail heal), so a repeated load within one store generation does
1537+
// not re-parse segments whose stat is unchanged.
15521538
// Metadata stays a fresh read: corrupt metadata.json soft-defaults so
15531539
// resume does not die or wipe pending ops.
1554-
async load(signal) {
1540+
async load() {
15551541
let turns: ConversationTurn[];
15561542
try {
1557-
turns = await readLiveTurnsCached(signal);
1543+
turns = await readLiveTurnsCached();
15581544
} catch (cause) {
15591545
// Unrecoverable history: rethrow with the file name in the message.
15601546
throw new Error(

0 commit comments

Comments
 (0)