From 8344a0e82337f858fbb723f90758af33ed779b3f Mon Sep 17 00:00:00 2001 From: Valentin De Matos Date: Fri, 14 Aug 2026 19:45:33 -0700 Subject: [PATCH 1/3] fix: stop mid-run compaction from awaiting lifecycle emits Detached auto-compaction start/end (and shake) so a hung auto_compaction_end handler cannot pin the next provider call. --- packages/coding-agent/CHANGELOG.md | 4 + .../src/session/session-maintenance.ts | 301 +++++++++++------- ...ent-session-goal-midrun-compaction.test.ts | 53 +++ 3 files changed, 243 insertions(+), 115 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5597d222b..52a26a6d5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed mid-run auto-compaction waiting on `auto_compaction_end` extension handlers before the next provider call, which could hang the live loop after a snapcompact or context-full pass. + ## [17.3.4] - 2026-08-14 ### Changed diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 4a62968fb..fd808516f 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -313,6 +313,17 @@ export class SessionMaintenance { set skipPostTurnMaintenanceAssistantTimestamp(timestamp: number | undefined) { this.#skipPostTurnMaintenanceAssistantTimestamp = timestamp; } + #emitLifecycleEvent(event: AgentSessionEvent, detach: boolean): Promise { + const emit = this.#host.emitSessionEvent(event); + if (!detach) return emit; + void emit.catch(error => { + logger.warn("Detached compaction lifecycle emit failed", { + type: event.type, + error: error instanceof Error ? error.message : String(error), + }); + }); + return Promise.resolve(); + } /** * Append plan-read protection to a prune/shake config so the active plan * file survives compaction alongside skill reads (the config defaults @@ -1150,6 +1161,7 @@ export class SessionMaintenance { suppressHandoff: true, triggerContextTokens: contextTokens, phase: "mid_turn", + detachPostCommit: true, }); if (result.automaticContinuationBlocked) { this.#midTurnCompactionDeadEnds.add(activeMessages); @@ -2166,6 +2178,8 @@ export class SessionMaintenance { suppressHandoff?: boolean; phase?: CodexCompactionContext["phase"]; terminalTextAnswer?: boolean; + /** Mid-turn: splice history then return; do not await UI/extension fan-out. */ + detachPostCommit?: boolean; } = {}, ): Promise { const compactionSettings = this.#host.settings.getGroup("compaction"); @@ -2191,6 +2205,7 @@ export class SessionMaintenance { terminalTextAnswer, options.triggerContextTokens, suppressContinuation, + options.detachPostCommit === true, ); if (outcome !== "fallback") return outcome; fallbackFromShake = true; @@ -2252,7 +2267,8 @@ export class SessionMaintenance { // for any listener — and for input routed during this emit's event-loop yield: // a message typed as the compaction loader appears must land in the compaction // queue, not the core steering queue (which handoff's agent.reset() would wipe). - await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action }); + const startEvent = { type: "auto_compaction_start" as const, reason, action }; + await this.#emitLifecycleEvent(startEvent, options.detachPostCommit === true); if (action === "handoff") { let handoffSwitchCancelled = false; const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; @@ -2266,13 +2282,16 @@ export class SessionMaintenance { if (!handoffResult) { const aborted = autoCompactionSignal.aborted || handoffSwitchCancelled; if (aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", { @@ -2281,13 +2300,16 @@ export class SessionMaintenance { action = "context-full"; } if (handoffResult) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + }, + options.detachPostCommit === true, + ); const continuationScheduled = !autoCompactionSignal.aborted && this.#host.scheduleCompactionContinuation({ @@ -2304,27 +2326,33 @@ export class SessionMaintenance { } if (!this.#model) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - skipped: true, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + skipped: true, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } const availableModels = this.#host.modelRegistry.getAvailable(); if (availableModels.length === 0) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - skipped: true, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + skipped: true, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } @@ -2411,17 +2439,20 @@ export class SessionMaintenance { // compaction entry — surface it as a real (non-skipped) result so // the TUI rebuilds the transcript instead of treating the pass as // a benign no-op. - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: frameRescueResult && { - ...frameRescueResult, - preserveData: snapcompact.stripPreservedArchive(frameRescueResult.preserveData), + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: frameRescueResult && { + ...frameRescueResult, + preserveData: snapcompact.stripPreservedArchive(frameRescueResult.preserveData), + }, + aborted: false, + willRetry: false, + skipped: frameRescueResult === undefined, }, - aborted: false, - willRetry: false, - skipped: frameRescueResult === undefined, - }); + options.detachPostCommit === true, + ); let continuationScheduled = false; if (frameRescueCreatedHeadroom) { continuationScheduled = this.#host.scheduleCompactionContinuation({ @@ -2469,13 +2500,16 @@ export class SessionMaintenance { })) as SessionBeforeCompactResult | undefined; if (hookResult?.cancel) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } @@ -2763,13 +2797,16 @@ export class SessionMaintenance { } if (autoCompactionSignal.aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } @@ -2804,11 +2841,20 @@ export class SessionMaintenance { | undefined; if (this.#host.extensionRunner && savedCompactionEntry) { - await this.#host.extensionRunner.emit({ + const compactEmit = this.#host.extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, }); + if (options.detachPostCommit) { + void compactEmit.catch(error => { + logger.warn("Detached session_compact emit failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } else { + await compactEmit; + } } const result: CompactionResult = { @@ -2910,7 +2956,10 @@ export class SessionMaintenance { } } - await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry }); + await this.#emitLifecycleEvent( + { type: "auto_compaction_end", action, result, aborted: false, willRetry }, + options.detachPostCommit === true, + ); if (retryFits) { this.#host.scheduleAgentContinue({ delayMs: 100, generation }); @@ -2931,29 +2980,35 @@ export class SessionMaintenance { return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; } catch (error) { if (autoCompactionSignal.aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } const errorMessage = error instanceof Error ? error.message : "compaction failed"; - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - errorMessage: - reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : reason === "incomplete" - ? `Incomplete response recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : reason === "incomplete" + ? `Incomplete response recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`, + }, + options.detachPostCommit === true, + ); } finally { if (this.#autoCompactionAbortController === autoCompactionAbortController) { this.#autoCompactionAbortController = undefined; @@ -2980,6 +3035,7 @@ export class SessionMaintenance { terminalTextAnswer: boolean, triggerContextTokens?: number, suppressContinuation = false, + detachPostCommit = false, ): Promise { const action = "shake"; this.#autoCompactionAbortController?.abort(); @@ -2987,16 +3043,19 @@ export class SessionMaintenance { this.#autoCompactionAbortController = controller; const signal = controller.signal; try { - await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action }); + await this.#emitLifecycleEvent({ type: "auto_compaction_start", reason, action }, detachPostCommit); const result = await this.#host.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal }); if (signal.aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + detachPostCommit, + ); return COMPACTION_CHECK_NONE; } const reclaimed = result.toolResultsDropped + result.blocksDropped > 0; @@ -3039,25 +3098,31 @@ export class SessionMaintenance { const errorMessage = reclaimed ? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.` : "Auto-shake found nothing eligible to drop; falling back to context-full compaction."; - await this.#host.emitSessionEvent({ + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + skipped: !reclaimed, + errorMessage, + }, + detachPostCommit, + ); + return "fallback"; + } + await this.#emitLifecycleEvent( + { type: "auto_compaction_end", action, result: undefined, aborted: false, - willRetry: false, + willRetry, skipped: !reclaimed, - errorMessage, - }); - return "fallback"; - } - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry, - skipped: !reclaimed, - }); + }, + detachPostCommit, + ); let continuationScheduled = false; if (willRetry) { @@ -3096,25 +3161,31 @@ export class SessionMaintenance { }; } catch (error) { if (signal.aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + detachPostCommit, + ); return COMPACTION_CHECK_NONE; } const message = error instanceof Error ? error.message : "shake failed"; - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - errorMessage: message, - skipped: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: message, + skipped: false, + }, + detachPostCommit, + ); // Overflow still needs recovery even if shake threw. return reason === "overflow" ? "fallback" : COMPACTION_CHECK_NONE; } finally { diff --git a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts index 88922785d..f32eb8a45 100644 --- a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts @@ -496,6 +496,59 @@ describe("AgentSession mid-run threshold compaction", () => { expect(JSON.stringify(session.messages)).not.toContain("display-variant"); }); + it("does not wait for auto_compaction_end handlers before the next provider call", async () => { + const releaseCompactionEnd = Promise.withResolvers(); + const compactionEndEntered = Promise.withResolvers(); + const nextProviderCall = Promise.withResolvers(); + const extensionRunner = { + hasHandlers: vi.fn((eventType: string) => eventType === "auto_compaction_end"), + emitBeforeAgentStart: vi.fn(async () => undefined), + emit: vi.fn(async (event: { type: string }) => { + if (event.type === "auto_compaction_end") { + compactionEndEntered.resolve(); + await releaseCompactionEnd.promise; + } + }), + } as unknown as ExtensionRunner; + const { session, observedContexts } = await createHarness( + {}, + { + extensionRunner, + onProviderCall: index => { + if (index === 1) nextProviderCall.resolve(); + }, + }, + ); + const compactSpy = mockCompaction("MID-RUN-COMPACTED-WITHOUT-WAITING-ON-END"); + + const prompt = session.prompt("work on the release"); + const compactionEndOutcome = await raceWithTimeout( + compactionEndEntered.promise.then(() => "entered" as const), + 2_000, + "blocked" as const, + ); + const providerOutcome = + compactionEndOutcome === "entered" + ? await raceWithTimeout( + nextProviderCall.promise.then(() => "dispatched" as const), + 2_000, + "blocked" as const, + ) + : "blocked"; + releaseCompactionEnd.resolve(); + const promptOutcome = await raceWithTimeout( + prompt.then(() => "settled" as const), + 2_000, + "blocked" as const, + ); + + expect(compactionEndOutcome).toBe("entered"); + expect(providerOutcome).toBe("dispatched"); + expect(promptOutcome).toBe("settled"); + expect(compactSpy).toHaveBeenCalledTimes(1); + expect(observedContexts[1].join("\n")).toContain("MID-RUN-COMPACTED-WITHOUT-WAITING-ON-END"); + }); + it("does not compact mid-run outside goal mode when disabled", async () => { const { session } = await createHarness({ "compaction.midTurnEnabled": false }); const compactSpy = mockCompaction("SHOULD-NOT-RUN"); From eb8c854c7092af0e4ac218564453e2ff3632a4a2 Mon Sep 17 00:00:00 2001 From: Valentin De Matos Date: Fri, 14 Aug 2026 19:52:20 -0700 Subject: [PATCH 2/3] docs: add JSDoc on detached compaction lifecycle emit Private helpers in this file carry a short block comment; the new emit helper was the exception. --- packages/coding-agent/src/session/session-maintenance.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index fd808516f..d63469afa 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -313,6 +313,11 @@ export class SessionMaintenance { set skipPostTurnMaintenanceAssistantTimestamp(timestamp: number | undefined) { this.#skipPostTurnMaintenanceAssistantTimestamp = timestamp; } + + /** + * Emit a compaction lifecycle event. Mid-turn callers pass `detach` so a + * hung extension/UI handler cannot pin the live loop after history rewrite. + */ #emitLifecycleEvent(event: AgentSessionEvent, detach: boolean): Promise { const emit = this.#host.emitSessionEvent(event); if (!detach) return emit; From e4ec9f7cc8b38a5db1f81e992c2650144789cd45 Mon Sep 17 00:00:00 2001 From: Valentin De Matos Date: Fri, 14 Aug 2026 19:54:28 -0700 Subject: [PATCH 3/3] Keep auto_compaction_start awaited on the mid-run path Only the post-commit end/session_compact fan-out is detached. The start emit still waits so input during that yield lands in the compaction queue, matching the existing comment. --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/session/session-maintenance.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 52a26a6d5..a2fdc08c8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed mid-run auto-compaction waiting on `auto_compaction_end` extension handlers before the next provider call, which could hang the live loop after a snapcompact or context-full pass. +- Fixed mid-run auto-compaction waiting on `auto_compaction_end` / `session_compact` extension handlers before the next provider call, which could hang the live loop after a snapcompact or context-full pass. Mid-run those handlers now run concurrently with the next turn; `auto_compaction_start` is still awaited. ## [17.3.4] - 2026-08-14 diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index d63469afa..ca4747e8c 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -315,8 +315,9 @@ export class SessionMaintenance { } /** - * Emit a compaction lifecycle event. Mid-turn callers pass `detach` so a - * hung extension/UI handler cannot pin the live loop after history rewrite. + * Emit a compaction lifecycle event. Mid-turn callers detach only the + * post-commit `auto_compaction_end` / `session_compact` fan-out so a hung + * handler cannot pin the next provider call after history rewrite. */ #emitLifecycleEvent(event: AgentSessionEvent, detach: boolean): Promise { const emit = this.#host.emitSessionEvent(event); @@ -2273,7 +2274,7 @@ export class SessionMaintenance { // a message typed as the compaction loader appears must land in the compaction // queue, not the core steering queue (which handoff's agent.reset() would wipe). const startEvent = { type: "auto_compaction_start" as const, reason, action }; - await this.#emitLifecycleEvent(startEvent, options.detachPostCommit === true); + await this.#emitLifecycleEvent(startEvent, false); if (action === "handoff") { let handoffSwitchCancelled = false; const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; @@ -3048,7 +3049,7 @@ export class SessionMaintenance { this.#autoCompactionAbortController = controller; const signal = controller.signal; try { - await this.#emitLifecycleEvent({ type: "auto_compaction_start", reason, action }, detachPostCommit); + await this.#emitLifecycleEvent({ type: "auto_compaction_start", reason, action }, false); const result = await this.#host.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal }); if (signal.aborted) { await this.#emitLifecycleEvent(