fix(compaction): resolve retry before goal continuation return

Active-goal threshold compaction can pre-empt the normal post-turn tail
and return once it schedules a deferred handoff or auto-continue. When
that turn is the successful response from an auto-retry, returning there
skips the later retry-gate cleanup and leaves isRetrying stuck.

Resolve the completed retry gate before the compaction-continuation
return, and cover the retry-success-over-threshold path so future
changes cannot strand prompt()/waitForIdle() behind a stale retry state.

Refs #3174
This commit is contained in:
roboomp
2026-06-21 09:08:59 +00:00
parent e70c71077e
commit eef2f7b7e9
3 changed files with 102 additions and 1 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Fixed
- Fixed `/goal` threshold auto-compaction skipping real sessions through three paths: per-turn supersede/drop-useless pruning no longer deflates the threshold trigger below the last provider-billed context; active-goal text stops now attempt threshold maintenance before unexpected-stop retry continuations can return from post-turn handling; and empty `toolUse` stops keep the existing cleanup pass that strips the orphan assistant from active context + session history before any compaction continuation. Added `agent_end maintenance routing` and `Auto-compaction threshold decision` debug logs so future no-start reports identify the exact early-return branch and the billed/stored/resolved/post-maintenance token counts that fed `shouldCompact`. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174))
- Fixed `/goal` threshold auto-compaction skipping real sessions through three paths: per-turn supersede/drop-useless pruning no longer deflates the threshold trigger below the last provider-billed context; active-goal text stops now attempt threshold maintenance before unexpected-stop retry continuations can return from post-turn handling; and empty `toolUse` stops keep the existing cleanup pass that strips the orphan assistant from active context + session history before any compaction continuation. Active-goal compaction continuations now also resolve completed retry gates before returning, preventing `isRetrying` from staying stuck after a retry succeeds over the threshold. Added `agent_end maintenance routing` and `Auto-compaction threshold decision` debug logs so future no-start reports identify the exact early-return branch and the billed/stored/resolved/post-maintenance token counts that fed `shouldCompact`. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174))
## [16.1.10] - 2026-06-21
@@ -2855,6 +2855,7 @@ export class AgentSession {
deferredHandoff: compactionResult.deferredHandoff,
continuationScheduled: compactionResult.continuationScheduled,
});
this.#resolveRetry();
await emitAgentEndNotification();
return;
}
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
@@ -472,6 +473,105 @@ describe("AgentSession auto-compaction queue resume", () => {
expect(getRuntimeSignals()).toContain("compaction:start:threshold");
});
it("resolves a pending retry before active-goal compaction continuation returns", async () => {
// Codex review on #3175: a retry can succeed with a non-empty text stop
// that is already over the active-goal compaction threshold. If the
// compaction pre-empt schedules its own continuation before the normal
// bottom-of-handler `#resolveRetry()` call runs, the session stays
// `isRetrying` and later prompt/idle gates remain blocked.
vi.useRealTimers();
const now = Date.now();
session.setGoalModeState({
enabled: true,
mode: "active",
goal: {
id: "goal-retry-threshold",
objective: "recover from retry and compact",
status: "active",
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
},
});
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.autoContinue", true);
session.settings.set("contextPromotion.enabled", false);
session.settings.set("retry.enabled", true);
session.settings.set("retry.baseDelayMs", 5);
session.settings.set("retry.maxDelayMs", 5_000);
session.settings.set("retry.maxRetries", 1);
session.settings.set("retry.modelFallback", false);
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});
const { promise: retryStarted, resolve: onRetryStarted } = Promise.withResolvers<void>();
const { promise: retryEnded, resolve: onRetryEnded } = Promise.withResolvers<void>();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_retry_start") onRetryStarted();
if (event.type === "auto_retry_end") onRetryEnded();
if (event.type === "auto_compaction_end") onCompactionDone();
});
const retryableError = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "Transient provider failure." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "error" as const,
errorMessage: "503 service unavailable: overloaded_error retry-after-ms=50",
usage: {
input: 100,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now - 1,
};
session.agent.emitExternalEvent({ type: "message_end", message: retryableError });
session.agent.emitExternalEvent({ type: "agent_end", messages: [retryableError] });
await withTimeout(retryStarted, 1000, "Retry start timed out");
expect(session.isRetrying).toBe(true);
const recoveredOverThreshold = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "Recovered; continuing the active goal." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "stop" as const,
usage: {
input: 5000,
output: 1000,
cacheRead: 85000,
cacheWrite: 0,
totalTokens: 91000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now,
};
session.agent.emitExternalEvent({ type: "message_end", message: recoveredOverThreshold });
await withTimeout(retryEnded, 1000, "Retry end timed out");
expect(session.isRetrying).toBe(true);
session.agent.emitExternalEvent({ type: "agent_end", messages: [recoveredOverThreshold] });
await withTimeout(compactionDone, 1000, "Compaction end timed out");
await session.waitForIdle();
expect(getRuntimeSignals()).toContain("compaction:start:threshold");
expect(session.isRetrying).toBe(false);
});
it("removes orphan toolUse assistant before active-goal threshold compaction continuation", async () => {
// Codex review on #3175: when an active goal turn is over threshold AND
// stops with an empty `toolUse` (no tool call), the new ordering must NOT