Skip to content

Commit c7d7932

Browse files
fix(session): skip handoff record when abort discards a stub (#1250)
The TUI wrapCompactor race can discard a completeness-gate certified stub. Recording the new spine inside that race left a compaction-handoff in the archive for a fold that never landed.
1 parent f6bbe7e commit c7d7932

5 files changed

Lines changed: 139 additions & 4 deletions

File tree

‎src/session/assemble-runtime.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,12 @@ export interface ChatAgentWiring {
556556
* side effects (stub notice, onFolded prune) run only after a fold lands.
557557
*/
558558
getCompactor: (wrapPruning?: (pruning: Compactor) => Compactor) => Compactor;
559+
/**
560+
* Bound to the TUI compaction lifecycle abort signal. The completeness
561+
* gate runs inside wrapCompactor's race, so a discarded certified stub
562+
* must not persist a compaction-handoff for a fold that never landed.
563+
*/
564+
isCompactionAborted?: () => boolean;
559565
/** Experimental Anthropic prompt shrink. Default off when omitted. */
560566
anthropicCachePrompt?: () => boolean;
561567
/**
@@ -786,7 +792,13 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
786792
primaryArchive === undefined
787793
? undefined
788794
: (pruning) =>
789-
wrapCompactorWithCompletenessGate(pruning, primaryArchive),
795+
wrapCompactorWithCompletenessGate(
796+
pruning,
797+
primaryArchive,
798+
wiring.isCompactionAborted === undefined
799+
? undefined
800+
: { isAborted: wiring.isCompactionAborted },
801+
),
790802
),
791803
},
792804
});

‎src/session/compaction-archive.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -922,9 +922,12 @@ async function recordFreshHandoffOutput(
922922
archive: CompactionArchive,
923923
input: readonly ConversationTurn[],
924924
output: readonly ConversationTurn[],
925+
isAborted?: () => boolean,
925926
): Promise<void> {
927+
if (isAborted?.() === true) return;
926928
const fresh = uncoveredContentUnits(output, input);
927929
for (const unit of fresh) {
930+
if (isAborted?.() === true) return;
928931
if (unit.kind !== "text" || unit.role !== "user") continue;
929932
const text = unit.text ?? "";
930933
if (text.length === 0) continue;
@@ -946,11 +949,14 @@ async function recordFreshHandoffOutput(
946949
* Synthetic handoff spines (and pre-format fat summaries) are adopted into
947950
* the archive as user_message so a later fold may change the live spine.
948951
* After the fold certifies, the new spine is recorded so the next fold can
949-
* drop it even when the summarizer does not echo it verbatim.
952+
* drop it even when the summarizer does not echo it verbatim. `isAborted`
953+
* skips that record: the TUI wrapCompactor race can discard a certified
954+
* stub, and a handoff for a fold that never landed is a phantom.
950955
*/
951956
export function wrapCompactorWithCompletenessGate(
952957
inner: Compactor,
953958
archive: CompactionArchive,
959+
opts?: { isAborted?: () => boolean },
954960
): Compactor {
955961
return {
956962
name: inner.name,
@@ -982,7 +988,15 @@ export function wrapCompactorWithCompletenessGate(
982988
if (certificate.status !== "complete") {
983989
return incompleteIdentity(inner, turns);
984990
}
985-
await recordFreshHandoffOutput(archive, turns, proposed.output);
991+
if (opts?.isAborted?.() === true) {
992+
return proposed;
993+
}
994+
await recordFreshHandoffOutput(
995+
archive,
996+
turns,
997+
proposed.output,
998+
opts?.isAborted,
999+
);
9861000
return proposed;
9871001
},
9881002
};

‎src/session/runtime-assembly.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ import {
3737
createCompactionArchive,
3838
wrapCompactorWithCompletenessGate,
3939
} from "./compaction-archive.js";
40+
import {
41+
COMPACTION_ABORTED_REASON,
42+
createCompactionLifecycle,
43+
} from "./compaction-lifecycle.js";
4044
import { generateSessionId, initSessionDir, sessionDir } from "./index.js";
4145
import type { PluginModule } from "../plugins/loader.js";
4246

@@ -876,6 +880,110 @@ describe("createSessionPruningCompactor stub fallback", () => {
876880
expect(notices).toHaveLength(1);
877881
expect(notices[0]).toContain("statistics-only stub");
878882
expect(notices[0]).toContain("failed");
883+
const committedHandoffs = (await archive.listOccurrences()).filter(
884+
(occurrence) => occurrence.provenance === "compaction-handoff",
885+
);
886+
expect(committedHandoffs.length).toBeGreaterThan(0);
887+
});
888+
889+
test("TUI abort after a gate-committed stub does not record a phantom handoff", async () => {
890+
const notices: string[] = [];
891+
const folds: { stub: boolean }[] = [];
892+
const summarize = createModelSummarizer({
893+
getSource: () =>
894+
({
895+
id: "test",
896+
provider: "openai",
897+
model: "test-model",
898+
baseURL: "http://localhost:1",
899+
credentialId: "test",
900+
}) as never,
901+
complete: async () => {
902+
throw new Error("model unreachable");
903+
},
904+
});
905+
const dir = await mkdtemp(join(tmpdir(), "compaction-gate-stub-abort-"));
906+
const blobs = new Map<string, Uint8Array>();
907+
const archive = createCompactionArchive({
908+
sessionId: "sess-gate-stub-abort",
909+
contextDir: dir,
910+
writeBlob: async (key, bytes) => {
911+
blobs.set(key, bytes);
912+
},
913+
readBlob: async (key) => {
914+
const bytes = blobs.get(key);
915+
if (bytes === undefined) throw new Error(`missing ${key}`);
916+
return bytes;
917+
},
918+
});
919+
const now = Date.now();
920+
const many = Array.from({ length: 8 }, (_, i) => ({
921+
role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
922+
content: [{ type: "text" as const, text: `t${i}` }],
923+
timestamp: now,
924+
}));
925+
for (const turn of many) {
926+
const block = turn.content[0];
927+
if (block?.type !== "text") continue;
928+
await archive.recordAuthorizedPayload({
929+
kind: turn.role === "assistant" ? "assistant_text" : "user_message",
930+
payload: block.text,
931+
});
932+
}
933+
const lifecycle = createCompactionLifecycle();
934+
const isAborted = () => lifecycle.getSignal().aborted;
935+
let releaseGated: () => void = () => undefined;
936+
const gatedFinished = new Promise<void>((resolve) => {
937+
releaseGated = resolve;
938+
});
939+
const abortingArchive = {
940+
...archive,
941+
certifyRange: async (ids: readonly string[]) => {
942+
const certificate = await archive.certifyRange(ids);
943+
lifecycle.abortCompaction("operator interrupt");
944+
return certificate;
945+
},
946+
};
947+
const wrapped = lifecycle.wrapCompactor(
948+
createSessionPruningCompactor({
949+
summarize,
950+
onFolded: (info) => folds.push(info),
951+
onFailure: (text) => notices.push(text),
952+
isAborted,
953+
compactionShape: { tailBudgetTokens: 1 },
954+
wrapPruning: (pruning) => {
955+
const gated = wrapCompactorWithCompletenessGate(
956+
pruning,
957+
abortingArchive,
958+
{ isAborted },
959+
);
960+
return {
961+
name: gated.name,
962+
version: gated.version,
963+
apply: async (turns, ctx) => {
964+
try {
965+
return await gated.apply(turns, ctx);
966+
} finally {
967+
releaseGated();
968+
}
969+
},
970+
};
971+
},
972+
}),
973+
);
974+
const result = await wrapped.apply(many as never, {
975+
state: {} as never,
976+
trigger: "test",
977+
});
978+
await gatedFinished;
979+
expect(result.output).toBe(many);
980+
expect(result.record.reason).toBe(COMPACTION_ABORTED_REASON);
981+
expect(folds).toEqual([]);
982+
expect(notices).toEqual([]);
983+
const handoffs = (await archive.listOccurrences()).filter(
984+
(occurrence) => occurrence.provenance === "compaction-handoff",
985+
);
986+
expect(handoffs).toEqual([]);
879987
});
880988
});
881989

‎src/session/runtime-assembly.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ export interface SessionPruningCompactorArgs {
444444
/**
445445
* Wraps the inner pruning apply before fold-commit side effects.
446446
* assembleChatAgent passes the completeness gate here so a discarded
447-
* fold never notices or prunes.
447+
* fold never notices, prunes, or records a phantom compaction-handoff.
448448
*/
449449
wrapPruning?: (pruning: Compactor) => Compactor;
450450

‎src/tui/runner/session.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ export async function assembleTUISession(
697697
? state.liveDefaultSource
698698
: state.liveSource.id,
699699
anthropicCachePrompt: () => config.anthropicCachePrompt,
700+
isCompactionAborted: () => compactionLifecycle.getSignal().aborted,
700701
getCompactor: (wrapPruning) =>
701702
compactionLifecycle.wrapCompactor(
702703
createSessionPruningCompactor({

0 commit comments

Comments
 (0)