fix: remove over-firing triage release-authorization gate
The triage release-authorization gate (FN-6481/FN-6469) false-flagged any spec that merely mentioned release tooling (scripts/release.mjs, pnpm release) and, because non-user sources made the in-band authorization marker inert, stranded ordinary tasks in awaiting-approval with no exit. - Delete triage-release-authorization.ts + its test and the finalizeApprovedTask parking block; release-class specs now flow through triage normally. - Remove the dashboard approve/reject-plan API guards and UI gating so tasks still carrying the legacy awaitingApprovalReason="release-authorization" hold render as ordinary manual plan-approval holds and can be resolved. - Keep the awaitingApprovalReason field + activity label for backward-compat. - Replace the engine gate with agent instruction (AGENTS.md -> Releasing): agents must never run a release from inside a Fusion task. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/remove-release-authorization-gate.md
Normal file
7
.changeset/remove-release-authorization-gate.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Tasks are no longer stuck "awaiting release authorization" — the over-firing release gate was removed.
|
||||
category: fix
|
||||
dev: Removed the triage release-authorization gate (packages/engine/src/triage-release-authorization.ts + finalizeApprovedTask block) and its dashboard approve/reject-plan guards. It false-flagged specs that merely mentioned release tooling and stranded tasks in awaiting-approval with no in-band exit. Legacy `awaitingApprovalReason: "release-authorization"` rows now render as ordinary manual plan-approval holds. Releases are kept out of Fusion by agent instruction (AGENTS.md → Releasing) instead.
|
||||
@@ -67,7 +67,9 @@ A linter (`pnpm check:changesets`) validates this format and runs in the PR-chec
|
||||
|
||||
### Releasing
|
||||
|
||||
Use only:
|
||||
**Never run a release from inside a Fusion task.** Do not run `pnpm release`, `changeset publish`, `pnpm publish`, `npm publish`, or cut git version tags as part of any Fusion-dispatched work (triage/executor/reviewer/merger/agent-heartbeat lanes). Releasing is an operator-only action performed by a human outside the task loop. If a task's spec appears to require a release, stop and leave it for a human operator — do not self-authorize or perform the publish. (The former engine "release authorization" gate that parked such tasks was removed because it over-fired on specs that merely *mentioned* release tooling; this instruction replaces it.)
|
||||
|
||||
When a human operator does release, use only:
|
||||
|
||||
```bash
|
||||
pnpm release --yes
|
||||
|
||||
@@ -2526,19 +2526,15 @@ export interface Task {
|
||||
* `nextRecoveryAt` is still in the future. Cleared alongside `recoveryRetryCount`. */
|
||||
nextRecoveryAt?: string;
|
||||
/*
|
||||
* FNXC:PlanApproval 2026-07-04-21:35:
|
||||
* FN-7559: release authorization (packages/engine/src/triage-release-authorization.ts)
|
||||
* and the ordinary manual plan-approval gate (packages/core/src/plan-approval.ts,
|
||||
* resolvePlanApprovalRequired) both park a task with status "awaiting-approval" and
|
||||
* previously rendered an identical badge/Approve-Plan affordance in the dashboard.
|
||||
* Project auto-approve-all (planApprovalMode: "auto-approve-all") bypasses ONLY the
|
||||
* manual gate — release authorization is an independent safety gate it never skips —
|
||||
* so an operator with auto-approve on could not tell a still-parked release hold from
|
||||
* a (never-fired) manual hold and reasonably concluded auto-approve was broken.
|
||||
* Set to "release-authorization" only by the release-authorization gate; the manual
|
||||
* gate always writes it back to undefined/null so a stale reason from an earlier pass
|
||||
* never survives past the manual gate's own awaiting-approval. Undefined means either
|
||||
* no hold or an ordinary manual-approval hold.
|
||||
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00:
|
||||
* DEPRECATED — the triage release-authorization gate that set this field was removed
|
||||
* (it over-fired on AI-authored specs that merely mention release tooling and stranded
|
||||
* ordinary tasks in "awaiting-approval" with no in-band exit). No code writes
|
||||
* "release-authorization" anymore; releases are kept out of Fusion by agent instruction
|
||||
* (AGENTS.md → "Releasing"), not an engine gate. The field is retained only so existing
|
||||
* task rows persisted with the legacy value still deserialize; the dashboard now treats
|
||||
* any such hold as an ordinary manual plan-approval hold (Approve/Reject Plan render
|
||||
* normally). Undefined means either no hold or a manual-approval hold.
|
||||
*/
|
||||
awaitingApprovalReason?: "release-authorization";
|
||||
/*
|
||||
|
||||
@@ -1271,14 +1271,12 @@ function TaskCardComponent({
|
||||
const taskAgeStalenessCopy = getTaskAgeStalenessCopy(task.ageStaleness);
|
||||
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
||||
/*
|
||||
* FNXC:PlanApproval 2026-07-04-21:35:
|
||||
* FN-7559: release-authorization holds and manual plan-approval holds both use
|
||||
* status "awaiting-approval" (auto-approve-all intentionally bypasses only the
|
||||
* manual gate — see FNXC:PlanApproval in types.ts). Distinguish them for the
|
||||
* operator via the awaitingApprovalReason discriminator instead of showing the
|
||||
* generic manual-approval badge/label for both.
|
||||
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00:
|
||||
* The triage release-authorization gate was removed; a legacy release-authorization
|
||||
* hold is now just an ordinary manual plan-approval hold and no longer gets a
|
||||
* distinct badge.
|
||||
*/
|
||||
const isReleaseAuthorizationHold = isAwaitingApproval && task.awaitingApprovalReason === "release-authorization";
|
||||
const isReleaseAuthorizationHold = false;
|
||||
const isAwaitingInput = task.status === "awaiting-user-input";
|
||||
const isArchived = task.column === "archived";
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && !isAwaitingInput && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string));
|
||||
|
||||
@@ -2668,16 +2668,13 @@ export function TaskDetailContent({
|
||||
|
||||
const isTaskPaused = task.paused || task.userPaused;
|
||||
/*
|
||||
* FNXC:PlanApproval 2026-07-04-21:35:
|
||||
* FN-7559: release-authorization holds and manual plan-approval holds both
|
||||
* use status "awaiting-approval" (auto-approve-all intentionally bypasses only
|
||||
* the manual gate — see FNXC:PlanApproval in types.ts). Gate the manual
|
||||
* Approve/Reject Plan affordance to genuine manual holds only — clicking
|
||||
* "Approve Plan" on a release-authorization hold would let a release-class
|
||||
* spec bypass FN-6481's explicit-marker requirement via a plain button click.
|
||||
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00:
|
||||
* The triage release-authorization gate was removed. Any task still carrying the
|
||||
* legacy awaitingApprovalReason === "release-authorization" is now treated as an
|
||||
* ordinary manual plan-approval hold so it shows Approve/Reject Plan and is not
|
||||
* stranded with no resolvable affordance.
|
||||
*/
|
||||
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
||||
const isReleaseAuthorizationHold = isAwaitingApproval && task.awaitingApprovalReason === "release-authorization";
|
||||
|
||||
const handleTogglePause = useCallback(async () => {
|
||||
try {
|
||||
@@ -5667,10 +5664,10 @@ export function TaskDetailContent({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Approve/Reject Plan buttons — only for genuine manual plan-approval
|
||||
holds (FN-7559: a release-authorization hold shares the same
|
||||
status but must never be resolvable via this plain button click). */}
|
||||
{isAwaitingApproval && !isReleaseAuthorizationHold && workingTask.prompt && (
|
||||
{/* Approve/Reject Plan buttons for manual plan-approval holds.
|
||||
FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: the release-authorization
|
||||
gate was removed, so legacy release-authorization holds render these too. */}
|
||||
{isAwaitingApproval && workingTask.prompt && (
|
||||
<>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleApprovePlan}>
|
||||
{t("taskDetail.plan.approveBtn", "Approve Plan")}
|
||||
@@ -5681,26 +5678,10 @@ export function TaskDetailContent({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* FN-7559: release-authorization holds are surfaced with a truthful,
|
||||
distinct reason instead of the manual Approve/Reject affordance —
|
||||
auto-approve-all does not (and must not) bypass this gate, and
|
||||
resolving it requires the explicit authorization marker in a
|
||||
regenerated spec, not a plain approval click. */}
|
||||
{isReleaseAuthorizationHold && (
|
||||
<span className="modal-hold-reason modal-hold-reason--release-authorization">
|
||||
{t(
|
||||
"taskDetail.plan.releaseAuthorizationHold",
|
||||
"Awaiting release authorization — add the explicit authorization marker to the spec and resubmit.",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Standalone Delete button for triage-column tasks — triage tasks
|
||||
hide the Actions dropdown (see condition below) so the user has
|
||||
no quick way to delete a freshly-created task otherwise. Release-
|
||||
authorization holds no longer render the Approve/Reject affordance,
|
||||
so Delete remains available for them too (FN-7559). */}
|
||||
{task.column === "triage" && (!isAwaitingApproval || isReleaseAuthorizationHold) && !canRetryTask && (
|
||||
no quick way to delete a freshly-created task otherwise. */}
|
||||
{task.column === "triage" && !isAwaitingApproval && !canRetryTask && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={handleDelete}
|
||||
|
||||
@@ -1948,25 +1948,12 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
* FN-7559: release-authorization holds and manual plan-approval holds both
|
||||
* use status "awaiting-approval" (auto-approve-all intentionally bypasses
|
||||
* only the manual gate), so the card badge must render a distinct label and
|
||||
* class for a release-authorization hold instead of the generic "Awaiting
|
||||
* Approval" manual-gate badge.
|
||||
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: the triage release-authorization
|
||||
* gate was removed. A legacy release-authorization hold now renders the generic
|
||||
* "Awaiting Approval" badge like any manual plan-approval hold — no distinct
|
||||
* release-authorization label or badge class.
|
||||
*/
|
||||
it("renders a distinct badge for a release-authorization hold vs a manual approval hold", () => {
|
||||
const { container: manualContainer, unmount: unmountManual } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "triage", status: "awaiting-approval" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
expect(within(manualContainer).getByText("Awaiting Approval")).toBeDefined();
|
||||
const manualBadge = manualContainer.querySelector(".card-status-badge") as HTMLElement;
|
||||
expect(manualBadge.className).not.toContain("awaiting-release-authorization");
|
||||
unmountManual();
|
||||
|
||||
it("renders the generic Awaiting Approval badge for a legacy release-authorization hold", () => {
|
||||
const { container: releaseContainer } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "triage", status: "awaiting-approval", awaitingApprovalReason: "release-authorization" } as any)}
|
||||
@@ -1974,10 +1961,10 @@ describe("TaskCard", () => {
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
expect(within(releaseContainer).getByText("Awaiting Release Authorization")).toBeDefined();
|
||||
expect(within(releaseContainer).queryByText("Awaiting Approval")).toBeNull();
|
||||
expect(within(releaseContainer).getByText("Awaiting Approval")).toBeDefined();
|
||||
expect(within(releaseContainer).queryByText("Awaiting Release Authorization")).toBeNull();
|
||||
const releaseBadge = releaseContainer.querySelector(".card-status-badge") as HTMLElement;
|
||||
expect(releaseBadge.className).toContain("awaiting-release-authorization");
|
||||
expect(releaseBadge.className).not.toContain("awaiting-release-authorization");
|
||||
});
|
||||
|
||||
it("renders stalled badge with visible reason when stalledReview is set", () => {
|
||||
|
||||
@@ -454,14 +454,12 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
* FN-7559: release-authorization holds share status "awaiting-approval" with
|
||||
* the manual gate (auto-approve-all intentionally does not bypass release
|
||||
* authorization), so the plain Approve/Reject affordance must never render
|
||||
* for them — clicking Approve would let a release-class spec bypass the
|
||||
* FN-6481 explicit-marker requirement via a button click. Instead a distinct,
|
||||
* truthful reason is shown and no leftover empty button shell remains.
|
||||
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: the triage release-authorization
|
||||
* gate was removed. A task still carrying the legacy release-authorization hold is
|
||||
* now treated as an ordinary manual plan-approval hold and renders Approve/Reject
|
||||
* Plan normally instead of a distinct, unresolvable reason string.
|
||||
*/
|
||||
it("shows a distinct release-authorization reason instead of Approve/Reject Plan buttons", () => {
|
||||
it("shows Approve/Reject Plan for a legacy release-authorization hold", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
@@ -480,9 +478,9 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Approve Plan")).toBeNull();
|
||||
expect(screen.queryByText("Reject Plan")).toBeNull();
|
||||
expect(screen.getByText(/Awaiting release authorization/i)).toBeTruthy();
|
||||
expect(screen.getByText("Approve Plan")).toBeTruthy();
|
||||
expect(screen.getByText("Reject Plan")).toBeTruthy();
|
||||
expect(screen.queryByText(/Awaiting release authorization/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show approval buttons when task is not in triage", () => {
|
||||
|
||||
@@ -2042,25 +2042,26 @@ describe("POST /tasks/:id/approve-plan", () => {
|
||||
expect(res.body.error).toBe("Database error");
|
||||
});
|
||||
|
||||
// FN-7564: a release-authorization hold (FN-7559's awaitingApprovalReason
|
||||
// discriminator) must reject a direct approve-plan API call with 400 — the
|
||||
// FN-6481 authorization-marker requirement must not be bypassable outside the UI.
|
||||
it("returns 400 and does not move the task for a release-authorization hold", async () => {
|
||||
// FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: the release-authorization gate
|
||||
// and its approve-plan guard were removed. A task still carrying the legacy
|
||||
// awaitingApprovalReason === "release-authorization" (parked by the old gate) must
|
||||
// now approve normally instead of being stranded with a 400 and no exit.
|
||||
it("approves a task carrying the legacy release-authorization hold", async () => {
|
||||
const releaseHoldTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "triage" as const,
|
||||
status: "awaiting-approval" as const,
|
||||
awaitingApprovalReason: "release-authorization" as const,
|
||||
};
|
||||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(releaseHoldTask);
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...movedTask, status: undefined });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("release authorization");
|
||||
expect(res.body.error).toContain("Release Authorized By User");
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
|
||||
// Passthrough: an ordinary manual-approval hold (no awaitingApprovalReason)
|
||||
@@ -2202,24 +2203,24 @@ describe("POST /tasks/:id/reject-plan", () => {
|
||||
expect(res.body.error).toBe("Database error");
|
||||
});
|
||||
|
||||
// FN-7564: a release-authorization hold must reject a direct reject-plan API
|
||||
// call with 400 too — rejecting must not silently wipe/regenerate the spec
|
||||
// without the operator ever acknowledging the FN-6481 authorization gate.
|
||||
it("returns 400 and does not clear status or remove PROMPT.md for a release-authorization hold", async () => {
|
||||
// FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: with the gate removed, a task
|
||||
// still carrying the legacy release-authorization hold must reject normally rather
|
||||
// than returning 400 — the old reject-plan guard was removed alongside the gate.
|
||||
it("rejects a task carrying the legacy release-authorization hold", async () => {
|
||||
const releaseHoldTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "triage" as const,
|
||||
status: "awaiting-approval" as const,
|
||||
awaitingApprovalReason: "release-authorization" as const,
|
||||
};
|
||||
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: undefined };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(releaseHoldTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("release authorization");
|
||||
expect(res.body.error).toContain("Release Authorized By User");
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined, approvedPlanFingerprint: null });
|
||||
});
|
||||
|
||||
// Passthrough: an ordinary manual-approval hold (no awaitingApprovalReason)
|
||||
|
||||
@@ -3280,19 +3280,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
if (task.status !== "awaiting-approval") {
|
||||
throw badRequest("Task must have status 'awaiting-approval' to approve plan");
|
||||
}
|
||||
// FNXC:ReleaseAuthorizationGate 2026-07-04-22:30:
|
||||
// FN-6481 requires a release-class task to carry an explicit
|
||||
// "**Release Authorized By User:** yes" marker before it can dispatch.
|
||||
// FN-7559 parks such tasks with awaitingApprovalReason === "release-authorization"
|
||||
// (distinct from an ordinary manual plan-approval hold) and hides the
|
||||
// Approve/Reject Plan buttons in the dashboard UI, but a direct API call bypasses
|
||||
// that UI protection. Enforce the gate here too so no client can dispatch a
|
||||
// release-class task without the authorization marker.
|
||||
if (task.awaitingApprovalReason === "release-authorization") {
|
||||
throw badRequest(
|
||||
"This task is held for release authorization. Add the **Release Authorized By User:** yes marker to its PROMPT.md and resubmit the spec instead of approving the plan.",
|
||||
);
|
||||
}
|
||||
// FNXC:ReleaseAuthorizationGate 2026-07-09-00:00:
|
||||
// The triage release-authorization gate was removed (it over-fired and stranded
|
||||
// ordinary tasks). The approve-plan guard that refused any task carrying the legacy
|
||||
// awaitingApprovalReason === "release-authorization" is gone too, so tasks parked by
|
||||
// the old gate can now be approved normally instead of staying stuck with no exit.
|
||||
|
||||
// Log the approval
|
||||
await scopedStore.logEntry(task.id, "Plan approved by user");
|
||||
@@ -3348,16 +3340,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
if (task.status !== "awaiting-approval") {
|
||||
throw badRequest("Task must have status 'awaiting-approval' to reject plan");
|
||||
}
|
||||
// FNXC:ReleaseAuthorizationGate 2026-07-04-22:30:
|
||||
// Mirror of the approve-plan guard above: a release-authorization hold
|
||||
// (FN-7559's awaitingApprovalReason discriminator) must not be rejectable
|
||||
// through the API either, since rejecting would wipe/regenerate the spec
|
||||
// without the operator ever acknowledging the FN-6481 authorization gate.
|
||||
if (task.awaitingApprovalReason === "release-authorization") {
|
||||
throw badRequest(
|
||||
"This task is held for release authorization. Add the **Release Authorized By User:** yes marker to its PROMPT.md and resubmit the spec instead of rejecting the plan.",
|
||||
);
|
||||
}
|
||||
// FNXC:ReleaseAuthorizationGate 2026-07-09-00:00:
|
||||
// Release-authorization gate removed — see the approve-plan handler above. A task
|
||||
// carrying the legacy release-authorization hold can now be rejected normally.
|
||||
|
||||
// Log the rejection
|
||||
await scopedStore.logEntry(task.id, "Plan rejected by user", "Specification will be regenerated");
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyReleaseTask,
|
||||
evaluateReleaseAuthorizationGate,
|
||||
isUserAuthoredSource,
|
||||
parseReleaseAuthorizationMarker,
|
||||
stripNegatedReleaseClauses,
|
||||
} from "../triage-release-authorization.js";
|
||||
|
||||
const releasePrompt = `# Task: FN-6469 - Release @runfusion/fusion patch
|
||||
|
||||
## Mission
|
||||
Publish @runfusion/fusion to npm using the release process.
|
||||
|
||||
## Steps
|
||||
- Run pnpm release --yes
|
||||
- Verify scripts/release.mjs completed
|
||||
`;
|
||||
|
||||
const marker = "**Release Authorized By User:** yes";
|
||||
|
||||
describe("triage release authorization gate", () => {
|
||||
it("blocks the FN-6469 incident shape before auto-dispatch", () => {
|
||||
const decision = evaluateReleaseAuthorizationGate({
|
||||
sourceType: "agent_heartbeat",
|
||||
title: "Release @runfusion/fusion patch",
|
||||
description: "Release the package",
|
||||
promptText: releasePrompt,
|
||||
});
|
||||
|
||||
expect(decision.action).toBe("block");
|
||||
expect(decision.isReleaseClass).toBe(true);
|
||||
expect(decision.signals).toContain("pnpm release");
|
||||
});
|
||||
|
||||
it("blocks agent-authored release tasks even when PROMPT.md contains the marker", () => {
|
||||
const decision = evaluateReleaseAuthorizationGate({
|
||||
sourceType: "agent_heartbeat",
|
||||
title: "Release @runfusion/fusion patch",
|
||||
promptText: `${releasePrompt}\n${marker}\n`,
|
||||
});
|
||||
|
||||
expect(decision.action).toBe("block");
|
||||
expect(decision.reason).toMatch(/non-user-authored source/);
|
||||
});
|
||||
|
||||
it("allows user-authored dashboard release tasks with the marker", () => {
|
||||
expect(evaluateReleaseAuthorizationGate({
|
||||
sourceType: "dashboard_ui",
|
||||
title: "Release @runfusion/fusion patch",
|
||||
promptText: `${releasePrompt}\n${marker}\n`,
|
||||
}).action).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows user-authored CLI release tasks with the marker", () => {
|
||||
expect(evaluateReleaseAuthorizationGate({
|
||||
sourceType: "cli",
|
||||
title: "Release @runfusion/fusion patch",
|
||||
promptText: `${releasePrompt}\n **Release Authorized By User:** YES \n`,
|
||||
}).action).toBe("allow");
|
||||
});
|
||||
|
||||
it("blocks user-authored release tasks without the marker", () => {
|
||||
const decision = evaluateReleaseAuthorizationGate({
|
||||
sourceType: "quick_chat",
|
||||
title: "Release @runfusion/fusion patch",
|
||||
promptText: releasePrompt,
|
||||
});
|
||||
|
||||
expect(decision.action).toBe("block");
|
||||
expect(decision.reason).toMatch(/missing/);
|
||||
});
|
||||
|
||||
it("blocks api-sourced release tasks even when the marker is present", () => {
|
||||
const decision = evaluateReleaseAuthorizationGate({
|
||||
sourceType: "api",
|
||||
title: "Release @runfusion/fusion patch",
|
||||
promptText: `${releasePrompt}\n${marker}\n`,
|
||||
});
|
||||
|
||||
expect(decision.action).toBe("block");
|
||||
expect(decision.reason).toMatch(/non-user-authored source 'api'/);
|
||||
});
|
||||
|
||||
it("blocks derived/internal release tasks even when the marker is present", () => {
|
||||
for (const sourceType of ["task_refine", "github_import"] as const) {
|
||||
expect(evaluateReleaseAuthorizationGate({
|
||||
sourceType,
|
||||
title: "Release @runfusion/fusion patch",
|
||||
promptText: `${releasePrompt}\n${marker}\n`,
|
||||
}).action).toBe("block");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows non-release tasks without changing dispatch behavior", () => {
|
||||
const decision = evaluateReleaseAuthorizationGate({
|
||||
sourceType: "agent_heartbeat",
|
||||
title: "Fix dashboard layout bug",
|
||||
description: "Adjust CSS for the task card footer.",
|
||||
promptText: "## Mission\nFix a dashboard layout bug without publishing anything.",
|
||||
});
|
||||
|
||||
expect(decision.action).toBe("allow");
|
||||
expect(decision.isReleaseClass).toBe(false);
|
||||
expect(decision.signals).toEqual([]);
|
||||
});
|
||||
|
||||
it("classifies all documented release signal surfaces", () => {
|
||||
const cases = [
|
||||
["pnpm release --yes", "pnpm release"],
|
||||
["node scripts/release.mjs --yes", "scripts/release.mjs"],
|
||||
["pnpm changeset publish", "changeset publish"],
|
||||
["npm publish ./dist for @runfusion/fusion", "npm publish @runfusion/fusion"],
|
||||
["pnpm publish @runfusion/fusion", "pnpm publish @runfusion/fusion"],
|
||||
["publish the package to npm", "publish to npm"],
|
||||
["git tag v1.2.3", "git tag v<semver>"],
|
||||
["create a version bump release commit for v1.2.3", "version-bump release commit"],
|
||||
] as const;
|
||||
|
||||
for (const [promptText, expectedSignal] of cases) {
|
||||
const classification = classifyReleaseTask({ promptText });
|
||||
expect(classification.isReleaseClass, promptText).toBe(true);
|
||||
expect(classification.signals, promptText).toContain(expectedSignal);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* FN-7560 regression: release disclaimers must not self-incriminate.
|
||||
* Symptom: FN-7525/FN-7554/FN-7556 (revert/undo/UI tasks) were parked in
|
||||
* awaiting-release-authorization solely because their AI-authored specs said
|
||||
* they perform NO release while naming `scripts/release.mjs` as the owner.
|
||||
* Surface enumeration below covers every documented signal in both its negated
|
||||
* (disclaimer → not release-class) and actionable (intent → still release-class)
|
||||
* form so the invariant holds across all known signal surfaces, not just the repro.
|
||||
*/
|
||||
describe("negated release disclaimers are not classified as release-class (FN-7560)", () => {
|
||||
const disclaimerRepros = [
|
||||
// FN-7525
|
||||
"This task does not perform any package release or publish (releases are owned by `scripts/release.mjs`).",
|
||||
// FN-7554
|
||||
"This task's delivery is the changeset FILE only — it performs no release/publish (`scripts/release.mjs` owns releases).",
|
||||
// FN-7556
|
||||
"Delivery is the changeset FILE only; this task performs no package release or publish (releases are owned by `scripts/release.mjs`).",
|
||||
];
|
||||
|
||||
for (const promptText of disclaimerRepros) {
|
||||
it(`does not flag disclaimer: ${promptText.slice(0, 48)}…`, () => {
|
||||
const classification = classifyReleaseTask({ promptText });
|
||||
expect(classification.isReleaseClass, promptText).toBe(false);
|
||||
expect(classification.signals, promptText).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("clears the awaiting-release-authorization hold for the real FN-7525 shape", () => {
|
||||
const decision = evaluateReleaseAuthorizationGate({
|
||||
sourceType: "agent_heartbeat",
|
||||
title: "Add Revert/Undo affordance to Done and Archived task cards",
|
||||
promptText:
|
||||
"## Scope\nThis task does not perform any package release or publish (releases are owned by `scripts/release.mjs`).\n\n## Git Commit Convention\nCommits at step boundaries.",
|
||||
});
|
||||
expect(decision.action).toBe("allow");
|
||||
expect(decision.isReleaseClass).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("still flags genuine release intent even alongside a disclaimer clause", () => {
|
||||
// A real release instruction lives in its own non-negated clause and must survive stripping.
|
||||
const classification = classifyReleaseTask({
|
||||
promptText:
|
||||
"Run pnpm release --yes to publish @runfusion/fusion. This other task performs no release.",
|
||||
});
|
||||
expect(classification.isReleaseClass).toBe(true);
|
||||
expect(classification.signals).toContain("pnpm release");
|
||||
});
|
||||
|
||||
it("still flags every documented signal when phrased as an actionable instruction", () => {
|
||||
const actionable = [
|
||||
"Run pnpm release --yes now.",
|
||||
"Execute node scripts/release.mjs to cut the build.",
|
||||
"Run pnpm changeset publish to ship.",
|
||||
"Then npm publish the @runfusion/fusion tarball.",
|
||||
"Run pnpm publish @runfusion/fusion.",
|
||||
"Publish the package to npm as the final step.",
|
||||
"Create git tag v1.2.3 for the release.",
|
||||
"Author a version bump release commit for v1.2.3.",
|
||||
];
|
||||
for (const promptText of actionable) {
|
||||
expect(classifyReleaseTask({ promptText }).isReleaseClass, promptText).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("stripNegatedReleaseClauses drops disclaimer clauses but keeps actionable ones", () => {
|
||||
const stripped = stripNegatedReleaseClauses(
|
||||
"Run pnpm release to publish. This task performs no other release; releases are owned by scripts/release.mjs.",
|
||||
);
|
||||
expect(stripped).toMatch(/pnpm release/);
|
||||
expect(stripped).not.toMatch(/scripts\/release\.mjs/);
|
||||
expect(stripped).not.toMatch(/performs no/);
|
||||
});
|
||||
|
||||
it("handles empty and undefined inputs without throwing or flagging", () => {
|
||||
expect(classifyReleaseTask({})).toEqual({ isReleaseClass: false, signals: [] });
|
||||
expect(evaluateReleaseAuthorizationGate({ sourceType: undefined }).action).toBe("allow");
|
||||
expect(parseReleaseAuthorizationMarker("")).toBe(false);
|
||||
});
|
||||
|
||||
it("only treats the four explicit user-authored source types as user authored", () => {
|
||||
const userAuthored = ["dashboard_ui", "quick_chat", "chat_session", "cli"];
|
||||
const nonUserAuthored = [
|
||||
"agent_heartbeat",
|
||||
"automation",
|
||||
"cron",
|
||||
"workflow_step",
|
||||
"recovery",
|
||||
"research",
|
||||
"unknown",
|
||||
"github_import",
|
||||
"task_refine",
|
||||
"task_duplicate",
|
||||
"api",
|
||||
undefined,
|
||||
null,
|
||||
"future_source",
|
||||
];
|
||||
|
||||
for (const sourceType of userAuthored) {
|
||||
expect(isUserAuthoredSource(sourceType), sourceType).toBe(true);
|
||||
}
|
||||
for (const sourceType of nonUserAuthored) {
|
||||
expect(isUserAuthoredSource(sourceType), String(sourceType)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2768,15 +2768,15 @@ describe("requirePlanApproval setting", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:PlanApproval 2026-07-04-12:28:
|
||||
* FN-7526 — auto-approve-all must NOT bypass the independent release-authorization
|
||||
* gate. Both gates set status: "awaiting-approval", so this asserts the release
|
||||
* gate's own activity/log evidence (recordActivity type
|
||||
* "task:release-authorization-required", distinct log copy) fires instead of the
|
||||
* ordinary manual-approval log line, proving the release gate — not the manual
|
||||
* gate — is what parked the task.
|
||||
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00:
|
||||
* The triage release-authorization gate was removed (it over-fired on AI-authored
|
||||
* specs that merely mention release tooling and stranded ordinary tasks in
|
||||
* awaiting-approval). A release-class spec now flows through triage like any other
|
||||
* task; releases are kept out of Fusion by agent instruction, not an engine gate.
|
||||
* Two former gate regression tests (release-class parks under auto-approve-all;
|
||||
* release-vs-manual awaitingApprovalReason distinction) were deleted with it.
|
||||
*/
|
||||
it("release-authorization gate still parks a release-class task even when auto-approve-all is on", async () => {
|
||||
it("does not park a release-class task in awaiting-approval when auto-approve-all is on", async () => {
|
||||
const task = createTriageTask({
|
||||
id: "FN-RELEASE",
|
||||
title: "Release @runfusion/fusion patch",
|
||||
@@ -2798,74 +2798,8 @@ describe("requirePlanApproval setting", () => {
|
||||
{ requirePlanApproval: false, planApprovalMode: "auto-approve-all" } as Settings,
|
||||
);
|
||||
|
||||
/*
|
||||
* FN-7559: also assert the release gate stamps awaitingApprovalReason so
|
||||
* the dashboard can distinguish this hold from a manual-approval hold that
|
||||
* shares the same status — this is the actual fix for "tasks wait for
|
||||
* approval even though auto-approve is on".
|
||||
*/
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-RELEASE", expect.objectContaining({ status: "awaiting-approval", awaitingApprovalReason: "release-authorization" }));
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "task:release-authorization-required" }));
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-RELEASE",
|
||||
"Release authorization required — leaving task in triage awaiting release authorization",
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:PlanApproval 2026-07-04-21:35:
|
||||
* FN-7559 — root cause + fix regression test. Confirms all three symptom cases
|
||||
* from the task's "Symptom Verification" section in one place: (a) a
|
||||
* release-class hold still parks with a distinct awaitingApprovalReason even
|
||||
* under auto-approve-all, (b) the manual gate's own awaiting-approval write
|
||||
* always clears/omits that reason (never "release-authorization"), proving the
|
||||
* operator can always tell the two holds apart from the persisted task state
|
||||
* alone — not just from log text.
|
||||
*/
|
||||
it("FN-7559: release-authorization hold carries a reason distinct from the manual gate's own awaiting-approval write", async () => {
|
||||
const releaseTask = createTriageTask({
|
||||
id: "FN-RELEASE2",
|
||||
title: "Release @runfusion/fusion patch",
|
||||
status: "planning",
|
||||
sourceType: "agent_heartbeat",
|
||||
} as Partial<Task>);
|
||||
const releaseStore = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(releaseTask),
|
||||
} as Partial<TaskStore>);
|
||||
const releaseProcessor = new TriageProcessor(releaseStore, rootDir);
|
||||
await (releaseProcessor as unknown as {
|
||||
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
|
||||
}).finalizeApprovedTask(
|
||||
releaseTask,
|
||||
"# Task: FN-RELEASE2 - Release @runfusion/fusion patch\n\n## Mission\n\nRun pnpm release --yes.\n",
|
||||
{ requirePlanApproval: false, planApprovalMode: "auto-approve-all" } as Settings,
|
||||
);
|
||||
const releaseUpdateCall = (releaseStore.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call: unknown[]) => (call[1] as Record<string, unknown>)?.status === "awaiting-approval",
|
||||
);
|
||||
expect(releaseUpdateCall?.[1]).toMatchObject({ awaitingApprovalReason: "release-authorization" });
|
||||
|
||||
const manualTask = createTriageTask({
|
||||
id: "FN-MANUAL2",
|
||||
status: "planning",
|
||||
} as Partial<Task>);
|
||||
const manualStore = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(manualTask),
|
||||
} as Partial<TaskStore>);
|
||||
const manualProcessor = new TriageProcessor(manualStore, rootDir);
|
||||
await (manualProcessor as unknown as {
|
||||
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
|
||||
}).finalizeApprovedTask(
|
||||
manualTask,
|
||||
"# Task: FN-MANUAL2 - Ordinary task\n\n## Mission\n\nDo the thing.\n",
|
||||
{ requirePlanApproval: true, planApprovalMode: "require-all" } as Settings,
|
||||
);
|
||||
const manualUpdateCall = (manualStore.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call: unknown[]) => (call[1] as Record<string, unknown>)?.status === "awaiting-approval",
|
||||
);
|
||||
expect(manualUpdateCall?.[1]).toMatchObject({ awaitingApprovalReason: null });
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-RELEASE", expect.objectContaining({ awaitingApprovalReason: "release-authorization" }));
|
||||
expect(recordActivity).not.toHaveBeenCalledWith(expect.objectContaining({ type: "task:release-authorization-required" }));
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
FNXC:ReleaseAuthorizationGate 2026-06-15-02:41:
|
||||
FN-6481 closes the FN-6469 policy gap: release-class triage specs must not auto-dispatch unless the task was created from a user-authored surface and its PROMPT.md carries an explicit user authorization marker.
|
||||
Agents and automation can write PROMPT.md, so the marker is ignored for every non-user SourceType; unknown or future source values fail closed by being treated as non-user-authored.
|
||||
*/
|
||||
|
||||
const USER_AUTHORED_SOURCE_TYPES = new Set(["dashboard_ui", "quick_chat", "chat_session", "cli"]);
|
||||
|
||||
export interface ReleaseTaskClassificationInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
promptText?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseTaskClassification {
|
||||
isReleaseClass: boolean;
|
||||
signals: string[];
|
||||
}
|
||||
|
||||
export interface ReleaseAuthorizationGateInput extends ReleaseTaskClassificationInput {
|
||||
sourceType: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ReleaseAuthorizationGateDecision extends ReleaseTaskClassification {
|
||||
action: "allow" | "block";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface ReleaseSignalPattern {
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
const RELEASE_SIGNAL_PATTERNS: ReleaseSignalPattern[] = [
|
||||
{ label: "pnpm release", pattern: /\bpnpm\s+release\b/i },
|
||||
{ label: "scripts/release.mjs", pattern: /(?:^|[^\w.-])scripts\/release\.mjs\b/i },
|
||||
{ label: "changeset publish", pattern: /\b(?:pnpm\s+)?changeset\s+publish\b/i },
|
||||
{ label: "npm publish @runfusion/fusion", pattern: /\bnpm\s+publish\b[\s\S]{0,240}@runfusion\/fusion\b|@runfusion\/fusion\b[\s\S]{0,240}\bnpm\s+publish\b/i },
|
||||
{ label: "pnpm publish @runfusion/fusion", pattern: /\bpnpm\s+publish\b[\s\S]{0,240}@runfusion\/fusion\b|@runfusion\/fusion\b[\s\S]{0,240}\bpnpm\s+publish\b/i },
|
||||
{ label: "publish to npm", pattern: /\bpublish\b[\s\S]{0,160}\b(?:to|on)\s+npm\b|\bnpm\b[\s\S]{0,160}\bpublish\b/i },
|
||||
{ label: "git tag v<semver>", pattern: /\b(?:git\s+)?tag\s+v\d+\.\d+\.\d+(?:[-+][0-9a-z.-]+)?\b/i },
|
||||
{ label: "version-bump release commit", pattern: /\b(?:version\s*bump|bump\s+version|release\s+commit|release\s+version)\b[\s\S]{0,120}\bv\d+\.\d+\.\d+\b|\bv\d+\.\d+\.\d+\b[\s\S]{0,120}\b(?:version\s*bump|bump\s+version|release\s+commit|release\s+version)\b/i },
|
||||
];
|
||||
|
||||
/*
|
||||
FNXC:ReleaseAuthorizationGate 2026-07-05-15:40:
|
||||
FN-7560: classifyReleaseTask matched a bare mention of a release signal (e.g. `scripts/release.mjs`) even when it appeared inside a disclaimer clause that explicitly states the task does NOT release — "this task performs no release/publish (releases are owned by `scripts/release.mjs`)". AI-authored specs routinely append such disclaimers, so revert/undo/UI tasks (FN-7525, FN-7554, FN-7556) were false-flagged as release-class and parked in awaiting-release-authorization with no in-band exit (their non-user sources make the authorization marker inert). Strip negated release-disclaimer clauses before signal matching so a spec that disclaims releasing does not self-incriminate. Genuine release intent survives because "run pnpm release" / "publish @runfusion/fusion" lives in a non-negated clause and is evaluated normally.
|
||||
*/
|
||||
const RELEASE_NEGATION_PATTERNS: RegExp[] = [
|
||||
// "performs no release", "performs no package release/publish"
|
||||
/\bperforms?\s+no\s+(?:[\w-]+\s+){0,3}?(?:release|publish)/i,
|
||||
// "does not perform any package release", "will not publish", "doesn't release"
|
||||
/\b(?:does|do|did|will|would|shall|can|could|should)(?:\s+not|n['’]?t)\b\s+(?:[\w-]+\s+){0,4}?(?:release|publish)/i,
|
||||
// "no release/publish", "no package/actual release"
|
||||
/\bno\s+(?:[\w-]+\s+){0,2}?(?:release|publish)\b/i,
|
||||
// "releases are owned by scripts/release.mjs" — ownership disclaimer, not intent
|
||||
/\breleases?\s+are\s+owned\s+by\b/i,
|
||||
// "never release/publish"
|
||||
/\bnever\s+(?:[\w-]+\s+){0,3}?(?:release|publish)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* FNXC:ReleaseAuthorizationGate 2026-07-05-15:40:
|
||||
* Split into clause-sized segments (sentence terminators and line breaks) and
|
||||
* drop any segment carrying a release-negation cue, keeping segments small so
|
||||
* removing one disclaimer clause never discards an adjacent genuine release
|
||||
* instruction. Returns the surviving text for signal matching.
|
||||
*/
|
||||
export function stripNegatedReleaseClauses(text: string): string {
|
||||
return text
|
||||
.split(/(?<=[.!?;])\s+|\n+/)
|
||||
.filter((clause) => !RELEASE_NEGATION_PATTERNS.some((pattern) => pattern.test(clause)))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function isUserAuthoredSource(sourceType: string | null | undefined): boolean {
|
||||
return typeof sourceType === "string" && USER_AUTHORED_SOURCE_TYPES.has(sourceType);
|
||||
}
|
||||
|
||||
export function classifyReleaseTask(input: ReleaseTaskClassificationInput): ReleaseTaskClassification {
|
||||
const rawText = [input.title, input.description, input.promptText]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.join("\n\n");
|
||||
|
||||
if (!rawText.trim()) {
|
||||
return { isReleaseClass: false, signals: [] };
|
||||
}
|
||||
|
||||
// Evaluate signals only against clauses that are not release disclaimers, so a
|
||||
// spec that says "this task performs no release" is not flagged as one.
|
||||
const text = stripNegatedReleaseClauses(rawText);
|
||||
|
||||
const signals: string[] = [];
|
||||
for (const { label, pattern } of RELEASE_SIGNAL_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
signals.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
return { isReleaseClass: signals.length > 0, signals };
|
||||
}
|
||||
|
||||
export function parseReleaseAuthorizationMarker(promptText: string): boolean {
|
||||
return /^\s*\*\*Release Authorized By User:\*\*\s*yes\s*$/im.test(promptText);
|
||||
}
|
||||
|
||||
export function evaluateReleaseAuthorizationGate(input: ReleaseAuthorizationGateInput): ReleaseAuthorizationGateDecision {
|
||||
const classification = classifyReleaseTask(input);
|
||||
if (!classification.isReleaseClass) {
|
||||
return {
|
||||
action: "allow",
|
||||
...classification,
|
||||
reason: "Task does not contain release/publish intent signals.",
|
||||
};
|
||||
}
|
||||
|
||||
const userAuthored = isUserAuthoredSource(input.sourceType);
|
||||
const hasMarker = parseReleaseAuthorizationMarker(input.promptText ?? "");
|
||||
if (userAuthored && hasMarker) {
|
||||
return {
|
||||
action: "allow",
|
||||
...classification,
|
||||
reason: "Release-class task was created from a user-authored source and includes an explicit user authorization marker.",
|
||||
};
|
||||
}
|
||||
|
||||
const sourceLabel = input.sourceType ?? "unknown";
|
||||
return {
|
||||
action: "block",
|
||||
...classification,
|
||||
reason: userAuthored
|
||||
? `Release-class task from user-authored source '${sourceLabel}' is missing **Release Authorized By User:** yes.`
|
||||
: `Release-class task from non-user-authored source '${sourceLabel}' requires operator review; PROMPT.md markers are ignored for this source.`,
|
||||
};
|
||||
}
|
||||
@@ -139,7 +139,6 @@ import {
|
||||
isResearchToolSurfaceEnabled,
|
||||
} from "./tool-availability.js";
|
||||
import { runGhostBugPreflight } from "./triage-preflight.js";
|
||||
import { evaluateReleaseAuthorizationGate } from "./triage-release-authorization.js";
|
||||
import { archiveAsGhostBug } from "./self-healing.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
|
||||
@@ -2513,63 +2512,10 @@ export class TriageProcessor {
|
||||
planLog.warn(`${task.id}: failed to re-read task before planning transition (${message}); proceeding with original task snapshot`);
|
||||
latestTransitionTask = task;
|
||||
}
|
||||
try {
|
||||
/**
|
||||
* FNXC:ReleaseAuthorizationGate 2026-06-15-02:47:
|
||||
* FN-6469 showed that agent-authored release specs can otherwise flow from triage directly to execution and publish npm packages. FN-6481 parks release-class tasks before every final triage dispatch branch unless a user-authored source supplied the explicit authorization marker.
|
||||
*/
|
||||
const releaseGateDecision = evaluateReleaseAuthorizationGate({
|
||||
sourceType: latestTransitionTask?.sourceType ?? task.sourceType,
|
||||
title: latestTransitionTask?.title ?? task.title ?? "",
|
||||
description: latestTransitionTask?.description ?? task.description ?? "",
|
||||
promptText: written,
|
||||
});
|
||||
if (releaseGateDecision.action === "block") {
|
||||
/*
|
||||
* FNXC:ReleaseAuthorizationGate 2026-07-04-21:35:
|
||||
* FN-7559: stamp awaitingApprovalReason so the dashboard can tell this
|
||||
* release-authorization hold apart from the (independently gated, never
|
||||
* bypassed by auto-approve-all) manual plan-approval hold, which shares
|
||||
* the same status: "awaiting-approval". See FNXC:PlanApproval in types.ts.
|
||||
*/
|
||||
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval", awaitingApprovalReason: "release-authorization" };
|
||||
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
|
||||
approvalUpdates.title = promptDeclaredTitle;
|
||||
}
|
||||
const signals = releaseGateDecision.signals.length > 0
|
||||
? releaseGateDecision.signals.join(", ")
|
||||
: "release intent";
|
||||
const details = `${releaseGateDecision.reason} Matched signals: ${signals}.`;
|
||||
await this.store.updateTask(task.id, approvalUpdates);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"Release authorization required — leaving task in triage awaiting release authorization",
|
||||
details,
|
||||
);
|
||||
try {
|
||||
await this.store.recordActivity({
|
||||
type: "task:release-authorization-required",
|
||||
taskId: task.id,
|
||||
taskTitle: promptDeclaredTitle ?? latestTransitionTask?.title ?? task.title ?? "",
|
||||
details,
|
||||
metadata: {
|
||||
reason: releaseGateDecision.reason,
|
||||
signals: releaseGateDecision.signals,
|
||||
sourceType: latestTransitionTask?.sourceType ?? task.sourceType ?? "unknown",
|
||||
},
|
||||
});
|
||||
} catch (activityError: unknown) {
|
||||
const message = activityError instanceof Error ? activityError.message : String(activityError);
|
||||
planLog.warn(`${task.id}: failed to record release-authorization-required activity (${message})`);
|
||||
}
|
||||
planLog.log(`${task.id} release authorization required — leaving in triage awaiting release authorization (${signals})`);
|
||||
return;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
planLog.warn(`${task.id}: release-authorization gate failed open: ${message}`);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ReleaseAuthorizationGate 2026-07-09-00:00:
|
||||
Removed the triage release-authorization gate (FN-6481/FN-6469). It over-fired: AI-authored specs routinely mention release tooling (`scripts/release.mjs`, `pnpm release`) in disclaimers and file-scope notes, and every non-user source made the in-band authorization marker inert, so ordinary revert/UI/refactor tasks were stranded in awaiting-approval with no exit (see FN-7560, FN-7525, FN-7554, FN-7556). Releases are now kept out of Fusion by agent instruction (AGENTS.md → "Releasing") instead of an engine gate: agents must never run `pnpm release`/publish from inside a Fusion task.
|
||||
*/
|
||||
if (latestTransitionTask?.paused === true || latestTransitionTask?.userPaused === true) {
|
||||
const restoreStatus = options.isReplan ? "needs-replan" : null;
|
||||
await this.store.updateTask(task.id, { status: restoreStatus });
|
||||
|
||||
Reference in New Issue
Block a user