[w8b] Fix release-proposal flow: reject frees dedup, surface execute outcome (#525)

Reject cancelled the proposal's status but never moved it out of the
held-proposal set, so the one-open-proposal dedup blocked the release
manager from ever re-assessing — a rejected proposal deadlocked the cycle.
reject() now sets CANCELLED (mirroring video_post_service), which
list_open_release_proposals already excludes, so a fresh proposal can
originate next cycle.

A failed ~40min background execute (gate red, CI red, or an unexpected
crash) left the proposal silently PENDING with no signal to the CEO.
_run_approve_background now writes a release_execute_outcome marker
(status + detail) on every terminal outcome, and an 'error' marker on an
unhandled exception. GET /proposal surfaces execute_status / execute_detail
/ execute_in_flight (derived from the in-memory _INFLIGHT_APPROVES registry)
so the panel can show a running badge, a failure block with the reason, and
a Retry-approve label instead of a silent wait.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 06:10:31 +02:00
committed by GitHub
co-authored by Renn F
parent bb3b4b0c6d
commit be553ee9dd
8 changed files with 260 additions and 15 deletions
@@ -124,3 +124,53 @@ describe("ReleaseProposalCard — query-failure surfacing (F082)", () => {
).toBeInTheDocument();
});
});
describe("ReleaseProposalCard — execute outcome surfacing (W8b)", () => {
it("renders the in-flight badge and disables actions while execute runs", () => {
// execute_in_flight is the UX for the Redis-mutex-protected background
// execute; the approve/reject buttons disable so the CEO can't double-click.
mockUseQuery.mockReturnValue({
data: { ...buildProposal(), execute_in_flight: true },
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<ReleaseProposalCard />));
expect(
screen.getByText(/release execute running in the background/i),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Reject with changes/i }),
).toBeDisabled();
// Approve stays present but disabled while the execute runs.
const approve = screen.getByRole("button", { name: /Approve & publish/i });
expect(approve).toBeDisabled();
});
it("renders the failure block and a Retry label from a persisted execute_status", () => {
// A failed ~40min execute left the proposal open with a persisted
// execute_status; the card surfaces the reason and flips Approve to Retry.
mockUseQuery.mockReturnValue({
data: {
...buildProposal(),
execute_status: "gate_failed",
execute_detail: "make quality failed",
},
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<ReleaseProposalCard />));
expect(screen.getByText(/last execute failed/i)).toBeInTheDocument();
expect(screen.getByText(/make quality failed/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Retry approve & publish/i }),
).toBeInTheDocument();
});
});
@@ -23,7 +23,7 @@ import {
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { CheckCircle2, XCircle, Rocket, AlertTriangle } from "lucide-react";
import { CheckCircle2, XCircle, Rocket, AlertTriangle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { usePageRefresh } from "@/hooks";
import { HelpTip } from "@/components/ui/help-tip";
@@ -101,7 +101,7 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
mutationFn: (changes: string) => releaseApi.reject(changes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["release", "proposal"] });
toast.success("Proposal sent back with required changes");
toast.success("Proposal rejected — a fresh assessment runs next cycle");
closeDialog();
},
onError: (error) => {
@@ -158,6 +158,12 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
const { report } = proposal;
const pending = approveMutation.isPending || rejectMutation.isPending;
// The ~40min execute runs in the background; the Redis mutex already refuses
// a double-click server-side — execute_in_flight is the UX (disable approve,
// show a running badge). A persisted execute_status on a still-open proposal
// is a failure (a publish would have completed + hidden the card).
const executeInFlight = !!proposal.execute_in_flight;
const executeFailed = !!proposal.execute_status && !executeInFlight;
return (
<>
@@ -238,12 +244,40 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
</p>
)}
{executeInFlight && (
<div className="flex items-center gap-2 rounded-md border border-blue-500/40 bg-blue-500/10 p-3">
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
<span className="text-sm text-blue-600 dark:text-blue-400">
Release execute running in the background (~40 min) this card
updates when it finishes.
</span>
</div>
)}
{executeFailed && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3">
<p className="flex items-center gap-1.5 text-sm font-medium text-red-600">
<XCircle className="h-4 w-4" />
Last execute failed ({proposal.execute_status})
</p>
{proposal.execute_detail && (
<p className="mt-1 text-sm text-muted-foreground">
{proposal.execute_detail}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
Fix the cause and approve again to retry.
</p>
</div>
)}
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setAction("reject")}
disabled={executeInFlight}
>
<XCircle className="mr-1 h-4 w-4" />
Reject with changes
@@ -252,9 +286,10 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => setAction("approve")}
disabled={executeInFlight}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; publish
{executeFailed ? "Retry approve & publish" : "Approve & publish"}
</Button>
</div>
</CardContent>
@@ -271,7 +306,7 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
<DialogDescription>
{action === "approve"
? "This runs the fail-closed executor: write the bumps + CHANGELOG, run make quality, commit, wait for green CI, then publish. It aborts on a red gate or red CI."
: "Record what must change. The proposal stays open for revision; nothing is published."}
: "Record what must change. The proposal is cancelled and the release manager re-assesses next cycle; nothing is published."}
</DialogDescription>
</DialogHeader>
+3
View File
@@ -28,6 +28,9 @@ export interface ReleaseProposal {
title: string;
status: string;
required_changes?: string | null;
execute_status?: string | null;
execute_detail?: string | null;
execute_in_flight?: boolean;
report: ReleaseReport;
}