Skip to content

Commit 36fadb5

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

3 files changed

Lines changed: 149 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: 16 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"]);

src/session/optimized-context-store.ts

Lines changed: 118 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,55 @@ async function resetIndexPaths(
377390
}
378391
}
379392

393+
/**
394+
* Contents of every turn segment on disk (`turns.jsonl` plus numbered tails,
395+
* gapped strays included), keyed by relative name. Captured before a staged
396+
* rewrite lands so a failed commit can put the working tree back on the
397+
* published generation.
398+
*/
399+
async function snapshotTurnSegments(dir: string): Promise<Map<string, string>> {
400+
const snapshot = new Map<string, string>();
401+
const highest = await highestSegmentIndex(dir, TURNS_FILE);
402+
for (let index = 0; index <= highest; index++) {
403+
const name = segmentFileName(TURNS_FILE, index);
404+
try {
405+
snapshot.set(
406+
name,
407+
await fs.promises.readFile(path.join(dir, name), "utf-8"),
408+
);
409+
} catch (cause) {
410+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
411+
continue;
412+
throw cause;
413+
}
414+
}
415+
return snapshot;
416+
}
417+
418+
/**
419+
* Inverse of snapshotTurnSegments: write every snapshotted segment back and
420+
* unlink any segment the landed rewrite created.
421+
*/
422+
async function restoreTurnSegments(
423+
dir: string,
424+
snapshot: ReadonlyMap<string, string>,
425+
): Promise<void> {
426+
const names = new Set<string>(snapshot.keys());
427+
const highest = await highestSegmentIndex(dir, TURNS_FILE);
428+
for (let index = 0; index <= highest; index++) {
429+
names.add(segmentFileName(TURNS_FILE, index));
430+
}
431+
for (const name of names) {
432+
const full = path.join(dir, name);
433+
const text = snapshot.get(name);
434+
if (text === undefined) {
435+
if (await pathExists(full)) await fs.promises.unlink(full);
436+
} else {
437+
await fs.promises.writeFile(full, text);
438+
}
439+
}
440+
}
441+
380442
/**
381443
* Stage every contiguous on-disk segment for `baseName` and unstage any
382444
* higher-numbered or gapped segment still on disk or tracked after a rewrite
@@ -493,14 +555,22 @@ export async function createSessionStores(
493555
return;
494556
}
495557
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
496-
const baseResult = await base.load();
558+
let baseTurns: ConversationTurn[];
559+
try {
560+
baseTurns = (await base.load()).turns;
561+
} catch (cause) {
562+
// A torn or poisoned base tail must not block the write that heals it;
563+
// recover the usable base turns the same way load() does.
564+
log.warn(
565+
"base context store load failed during writeTurns; recovering base segment from disk",
566+
{ cause: cause instanceof Error ? cause.message : String(cause) },
567+
);
568+
baseTurns = await readBaseTurnsFromDisk(dir);
569+
}
497570
const live =
498571
extraTexts.length === 0
499-
? baseResult.turns
500-
: await loadTurnsWithoutMalformedToolSequence(
501-
baseResult.turns,
502-
extraTexts,
503-
);
572+
? baseTurns
573+
: await loadTurnsWithoutMalformedToolSequence(baseTurns, extraTexts);
504574
if (live.length > 0 && contentPrefixLength(live, turns) < live.length) {
505575
unpublishedRewrite = [...turns];
506576
return;
@@ -577,13 +647,7 @@ export async function createSessionStores(
577647
// Prefer resilient parse of segment 0 alone so orphan-tail heal still runs.
578648
// skipMalformed: mid-file garbage/interleaved records must not kill resume
579649
// (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-
}
650+
baseTurns = await readBaseTurnsFromDisk(dir);
587651
} catch (parseCause) {
588652
// Unrecoverable: rethrow with the file name in the message.
589653
throw new Error(
@@ -642,36 +706,45 @@ export async function createSessionStores(
642706
async commit(options, signal) {
643707
return withResolvedDirLock(dir, async () => {
644708
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-
}
709+
// The staged rewrite lands on the working-tree segments before the git
710+
// operations below; snapshot them so a failed commit can put the files
711+
// back on the published generation. `unpublishedRewrite` stays staged
712+
// so a retried commit can still publish it.
713+
const segmentSnapshot =
714+
stagedRewrite === null ? null : await snapshotTurnSegments(dir);
715+
let extraPaths: string[] = [];
658716

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);
717+
try {
718+
if (stagedRewrite !== null) {
719+
await writeSegmented(writeTurnsSegmented, stagedRewrite);
720+
}
721+
const toAdd: string[] = [];
722+
const toRemove: string[] = [];
723+
724+
for (const filepath of [
725+
...pendingSegmentPaths,
726+
...pendingBlobFilepaths,
727+
]) {
728+
if (await pathExists(path.join(dir, filepath)))
729+
toAdd.push(filepath);
730+
else toRemove.push(filepath);
731+
}
663732

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

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])];
738+
if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) {
739+
toAdd.push(EVIDENCE_ARCHIVE_DIR);
740+
}
741+
742+
const add = extraCommitPaths([...new Set(toAdd)]);
743+
const remove = extraCommitPaths([...new Set(toRemove)]).filter(
744+
(p) => !add.includes(p),
745+
);
746+
extraPaths = [...new Set([...add, ...remove])];
673747

674-
try {
675748
for (const filepath of add) {
676749
await git.add({ fs, dir, filepath });
677750
}
@@ -692,7 +765,12 @@ export async function createSessionStores(
692765
return committed;
693766
} catch (cause) {
694767
await resetIndexPaths(dir, extraPaths);
695-
if (stagedRewrite !== null) {
768+
if (segmentSnapshot !== null) {
769+
// The rewrite already landed on the working-tree segments; restore
770+
// them so load() keeps serving the published generation, and drop
771+
// the writer's stale in-memory state so a retry rewrites them.
772+
await restoreTurnSegments(dir, segmentSnapshot);
773+
writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
696774
liveTurnRefs = null;
697775
}
698776
throw cause;

0 commit comments

Comments
 (0)