From 447a57ae0f05c1c540beab69290a386ca3aeb376 Mon Sep 17 00:00:00 2001 From: mastertyko <11311479+mastertyko@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:50:40 +0100 Subject: [PATCH] fix(gsd): resume auto-mode after transient provider pause (#2822) Transient provider recovery previously sent a hidden continue message after the backoff timer elapsed, but the auto loop had already exited. Resume the paused session through startAuto() instead so the timer actually restarts auto-mode, and cover the resumed, duplicate-resume, and missing-base-path cases with regression tests. Closes #2813 --- .../gsd/bootstrap/agent-end-recovery.ts | 9 +- .../gsd/bootstrap/provider-error-resume.ts | 53 ++++++++++ .../gsd/tests/provider-errors.test.ts | 98 +++++++++++++++++++ 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 src/resources/extensions/gsd/bootstrap/provider-error-resume.ts diff --git a/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts b/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts index 89de63a58..22dd56075 100644 --- a/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +++ b/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts @@ -7,6 +7,7 @@ import { pauseAutoForProviderError } from "../provider-error-pause.js"; import { isSessionSwitchInFlight, resolveAgentEnd } from "../auto-loop.js"; import { resolveModelId } from "../auto-model-selection.js"; import { clearDiscussionFlowState } from "./write-gate.js"; +import { resumeAutoAfterProviderDelay } from "./provider-error-resume.js"; import { classifyError, createRetryState, @@ -44,10 +45,10 @@ async function pauseTransientWithBackoff( retryAfterMs, resume: allowAutoResume ? () => { - pi.sendMessage( - { customType: "gsd-auto-timeout-recovery", content: "Continue execution — provider error recovery delay elapsed.", display: false }, - { triggerTurn: true }, - ); + void resumeAutoAfterProviderDelay(pi, ctx).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Provider error recovery delay elapsed, but auto-mode failed to resume: ${message}`, "error"); + }); } : undefined, }); diff --git a/src/resources/extensions/gsd/bootstrap/provider-error-resume.ts b/src/resources/extensions/gsd/bootstrap/provider-error-resume.ts new file mode 100644 index 000000000..35efdcbf5 --- /dev/null +++ b/src/resources/extensions/gsd/bootstrap/provider-error-resume.ts @@ -0,0 +1,53 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@gsd/pi-coding-agent"; + +import { getAutoDashboardData, startAuto, type AutoDashboardData } from "../auto.js"; + +type AutoResumeSnapshot = Pick; + +export interface ProviderErrorResumeDeps { + getSnapshot(): AutoResumeSnapshot; + startAuto( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + base: string, + verboseMode: boolean, + options?: { step?: boolean }, + ): Promise; +} + +const defaultDeps: ProviderErrorResumeDeps = { + getSnapshot: () => getAutoDashboardData(), + startAuto, +}; + +export async function resumeAutoAfterProviderDelay( + pi: ExtensionAPI, + ctx: ExtensionContext, + deps: ProviderErrorResumeDeps = defaultDeps, +): Promise<"resumed" | "already-active" | "not-paused" | "missing-base"> { + const snapshot = deps.getSnapshot(); + + if (snapshot.active) return "already-active"; + if (!snapshot.paused) return "not-paused"; + + if (!snapshot.basePath) { + ctx.ui.notify( + "Provider error recovery delay elapsed, but no paused auto-mode base path was available. Leaving auto-mode paused.", + "warning", + ); + return "missing-base"; + } + + await deps.startAuto( + ctx as ExtensionCommandContext, + pi, + snapshot.basePath, + false, + { step: snapshot.stepMode }, + ); + return "resumed"; +} diff --git a/src/resources/extensions/gsd/tests/provider-errors.test.ts b/src/resources/extensions/gsd/tests/provider-errors.test.ts index dfe07867c..832cea206 100644 --- a/src/resources/extensions/gsd/tests/provider-errors.test.ts +++ b/src/resources/extensions/gsd/tests/provider-errors.test.ts @@ -12,6 +12,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { classifyError, isTransient, isTransientNetworkError } from "../error-classifier.ts"; import { pauseAutoForProviderError } from "../provider-error-pause.ts"; +import { resumeAutoAfterProviderDelay } from "../bootstrap/provider-error-resume.ts"; import { getNextFallbackModel } from "../preferences.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -268,6 +269,90 @@ test("pauseAutoForProviderError falls back to indefinite pause when not rate lim ]); }); +// ── resumeAutoAfterProviderDelay ──────────────────────────────────────────── + +test("resumeAutoAfterProviderDelay restarts paused auto-mode from the recorded base path", async () => { + const startCalls: Array<{ base: string; verboseMode: boolean; step?: boolean }> = []; + const result = await resumeAutoAfterProviderDelay( + {} as any, + { ui: { notify() {} } } as any, + { + getSnapshot: () => ({ + active: false, + paused: true, + stepMode: true, + basePath: "/tmp/project", + }), + startAuto: async (_ctx, _pi, base, verboseMode, options) => { + startCalls.push({ base, verboseMode, step: options?.step }); + }, + }, + ); + + assert.equal(result, "resumed"); + assert.deepEqual(startCalls, [ + { base: "/tmp/project", verboseMode: false, step: true }, + ]); +}); + +test("resumeAutoAfterProviderDelay does not double-start when auto-mode is already active", async () => { + let startCalls = 0; + const result = await resumeAutoAfterProviderDelay( + {} as any, + { ui: { notify() {} } } as any, + { + getSnapshot: () => ({ + active: true, + paused: false, + stepMode: false, + basePath: "/tmp/project", + }), + startAuto: async () => { + startCalls += 1; + }, + }, + ); + + assert.equal(result, "already-active"); + assert.equal(startCalls, 0); +}); + +test("resumeAutoAfterProviderDelay leaves auto paused when no base path is available", async () => { + const notifications: Array<{ message: string; level: string }> = []; + let startCalls = 0; + + const result = await resumeAutoAfterProviderDelay( + {} as any, + { + ui: { + notify(message: string, level?: string) { + notifications.push({ message, level: level ?? "info" }); + }, + }, + } as any, + { + getSnapshot: () => ({ + active: false, + paused: true, + stepMode: false, + basePath: "", + }), + startAuto: async () => { + startCalls += 1; + }, + }, + ); + + assert.equal(result, "missing-base"); + assert.equal(startCalls, 0); + assert.deepEqual(notifications, [ + { + message: "Provider error recovery delay elapsed, but no paused auto-mode base path was available. Leaving auto-mode paused.", + level: "warning", + }, + ]); +}); + // ── Escalating backoff for transient errors (#1166) ───────────────────────── test("agent-end-recovery.ts tracks consecutive transient errors for escalating backoff", () => { @@ -303,6 +388,19 @@ test("agent-end-recovery.ts applies escalating delay for repeated transient erro ); }); +test("agent-end-recovery.ts resumes transient provider pauses through startAuto instead of a hidden prompt", () => { + const src = readFileSync(join(__dirname, "..", "bootstrap", "agent-end-recovery.ts"), "utf-8"); + + assert.ok( + src.includes("resumeAutoAfterProviderDelay"), + "agent-end-recovery.ts must resume paused auto-mode through resumeAutoAfterProviderDelay (#2813)", + ); + assert.ok( + !src.includes('Continue execution — provider error recovery delay elapsed.'), + "transient provider resume must not rely on a hidden continue prompt (#2813)", + ); +}); + // ── Codex error extraction (#1166) ────────────────────────────────────────── test("openai-codex-responses.ts extracts nested error fields", () => {