Merge PR #8604: fix: stop mid-run compaction from hanging the live loop (@Thytu)

This commit is contained in:
can1357
2026-08-16 02:13:37 +02:00
3 changed files with 248 additions and 115 deletions
+3
View File
@@ -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
@@ -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<void> {
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<CompactionCheckResult> {
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<CompactionCheckResult | "fallback"> {
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 {
@@ -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<void>();
const compactionEndEntered = Promise.withResolvers<void>();
const nextProviderCall = Promise.withResolvers<void>();
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");