From d1ee0a9d25614d68472179957dc1e7c858ef254c Mon Sep 17 00:00:00 2001 From: GunP4ng <140302315+GunP4ng@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:42:16 +0000 Subject: [PATCH 1/2] fix(compaction): recover stalled pre-prompt sessions Route mandatory pre-prompt failures through deterministic recovery and advance past unsafe split-turn payloads to the earliest replay-safe suffix. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../extensions/builtin/compaction/changes.md | 25 ++++++ .../compaction/deterministic-fallback.ts | 21 ++++- .../builtin/compaction/extension-wiring.ts | 2 +- ...-compaction-deterministic-fallback.test.ts | 79 ++++++++++++++++++- .../summarization-tooluse-retry.test.ts | 42 +++++----- 5 files changed, 144 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md index b932e2e493..7c643326c8 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -1,5 +1,30 @@ # changes.md — builtin compaction policy +## Recover stalled pre-prompt compaction across unsafe split turns (2026-09-16) + +### What changed + +- Mandatory `pre_prompt` compaction now enters the same deterministic failure + recovery route as manual, threshold, and overflow compaction. +- When every prepared or earlier boundary retains unsafe split-turn content, + deterministic recovery now scans forward and selects the earliest suffix that + already passes the existing replay-safety, atomic tool-chain, and effective + token-budget checks. + +### Why + +- A resumed session above its compaction threshold could stall during provider + summarization, reject compaction, and then fail admission without attempting + the deterministic fallback. +- Long split turns can contain an unsafe historical tool result with no later + user boundary. Scanning only prepared, user, and earlier boundaries retains + that unsafe result forever even when a later assistant boundary is safe. + +### Expected merge conflict zones + +- `extension-wiring.ts`: required fallback reason classification. +- `deterministic-fallback.ts`: retained-suffix candidate ordering. + ## Deterministic resume slice for an over-window restored context (2026-09-10) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts index 8557d7ab02..a82509d641 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts @@ -38,7 +38,7 @@ interface DeterministicFallbackDetails { origin: "required-compaction-recovery"; failureKind: RequiredCompactionFallbackFailure; taskIntent?: string; - retainedSuffix?: "prepared" | "latest-user-turn" | "earlier-safe-boundary"; + retainedSuffix?: "prepared" | "latest-user-turn" | "earlier-safe-boundary" | "later-safe-boundary"; } export interface DeterministicFallbackDiagnostic { @@ -208,6 +208,7 @@ export function createRequiredCompactionFallback( ...(taskIntent ? { taskIntent } : {}), }; let candidateCount = 0; + let sawUnsafeRetainedContent = false; const syntheticCompaction: CompactionEntry = { type: "compaction", @@ -379,6 +380,7 @@ export function createRequiredCompactionFallback( if (startIndex === undefined) return reject("context-reconstruction-failed"); const retainedStart = Math.min(startIndex, projectedMessages.length); if (unsafeSuffix[retainedStart]) { + sawUnsafeRetainedContent = true; const unsafeMessageIndex = unsafeIndexSuffix[retainedStart]; const unsafeMessage = projectedMessages[unsafeMessageIndex]; return reject("unsafe-retained-content", { @@ -452,6 +454,23 @@ export function createRequiredCompactionFallback( break; } + // A split turn can contain an unsafe persisted tool payload after the + // prepared boundary and no later user message. Advance to the earliest safe + // suffix rather than retaining that payload forever. `projectCandidate` + // still rejects orphaned tool results, incomplete call chains, unsafe + // content, and over-budget suffixes. + if (sawUnsafeRetainedContent) { + for (let index = preparedBoundaryIndex + 1; index < branchEntries.length; index++) { + const entry = branchEntries[index]; + if (entry.type === "compaction") continue; + const laterSafe = projectCandidate(entry.id, "later-safe-boundary"); + if (laterSafe) { + if (diagnostics) diagnostics.candidatesChecked = candidateCount; + return laterSafe; + } + } + } + if (diagnostics) diagnostics.candidatesChecked = candidateCount; return undefined; } diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts index a14f2d44e4..7559abce4e 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts @@ -44,7 +44,7 @@ export function isAbortedAssistantMessage(event: { message: AgentMessage }): boo } export function isRequiredCompactionFallbackReason(reason: SessionBeforeCompactEvent["reason"]): boolean { - return reason === "manual" || reason === "threshold" || reason === "overflow"; + return reason === "manual" || reason === "threshold" || reason === "overflow" || reason === "pre_prompt"; } export function recentCheckpoint(ctx: ExtensionContext): checkpointState.AgentCheckpoint | null { diff --git a/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts b/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts index 437597f436..0ca7d49035 100644 --- a/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts +++ b/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts @@ -177,7 +177,7 @@ describe("required compaction deterministic fallback", () => { }); it("fails closed for every non-required reason even when typed truncation recovery would fit", async () => { - const nonRequiredReasons = ["pre_prompt", "branch", "extension"] satisfies CompactionReason[]; + const nonRequiredReasons = ["branch", "extension"] satisfies CompactionReason[]; for (const reason of nonRequiredReasons) { const handlers = createCompactionHandlers(); const harness = createBlockingContext({ usageTokens: 9_900 }); @@ -219,12 +219,89 @@ describe("required compaction deterministic fallback", () => { } }); + it("recovers mandatory pre-prompt compaction after a typed summarizer failure", async () => { + const handlers = createCompactionHandlers(); + const harness = createBlockingContext({ usageTokens: 9_900 }); + harness.registration.setResponses([ + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "upstream_stream_truncated: Responses stream ended before a terminal event", + }), + ]); + const branchEntries = harness.ctx.sessionManager.getBranch(); + const preparation = { + ...prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true)!, + firstKeptEntryId: branchEntries.at(-1)?.id ?? "", + }; + + const result = await handlers.sessionBeforeCompact( + { + type: "session_before_compact", + reason: "pre_prompt", + willRetry: false, + requestId: "pre-prompt-required-recovery", + preparation, + branchEntries, + signal: new AbortController().signal, + }, + harness.ctx, + ); + + expect(result).toMatchObject({ + compaction: { + firstKeptEntryId: preparation.firstKeptEntryId, + details: { retainedSuffix: "prepared" }, + }, + }); + expect(result).not.toHaveProperty("cancel"); + expect(harness.registration.getCallLog()).toHaveLength(1); + }); + it("classifies a duration watchdog without sleeping", () => { expect(classifyRequiredCompactionFallbackFailure(new StreamDurationBudgetError(120_000))).toBe( "summarization-timeout", ); }); + it("advances past unsafe split-turn content to the earliest replay-safe suffix", () => { + const harness = createBlockingContext({ usageTokens: 9_900 }); + const preparedBoundaryId = harness.sessionManager.appendMessage({ + role: "user", + content: "Continue the current turn.", + timestamp: 4, + }); + harness.sessionManager.appendMessage({ + ...fauxAssistantMessage("", { timestamp: 5, stopReason: "toolUse" }), + content: [{ type: "toolCall", id: "unsafe-tool", name: "read", arguments: { path: "image.png" } }], + }); + harness.sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "unsafe-tool", + toolName: "read", + content: [ + { type: "text", text: "Malformed image result" }, + { type: "image", mimeType: "image/png" }, + ] as never, + isError: false, + timestamp: 6, + }); + const safeTailId = harness.sessionManager.appendMessage( + fauxAssistantMessage("Work continued safely.", { timestamp: 7 }), + ); + const branchEntries = harness.sessionManager.getBranch(); + const preparation = { + ...prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true)!, + firstKeptEntryId: preparedBoundaryId, + }; + + const result = createRequiredCompactionFallback(preparation, 100_000, "summarization-timeout", {}, branchEntries); + + expect(result).toMatchObject({ + firstKeptEntryId: safeTailId, + details: { retainedSuffix: "later-safe-boundary" }, + }); + }); + it("rejects truncation-looking generic errors and requires structured summary-request provenance", () => { const truncationMessage = "upstream_stream_truncated: Responses stream ended before a terminal event"; for (const error of [ diff --git a/packages/coding-agent/test/compaction/summarization-tooluse-retry.test.ts b/packages/coding-agent/test/compaction/summarization-tooluse-retry.test.ts index 374e01fb9d..5b636cede4 100644 --- a/packages/coding-agent/test/compaction/summarization-tooluse-retry.test.ts +++ b/packages/coding-agent/test/compaction/summarization-tooluse-retry.test.ts @@ -260,28 +260,26 @@ describe("required compaction recovery from summarizer tool-call hijack", () => } }); - it("keeps idle and speculative-origin failures fail-closed", async () => { - for (const reason of ["pre_prompt", "extension"] as const) { - const handlers = createCompactionHandlers(); - const harness = createBlockingContext({ usageTokens: 9_900 }); - harness.registration.setResponses([bareToolCallResponse()]); - const branchEntries = harness.ctx.sessionManager.getBranch(); - const preparation = prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true); - const result = await handlers.sessionBeforeCompact( - { - type: "session_before_compact", - reason, - willRetry: false, - requestId: `fail-closed-${reason}`, - preparation: preparation!, - branchEntries, - signal: new AbortController().signal, - }, - harness.ctx, - ); - expect(result).toMatchObject({ cancel: true }); - expect(result).not.toHaveProperty("compaction"); - } + it("keeps speculative-origin failures fail-closed", async () => { + const handlers = createCompactionHandlers(); + const harness = createBlockingContext({ usageTokens: 9_900 }); + harness.registration.setResponses([bareToolCallResponse()]); + const branchEntries = harness.ctx.sessionManager.getBranch(); + const preparation = prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true); + const result = await handlers.sessionBeforeCompact( + { + type: "session_before_compact", + reason: "extension", + willRetry: false, + requestId: "fail-closed-extension", + preparation: preparation!, + branchEntries, + signal: new AbortController().signal, + }, + harness.ctx, + ); + expect(result).toMatchObject({ cancel: true }); + expect(result).not.toHaveProperty("compaction"); }); it("keeps manual auth failures fail-closed", async () => { From 29f8d9ea010a741a842dc7f283f8f03690d4c55c Mon Sep 17 00:00:00 2001 From: GunP4ng <140302315+GunP4ng@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:00:02 +0000 Subject: [PATCH 2/2] fix(compaction): gate pre-prompt fallback at hard cap Preserve full context when proactive below-cap pre-prompt compaction fails while retaining deterministic recovery at the effective hard limit. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../extensions/builtin/compaction/changes.md | 12 ++++--- .../builtin/compaction/extension-wiring.ts | 13 +++++-- .../extensions/builtin/compaction/index.ts | 6 ++-- ...-compaction-deterministic-fallback.test.ts | 36 ++++++++++++++++++- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md index 976af5d4d5..6777f9e12a 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -29,8 +29,9 @@ ### What changed -- Mandatory `pre_prompt` compaction now enters the same deterministic failure - recovery route as manual, threshold, and overflow compaction. +- `pre_prompt` compaction now enters deterministic failure recovery only when + known usage has reached the effective hard cap. A failed proactive + below-cap attempt remains fail-closed and preserves the full context. - When every prepared or earlier boundary retains unsafe split-turn content, deterministic recovery now scans forward and selects the earliest suffix that already passes the existing replay-safety, atomic tool-chain, and effective @@ -38,9 +39,10 @@ ### Why -- A resumed session above its compaction threshold could stall during provider - summarization, reject compaction, and then fail admission without attempting - the deterministic fallback. +- A resumed session at its hard cap could stall during provider summarization, + reject compaction, and then fail admission without attempting the + deterministic fallback. Below the cap, provider admission remains possible, + so destructive recovery would lose context without a liveness benefit. - Long split turns can contain an unsafe historical tool result with no later user boundary. Scanning only prepared, user, and earlier boundaries retains that unsafe result forever even when a later assistant boundary is safe. diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts index 7559abce4e..fbbf717023 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ContextUsage, ExtensionContext, MessageEndEvent, SessionBeforeCompactEvent } from "../../types.ts"; import * as checkpointState from "./checkpoint-state.ts"; +import { resolveEffectiveReserveTokens } from "./policy.ts"; import type { SpeculativeCompactionResult, SpeculativeCompactionSnapshot } from "./speculative.ts"; const IMAGE_PROMPT_TOKEN_ESTIMATE = 1_200; @@ -43,8 +44,16 @@ export function isAbortedAssistantMessage(event: { message: AgentMessage }): boo return event.message.role === "assistant" && "stopReason" in event.message && event.message.stopReason === "aborted"; } -export function isRequiredCompactionFallbackReason(reason: SessionBeforeCompactEvent["reason"]): boolean { - return reason === "manual" || reason === "threshold" || reason === "overflow" || reason === "pre_prompt"; +export function requiresDeterministicCompactionFallback( + event: SessionBeforeCompactEvent, + usage: ContextUsage | undefined, +): boolean { + if (event.reason === "manual" || event.reason === "threshold" || event.reason === "overflow") return true; + if (event.reason !== "pre_prompt" || usage === undefined || usage.tokens === null || usage.contextWindow <= 0) { + return false; + } + const reserveTokens = resolveEffectiveReserveTokens(usage.contextWindow, event.preparation.settings); + return usage.tokens >= usage.contextWindow - reserveTokens; } export function recentCheckpoint(ctx: ExtensionContext): checkpointState.AgentCheckpoint | null { diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts index ead8f8bc57..4bd9b1d1bb 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -82,9 +82,9 @@ import { getPromptContextWindow, isAbortedAssistantMessage, isMonitorableMessageEvent, - isRequiredCompactionFallbackReason, linkAbortSignal, recentCheckpoint, + requiresDeterministicCompactionFallback, withAdditionalTokens, } from "./extension-wiring.ts"; import { isIneffectiveCompaction } from "./yield.ts"; @@ -684,7 +684,7 @@ export default function compactionExtension( } if ( warmFailure !== undefined && - isRequiredCompactionFallbackReason(event.reason) && + requiresDeterministicCompactionFallback(event, ctx.getContextUsage()) && classifyRequiredCompactionFallbackFailure(warmFailure) !== undefined && !event.signal.aborted && speculativeGeneration === claimedGeneration && @@ -717,7 +717,7 @@ export default function compactionExtension( const message = error instanceof Error ? error.message : String(error); const failureKind = classifyRequiredCompactionFallbackFailure(error); if ( - isRequiredCompactionFallbackReason(event.reason) && + requiresDeterministicCompactionFallback(event, ctx.getContextUsage()) && failureKind !== undefined && !event.signal.aborted ) { diff --git a/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts b/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts index 84b399eb4d..933dc94ec5 100644 --- a/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts +++ b/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts @@ -224,7 +224,7 @@ describe("required compaction deterministic fallback", () => { it("recovers mandatory pre-prompt compaction after a typed summarizer failure", async () => { const handlers = createCompactionHandlers(); - const harness = createBlockingContext({ usageTokens: 9_900 }); + const harness = createBlockingContext({ usageTokens: 9_600 }); harness.registration.setResponses([ fauxAssistantMessage("", { stopReason: "error", @@ -260,6 +260,40 @@ describe("required compaction deterministic fallback", () => { expect(harness.registration.getCallLog()).toHaveLength(1); }); + it("preserves full context when proactive pre-prompt compaction fails below the hard cap", async () => { + const handlers = createCompactionHandlers(); + const harness = createBlockingContext({ usageTokens: 9_599 }); + harness.registration.setResponses([ + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "upstream_stream_truncated: Responses stream ended before a terminal event", + }), + ]); + const branchEntries = harness.ctx.sessionManager.getBranch(); + const preparation = { + ...prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true)!, + firstKeptEntryId: branchEntries.at(-1)?.id ?? "", + }; + + const result = await handlers.sessionBeforeCompact( + { + type: "session_before_compact", + reason: "pre_prompt", + willRetry: false, + requestId: "pre-prompt-proactive-fail-closed", + preparation, + branchEntries, + signal: new AbortController().signal, + }, + harness.ctx, + ); + + expect(result).toMatchObject({ cancel: true }); + expect(result).not.toHaveProperty("compaction"); + expect(harness.ctx.sessionManager.getBranch()).toEqual(branchEntries); + expect(harness.registration.getCallLog()).toHaveLength(1); + }); + it("classifies a duration watchdog without sleeping", () => { expect(classifyRequiredCompactionFallbackFailure(new StreamDurationBudgetError(120_000))).toBe( "summarization-timeout",