From 276f32d4e0e00632f7e0fb4cb3767b2a2604e949 Mon Sep 17 00:00:00 2001 From: asdfqwerzxcc Date: Mon, 14 Sep 2026 00:42:17 +0900 Subject: [PATCH 1/4] fix(coding-agent): stop retry watchdog after recovery Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../coding-agent/src/core/agent-session.ts | 1 + .../src/core/provider-timeout-retry.ts | 4 +- .../provider-idle-recovery.test.ts | 96 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 45ff863db4..b163991524 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -6844,6 +6844,7 @@ export class AgentSession { } }, getActiveSignal: () => this.agent.signal, + isRetryRequestPending: () => this._retryAttempt > 0, abortActive: () => this.agent.abort( new ProviderRetryWatchdogAbortError( diff --git a/packages/coding-agent/src/core/provider-timeout-retry.ts b/packages/coding-agent/src/core/provider-timeout-retry.ts index 19b876fc82..92a66f726c 100644 --- a/packages/coding-agent/src/core/provider-timeout-retry.ts +++ b/packages/coding-agent/src/core/provider-timeout-retry.ts @@ -39,6 +39,7 @@ function reconcileWatchdogTimeoutMs( export interface BoundedRetryContinuation { continueRun(): Promise; getActiveSignal(): AbortSignal | undefined; + isRetryRequestPending?(): boolean; abortActive(): void; timeoutMs: number | undefined; } @@ -76,6 +77,7 @@ export function createProviderTimeoutRetryPlan({ export async function runBoundedRetryContinuation({ continueRun, getActiveSignal, + isRetryRequestPending, abortActive, timeoutMs, }: BoundedRetryContinuation): Promise { @@ -87,7 +89,7 @@ export async function runBoundedRetryContinuation({ } const timer = setTimeout(() => { - if (getActiveSignal() === ownedSignal) { + if (getActiveSignal() === ownedSignal && (isRetryRequestPending?.() ?? true)) { abortActive(); } }, timeoutMs); diff --git a/packages/coding-agent/test/suite/regressions/provider-idle-recovery.test.ts b/packages/coding-agent/test/suite/regressions/provider-idle-recovery.test.ts index 5c0a32dc4d..03c48507ca 100644 --- a/packages/coding-agent/test/suite/regressions/provider-idle-recovery.test.ts +++ b/packages/coding-agent/test/suite/regressions/provider-idle-recovery.test.ts @@ -1,9 +1,12 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; import { type AssistantMessage, type AssistantMessageEvent, EventStream, fauxAssistantMessage, + fauxToolCall, } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createHarness, type Harness } from "../harness.ts"; @@ -113,6 +116,99 @@ describe("provider idle recovery", () => { ]); }); + // The screenshot showed foreground eval aborting without any further user input. + // Its 276.3s duration is compatible with a leftover continuation deadline, not + // an eval hard limit: provider recovery must stop that timer before local work. + it("does not abort a recovered retry's long-running tool before provider call 3 receives its result", async () => { + vi.useFakeTimers(); + const retryTimeoutMs = 120_000; + const toolDurationMs = 276_300; + const toolEntered = createDeferred(); + const releaseTool = createDeferred(); + let toolSignal: AbortSignal | undefined; + const toolResult = { content: [{ type: "text" as const, text: "local-eval-result" }], details: {} }; + const tool: AgentTool = { + name: "eval", + label: "Eval", + description: "Run deferred local work", + parameters: Type.Object({}), + async execute(_toolCallId, _params, signal) { + toolSignal = signal; + toolEntered.resolve(); + await releaseTool.promise; + return toolResult; + }, + }; + const harness = await createHarness({ + tools: [tool], + settings: { + retry: { + enabled: true, + modelFallback: false, + maxRetries: 1, + baseDelayMs: 0, + provider: { streamRetryTimeoutMs: retryTimeoutMs }, + }, + }, + }); + harnesses.push(harness); + harness.agent.timeoutMs = DEFAULT_PROVIDER_IDLE_TIMEOUT_MS; + harness.agent.streamStartTimeoutMs = DEFAULT_STREAM_START_TIMEOUT_MS; + harness.setResponses([ + idleTimeoutError(), + fauxAssistantMessage(fauxToolCall("eval", {}, { id: "recovered-eval" }), { stopReason: "toolUse" }), + fauxAssistantMessage("completed"), + ]); + const retryStarted = createDeferred(); + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "auto_retry_start") retryStarted.resolve(); + }); + + const prompt = harness.session.prompt("run the local evaluation"); + try { + await retryStarted.promise; + await vi.advanceTimersByTimeAsync(0); + await toolEntered.promise; + const parentSignal = harness.agent.signal; + expect(parentSignal).toBeDefined(); + expect(toolSignal).toBe(parentSignal); + expect(harness.eventsOfType("auto_retry_end")).toMatchObject([{ success: true, attempt: 1 }]); + expect(harness.faux.getCallLog()).toHaveLength(2); + const settledWork = harness.session.waitForSettledSessionWork(); + + await vi.advanceTimersByTimeAsync(retryTimeoutMs - 1); + expect(parentSignal?.aborted).toBe(false); + expect(harness.eventsOfType("tool_execution_end")).toHaveLength(0); + await vi.advanceTimersByTimeAsync(2); + expect.soft(parentSignal?.aborted, "recovered provider watchdog must not abort the parent Agent").toBe(false); + + await vi.advanceTimersByTimeAsync(toolDurationMs - retryTimeoutMs - 1); + releaseTool.resolve(); + await settledWork; + await prompt; + + const calls = harness.faux.getCallLog(); + expect(calls, "tool result must reach provider call 3 without another user prompt").toHaveLength(3); + expect(calls[2].context.messages).toContainEqual( + expect.objectContaining({ + role: "toolResult", + toolCallId: "recovered-eval", + isError: false, + content: toolResult.content, + }), + ); + expect(parentSignal?.aborted).toBe(false); + expect(harness.session.messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + expect(harness.session.isStreaming).toBe(false); + } finally { + unsubscribe(); + releaseTool.resolve(); + if (harness.session.isStreaming) await harness.session.abort(); + await prompt; + await harness.session.waitForSettledSessionWork(); + } + }); + it("bounds a hung retry continuation after a provider transport timeout", async () => { vi.useFakeTimers(); const retryTimeoutMs = 1_000; From 7363bb32576e30e45b7a9291401d643c89cc42d7 Mon Sep 17 00:00:00 2001 From: asdfqwerzxcc Date: Mon, 14 Sep 2026 00:42:27 +0900 Subject: [PATCH 2/4] docs(coding-agent): record retry watchdog ownership Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/src/changes.md | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 339582361b..fa4668ab03 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,5 +1,37 @@ # changes +## 2026-09-13 - End provider retry watchdog ownership at recovery + +### What changed + +- `packages/coding-agent/src/core/provider-timeout-retry.ts` accepts an + `isRetryRequestPending` ownership check before its continuation watchdog aborts + the active Agent. +- `packages/coding-agent/src/core/agent-session.ts` reports that ownership from + retry-attempt state, which resets when the recovered provider response reaches + `message_end`, before any returned local tool executes. + +### Why + +- A successful timeout retry could return a long-running `eval` tool call while + the retry continuation's absolute watchdog remained armed. When the old deadline + expired, it aborted the parent Agent after `auto_retry_end { success: true }`, + producing `eval ? error`, `Tool execution aborted`, and a stopped session without + user input. + +### Why an extension could not handle it + +- Retry ownership and the parent Agent abort are decided inside AgentSession before + extension result hooks can distinguish a recovered provider from a still-wedged + retry continuation. + +### Expected merge conflict zones + +- LOW: the watchdog callback in + `packages/coding-agent/src/core/provider-timeout-retry.ts`. +- LOW: the `runBoundedRetryContinuation()` arguments in + `packages/coding-agent/src/core/agent-session.ts`. + ## 2026-09-13 - Centralize standalone provider registration ### What changed From 244f6c3ff27ec7afe8324602735b57d065ce6692 Mon Sep 17 00:00:00 2001 From: asdfqwerzxcc Date: Mon, 14 Sep 2026 00:42:37 +0900 Subject: [PATCH 3/4] docs(coding-agent): note recovered retry tool fix Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 948d69b084..2839ceb6cf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Fixed a recovered provider-timeout retry aborting a long-running local tool when the old retry-continuation watchdog expired, which could stop the session with `Tool execution aborted` after the provider had already succeeded. + ### Removed ## [2026.9.13-2] - 2026-09-13 From 168f2eec98cd87774aea62981c47ab506318bac2 Mon Sep 17 00:00:00 2001 From: asdfqwerzxcc Date: Mon, 14 Sep 2026 00:45:42 +0900 Subject: [PATCH 4/4] docs(coding-agent): move watchdog tracker to core Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/src/changes.md | 32 ----------------------- packages/coding-agent/src/core/changes.md | 32 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index fa4668ab03..339582361b 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,37 +1,5 @@ # changes -## 2026-09-13 - End provider retry watchdog ownership at recovery - -### What changed - -- `packages/coding-agent/src/core/provider-timeout-retry.ts` accepts an - `isRetryRequestPending` ownership check before its continuation watchdog aborts - the active Agent. -- `packages/coding-agent/src/core/agent-session.ts` reports that ownership from - retry-attempt state, which resets when the recovered provider response reaches - `message_end`, before any returned local tool executes. - -### Why - -- A successful timeout retry could return a long-running `eval` tool call while - the retry continuation's absolute watchdog remained armed. When the old deadline - expired, it aborted the parent Agent after `auto_retry_end { success: true }`, - producing `eval ? error`, `Tool execution aborted`, and a stopped session without - user input. - -### Why an extension could not handle it - -- Retry ownership and the parent Agent abort are decided inside AgentSession before - extension result hooks can distinguish a recovered provider from a still-wedged - retry continuation. - -### Expected merge conflict zones - -- LOW: the watchdog callback in - `packages/coding-agent/src/core/provider-timeout-retry.ts`. -- LOW: the `runBoundedRetryContinuation()` arguments in - `packages/coding-agent/src/core/agent-session.ts`. - ## 2026-09-13 - Centralize standalone provider registration ### What changed diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 175634208b..8728f8408b 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,37 @@ # changes +## 2026-09-13 - End provider retry watchdog ownership at recovery + +### What changed + +- `packages/coding-agent/src/core/provider-timeout-retry.ts` accepts an + `isRetryRequestPending` ownership check before its continuation watchdog aborts + the active Agent. +- `packages/coding-agent/src/core/agent-session.ts` reports that ownership from + retry-attempt state, which resets when the recovered provider response reaches + `message_end`, before any returned local tool executes. + +### Why + +- A successful timeout retry could return a long-running `eval` tool call while + the retry continuation's absolute watchdog remained armed. When the old deadline + expired, it aborted the parent Agent after `auto_retry_end { success: true }`, + producing `eval ? error`, `Tool execution aborted`, and a stopped session without + user input. + +### Why an extension could not handle it + +- Retry ownership and the parent Agent abort are decided inside AgentSession before + extension result hooks can distinguish a recovered provider from a still-wedged + retry continuation. + +### Expected merge conflict zones + +- LOW: the watchdog callback in + `packages/coding-agent/src/core/provider-timeout-retry.ts`. +- LOW: the `runBoundedRetryContinuation()` arguments in + `packages/coding-agent/src/core/agent-session.ts`. + ## 2026-09-13 - Session cwd and authoritative goal-store environment (#1663)