Skip to content

Commit 96066e6

Browse files
committed
Heal torn base tails on write and keep failed rewrite commits on HEAD
1 parent 3391db2 commit 96066e6

3 files changed

Lines changed: 180 additions & 52 deletions

File tree

src/session/assemble-runtime.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import type { Agent } from "@intx/agent";
66
import type {
7+
AuditStore,
78
Compactor,
89
ContextStore,
910
ToolDefinition,
@@ -109,6 +110,14 @@ function stubCompactor(name: string): Compactor {
109110
};
110111
}
111112

113+
function stubAuditStore(): AuditStore {
114+
return {
115+
commitAudit: async () => undefined,
116+
commitErrors: async () => undefined,
117+
loadAudit: async () => [],
118+
};
119+
}
120+
112121
function stubInferenceDeps(): ChatAgentWiring["inferenceDeps"] {
113122
return {
114123
fetch: globalThis.fetch.bind(globalThis),
@@ -188,9 +197,9 @@ describe("assembleChatAgent", () => {
188197
import.meta.resolve("./optimized-context-store.js"),
189198
(real: typeof import("./optimized-context-store.js")) => ({
190199
...real,
191-
createOptimizedContextStore: async (dir: string) => {
200+
createSessionStores: async (dir: string) => {
192201
storeDirs.push(dir);
193-
return fakeStorage;
202+
return { storage: fakeStorage, audit: stubAuditStore() };
194203
},
195204
}),
196205
async () => {
@@ -267,7 +276,10 @@ describe("assembleChatAgent", () => {
267276
import.meta.resolve("./optimized-context-store.js"),
268277
(real: typeof import("./optimized-context-store.js")) => ({
269278
...real,
270-
createOptimizedContextStore: async () => fakeStorage,
279+
createSessionStores: async () => ({
280+
storage: fakeStorage,
281+
audit: stubAuditStore(),
282+
}),
271283
}),
272284
async () => {
273285
await withMockedModuleDuring(

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

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -798,15 +798,22 @@ describe("createOptimizedContextStore unpublished rewrite", () => {
798798

799799
await store.writeTurns([turn("[Compacted prior context]"), turn("keep-b")]);
800800

801-
const hookDir = path.join(dir, ".git", "hooks");
802-
fs.mkdirSync(hookDir, { recursive: true });
803-
const hook = path.join(hookDir, "commit-msg");
804-
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n");
805-
fs.chmodSync(hook, 0o755);
806-
807-
await expect(
808-
store.commit({ message: "publish compact" }),
809-
).rejects.toThrow();
801+
// The store commits through isomorphic-git, which never runs hooks. Force
802+
// a deterministic commit failure instead: replace .git/objects with a
803+
// regular file so every object write fails with ENOTDIR, then restore it
804+
// so the readAt assertion below can read the published commit.
805+
const objectsDir = path.join(dir, ".git", "objects");
806+
const objectsHeld = `${objectsDir}.held`;
807+
fs.renameSync(objectsDir, objectsHeld);
808+
fs.writeFileSync(objectsDir, "held");
809+
try {
810+
await expect(
811+
store.commit({ message: "publish compact" }),
812+
).rejects.toThrow();
813+
} finally {
814+
fs.unlinkSync(objectsDir);
815+
fs.renameSync(objectsHeld, objectsDir);
816+
}
810817

811818
const loaded = await store.load();
812819
expect(turnTexts(loaded.turns)).toEqual(["keep-a", "keep-b", "drop-me"]);
@@ -815,6 +822,14 @@ describe("createOptimizedContextStore unpublished rewrite", () => {
815822
"keep-b",
816823
"drop-me",
817824
]);
825+
826+
// The failed commit keeps the rewrite staged: retrying publishes it.
827+
await store.commit({ message: "retry publish compact" });
828+
const retried = await store.load();
829+
expect(turnTexts(retried.turns)).toEqual([
830+
"[Compacted prior context]",
831+
"keep-b",
832+
]);
818833
});
819834

820835
test("append writeTurns is still visible before commit", async () => {

src/session/optimized-context-store.ts

Lines changed: 141 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,19 @@ export async function loadRecentTurns(
336336
return turns;
337337
}
338338

339+
/**
340+
* Resilient parse of the base turn segment alone (`turns.jsonl`); extra
341+
* segments are merged by the caller. Mirrors the recovery `load()` applies
342+
* when the isogit base store hard-fails, so a torn or poisoned base cannot
343+
* block the write that heals it.
344+
*/
345+
async function readBaseTurnsFromDisk(dir: string): Promise<ConversationTurn[]> {
346+
const basePath = path.join(dir, TURNS_FILE);
347+
if (!(await pathExists(basePath))) return [];
348+
const text = await fs.promises.readFile(basePath, "utf-8");
349+
return parseSegmentTurns(text, true, TURNS_FILE, true);
350+
}
351+
339352
async function listIndexPaths(dir: string): Promise<Set<string>> {
340353
return new Set(await git.listFiles({ fs, dir }));
341354
}
@@ -377,6 +390,63 @@ async function resetIndexPaths(
377390
}
378391
}
379392

393+
async function headOid(dir: string): Promise<string | null> {
394+
try {
395+
return await git.resolveRef({ fs, dir, ref: "HEAD" });
396+
} catch {
397+
return null;
398+
}
399+
}
400+
401+
/**
402+
* Contents of every turn segment on disk (`turns.jsonl` plus numbered tails,
403+
* gapped strays included), keyed by relative name. Captured before a staged
404+
* rewrite lands so a failed commit can put the working tree back on the
405+
* published generation.
406+
*/
407+
async function snapshotTurnSegments(dir: string): Promise<Map<string, string>> {
408+
const snapshot = new Map<string, string>();
409+
const highest = await highestSegmentIndex(dir, TURNS_FILE);
410+
for (let index = 0; index <= highest; index++) {
411+
const name = segmentFileName(TURNS_FILE, index);
412+
try {
413+
snapshot.set(
414+
name,
415+
await fs.promises.readFile(path.join(dir, name), "utf-8"),
416+
);
417+
} catch (cause) {
418+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
419+
continue;
420+
throw cause;
421+
}
422+
}
423+
return snapshot;
424+
}
425+
426+
/**
427+
* Inverse of snapshotTurnSegments: write every snapshotted segment back and
428+
* unlink any segment the landed rewrite created.
429+
*/
430+
async function restoreTurnSegments(
431+
dir: string,
432+
snapshot: ReadonlyMap<string, string>,
433+
): Promise<void> {
434+
const names = new Set<string>(snapshot.keys());
435+
const highest = await highestSegmentIndex(dir, TURNS_FILE);
436+
for (let index = 0; index <= highest; index++) {
437+
names.add(segmentFileName(TURNS_FILE, index));
438+
}
439+
for (const name of names) {
440+
const full = path.join(dir, name);
441+
const text = snapshot.get(name);
442+
if (text === undefined) {
443+
if (await pathExists(full)) await fs.promises.unlink(full);
444+
} else {
445+
await fs.promises.writeFile(full, text);
446+
}
447+
}
448+
}
449+
380450
/**
381451
* Stage every contiguous on-disk segment for `baseName` and unstage any
382452
* higher-numbered or gapped segment still on disk or tracked after a rewrite
@@ -493,14 +563,23 @@ export async function createSessionStores(
493563
return;
494564
}
495565
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
496-
const baseResult = await base.load();
566+
let baseTurns: ConversationTurn[];
567+
try {
568+
baseTurns = (await base.load()).turns;
569+
} catch (cause) {
570+
// A torn or poisoned base tail must not block the write that heals it;
571+
// recover the usable base turns the same way load() does. This also lets
572+
// a corrupt metadata.json slide — writeTurns only needs the turns.
573+
log.warn(
574+
"base context store load failed during writeTurns; recovering base segment from disk",
575+
{ cause: cause instanceof Error ? cause.message : String(cause) },
576+
);
577+
baseTurns = await readBaseTurnsFromDisk(dir);
578+
}
497579
const live =
498580
extraTexts.length === 0
499-
? baseResult.turns
500-
: await loadTurnsWithoutMalformedToolSequence(
501-
baseResult.turns,
502-
extraTexts,
503-
);
581+
? baseTurns
582+
: await loadTurnsWithoutMalformedToolSequence(baseTurns, extraTexts);
504583
if (live.length > 0 && contentPrefixLength(live, turns) < live.length) {
505584
unpublishedRewrite = [...turns];
506585
return;
@@ -577,13 +656,7 @@ export async function createSessionStores(
577656
// Prefer resilient parse of segment 0 alone so orphan-tail heal still runs.
578657
// skipMalformed: mid-file garbage/interleaved records must not kill resume
579658
// (CL-7052); null-pad stripping and torn-tail drop still apply.
580-
const basePath = path.join(dir, TURNS_FILE);
581-
if (await pathExists(basePath)) {
582-
const text = await fs.promises.readFile(basePath, "utf-8");
583-
baseTurns = parseSegmentTurns(text, true, TURNS_FILE, true);
584-
} else {
585-
baseTurns = [];
586-
}
659+
baseTurns = await readBaseTurnsFromDisk(dir);
587660
} catch (parseCause) {
588661
// Unrecoverable: rethrow with the file name in the message.
589662
throw new Error(
@@ -642,36 +715,46 @@ export async function createSessionStores(
642715
async commit(options, signal) {
643716
return withResolvedDirLock(dir, async () => {
644717
const stagedRewrite = unpublishedRewrite;
645-
if (stagedRewrite !== null) {
646-
await writeSegmented(writeTurnsSegmented, stagedRewrite);
647-
}
648-
const toAdd: string[] = [];
649-
const toRemove: string[] = [];
650-
651-
for (const filepath of [
652-
...pendingSegmentPaths,
653-
...pendingBlobFilepaths,
654-
]) {
655-
if (await pathExists(path.join(dir, filepath))) toAdd.push(filepath);
656-
else toRemove.push(filepath);
657-
}
718+
// The staged rewrite lands on the working-tree segments before the git
719+
// operations below; snapshot them so a failed commit can put the files
720+
// back on the published generation. `unpublishedRewrite` stays staged
721+
// so a retried commit can still publish it.
722+
const segmentSnapshot =
723+
stagedRewrite === null ? null : await snapshotTurnSegments(dir);
724+
const headBefore = stagedRewrite === null ? null : await headOid(dir);
725+
let extraPaths: string[] = [];
658726

659-
// Disk is source of truth for which turn/prompt segments should remain
660-
// tracked after a rewrite or heal, even if pendingSegmentPaths was lost.
661-
await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove);
662-
await reconcileSegmentStaging(dir, PROMPT_FILE, toAdd, toRemove);
727+
try {
728+
if (stagedRewrite !== null) {
729+
await writeSegmented(writeTurnsSegmented, stagedRewrite);
730+
}
731+
const toAdd: string[] = [];
732+
const toRemove: string[] = [];
733+
734+
for (const filepath of [
735+
...pendingSegmentPaths,
736+
...pendingBlobFilepaths,
737+
]) {
738+
if (await pathExists(path.join(dir, filepath)))
739+
toAdd.push(filepath);
740+
else toRemove.push(filepath);
741+
}
663742

664-
if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) {
665-
toAdd.push(EVIDENCE_ARCHIVE_DIR);
666-
}
743+
// Disk is source of truth for which turn/prompt segments should remain
744+
// tracked after a rewrite or heal, even if pendingSegmentPaths was lost.
745+
await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove);
746+
await reconcileSegmentStaging(dir, PROMPT_FILE, toAdd, toRemove);
667747

668-
const add = extraCommitPaths([...new Set(toAdd)]);
669-
const remove = extraCommitPaths([...new Set(toRemove)]).filter(
670-
(p) => !add.includes(p),
671-
);
672-
const extraPaths = [...new Set([...add, ...remove])];
748+
if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) {
749+
toAdd.push(EVIDENCE_ARCHIVE_DIR);
750+
}
751+
752+
const add = extraCommitPaths([...new Set(toAdd)]);
753+
const remove = extraCommitPaths([...new Set(toRemove)]).filter(
754+
(p) => !add.includes(p),
755+
);
756+
extraPaths = [...new Set([...add, ...remove])];
673757

674-
try {
675758
for (const filepath of add) {
676759
await git.add({ fs, dir, filepath });
677760
}
@@ -692,7 +775,25 @@ export async function createSessionStores(
692775
return committed;
693776
} catch (cause) {
694777
await resetIndexPaths(dir, extraPaths);
695-
if (stagedRewrite !== null) {
778+
if (segmentSnapshot !== null) {
779+
// The rewrite already landed on the working-tree segments; restore
780+
// them so load() keeps serving the published generation — unless
781+
// the commit actually landed despite throwing (a ref write or
782+
// post-commit check can fail after HEAD moved), in which case the
783+
// on-disk rewrite already matches the new HEAD.
784+
const headNow = await headOid(dir);
785+
const landed =
786+
headBefore !== null && headNow !== null && headNow !== headBefore;
787+
if (!landed) {
788+
try {
789+
await restoreTurnSegments(dir, segmentSnapshot);
790+
} catch {
791+
// A partial restore must not mask the real commit error.
792+
}
793+
}
794+
// Drop the writer's stale in-memory state so a retry rewrites the
795+
// staged segments from scratch.
796+
writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
696797
liveTurnRefs = null;
697798
}
698799
throw cause;

0 commit comments

Comments
 (0)