From 800cff4bc0fe974f44a4375ae6e3b9ac88230ade Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 14:38:03 -0400 Subject: [PATCH] fix(auto): resolve pending unitPromise in stopAuto to prevent hang (#1818) stopAuto() and pauseAuto() now call resolveAgentEnd() and _resetPendingResolve() before resetting session state, unblocking autoLoop's `await unitPromise` so it can see s.active===false and exit cleanly. Without this, the main interactive loop hangs forever. Fixes #1799 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto.ts | 17 +++++++- .../extensions/gsd/tests/auto-loop.test.ts | 42 ++++++++++--------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index ebbbcfbd7..f3ada821c 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -197,7 +197,7 @@ import { postUnitPostVerification, } from "./auto-post-unit.js"; import { bootstrapAutoSession, type BootstrapDeps } from "./auto-start.js"; -import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, isSessionSwitchInFlight, type LoopDeps } from "./auto-loop.js"; +import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps } from "./auto-loop.js"; import { WorktreeResolver, type WorktreeResolverDeps, @@ -688,6 +688,17 @@ export async function stopAuto( } catch (e) { debugLog("stop-cleanup-model", { error: e instanceof Error ? e.message : String(e) }); } + + // ── Step 14: Unblock pending unitPromise (#1799) ── + // resolveAgentEnd unblocks autoLoop's `await unitPromise` so it can see + // s.active === false and exit cleanly. Without this, autoLoop hangs + // forever and the interactive loop is blocked. + try { + resolveAgentEnd({ messages: [] }); + _resetPendingResolve(); + } catch (e) { + debugLog("stop-cleanup-pending-resolve", { error: e instanceof Error ? e.message : String(e) }); + } } finally { // ── Critical invariants: these MUST execute regardless of errors ── // Browser teardown — prevent orphaned Chrome processes across retries (#1733) @@ -776,6 +787,10 @@ export async function pauseAuto( deregisterSigtermHandler(); + // Unblock pending unitPromise so autoLoop exits cleanly (#1799) + resolveAgentEnd({ messages: [] }); + _resetPendingResolve(); + s.active = false; s.paused = true; s.pendingVerificationRetry = null; diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index 49805d22c..60d22b7d1 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -1757,7 +1757,6 @@ test("resolveAgentEndCancelled prevents orphaned promise after abort path", asyn await new Promise((r) => setTimeout(r, 10)); - // Simulate abort: deactivate session then cancel s.active = false; resolveAgentEndCancelled(); @@ -1792,7 +1791,6 @@ test("autoLoop re-iterates when postUnitPreVerification returns retry (#1571)", postUnitPreVerification: async () => { deps.callLog.push("postUnitPreVerification"); preVerifyCallCount++; - // First call returns "retry" (artifact missing), second returns "continue" if (preVerifyCallCount === 1) { return "retry" as const; } @@ -1800,7 +1798,6 @@ test("autoLoop re-iterates when postUnitPreVerification returns retry (#1571)", }, postUnitPostVerification: async () => { deps.callLog.push("postUnitPostVerification"); - // After the retry succeeds (second iteration), stop the loop s.active = false; return "continue" as const; }, @@ -1808,22 +1805,16 @@ test("autoLoop re-iterates when postUnitPreVerification returns retry (#1571)", const loopPromise = autoLoop(ctx, pi, s, deps); - // First iteration: runUnit completes → preVerification returns "retry" → loop continues await new Promise((r) => setTimeout(r, 50)); resolveAgentEnd(makeEvent()); - // Second iteration: runUnit completes → preVerification returns "continue" → full finalize await new Promise((r) => setTimeout(r, 50)); resolveAgentEnd(makeEvent()); await loopPromise; - // preVerification should have been called twice (retry + success) assert.equal(preVerifyCallCount, 2, "preVerification should be called twice"); - // When preVerification returns "retry", runPostUnitVerification and - // postUnitPostVerification should be skipped for that iteration. - // So we expect 1 call each (only the second iteration proceeds past pre-verification). const postVerifyCalls = deps.callLog.filter( (c: string) => c === "runPostUnitVerification", ); @@ -1831,14 +1822,27 @@ test("autoLoop re-iterates when postUnitPreVerification returns retry (#1571)", (c: string) => c === "postUnitPostVerification", ); - assert.equal( - postVerifyCalls.length, - 1, - "runPostUnitVerification should only be called once (skipped on retry iteration)", - ); - assert.equal( - postPostVerifyCalls.length, - 1, - "postUnitPostVerification should only be called once (skipped on retry iteration)", - ); + assert.equal(postVerifyCalls.length, 1, "runPostUnitVerification should only be called once"); + assert.equal(postPostVerifyCalls.length, 1, "postUnitPostVerification should only be called once"); +}); + +// ─── stopAuto unitPromise leak regression (#1799) ──────────────────────────── + +test("resolveAgentEnd unblocks pending runUnit when called before session reset (#1799)", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession(); + + const resultPromise = runUnit(ctx, pi, s, "task", "T01", "do work"); + + await new Promise((r) => setTimeout(r, 10)); + + resolveAgentEnd({ messages: [] }); + _resetPendingResolve(); + s.active = false; + + const result = await resultPromise; + assert.equal(result.status, "completed", "runUnit should resolve, not hang"); });