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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user