Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion client/src/pages/ConsiliumLoopDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
<div className="flex items-center gap-2 font-medium text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
Publish failed — nothing to merge
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
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.
</p>
</div>
)}

{/* 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). */}
Expand Down
10 changes: 10 additions & 0 deletions server/routes/consilium-loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
16 changes: 16 additions & 0 deletions tests/integration/consilium/loop-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading