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:
gsxdsm
2026-07-09 10:08:17 -07:00
parent 7420abe80e
commit e4a59f7269
13 changed files with 91 additions and 624 deletions

View 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.

View File

@@ -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

View File

@@ -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";
/*

View File

@@ -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));

View File

@@ -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}

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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)

View File

@@ -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");

View File

@@ -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);
}
});
});

View File

@@ -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" }));
});
/*

View File

@@ -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.`,
};
}

View File

@@ -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 });