From b619373f0d5e5f3ae2acb1da8f85392ff26fe8bd Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 18:15:31 -0500 Subject: [PATCH 1/2] fix(auto): increase session timeout to 120s and treat timeout as recoverable pause (#3767) NEW_SESSION_TIMEOUT_MS was 30s which fired before agent_end on slow-starting tasks, causing spurious stopAuto hard stops. Increased to 120s and added a dedicated timeout branch in runUnitPhase that calls pauseAuto (recoverable) instead of stopAuto (destructive teardown). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto/phases.ts | 6 +++--- src/resources/extensions/gsd/auto/session.ts | 2 +- .../integration/state-machine-runtime-failures.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 78bbd696e..a561d76c2 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -1303,8 +1303,8 @@ export async function runUnitPhase( return { action: "break", reason: "provider-pause" }; } // Session creation timeout (not a structural error): pause auto-mode - // and let the provider-error-resume timer handle recovery. This matches - // the provider-pause path — break out cleanly, don't hard-stop. + // and let the provider-error-resume timer handle recovery (#3767). This + // matches the provider-pause path — break out cleanly, don't hard-stop. // Structural errors (TypeError, is not a function) are NOT transient // and must hard-stop to avoid infinite retry loops. if ( @@ -1312,7 +1312,7 @@ export async function runUnitPhase( unitResult.errorContext?.category === "timeout" ) { ctx.ui.notify( - `Session creation timed out for ${unitType} ${unitId}. Will retry.`, + `Session creation timed out for ${unitType} ${unitId}. Pausing auto-mode (recoverable).`, "warning", ); debugLog("autoLoop", { phase: "session-timeout-pause", unitType, unitId }); diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index b191d8591..5f822a51f 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -67,7 +67,7 @@ export interface SidecarItem { export const MAX_UNIT_DISPATCHES = 3; export const STUB_RECOVERY_THRESHOLD = 2; export const MAX_LIFETIME_DISPATCHES = 6; -export const NEW_SESSION_TIMEOUT_MS = 30_000; +export const NEW_SESSION_TIMEOUT_MS = 120_000; // ─── AutoSession ───────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts b/src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts index 1c0eadf49..beba08221 100644 --- a/src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts +++ b/src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts @@ -360,8 +360,8 @@ describe("session management", () => { assert.equal(s.unitRecoveryCount.size, 0, "recovery counts cleared"); }); - test("NEW_SESSION_TIMEOUT_MS is 30 seconds", () => { - assert.equal(NEW_SESSION_TIMEOUT_MS, 30_000, "session timeout should be 30s"); + test("NEW_SESSION_TIMEOUT_MS is 120 seconds", () => { + assert.equal(NEW_SESSION_TIMEOUT_MS, 120_000, "session timeout should be 120s"); }); test("MAX_UNIT_DISPATCHES limits retries for a single unit", () => { From 74fb4913b125143b40490f6b3846d4e7167ad47a Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 19:08:33 -0500 Subject: [PATCH 2/2] fix(remote-questions): cancel local TUI when remote answer wins the race showInterviewRound now accepts an AbortSignal via opts.signal. When the remote channel wins the race, controller.abort() closes the local TUI modal instead of leaving an orphaned interactive prompt capturing input. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/ask-user-questions.ts | 2 +- src/resources/extensions/shared/interview-ui.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index ab7ff280a..3cb7e2ae1 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -254,7 +254,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { const raceResult = await raceRemoteAndLocal( () => tryRemoteQuestions(params.questions, raceSignal), - () => showInterviewRound(params.questions, {}, ctx as any), + () => showInterviewRound(params.questions, { signal: raceSignal }, ctx as any), raceController, params.questions, ); diff --git a/src/resources/extensions/shared/interview-ui.ts b/src/resources/extensions/shared/interview-ui.ts index c38833761..66771bc84 100644 --- a/src/resources/extensions/shared/interview-ui.ts +++ b/src/resources/extensions/shared/interview-ui.ts @@ -80,6 +80,12 @@ export interface InterviewRoundOptions { * Label for the Esc-confirm overlay header. Defaults to "End interview?". */ exitHeadline?: string; + /** + * Optional AbortSignal to cancel the interview externally (e.g. when racing + * against a remote question channel). When aborted, the TUI closes and the + * promise resolves with an empty answers object. + */ + signal?: AbortSignal; /** * Text for the "exit" hint shown in the review screen footer and exit confirm overlay. * Defaults to "end interview". @@ -207,6 +213,13 @@ export async function showInterviewRound( let exitCursor = 0; // 0 = keep going (default), 1 = end interview let cachedLines: string[] | undefined; + // External cancellation (e.g. remote channel won the race) + if (opts.signal) { + const onAbort = () => done({ endInterview: false, answers: {} }); + if (opts.signal.aborted) { onAbort(); } + else { opts.signal.addEventListener("abort", onAbort, { once: true }); } + } + // Editor is created once; editorTheme comes from the design system const editorRef = { current: null as Editor | null };