diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a28c60c7b..8ca040bec 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -24,6 +24,9 @@ ### Fixed - Fixed toggling `display.showTokenUsage` from `/settings` leaving existing token-usage rows stale until the transcript was rebuilt. +### Fixed + +- 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 4a62968fb..ca4747e8c 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -313,6 +313,23 @@ export class SessionMaintenance { set skipPostTurnMaintenanceAssistantTimestamp(timestamp: number | undefined) { this.#skipPostTurnMaintenanceAssistantTimestamp = timestamp; } + + /** + * 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); + 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 +1167,7 @@ export class SessionMaintenance { suppressHandoff: true, triggerContextTokens: contextTokens, phase: "mid_turn", + detachPostCommit: true, }); if (result.automaticContinuationBlocked) { this.#midTurnCompactionDeadEnds.add(activeMessages); @@ -2166,6 +2184,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 +2211,7 @@ export class SessionMaintenance { terminalTextAnswer, options.triggerContextTokens, suppressContinuation, + options.detachPostCommit === true, ); if (outcome !== "fallback") return outcome; fallbackFromShake = true; @@ -2252,7 +2273,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, false); if (action === "handoff") { let handoffSwitchCancelled = false; const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; @@ -2266,13 +2288,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 +2306,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 +2332,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 +2445,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 +2506,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 +2803,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 +2847,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 +2962,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 +2986,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 +3041,7 @@ export class SessionMaintenance { terminalTextAnswer: boolean, triggerContextTokens?: number, suppressContinuation = false, + detachPostCommit = false, ): Promise { const action = "shake"; this.#autoCompactionAbortController?.abort(); @@ -2987,16 +3049,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 }, false); 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 +3104,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 +3167,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");