From b6fc9fb78ff7246e8605147e005f5c9494141087 Mon Sep 17 00:00:00 2001 From: Igor Gerasimov Date: Tue, 21 Jul 2026 10:56:17 +0200 Subject: [PATCH] fix(consilium): no merge gate for a publish-failed dev round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dev close-out that ERRORED without producing a PR (e.g. the branch push was rejected by a server-side policy hook) parks in awaiting_merge so the human sees the error — but the UI still offered 'Approve merge & continue' with nothing to merge, and the route would happily transition as if the round had shipped. - route: merge-approved returns 409 ('failed to publish') when the loop has an error and no prRef. Research loops (prRef null, NO error) keep their approve-and-continue gate. - UI: the approve button is suppressed in that state and a 'Publish failed — nothing to merge' callout explains what happened and that the round branch is intact. loop-routes integration: 20 passed (1 new). tsc + vite build clean. --- client/src/pages/ConsiliumLoopDetail.tsx | 27 ++++++++++++++++++- server/routes/consilium-loops.ts | 10 +++++++ .../integration/consilium/loop-routes.test.ts | 16 +++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) 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;