Skip to content

Commit a3caa71

Browse files
committed
fix(session): union fat-file facts and adopt dropped handoff spines
Iterative folds dropped fold-1 paths when the next region was only the prior spine plus later turns, and a growing Evidence/Constraints line fail-closed the completeness gate. Union the previous fat file, scan tool_result evidence, and archive synthetic spines as user_message.
1 parent 0cdb84a commit a3caa71

11 files changed

Lines changed: 643 additions & 144 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ import {
137137
resolveLiveSessionSources,
138138
} from "../session/assemble-runtime.js";
139139
import type { CompactionArchive } from "../session/compaction-archive.js";
140+
import { tryReadPriorHandoffFile } from "../session/compaction-handoff.js";
140141
import { emitPluginWarningSummary } from "../plugins/diagnostics.js";
141142
import { createModelSummarizer } from "../session/summarizer.js";
142143
import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
@@ -848,6 +849,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
848849
const tools = activatedToolNames.list();
849850
return tools.length > 0 ? { activatedTools: tools } : undefined;
850851
},
852+
readPriorHandoff: () =>
853+
tryReadPriorHandoffFile(
854+
currentStorage?.readBlob.bind(currentStorage),
855+
),
851856
telemetry: liveTelemetry,
852857
onFolded: () => {
853858
// Fold restarts the cached prefix, so catch promotions still

‎src/session/compaction-archive.test.ts‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,4 +998,150 @@ describe("wrapCompactorWithCompletenessGate", () => {
998998
expect(result.output).toHaveLength(1);
999999
expect(result.output[0]).not.toBe(turns[1]);
10001000
});
1001+
1002+
test("second fold that grows the spine still compact-succeeds by adopting the prior handoff", async () => {
1003+
const { wrapCompactorWithCompletenessGate } =
1004+
await import("./compaction-archive.js");
1005+
const { createPruningCompactor } = await import("./compactor.js");
1006+
const { COMPACTED_PREFIX, buildHandoffFold } =
1007+
await import("./compaction-handoff.js");
1008+
const { archive } = memoryArchive();
1009+
1010+
const first = buildHandoffFold(
1011+
[
1012+
{
1013+
role: "user",
1014+
content: [{ type: "text", text: "Ship the widget." }],
1015+
timestamp: 1,
1016+
},
1017+
],
1018+
"first narrative",
1019+
);
1020+
const priorSpine = first.spineText;
1021+
await archive.recordAuthorizedPayload({
1022+
kind: "user_message",
1023+
payload: "Must never write to /tmp. [[evidence:decision|op|no-tmp]]",
1024+
});
1025+
await archive.recordAuthorizedPayload({
1026+
kind: "assistant_text",
1027+
payload: "ok",
1028+
});
1029+
await archive.recordAuthorizedPayload({
1030+
kind: "assistant_text",
1031+
payload: "working",
1032+
});
1033+
1034+
const inner = createPruningCompactor({
1035+
keepRecentTurns: 2,
1036+
maxAnchorTurns: 0,
1037+
summaryMaxChars: 500,
1038+
});
1039+
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
1040+
const turns: import("@intx/types/runtime").ConversationTurn[] = [
1041+
{
1042+
role: "user",
1043+
content: [{ type: "text", text: priorSpine }],
1044+
timestamp: 1,
1045+
},
1046+
{
1047+
role: "user",
1048+
content: [{ type: "text", text: "Continue the widget." }],
1049+
timestamp: 2,
1050+
},
1051+
{
1052+
role: "assistant",
1053+
content: [{ type: "text", text: "working" }],
1054+
timestamp: 3,
1055+
},
1056+
{
1057+
role: "user",
1058+
content: [
1059+
{
1060+
type: "text",
1061+
text: "Must never write to /tmp. [[evidence:decision|op|no-tmp]]",
1062+
},
1063+
],
1064+
timestamp: 4,
1065+
},
1066+
{
1067+
role: "assistant",
1068+
content: [{ type: "text", text: "ok" }],
1069+
timestamp: 5,
1070+
},
1071+
{
1072+
role: "user",
1073+
content: [{ type: "text", text: "keep one" }],
1074+
timestamp: 6,
1075+
},
1076+
{
1077+
role: "assistant",
1078+
content: [{ type: "text", text: "keep two" }],
1079+
timestamp: 7,
1080+
},
1081+
];
1082+
const result = await wrapped.apply(turns, ctx);
1083+
expect(result.record.reason).not.toBe("incomplete-evidence-archive");
1084+
const spine = result.output[0]?.content.find((b) => b.type === "text");
1085+
expect(spine?.type).toBe("text");
1086+
if (spine?.type !== "text") throw new Error("unreachable");
1087+
expect(spine.text.startsWith(COMPACTED_PREFIX)).toBe(true);
1088+
expect(spine.text).toContain("Must never write to /tmp.");
1089+
expect(spine.text).toContain("[[evidence:decision|op|no-tmp]]");
1090+
});
1091+
1092+
test("pre-format fat Compacted prior context summaries still compact", async () => {
1093+
const { wrapCompactorWithCompletenessGate } =
1094+
await import("./compaction-archive.js");
1095+
const { createPruningCompactor } = await import("./compactor.js");
1096+
const { COMPACTED_PREFIX } = await import("./compaction-handoff.js");
1097+
const { archive } = memoryArchive();
1098+
const fatSummary = `${COMPACTED_PREFIX}\nLong pre-format narrative about the auth migration and every file that was touched.`;
1099+
await archive.recordAuthorizedPayload({
1100+
kind: "user_message",
1101+
payload: "next ask after the old summary",
1102+
});
1103+
await archive.recordAuthorizedPayload({
1104+
kind: "assistant_text",
1105+
payload: "working",
1106+
});
1107+
const inner = createPruningCompactor({
1108+
keepRecentTurns: 2,
1109+
maxAnchorTurns: 0,
1110+
summaryMaxChars: 500,
1111+
});
1112+
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
1113+
const turns: import("@intx/types/runtime").ConversationTurn[] = [
1114+
{
1115+
role: "user",
1116+
content: [{ type: "text", text: fatSummary }],
1117+
timestamp: 1,
1118+
},
1119+
{
1120+
role: "user",
1121+
content: [{ type: "text", text: "next ask after the old summary" }],
1122+
timestamp: 2,
1123+
},
1124+
{
1125+
role: "assistant",
1126+
content: [{ type: "text", text: "working" }],
1127+
timestamp: 3,
1128+
},
1129+
{
1130+
role: "user",
1131+
content: [{ type: "text", text: "keep one" }],
1132+
timestamp: 4,
1133+
},
1134+
{
1135+
role: "assistant",
1136+
content: [{ type: "text", text: "keep two" }],
1137+
timestamp: 5,
1138+
},
1139+
];
1140+
const result = await wrapped.apply(turns, ctx);
1141+
expect(result.record.reason).not.toBe("incomplete-evidence-archive");
1142+
const spine = result.output[0]?.content.find((b) => b.type === "text");
1143+
expect(spine?.type).toBe("text");
1144+
if (spine?.type !== "text") throw new Error("unreachable");
1145+
expect(spine.text.startsWith(COMPACTED_PREFIX)).toBe(true);
1146+
});
10011147
});

‎src/session/compaction-archive.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
type ToolRecordingLifecycle,
3030
} from "./compaction-archive-schema.js";
3131
import { parseAgedImageMarker } from "./attachment-uri.js";
32+
import { COMPACTED_PREFIX } from "./compaction-handoff.js";
3233

3334
const INDEX_DIR = "evidence-archive";
3435
const INDEX_FILE = "index.jsonl";
@@ -892,9 +893,30 @@ function incompleteIdentity(inner: Compactor, turns: ConversationTurn[]) {
892893
};
893894
}
894895

896+
/**
897+
* Archive a synthetic `[Compacted prior context]` turn as a user_message so
898+
* the completeness gate can certify dropping it when the next spine grows.
899+
*/
900+
export async function recordAdoptedHandoff(
901+
archive: CompactionArchive,
902+
text: string,
903+
): Promise<ArchiveOccurrence> {
904+
return archive.recordAuthorizedPayload({
905+
kind: "user_message",
906+
payload: text,
907+
provenance: "adopted-handoff",
908+
});
909+
}
910+
911+
function isSyntheticHandoffText(text: string): boolean {
912+
return text.startsWith(COMPACTED_PREFIX);
913+
}
914+
895915
/**
896916
* Refuse a destructive compact when the evidence archive cannot certify the
897917
* dropped prefix. Historical gap:true rows are not part of the expected set.
918+
* Synthetic handoff spines (and pre-format fat summaries) are adopted into
919+
* the archive as user_message so a later fold may change the live spine.
898920
*/
899921
export function wrapCompactorWithCompletenessGate(
900922
inner: Compactor,
@@ -908,6 +930,15 @@ export function wrapCompactorWithCompletenessGate(
908930
const proposed = await inner.apply(turns, ctx);
909931
const units = uncoveredContentUnits(turns, proposed.output);
910932
if (units.length === 0) return proposed;
933+
for (const unit of units) {
934+
if (
935+
unit.kind === "text" &&
936+
unit.role === "user" &&
937+
unit.text !== undefined &&
938+
isSyntheticHandoffText(unit.text)
939+
)
940+
await recordAdoptedHandoff(archive, unit.text);
941+
}
911942
const occurrences = await archive.listOccurrences();
912943
const covering = coveringOccurrence(
913944
units,

0 commit comments

Comments
 (0)