diff --git a/client/src/pages/ConsiliumLoopDetail.tsx b/client/src/pages/ConsiliumLoopDetail.tsx index df698bf..b14ba87 100644 --- a/client/src/pages/ConsiliumLoopDetail.tsx +++ b/client/src/pages/ConsiliumLoopDetail.tsx @@ -2784,7 +2784,13 @@ export default function ConsiliumLoopDetail() { const terminal = isTerminalLoopState(loop.state); const canStart = loop.state === "pending"; const canCancel = !terminal; - const canApprove = loop.state === "awaiting_merge"; + // PUBLISH-FAILED: a dev round that errored WITHOUT producing a PR (push rejected, + // MR open failed) parks in awaiting_merge so the error is visible — but there is + // NOTHING to merge, so the approve gate must not render. Research loops (prRef + // null, NO error) keep their legitimate approve-and-continue gate. + const publishFailed = + loop.state === "awaiting_merge" && !loop.prRef && Boolean(loop.error); + const canApprove = loop.state === "awaiting_merge" && !publishFailed; // Develop hand-off (design §9): a verdict-terminal loop whose latest verdict // still carries action points may be promoted into a VISIBLE `developing` @@ -3010,6 +3016,25 @@ export default function ConsiliumLoopDetail() { /> )} + {/* PUBLISH-FAILED callout: the dev round finished but its push/MR was + rejected — nothing reached the forge, so there is no merge to approve + (the approve button is suppressed above). The round branch with the + commits is intact in the local repo. */} + {publishFailed && ( +
+
+ + Publish failed — nothing to merge +
+

+ The develop round completed its commits, but pushing the branch / opening + the MR was rejected (see the round error below). The round branch is + intact in the repo — fix the push blocker and re-run develop, or Finish + the loop. +

+
+ )} + {/* Research report (Stage 3) — the researched outcome of a `research` loop, on the latest round. Rendered only when a report is present (repo-assessment loops render nothing here). */} diff --git a/server/routes/consilium-loops.ts b/server/routes/consilium-loops.ts index efe9ddb..4333997 100644 --- a/server/routes/consilium-loops.ts +++ b/server/routes/consilium-loops.ts @@ -374,6 +374,16 @@ export function registerConsiliumLoopRoutes( if (auth.loop.state !== "awaiting_merge") { return res.status(409).json({ error: "loop is not AWAITING_MERGE" }); } + // PUBLISH-FAILED gate: a dev close-out that ERRORED without producing a PR + // (e.g. the push was rejected by a server-side policy) parks in awaiting_merge + // so the human sees the error — but there is NOTHING to merge, and approving + // would re-enter the loop as if the round had shipped. Research loops + // (prRef null, NO error) keep their legitimate approve-and-continue gate. + if (!auth.loop.prRef && auth.loop.error) { + return res + .status(409) + .json({ error: "nothing to merge — the develop round failed to publish (no PR)" }); + } // M-3: the merged HEAD is read SERVER-side (never a client-supplied sha). const merged = await controller.onMergeApproved(auth.loop.id, ""); if (!merged) return res.status(409).json({ error: "merge approval could not be applied" }); diff --git a/tests/integration/consilium/loop-routes.test.ts b/tests/integration/consilium/loop-routes.test.ts index 7cc292c..468c83a 100644 --- a/tests/integration/consilium/loop-routes.test.ts +++ b/tests/integration/consilium/loop-routes.test.ts @@ -204,6 +204,22 @@ describe("consilium-loop routes", () => { expect(res.status).toBe(403); }); + it("PUBLISH-FAILED: awaiting_merge with an error and NO prRef → 409 on merge-approved (nothing to merge)", async () => { + const created = await post("/api/consilium-loops", { groupId: ctx.group.id, repoPath: REPO_ROOT }); + const id = created.body.id; + // A dev close-out whose push was rejected: error persisted, no PR produced. + await ctx.storage.updateLoop(id, { + state: "awaiting_merge", + headCommitAtReview: "abc1234", + prRef: null, + error: "push failed: remote rejected (pre-receive hook declined)", + }); + ctx.setUser(MAINTAINER_USER); // pass the role gate — the publish gate must still 409 + const res = await post(`/api/consilium-loops/${id}/merge-approved`); + expect(res.status).toBe(409); + expect(String(res.body.error)).toContain("failed to publish"); + }); + it("B-2: maintainer (same identity) WITH visibility → 200 on merge-approved", async () => { const created = await post("/api/consilium-loops", { groupId: ctx.group.id, repoPath: REPO_ROOT }); const id = created.body.id;