Skip to content

Commit bbe2997

Browse files
committed
fix(session): commit an in-place evidence index append
1 parent 039042f commit bbe2997

2 files changed

Lines changed: 147 additions & 2 deletions

File tree

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,88 @@ describe("createOptimizedContextStore checkpoint", () => {
843843
expect(new TextDecoder().decode(blob)).toBe("rewritten-in-place");
844844
});
845845

846+
test("an in-place append of evidence-archive/index.jsonl is in the next commit", async () => {
847+
const dir = tempDir();
848+
const store = await createOptimizedContextStore(dir);
849+
const indexDir = path.join(dir, "evidence-archive");
850+
fs.mkdirSync(indexDir);
851+
const indexPath = path.join(indexDir, "index.jsonl");
852+
fs.writeFileSync(indexPath, '{"id":"seed"}\n');
853+
await store.writeTurns([turn("seed")]);
854+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
855+
const first = await store.commit({ message: "seed evidence index" });
856+
857+
const before = fs.statSync(indexPath);
858+
const dirBefore = fs.statSync(indexDir);
859+
fs.appendFileSync(indexPath, '{"id":"appended"}\n');
860+
const after = fs.statSync(indexPath);
861+
const dirAfter = fs.statSync(indexDir);
862+
expect(after.ino).toBe(before.ino);
863+
expect(dirAfter.mtimeMs).toBe(dirBefore.mtimeMs);
864+
expect(dirAfter.ino).toBe(dirBefore.ino);
865+
866+
const second = await store.commit({ message: "publish evidence index append" });
867+
expect(second.hash).not.toBe(first.hash);
868+
const { blob } = await git.readBlob({
869+
fs,
870+
dir,
871+
oid: second.hash,
872+
filepath: "evidence-archive/index.jsonl",
873+
});
874+
expect(new TextDecoder().decode(blob)).toContain('{"id":"appended"}');
875+
});
876+
877+
test("an unchanged evidence index is not re-read and a no-op does not walk tool-output", async () => {
878+
const dir = tempDir();
879+
const store = await createOptimizedContextStore(dir);
880+
const blobCount = 24;
881+
for (let index = 0; index < blobCount; index++) {
882+
await store.writeBlob(
883+
`blob-${index}`,
884+
new TextEncoder().encode(`body-${index}`),
885+
"text/plain",
886+
);
887+
}
888+
const indexDir = path.join(dir, "evidence-archive");
889+
fs.mkdirSync(indexDir);
890+
fs.writeFileSync(path.join(indexDir, "index.jsonl"), '{"id":"seed"}\n');
891+
await store.writeTurns([turn("seed")]);
892+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
893+
await store.commit({ message: "seed evidence and blobs" });
894+
895+
const walkedToolOutput: string[][] = [];
896+
const originalStatus = git.statusMatrix.bind(git);
897+
git.statusMatrix = (async (args) => {
898+
const filepaths = args.filepaths ?? [];
899+
if (
900+
filepaths.some(
901+
(filepath) =>
902+
filepath === "tool-output" || filepath.startsWith("tool-output/"),
903+
)
904+
) {
905+
walkedToolOutput.push([...filepaths]);
906+
}
907+
return originalStatus(args);
908+
}) as typeof git.statusMatrix;
909+
let reads = 0;
910+
let lstats = 0;
911+
try {
912+
reads = await countPathOps("readFile", "index.jsonl", () =>
913+
store.commit({ message: "no writes" }),
914+
);
915+
lstats = await countPathOps(
916+
"lstat",
917+
`${path.sep}tool-output${path.sep}`,
918+
() => store.commit({ message: "still no writes" }),
919+
);
920+
} finally {
921+
git.statusMatrix = originalStatus;
922+
}
923+
expect(reads).toBe(0);
924+
expect(walkedToolOutput).toEqual([]);
925+
expect(lstats).toBe(blobCount);
926+
});
927+
846928
test("a turn commit does not lstat every seeded tool-output blob", async () => {
847929
const dir = tempDir();
848930
const store = await createOptimizedContextStore(dir);

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const METADATA_FILE = "metadata.json";
4646
const TOKEN_USAGE_SIDECAR_FILE = "token-usage.jsonl";
4747
const TOOL_OUTPUT_DIR = "tool-output";
4848
const EVIDENCE_ARCHIVE_DIR = "evidence-archive";
49+
const EVIDENCE_INDEX_FILE = `${EVIDENCE_ARCHIVE_DIR}/index.jsonl`;
4950
const OWNER_MARKER_FILE = "session-owner.json";
5051

5152
const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]);
@@ -1489,9 +1490,13 @@ export async function createSessionStores(
14891490
// Fingerprints from the last publish. Segment stats avoid re-reading an
14901491
// unchanged segment. Tool-output blobs are remembered per file: a directory
14911492
// mtime does not move when an existing blob is rewritten in place, and
1492-
// statusMatrix is not asked to walk that directory on a no-op.
1493+
// statusMatrix is not asked to walk that directory on a no-op. The evidence
1494+
// index is one file: an in-place append does not move the archive directory,
1495+
// so it is fingerprinted on its own and is not a reason to walk tool-output.
14931496
let publishedSegmentStats = new Map<string, SegmentStat>();
14941497
let publishedToolBlobs = new Map<string, SegmentStat>();
1498+
let publishedEvidenceIndex: SegmentStat | undefined;
1499+
let evidenceIndexObserved = false;
14951500
let blobDirsObserved = false;
14961501
let toolOutputFingerprint: DirectoryFingerprint | null = null;
14971502
let evidenceFingerprint: DirectoryFingerprint | null = null;
@@ -1575,6 +1580,53 @@ export async function createSessionStores(
15751580
return drifted;
15761581
}
15771582

1583+
async function statEvidenceIndex(): Promise<SegmentStat | undefined> {
1584+
try {
1585+
const fileStats = await fs.promises.lstat(
1586+
path.join(dir, EVIDENCE_INDEX_FILE),
1587+
);
1588+
if (!fileStats.isFile()) return undefined;
1589+
return {
1590+
bytes: fileStats.size,
1591+
mtimeMs: fileStats.mtimeMs,
1592+
ino: fileStats.ino,
1593+
};
1594+
} catch (cause) {
1595+
if (
1596+
cause instanceof Error &&
1597+
"code" in cause &&
1598+
cause.code === "ENOENT"
1599+
) {
1600+
return undefined;
1601+
}
1602+
throw cause;
1603+
}
1604+
}
1605+
1606+
// In-place appends do not move the archive directory. Compare the one index
1607+
// file saved at the last publish. Absent before and after is not drift, and
1608+
// a match is not read. A store that has not published yet leaves this to the
1609+
// directory walk.
1610+
async function driftedPublishedEvidenceIndex(): Promise<{
1611+
filepath: string;
1612+
present: boolean;
1613+
} | null> {
1614+
if (!evidenceIndexObserved) return null;
1615+
const observed = await statEvidenceIndex();
1616+
if (observed === undefined) {
1617+
return publishedEvidenceIndex === undefined
1618+
? null
1619+
: { filepath: EVIDENCE_INDEX_FILE, present: false };
1620+
}
1621+
if (
1622+
publishedEvidenceIndex === undefined ||
1623+
segmentStatDrifted(observed, publishedEvidenceIndex)
1624+
) {
1625+
return { filepath: EVIDENCE_INDEX_FILE, present: true };
1626+
}
1627+
return null;
1628+
}
1629+
15781630
async function blobDirectoriesNeedWalk(): Promise<boolean> {
15791631
if (!blobDirsObserved) return true;
15801632
const tool = await directoryFingerprint(dir, TOOL_OUTPUT_DIR);
@@ -1612,6 +1664,8 @@ export async function createSessionStores(
16121664
toolOutputFingerprint = tool;
16131665
evidenceFingerprint = evidence;
16141666
blobDirsObserved = true;
1667+
publishedEvidenceIndex = await statEvidenceIndex();
1668+
evidenceIndexObserved = true;
16151669
}
16161670
const ownerToken = opts?.ownerId ?? globalThis.crypto.randomUUID();
16171671
const explicitOwnerId = opts?.ownerId;
@@ -2466,7 +2520,10 @@ export async function createSessionStores(
24662520
// mtime or inode, so the directory walk stays off. Stat only the
24672521
// blobs recorded at the last publish, and only when nothing else
24682522
// is already forcing a commit (that path git.adds the directory).
2523+
// The evidence index is the same kind of in-place append, and it
2524+
// is one file: stat it, do not walk tool-output to notice it.
24692525
let toolBlobsDrifted = false;
2526+
let evidenceIndexDrifted = false;
24702527
if (!rootsDiffer) {
24712528
for (const blob of await driftedPublishedToolBlobs()) {
24722529
toolBlobsDrifted = true;
@@ -2475,8 +2532,14 @@ export async function createSessionStores(
24752532
restatBlobs.add(blob.filepath);
24762533
} else toRemove.push(blob.filepath);
24772534
}
2535+
const index = await driftedPublishedEvidenceIndex();
2536+
if (index !== null) {
2537+
evidenceIndexDrifted = true;
2538+
if (index.present) toAdd.push(index.filepath);
2539+
else toRemove.push(index.filepath);
2540+
}
24782541
}
2479-
if (!rootsDiffer && !toolBlobsDrifted) {
2542+
if (!rootsDiffer && !toolBlobsDrifted && !evidenceIndexDrifted) {
24802543
const [head] = await base.log(1);
24812544
if (head !== undefined) {
24822545
pendingBlobFilepaths.clear();

0 commit comments

Comments
 (0)