diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 040d038c0..252797be1 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -504,7 +504,17 @@ export async function runDispatch( if (dispatchResult.action === "stop") { deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "dispatch-stop", rule: dispatchResult.matchedRule, data: { reason: dispatchResult.reason } }); - await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason); + // Warning-level stops are recoverable human checkpoints (e.g. UAT verdict + // gate) — pause instead of hard-stopping so the session is resumable with + // `/gsd auto`. Error/info-level stops remain hard stops for infrastructure + // failures and terminal conditions respectively. + // See: https://github.com/gsd-build/gsd-2/issues/2474 + if (dispatchResult.level === "warning") { + ctx.ui.notify(dispatchResult.reason, "warning"); + await deps.pauseAuto(ctx, pi); + } else { + await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason); + } debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" }); return { action: "break", reason: "dispatch-stop" }; } diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index 5e01617eb..af3fda5ca 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -882,6 +882,74 @@ test("autoLoop handles dispatch stop action", async (t) => { ); }); +// #2474: warning-level dispatch stop should pause (resumable), not hard-stop +test("autoLoop pauses instead of stopping for warning-level dispatch stop", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + const deps = makeMockDeps({ + resolveDispatch: async () => { + deps.callLog.push("resolveDispatch"); + return { + action: "stop" as const, + reason: 'UAT verdict for S01 is "partial" — blocking progression.', + level: "warning" as const, + }; + }, + }); + + await autoLoop(ctx, pi, s, deps); + + assert.ok( + deps.callLog.includes("resolveDispatch"), + "should have called resolveDispatch", + ); + assert.ok( + deps.callLog.includes("pauseAuto"), + "warning-level stop should call pauseAuto (resumable)", + ); + assert.ok( + !deps.callLog.includes("stopAuto"), + "warning-level stop should NOT call stopAuto (hard stop)", + ); +}); + +// #2474: error-level dispatch stop should still hard-stop +test("autoLoop hard-stops for error-level dispatch stop", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + const deps = makeMockDeps({ + resolveDispatch: async () => { + deps.callLog.push("resolveDispatch"); + return { + action: "stop" as const, + reason: "Cannot complete milestone: missing SUMMARY files.", + level: "error" as const, + }; + }, + }); + + await autoLoop(ctx, pi, s, deps); + + assert.ok( + deps.callLog.includes("stopAuto"), + "error-level stop should call stopAuto (hard stop)", + ); + assert.ok( + !deps.callLog.includes("pauseAuto"), + "error-level stop should NOT call pauseAuto", + ); +}); + test("autoLoop handles dispatch skip action by continuing", async (t) => { _resetPendingResolve();