fix(compaction): charge the kept tail in the rescue budget and badge the active entry

Review follow-ups (Codex on #6362, round 5):
- #computeSnapcompactRescueMaxFrames now subtracts the kept tail AFTER the
  archive (plus the existing fixed-context reserves) so the budget mirrors
  what #compactionCreatedHeadroom will measure, and returns 0 when not even
  one frame fits — the rescue bails instead of appending a rebuild that can
  never create headroom (and would wedge prepareCompaction behind its
  last-entry guard once elide fixes the real tail).
- Dead-end warnings now stamp the branch's LATEST compaction entry: the
  post-pass path no longer badges the entry the rescue just superseded, and
  the no-preparation path badges the rebuilt entry when the rescue appended
  without creating headroom.

Claude-Session: https://claude.ai/code/session_014rh4JyWFkxgMhgFaEf8VBY
This commit is contained in:
Hugo Lopes
2026-07-23 14:03:12 +02:00
parent f48004a182
commit d8554c92d0
2 changed files with 89 additions and 39 deletions
@@ -373,6 +373,44 @@ describe("AgentSession snapcompact frame dead-end rescue", () => {
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
expect(noProgress[0].level).toBe("warning");
// The dead-end badge must live on the ACTIVE (rebuilt) entry — the
// collapsed transcript only shows the latest compaction divider.
const compactions = sessionManager.getBranch().filter(e => e.type === "compaction") as CompactionEntry[];
const active = compactions.at(-1);
expect(snapcompact.getPreservedArchive(active?.preserveData)?.frames.length).toBe(4);
expect(active?.warning).toContain(NO_PROGRESS_FRAGMENT);
});
it("bails when the kept tail plus fixed context leaves no frame budget", async () => {
// Codex review on #6362 (round 5): a tail just under the recovery band
// still cannot coexist with the fixed context + a minimum rebuilt
// archive. The budget now charges the kept tail like
// #compactionCreatedHeadroom does, so the rescue must bail instead of
// appending a rebuild that can never create headroom.
await createSession({ frameCount: SEEDED_FRAME_COUNT });
// ~40k estimated tokens: under the 48k band, but over band − edges/template.
sessionManager.appendMessage({
role: "toolResult",
toolCallId: "call-mid",
toolName: "bash",
content: [{ type: "text", text: "y".repeat(160_000) }],
isError: false,
timestamp: Date.now(),
});
vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined);
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 });
const shakeSpy = vi
.spyOn(session, "shake")
.mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 });
const compactSpy = vi.spyOn(snapcompact, "compact");
await triggerMaintenance();
expect(compactSpy).not.toHaveBeenCalled();
expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything());
expect(sessionManager.getBranch().at(-1)?.type).not.toBe("compaction");
});
it("leaves an oversized non-archive tail to the elide tiers instead of rescuing the archive", async () => {