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
@@ -13854,9 +13854,13 @@ export class AgentSession {
* {@link #computeSnapcompactMaxFrames} sizes against — a rebuilt archive
* must land back under the maintenance trigger, or the next settle
* re-enters the same dead-end. Cap reserve mirrors
* #computeSnapcompactMaxFrames (text edges + summary template).
* #computeSnapcompactMaxFrames (text edges + summary template), and
* `keptTailTokens` charges the kept entries AFTER the archive so the
* budget mirrors what #compactionCreatedHeadroom will actually measure.
* Returns 0 when not even one frame fits that budget — the rebuild could
* never create headroom, so the caller must not append it.
*/
#computeSnapcompactRescueMaxFrames(settings: CompactionSettings): number {
#computeSnapcompactRescueMaxFrames(settings: CompactionSettings, keptTailTokens: number): number {
const ctxWindow = this.model?.contextWindow ?? 0;
if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
const thresholdTokens = resolveThresholdTokens(ctxWindow, settings);
@@ -13866,18 +13870,15 @@ export class AgentSession {
const edgeCap = snapcompact.geometry(shape).capacity;
const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
const SUMMARY_TEMPLATE_TOKENS = 2000;
const frameBudget = recoveryBandTokens - baseTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS;
if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
const frameBudget = recoveryBandTokens - baseTokens - keptTailTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS;
if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 0;
// Same hard caps as #computeSnapcompactMaxFrames: a threshold-derived
// count above the per-request payload budget would "shrink" a huge
// archive to a frame count the rebuilt prompt can never attach anyway.
return Math.max(
1,
Math.min(
Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
snapcompact.MAX_FRAMES_DEFAULT,
snapcompact.maxFramesForDataBudget(),
),
return Math.min(
Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
snapcompact.MAX_FRAMES_DEFAULT,
snapcompact.maxFramesForDataBudget(),
);
}
@@ -13913,30 +13914,27 @@ export class AgentSession {
const staleEntry = getLatestCompactionEntry(branchEntries);
if (!staleEntry) return undefined;
// Only rescue when the archive is the actual source of the overflow.
// If the kept tail AFTER it is itself over the recovery band (e.g. a
// huge kept tool result), rebuilding the archive would append the
// replacement compaction at the leaf — turning the branch tail into a
// compaction entry, which prepareCompaction's last-entry guard can
// never summarize past even after an elide shrinks the real culprit.
// Bail and let the elide/image tiers handle that tail instead.
const ctxWindow = this.model.contextWindow ?? 0;
if (ctxWindow > 0) {
const tailBar = Math.floor(resolveThresholdTokens(ctxWindow, settings) * COMPACTION_RECOVERY_BAND);
let tailTokens = 0;
for (let i = branchEntries.length - 1; i >= 0; i--) {
const entry = branchEntries[i];
if (entry.id === staleEntry.id) break;
const message = (entry as { message?: AgentMessage }).message;
if (message) tailTokens += estimateTokens(message);
}
if (tailTokens > tailBar) return undefined;
// The frame budget below charges the kept tail AFTER the archive plus
// the fixed context, mirroring what #compactionCreatedHeadroom will
// measure. When not even one frame fits (e.g. a huge kept tool result
// dominates), rebuilding would append the replacement compaction at
// the leaf — turning the branch tail into a compaction entry, which
// prepareCompaction's last-entry guard can never summarize past even
// after an elide shrinks the real culprit. Bail and let the
// elide/image tiers handle that tail instead.
let keptTailTokens = 0;
for (let i = branchEntries.length - 1; i >= 0; i--) {
const entry = branchEntries[i];
if (entry.id === staleEntry.id) break;
const message = (entry as { message?: AgentMessage }).message;
if (message) keptTailTokens += estimateTokens(message);
}
const archive = snapcompact.getPreservedArchive(staleEntry.preserveData);
if (!archive || archive.frames.length <= 1) return undefined;
const archiveText = snapcompact.archiveSourceText(archive);
if (!archiveText) return undefined;
const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings);
if (maxFrames >= archive.frames.length) return undefined;
const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings, keptTailTokens);
if (maxFrames < 1 || maxFrames >= archive.frames.length) return undefined;
const staleDetails = staleEntry.details as snapcompact.CompactionDetails | undefined;
const fileOps = snapcompact.createFileOps();
@@ -14298,11 +14296,19 @@ export class AgentSession {
continuationScheduled = true;
}
if (noProgressDeadEnd) {
this.emitNotice(
"warning",
compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
"compaction",
);
const deadEndWarning = compactionDeadEndWarning("shrink it (e.g. clear large tool output)");
this.emitNotice("warning", deadEndWarning, "compaction");
// A rescue that appended a rebuilt archive without creating
// headroom must carry the dead-end badge on the entry the
// transcript actually shows (the rebuilt one), or the pause
// loses its explanation once the notice scrolls away.
if (frameRescueResult) {
const stampEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
if (stampEntry) {
stampEntry.warning = deadEndWarning;
await this.sessionManager.rewriteEntries();
}
}
}
// A rescue that offloaded content but still could not produce a
// preparation rewrote the branch; flag it so the overflow-recovery
@@ -14723,12 +14729,18 @@ export class AgentSession {
}
const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined;
if (deadEndWarning && savedCompactionEntry) {
if (deadEndWarning) {
// Stamp the divider: the compaction bar badges the dead-end and
// carries the full warning in its ctrl+o detail, so the pause
// stays explained even after the notice row scrolls away.
savedCompactionEntry.warning = deadEndWarning;
await this.sessionManager.rewriteEntries();
// stays explained even after the notice row scrolls away. Stamp
// the branch's LATEST compaction entry — a frame rescue may have
// superseded `savedCompactionEntry` with a rebuilt one, and the
// collapsed transcript badges only the active entry.
const stampEntry = getLatestCompactionEntry(this.sessionManager.getBranch()) ?? savedCompactionEntry;
if (stampEntry) {
stampEntry.warning = deadEndWarning;
await this.sessionManager.rewriteEntries();
}
}
await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });