fix: use pauseAuto instead of stopAuto for warning-level dispatch stops (#2666)

When the uat-verdict-gate returns a non-PASS verdict, it returns
action: "stop" with level: "warning". This was routed to
closeoutAndStop() → stopAuto(), which destroys the session — the user
must cold-restart `/gsd auto` from scratch.

A non-PASS UAT verdict is a recoverable human checkpoint, not an
infrastructure failure. The fix routes warning-level stops to
pauseAuto() instead, making the session resumable with `/gsd auto`.
Error and info-level stops continue to use closeoutAndStop() for
infrastructure failures and terminal conditions respectively.

Closes #2474
This commit is contained in:
mastertyko 2026-03-26 16:13:28 +01:00 committed by GitHub
parent 43b1de6d59
commit c9da003151
2 changed files with 79 additions and 1 deletions

View file

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

View file

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