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;