Skip to content

Commit bfeda6a

Browse files
committed
Keep failed compact commits from replacing live history
A failed compact commit must not later replaceTurns stale output, and an unpublished rewrite stays off the live generation until git commit succeeds.
1 parent 1bdfa13 commit bfeda6a

9 files changed

Lines changed: 265 additions & 41 deletions

src/session/assemble-runtime.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
applyRecordingPolicyToText,
5555
createCompactionArchive,
5656
createPrimaryDeliveryAdmission,
57+
hashAuthorizedBytes,
5758
wrapAuthorizeWithEvidenceArchive,
5859
wrapCompactorWithCompletenessGate,
5960
type CompactionArchive,
@@ -454,6 +455,16 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
454455
? storage
455456
: {
456457
...storage,
458+
async writeBlob(key, bytes, contentType, signal) {
459+
await storage.writeBlob(key, bytes, contentType, signal);
460+
if (!key.startsWith("img-")) return;
461+
await primaryArchive.recordExistingBlobReference({
462+
kind: "attachment",
463+
blobKey: key,
464+
contentHash: hashAuthorizedBytes(bytes),
465+
provenance: "persistBlobs:aged-image",
466+
});
467+
},
457468
async writeResponse(turn, signal) {
458469
const content = turn.content.map((block) => {
459470
if (block.type !== "text") return block;

src/session/attachment-store.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,31 @@ describe("ageImageBlocks / rehydrateAttachmentImages", () => {
104104
expect(result.output[0]!.content).toEqual([{ type: "text", text }]);
105105
expect(result.record.decisions.restoredImageCount).toBe(0);
106106
});
107+
108+
test("aging base64 images does not record before persistBlobs", async () => {
109+
const recorded: string[] = [];
110+
const archive = {
111+
recordAuthorizedPayload: async () => {
112+
recorded.push("payload");
113+
return {} as never;
114+
},
115+
recordExistingBlobReference: async () => {
116+
recorded.push("existing");
117+
return {} as never;
118+
},
119+
};
120+
const turn: ConversationTurn = {
121+
role: "user",
122+
content: [
123+
{
124+
type: "image",
125+
source: { kind: "base64", mimeType: "image/png", data: PNG_B64 },
126+
},
127+
],
128+
timestamp: 1,
129+
};
130+
const aged = await ageImageBlocks(turn, { archive: archive as never });
131+
expect(aged.blobs).toHaveLength(1);
132+
expect(recorded).toEqual([]);
133+
});
107134
});

src/session/compaction-archive.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,4 +680,44 @@ describe("wrapCompactorWithCompletenessGate", () => {
680680
expect(result.output).toHaveLength(1);
681681
expect(result.record.reason).toBe("compact");
682682
});
683+
684+
test("image-only dropped turns require covering attachment evidence", async () => {
685+
const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js");
686+
const { archive } = memoryArchive();
687+
const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive);
688+
const png = "iVBORw0KGgo=";
689+
const turns: import("@intx/types/runtime").ConversationTurn[] = [
690+
{
691+
role: "user",
692+
content: [
693+
{
694+
type: "image",
695+
source: { kind: "base64", mimeType: "image/png", data: png },
696+
},
697+
],
698+
timestamp: 1,
699+
},
700+
{
701+
role: "user",
702+
content: [{ type: "text", text: "keep" }],
703+
timestamp: 2,
704+
},
705+
];
706+
707+
const blocked = await wrapped.apply(turns, ctx);
708+
expect(blocked.output).toBe(turns);
709+
expect(blocked.record.reason).toBe("incomplete-evidence-archive");
710+
711+
await archive.recordAuthorizedPayload({
712+
kind: "attachment",
713+
payload: png,
714+
});
715+
await archive.recordAuthorizedPayload({
716+
kind: "user_message",
717+
payload: "keep",
718+
});
719+
const allowed = await wrapped.apply(turns, ctx);
720+
expect(allowed.output).toHaveLength(1);
721+
expect(allowed.record.reason).toBe("compact");
722+
});
683723
});

src/session/compaction-archive.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type ArchiveKind,
2626
type ToolRecordingLifecycle,
2727
} from "./compaction-archive-schema.js";
28+
import { parseAgedImageMarker } from "./attachment-uri.js";
2829

2930
const INDEX_DIR = "evidence-archive";
3031
const INDEX_FILE = "index.jsonl";
@@ -626,10 +627,12 @@ function textKindForRole(role: ConversationTurn["role"]): ArchiveKind {
626627

627628
function coveringOccurrence(
628629
units: readonly {
629-
kind: "text" | "tool_call" | "tool_result";
630+
kind: "text" | "tool_call" | "tool_result" | "image";
630631
text?: string;
631632
role?: ConversationTurn["role"];
632633
callId?: string;
634+
data?: string;
635+
blobKey?: string;
633636
}[],
634637
occurrences: readonly ArchiveOccurrence[],
635638
): { ids: string[]; unmatched: boolean } {
@@ -638,6 +641,12 @@ function coveringOccurrence(
638641
for (const unit of units) {
639642
const match = occurrences.find((occ) => {
640643
if (used.has(occ.occurrenceId) || occ.gap === true) return false;
644+
if (unit.kind === "image") {
645+
if (occ.kind !== "attachment") return false;
646+
if (unit.blobKey !== undefined) return occ.blobKey === unit.blobKey;
647+
if (unit.data === undefined) return false;
648+
return occ.contentHash === hashAuthorizedBytes(new TextEncoder().encode(unit.data));
649+
}
641650
if (unit.kind === "text") {
642651
if (unit.role === undefined || unit.text === undefined) return false;
643652
if (occ.kind !== textKindForRole(unit.role)) return false;
@@ -655,25 +664,40 @@ function coveringOccurrence(
655664
}
656665

657666
function droppedContentUnits(dropped: readonly ConversationTurn[]): {
658-
kind: "text" | "tool_call" | "tool_result";
667+
kind: "text" | "tool_call" | "tool_result" | "image";
659668
text?: string;
660669
role?: ConversationTurn["role"];
661670
callId?: string;
671+
data?: string;
672+
blobKey?: string;
662673
}[] {
663674
const units: {
664-
kind: "text" | "tool_call" | "tool_result";
675+
kind: "text" | "tool_call" | "tool_result" | "image";
665676
text?: string;
666677
role?: ConversationTurn["role"];
667678
callId?: string;
679+
data?: string;
680+
blobKey?: string;
668681
}[] = [];
669682
for (const turn of dropped) {
670683
for (const block of turn.content) {
671684
if (block.type === "text" && block.text.length > 0) {
672-
units.push({ kind: "text", role: turn.role, text: block.text });
685+
const marker = parseAgedImageMarker(block.text);
686+
if (marker !== undefined) {
687+
units.push({ kind: "image", blobKey: marker.id });
688+
} else {
689+
units.push({ kind: "text", role: turn.role, text: block.text });
690+
}
673691
} else if (block.type === "tool_call") {
674692
units.push({ kind: "tool_call", callId: block.id });
675693
} else if (block.type === "tool_result") {
676694
units.push({ kind: "tool_result", callId: block.callId });
695+
} else if (block.type === "image") {
696+
if (block.source.kind === "base64") {
697+
units.push({ kind: "image", data: block.source.data });
698+
} else {
699+
units.push({ kind: "image" });
700+
}
677701
}
678702
}
679703
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,29 @@ describe("createOptimizedContextStore unpublished rewrite", () => {
666666
expect(turnTexts(loaded.turns)).toEqual(["old-a", "old-b"]);
667667
});
668668

669+
test("git commit failure after rewrite lands keeps load on HEAD", async () => {
670+
const dir = tempDir();
671+
const store = await createOptimizedContextStore(dir);
672+
const original = [turn("keep-a"), turn("keep-b"), turn("drop-me")];
673+
await store.writeTurns(original);
674+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
675+
const published = await store.commit({ message: "published original" });
676+
677+
await store.writeTurns([turn("[Compacted prior context]"), turn("keep-b")]);
678+
679+
const hookDir = path.join(dir, ".git", "hooks");
680+
fs.mkdirSync(hookDir, { recursive: true });
681+
const hook = path.join(hookDir, "commit-msg");
682+
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n");
683+
fs.chmodSync(hook, 0o755);
684+
685+
await expect(store.commit({ message: "publish compact" })).rejects.toThrow();
686+
687+
const loaded = await store.load();
688+
expect(turnTexts(loaded.turns)).toEqual(["keep-a", "keep-b", "drop-me"]);
689+
expect(turnTexts(await store.readAt(published.hash))).toEqual(["keep-a", "keep-b", "drop-me"]);
690+
});
691+
669692
test("append writeTurns is still visible before commit", async () => {
670693
const dir = tempDir();
671694
const store = await createOptimizedContextStore(dir);

src/session/optimized-context-store.ts

Lines changed: 67 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,25 @@ async function extraSegmentNamesAtCommit(dir: string, hash: string): Promise<str
381381
return names;
382382
}
383383

384+
async function restorePublishedTurnFiles(dir: string): Promise<void> {
385+
const listing = await runGit(dir, ["ls-tree", "--name-only", "HEAD"]);
386+
const present = new Set(listing.split("\n").filter((line) => line.length > 0));
387+
const tracked: string[] = [];
388+
if (present.has(TURNS_FILE)) tracked.push(TURNS_FILE);
389+
for (let index = 1; ; index++) {
390+
const name = segmentFileName(TURNS_FILE, index);
391+
if (!present.has(name)) break;
392+
tracked.push(name);
393+
}
394+
if (tracked.length > 0) {
395+
await runGit(dir, ["checkout", "HEAD", "--", ...tracked]);
396+
}
397+
const disk = await listSegmentFiles(dir, TURNS_FILE);
398+
for (const name of disk) {
399+
if (!present.has(name)) await fs.promises.unlink(path.join(dir, name));
400+
}
401+
}
402+
384403
async function describeHead(dir: string, message: string): Promise<ContextCommit> {
385404
const [hash, seconds, parents] = (await runGit(dir, ["log", "-1", "--format=%H%n%ct%n%P"])).split(
386405
"\n",
@@ -446,7 +465,7 @@ export async function createOptimizedContextStore(
446465
const base = await createIsogitStore(dir);
447466
const pendingBlobFilepaths = new Set<string>();
448467
const pendingSegmentPaths = new Set<string>();
449-
const writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
468+
let writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
450469
const writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
451470
let liveTurnRefs: readonly ConversationTurn[] | null = null;
452471
let unpublishedRewrite: ConversationTurn[] | null = null;
@@ -620,50 +639,62 @@ export async function createOptimizedContextStore(
620639
pendingBlobFilepaths.add(`${TOOL_OUTPUT_DIR}/${filename}`);
621640
},
622641
async commit(options, _signal) {
623-
if (unpublishedRewrite !== null) {
624-
await writeSegmented(writeTurnsSegmented, unpublishedRewrite);
625-
liveTurnRefs = unpublishedRewrite;
626-
unpublishedRewrite = null;
642+
const stagedRewrite = unpublishedRewrite;
643+
if (stagedRewrite !== null) {
644+
await writeSegmented(writeTurnsSegmented, stagedRewrite);
627645
}
628646

629-
const toAdd: string[] = [];
630-
const toRemove: string[] = [];
647+
try {
648+
const toAdd: string[] = [];
649+
const toRemove: string[] = [];
631650

632-
const rewrittenEachCycle = [RESPONSE_FILE, MANIFEST_FILE, METADATA_FILE];
633-
for (const filepath of rewrittenEachCycle) {
634-
if (await pathExists(path.join(dir, filepath))) toAdd.push(filepath);
635-
}
651+
const rewrittenEachCycle = [RESPONSE_FILE, MANIFEST_FILE, METADATA_FILE];
652+
for (const filepath of rewrittenEachCycle) {
653+
if (await pathExists(path.join(dir, filepath))) toAdd.push(filepath);
654+
}
636655

637-
for (const filepath of [...pendingSegmentPaths, ...pendingBlobFilepaths]) {
638-
if (await pathExists(path.join(dir, filepath))) toAdd.push(filepath);
639-
else toRemove.push(filepath);
640-
}
656+
for (const filepath of [...pendingSegmentPaths, ...pendingBlobFilepaths]) {
657+
if (await pathExists(path.join(dir, filepath))) toAdd.push(filepath);
658+
else toRemove.push(filepath);
659+
}
641660

642-
if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) {
643-
toAdd.push(EVIDENCE_ARCHIVE_DIR);
644-
}
661+
if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) {
662+
toAdd.push(EVIDENCE_ARCHIVE_DIR);
663+
}
645664

646-
// Disk is source of truth for which turn/prompt segments should remain
647-
// tracked after a rewrite or heal, even if pendingSegmentPaths was lost.
648-
await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove);
649-
await reconcileSegmentStaging(dir, PROMPT_FILE, toAdd, toRemove);
665+
// Disk is source of truth for which turn/prompt segments should remain
666+
// tracked after a rewrite or heal, even if pendingSegmentPaths was lost.
667+
await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove);
668+
await reconcileSegmentStaging(dir, PROMPT_FILE, toAdd, toRemove);
650669

651-
const add = [...new Set(toAdd)];
652-
const remove = [...new Set(toRemove)].filter((p) => !add.includes(p));
670+
const add = [...new Set(toAdd)];
671+
const remove = [...new Set(toRemove)].filter((p) => !add.includes(p));
653672

654-
if (add.length > 0) await runGit(dir, ["add", "--", ...add]);
655-
if (remove.length > 0) {
656-
await runGit(dir, ["rm", "--cached", "--ignore-unmatch", "--", ...remove]);
673+
if (add.length > 0) await runGit(dir, ["add", "--", ...add]);
674+
if (remove.length > 0) {
675+
await runGit(dir, ["rm", "--cached", "--ignore-unmatch", "--", ...remove]);
676+
}
677+
await runGit(
678+
dir,
679+
["commit", "-m", options.message, `--author=${author.name} <${author.email}>`],
680+
author,
681+
gitEnv,
682+
);
683+
pendingBlobFilepaths.clear();
684+
pendingSegmentPaths.clear();
685+
if (stagedRewrite !== null) {
686+
liveTurnRefs = stagedRewrite;
687+
unpublishedRewrite = null;
688+
}
689+
return describeHead(dir, options.message);
690+
} catch (cause) {
691+
if (stagedRewrite !== null) {
692+
await restorePublishedTurnFiles(dir);
693+
writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
694+
liveTurnRefs = null;
695+
}
696+
throw cause;
657697
}
658-
await runGit(
659-
dir,
660-
["commit", "-m", options.message, `--author=${author.name} <${author.email}>`],
661-
author,
662-
gitEnv,
663-
);
664-
pendingBlobFilepaths.clear();
665-
pendingSegmentPaths.clear();
666-
return describeHead(dir, options.message);
667698
},
668699
};
669700
}

vendor/intx-inference/PATCHES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,9 @@ tracks the skip-unchanged-history patch.
299299
`reactor.ts``executeCompact` persists blobs, stages `writeTurns`, and
300300
leaves reactor memory on the old generation until `commitCycle` publishes.
301301
`replaceTurns` runs only after a successful commit. `commitCycle` must not
302-
`writeTurns` live (old) memory over that staging.
302+
`writeTurns` live (old) memory over that staging. A failed commit clears
303+
`pendingCompactOutput` so a later infer/tools cycle writes live history and
304+
does not `replaceTurns` with the unpublished compact.
303305

304306
**Disposition:** Promotion candidate. Compaction durability — an interrupt
305307
between stage and commit must resume the complete old generation, not a

0 commit comments

Comments
 (0)