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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 14:38:03 -04:00 committed by GitHub
parent dd9e66f089
commit 800cff4bc0
2 changed files with 39 additions and 20 deletions

View file

@ -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;

View file

@ -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");
});