Skip to content

Commit 671cbf2

Browse files
committed
Restore archive leaf entries after failed compaction
1 parent 07c5407 commit 671cbf2

2 files changed

Lines changed: 230 additions & 21 deletions

File tree

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

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, test, expect } from "bun:test";
1+
import { describe, test, expect, spyOn } from "bun:test";
22
import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
@@ -616,6 +616,188 @@ async function gitLsTree(dir: string): Promise<string[]> {
616616
}
617617

618618
describe("createOptimizedContextStore unpublished rewrite", () => {
619+
test("partial archive staging and failed rollback fence shared audit publication until reconciliation succeeds", async () => {
620+
const dir = tempDir();
621+
const { storage } = await createSessionStores(dir);
622+
const archiveDir = path.join(dir, "evidence-archive");
623+
fs.mkdirSync(archiveDir, { recursive: true });
624+
fs.writeFileSync(path.join(archiveDir, "index.jsonl"), "original");
625+
await storage.writeTurns([turn("old-a"), turn("old-b")]);
626+
await storage.writeMetadata(EMPTY_CHECKPOINT_METADATA);
627+
const published = await storage.commit({ message: "original" });
628+
const { audit } = await createSessionStores(dir);
629+
fs.writeFileSync(path.join(archiveDir, "index.jsonl"), "changed");
630+
fs.writeFileSync(path.join(archiveDir, "added.jsonl"), "new");
631+
await storage.writeTurns([turn("[Compacted prior context]")]);
632+
const originalAdd = git.add;
633+
const originalReset = git.resetIndex;
634+
let resetFails = true;
635+
const staged: string[] = [];
636+
const addSpy = spyOn(git, "add").mockImplementation(async (args) => {
637+
if (args.dir === dir && args.filepath === "evidence-archive/index.jsonl")
638+
throw new Error("partial staging failure");
639+
await originalAdd(args);
640+
if (args.dir === dir)
641+
staged.push(...(Array.isArray(args.filepath) ? args.filepath : [args.filepath]));
642+
});
643+
const resetSpy = spyOn(git, "resetIndex").mockImplementation(async (args) => {
644+
if (args.dir === dir && args.filepath.startsWith("evidence-archive/") && resetFails)
645+
throw new Error("injected reset failure");
646+
return originalReset(args);
647+
});
648+
const errorRecord = {
649+
source: "reactor" as const,
650+
category: "test",
651+
message: "error",
652+
fatal: false,
653+
timestamp: new Date().toISOString(),
654+
sessionId: "s",
655+
seq: 1,
656+
};
657+
try {
658+
await expect(storage.commit({ message: "partial" })).rejects.toThrow(
659+
"rollback remains pending",
660+
);
661+
expect(staged).toContain("evidence-archive/added.jsonl");
662+
await expect(audit.commitErrors([errorRecord])).rejects.toThrow("injected reset failure");
663+
await expect(
664+
audit.commitAudit([
665+
{
666+
callId: "a",
667+
tool: "read_file",
668+
arguments: {},
669+
authz: null,
670+
result: { content: "ok", isError: false },
671+
timestamp: new Date().toISOString(),
672+
sessionId: "s",
673+
seq: 2,
674+
},
675+
]),
676+
).rejects.toThrow("injected reset failure");
677+
expect(await git.resolveRef({ fs, dir, ref: "HEAD" })).toBe(published.hash);
678+
resetFails = false;
679+
await audit.commitErrors([errorRecord]);
680+
expect(
681+
(await git.listFiles({ fs, dir, ref: "HEAD" })).filter((name) =>
682+
name.startsWith("evidence-archive"),
683+
),
684+
).toEqual(["evidence-archive/index.jsonl"]);
685+
const { blob } = await git.readBlob({
686+
fs,
687+
dir,
688+
oid: await git.resolveRef({ fs, dir, ref: "HEAD" }),
689+
filepath: "evidence-archive/index.jsonl",
690+
});
691+
expect(new TextDecoder().decode(blob)).toBe("original");
692+
expect(fs.readFileSync(path.join(archiveDir, "index.jsonl"), "utf8")).toBe("changed");
693+
} finally {
694+
addSpy.mockRestore();
695+
resetSpy.mockRestore();
696+
}
697+
await storage.commit({ message: "retry" });
698+
expect(
699+
(await git.listFiles({ fs, dir, ref: "HEAD" })).filter((name) =>
700+
name.startsWith("evidence-archive"),
701+
),
702+
).toEqual(["evidence-archive/added.jsonl", "evidence-archive/index.jsonl"]);
703+
});
704+
test.each([true, false])(
705+
"failed compact restores concrete archive index leaves (already tracked: %s)",
706+
async (tracked) => {
707+
const dir = tempDir();
708+
const { loadOrCreateCommitSigner } = await import("./commit-signer.js");
709+
const sign = await loadOrCreateCommitSigner(dir);
710+
let fail = false;
711+
const { storage, audit } = await createSessionStores(dir, {
712+
signer: (payload) => {
713+
if (fail) throw new Error("injected signing failure");
714+
return sign(payload);
715+
},
716+
});
717+
const archiveDir = path.join(dir, "evidence-archive");
718+
fs.mkdirSync(archiveDir, { recursive: true });
719+
if (tracked) {
720+
fs.writeFileSync(path.join(archiveDir, "index.jsonl"), "original evidence");
721+
fs.writeFileSync(path.join(archiveDir, "deleted.jsonl"), "old evidence");
722+
}
723+
await storage.writeTurns([turn("old-a"), turn("old-b")]);
724+
await storage.writeMetadata(EMPTY_CHECKPOINT_METADATA);
725+
const original = await storage.commit({ message: "original" });
726+
const originalLeaves = (await git.listFiles({ fs, dir })).filter((name) =>
727+
name.startsWith("evidence-archive"),
728+
);
729+
fs.writeFileSync(path.join(archiveDir, "index.jsonl"), "new evidence");
730+
fs.writeFileSync(path.join(archiveDir, "added.jsonl"), "added evidence");
731+
if (tracked) fs.unlinkSync(path.join(archiveDir, "deleted.jsonl"));
732+
await storage.writeTurns([turn("[Compacted prior context]")]);
733+
fail = true;
734+
await expect(storage.commit({ message: "failed compact" })).rejects.toThrow(
735+
"injected signing failure",
736+
);
737+
expect(
738+
(await git.listFiles({ fs, dir })).filter((name) => name.startsWith("evidence-archive")),
739+
).toEqual(originalLeaves);
740+
expect(await git.resolveRef({ fs, dir, ref: "HEAD" })).toBe(original.hash);
741+
for (const [, head, , stage] of await git.statusMatrix({
742+
fs,
743+
dir,
744+
filepaths: ["evidence-archive"],
745+
}))
746+
expect(stage).toBe(head);
747+
expect(fs.readFileSync(path.join(archiveDir, "index.jsonl"), "utf8")).toBe("new evidence");
748+
fail = false;
749+
await audit.commitAudit([
750+
{
751+
callId: "audit",
752+
tool: "read_file",
753+
arguments: {},
754+
authz: null,
755+
result: { content: "ok", isError: false },
756+
timestamp: new Date().toISOString(),
757+
sessionId: "s",
758+
seq: 1,
759+
},
760+
]);
761+
await audit.commitErrors([
762+
{
763+
source: "reactor",
764+
category: "test",
765+
message: "test error",
766+
fatal: false,
767+
timestamp: new Date().toISOString(),
768+
sessionId: "s",
769+
seq: 2,
770+
},
771+
]);
772+
expect(
773+
(await git.listFiles({ fs, dir, ref: "HEAD" })).filter((name) =>
774+
name.startsWith("evidence-archive"),
775+
),
776+
).toEqual(originalLeaves);
777+
if (tracked) {
778+
const { blob } = await git.readBlob({
779+
fs,
780+
dir,
781+
oid: await git.resolveRef({ fs, dir, ref: "HEAD" }),
782+
filepath: "evidence-archive/index.jsonl",
783+
});
784+
expect(new TextDecoder().decode(blob)).toBe("original evidence");
785+
}
786+
await storage.commit({ message: "retry compact" });
787+
expect(
788+
(await git.listFiles({ fs, dir, ref: "HEAD" })).filter((name) =>
789+
name.startsWith("evidence-archive"),
790+
),
791+
).toEqual(["evidence-archive/added.jsonl", "evidence-archive/index.jsonl"]);
792+
const { blob } = await git.readBlob({
793+
fs,
794+
dir,
795+
oid: await git.resolveRef({ fs, dir, ref: "HEAD" }),
796+
filepath: "evidence-archive/index.jsonl",
797+
});
798+
expect(new TextDecoder().decode(blob)).toBe("new evidence");
799+
},
800+
);
619801
test("rewrite writeTurns stays off the live generation until commit", async () => {
620802
const dir = tempDir();
621803
const store = await createOptimizedContextStore(dir);

src/session/optimized-context-store.ts

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -330,14 +330,29 @@ async function blobTextAtCommit(dir: string, hash: string, filepath: string): Pr
330330

331331
async function resetIndexPaths(dir: string, filepaths: readonly string[]): Promise<void> {
332332
for (const filepath of filepaths) {
333-
try {
334-
await git.resetIndex({ fs, dir, filepath });
335-
} catch {
336-
// Not in the index; vendor restore already covers its own paths.
337-
}
333+
await git.resetIndex({ fs, dir, filepath });
338334
}
339335
}
340336

337+
const pendingCheckpointRollbacks = new Map<string, () => Promise<void>>();
338+
339+
async function reconcilePendingCheckpoint(dir: string): Promise<void> {
340+
const key = path.resolve(dir);
341+
const rollback = pendingCheckpointRollbacks.get(key);
342+
if (rollback === undefined) return;
343+
await rollback();
344+
pendingCheckpointRollbacks.delete(key);
345+
}
346+
347+
function withReconciledDirLock<T>(dir: string, operation: () => Promise<T>): Promise<T> {
348+
return withResolvedDirLock(dir, async () => {
349+
// All store instances sharing this index must finish a failed rollback
350+
// before an audit, error, or checkpoint commit can publish it.
351+
await reconcilePendingCheckpoint(dir);
352+
return operation();
353+
});
354+
}
355+
341356
async function restorePublishedTurnFiles(dir: string): Promise<void> {
342357
const hash = await git.resolveRef({ fs, dir, ref: "HEAD" });
343358
const present = new Set(await git.listFiles({ fs, dir, ref: hash }));
@@ -589,7 +604,7 @@ export async function createSessionStores(
589604
pendingBlobFilepaths.add(`${TOOL_OUTPUT_DIR}/${filename}`);
590605
},
591606
async commit(options, signal) {
592-
return withResolvedDirLock(dir, async () => {
607+
return withReconciledDirLock(dir, async () => {
593608
const stagedRewrite = unpublishedRewrite;
594609
const extraPaths: string[] = [];
595610
try {
@@ -602,8 +617,14 @@ export async function createSessionStores(
602617
if (await pathExists(path.join(dir, filepath))) toAdd.push(filepath);
603618
else toRemove.push(filepath);
604619
}
605-
if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) {
606-
toAdd.push(EVIDENCE_ARCHIVE_DIR);
620+
const archiveRows = await git.statusMatrix({
621+
fs,
622+
dir,
623+
filepaths: [EVIDENCE_ARCHIVE_DIR],
624+
});
625+
for (const [filepath, , worktree] of archiveRows) {
626+
if (worktree === 0) toRemove.push(filepath);
627+
else toAdd.push(filepath);
607628
}
608629
// Reconcile disk segments even if a prior process lost its pending set.
609630
await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove);
@@ -615,11 +636,7 @@ export async function createSessionStores(
615636
await git.add({ fs, dir, filepath });
616637
}
617638
for (const filepath of remove) {
618-
try {
619-
await git.remove({ fs, dir, filepath });
620-
} catch {
621-
// Already absent from the index.
622-
}
639+
await git.remove({ fs, dir, filepath });
623640
}
624641
const committed = await base.commit(options, signal);
625642
pendingBlobFilepaths.clear();
@@ -630,20 +647,30 @@ export async function createSessionStores(
630647
}
631648
return committed;
632649
} catch (cause) {
633-
await resetIndexPaths(dir, extraPaths);
634-
if (stagedRewrite !== null) {
635-
await restorePublishedTurnFiles(dir);
636-
writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
637-
liveTurnRefs = null;
650+
pendingCheckpointRollbacks.set(path.resolve(dir), async () => {
651+
await resetIndexPaths(dir, extraPaths);
652+
if (stagedRewrite !== null) {
653+
await restorePublishedTurnFiles(dir);
654+
writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
655+
liveTurnRefs = null;
656+
}
657+
});
658+
try {
659+
await reconcilePendingCheckpoint(dir);
660+
} catch (rollbackCause) {
661+
throw new AggregateError(
662+
[cause, rollbackCause],
663+
"Checkpoint failed; index rollback remains pending",
664+
);
638665
}
639666
throw cause;
640667
}
641668
});
642669
},
643670
commitAudit: (records, signal) =>
644-
withResolvedDirLock(dir, () => base.commitAudit(records, signal)),
671+
withReconciledDirLock(dir, () => base.commitAudit(records, signal)),
645672
commitErrors: (records, signal) =>
646-
withResolvedDirLock(dir, () => base.commitErrors(records, signal)),
673+
withReconciledDirLock(dir, () => base.commitErrors(records, signal)),
647674
loadAudit: (sessionId, signal) => base.loadAudit(sessionId, signal),
648675
};
649676

0 commit comments

Comments
 (0)