Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6844,6 +6844,7 @@ export class AgentSession {
}
},
getActiveSignal: () => this.agent.signal,
isRetryRequestPending: () => this._retryAttempt > 0,
abortActive: () =>
this.agent.abort(
new ProviderRetryWatchdogAbortError(
Expand Down
32 changes: 32 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
4 changes: 3 additions & 1 deletion packages/coding-agent/src/core/provider-timeout-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function reconcileWatchdogTimeoutMs(
export interface BoundedRetryContinuation {
continueRun(): Promise<void>;
getActiveSignal(): AbortSignal | undefined;
isRetryRequestPending?(): boolean;
abortActive(): void;
timeoutMs: number | undefined;
}
Expand Down Expand Up @@ -76,6 +77,7 @@ export function createProviderTimeoutRetryPlan({
export async function runBoundedRetryContinuation({
continueRun,
getActiveSignal,
isRetryRequestPending,
abortActive,
timeoutMs,
}: BoundedRetryContinuation): Promise<void> {
Expand All @@ -87,7 +89,7 @@ export async function runBoundedRetryContinuation({
}

const timer = setTimeout(() => {
if (getActiveSignal() === ownedSignal) {
if (getActiveSignal() === ownedSignal && (isRetryRequestPending?.() ?? true)) {
abortActive();
}
}, timeoutMs);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
Expand Down