FN-6481: require release triage authorization
Block release-class tasks from automatic triage dispatch unless they come from a user-authored source with explicit authorization. - Add release intent classification and authorization-marker enforcement before final triage transitions. - Record activity/log details when release tasks are parked awaiting manual approval. - Surface the new release-authorization activity in dashboard activity views. - Cover release gating behavior with engine tests and document the architecture pattern. - Add a changeset for the published CLI package. Files changed: .changeset/fn-6481-release-triage-authorization.md | 5 + .../release-triage-requires-user-authorization.md | 33 +++++ packages/core/src/types.ts | 2 + packages/core/vitest.config.ts | 4 + packages/dashboard/app/components/ActivityFeed.tsx | 5 + .../dashboard/app/components/ActivityLogModal.tsx | 6 + .../__tests__/triage-release-authorization.test.ts | 158 +++++++++++++++++++++ .../engine/src/triage-release-authorization.ts | 100 +++++++++++++ packages/engine/src/triage.ts | 51 +++++++ scripts/lib/test-quarantine.json | 8 +- 10 files changed, 371 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-6481 Fusion-Task-Lineage: 0bddb77a-87e5-4fa5-b31a-e773bdae7a29
This commit is contained in:
5
.changeset/fn-6481-release-triage-authorization.md
Normal file
5
.changeset/fn-6481-release-triage-authorization.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Block release and publish-class tasks during triage unless they were explicitly authorized by a user-authored source.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
category: architecture
|
||||||
|
module: engine
|
||||||
|
tags:
|
||||||
|
- triage
|
||||||
|
- release-safety
|
||||||
|
- authorization
|
||||||
|
problem_type: security
|
||||||
|
applies_when:
|
||||||
|
- triage finalizes tasks that mention package release or publish commands
|
||||||
|
- agents or automation can create follow-up tasks
|
||||||
|
---
|
||||||
|
|
||||||
|
# Release-class triage requires explicit user authorization
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Autonomous agents can draft tasks that mention release mechanics such as `pnpm release --yes`, `scripts/release.mjs`, changeset publish, npm publish, semver tags, or release-version commits. Without a triage boundary, an agent-authored release task can be dispatched to execution and reach publish-class commands without a user intentionally authorizing the release.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Release authorization is enforced as a pure triage gate before finalize dispatch moves work to `todo`:
|
||||||
|
|
||||||
|
1. Classify release-class tasks from the combined title, description, and prompt text.
|
||||||
|
2. For release-class tasks, require a user-authored source (`dashboard_ui`, `quick_chat`, `chat_session`, or `cli`).
|
||||||
|
3. Require the prompt marker `**Release Authorized By User:** yes` for those user-authored sources.
|
||||||
|
4. Fail closed for unknown, internal, API, imported, duplicated, refined, workflow, recovery, research, cron, and agent-authored sources.
|
||||||
|
|
||||||
|
The marker alone is intentionally insufficient. A non-user source that embeds the marker remains blocked because agents and integrations can write prompt text.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Use the pure classifier tests in `packages/engine/src/__tests__/triage-release-authorization.test.ts` to cover the invariant without store, network, or timer dependencies. The test matrix should include the FN-6469 incident shape, all documented release signal patterns, all user-authored sources, representative non-user sources, marker parsing, and non-release pass-through behavior.
|
||||||
@@ -1240,6 +1240,8 @@ export type ActivityEventType =
|
|||||||
| "task:auto-archived-deterministic-duplicate"
|
| "task:auto-archived-deterministic-duplicate"
|
||||||
| "task:auto-archived-near-duplicate"
|
| "task:auto-archived-near-duplicate"
|
||||||
| "task:near-duplicate-flagged"
|
| "task:near-duplicate-flagged"
|
||||||
|
/** FNXC:ReleaseAuthorizationGate 2026-06-15-02:44: Release-class tasks parked by triage need a distinct activity so operators can see that explicit user approval is required before dispatch. */
|
||||||
|
| "task:release-authorization-required"
|
||||||
| "task:auto-archived-ghost-bug"
|
| "task:auto-archived-ghost-bug"
|
||||||
| "task:auto-archived-duplicate"
|
| "task:auto-archived-duplicate"
|
||||||
| "task:merge-worktree-reacquired"
|
| "task:merge-worktree-reacquired"
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ const quarantinedCoreTests = [
|
|||||||
|
|
||||||
FNXC:CoreTests 2026-06-14-02:14:
|
FNXC:CoreTests 2026-06-14-02:14:
|
||||||
FN-6433 re-ran the core quarantine batch after FN-6430's shared fixture cleanup and rescued all five files without timeout or assertion changes. Keep this array empty unless a future quarantine is mirrored in scripts/lib/test-quarantine.json in the same commit.
|
FN-6433 re-ran the core quarantine batch after FN-6430's shared fixture cleanup and rescued all five files without timeout or assertion changes. Keep this array empty unless a future quarantine is mirrored in scripts/lib/test-quarantine.json in the same commit.
|
||||||
|
|
||||||
|
FNXC:CoreTests 2026-06-15-03:13:
|
||||||
|
FN-6481 observed the disk-backed concurrent write test fail in the changed-package workspace lane with a transient SQLite BEGIN IMMEDIATE lock after the gate had already passed. Quarantine the flaky file instead of widening lock-recovery timeouts or weakening assertions.
|
||||||
*/
|
*/
|
||||||
|
"src/__tests__/store-concurrent-writes.test.ts",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ const TYPE_CONFIG: Record<ActivityFeedEntry["type"], {
|
|||||||
"task:deleted": { label: "Deleted", icon: XCircle, color: "var(--color-error)" },
|
"task:deleted": { label: "Deleted", icon: XCircle, color: "var(--color-error)" },
|
||||||
"task:merged": { label: "Merged", icon: GitMerge, color: "var(--color-success)" },
|
"task:merged": { label: "Merged", icon: GitMerge, color: "var(--color-success)" },
|
||||||
"task:failed": { label: "Failed", icon: AlertTriangle, color: "var(--color-error)" },
|
"task:failed": { label: "Failed", icon: AlertTriangle, color: "var(--color-error)" },
|
||||||
|
/*
|
||||||
|
FNXC:ReleaseAuthorizationGate 2026-06-15-04:00:
|
||||||
|
Release-authorization blocks are operator-actionable security events, so activity surfaces must render the event instead of hiding it behind an exhaustive type gap.
|
||||||
|
*/
|
||||||
|
"task:release-authorization-required": { label: "Release Authorization Required", icon: AlertTriangle, color: "var(--color-warning)" },
|
||||||
"task:duplicate-warning-overridden": { label: "Duplicate Override", icon: AlertTriangle, color: "var(--color-warning)" },
|
"task:duplicate-warning-overridden": { label: "Duplicate Override", icon: AlertTriangle, color: "var(--color-warning)" },
|
||||||
"task:auto-archived-ghost-bug": { label: "Auto-Archived (Ghost Bug)", icon: AlertTriangle, color: "var(--color-warning)" },
|
"task:auto-archived-ghost-bug": { label: "Auto-Archived (Ghost Bug)", icon: AlertTriangle, color: "var(--color-warning)" },
|
||||||
"task:auto-archived-duplicate": { label: "Auto-Archived (Duplicate)", icon: Trash2, color: "var(--text-muted)" },
|
"task:auto-archived-duplicate": { label: "Auto-Archived (Duplicate)", icon: Trash2, color: "var(--text-muted)" },
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function getEventTypeLabels(t: TFunction<"app">): Record<ActivityEventType, stri
|
|||||||
"task:deleted": t("activityLog.eventType.taskDeleted", "Task Deleted"),
|
"task:deleted": t("activityLog.eventType.taskDeleted", "Task Deleted"),
|
||||||
"task:merged": t("activityLog.eventType.taskMerged", "Task Merged"),
|
"task:merged": t("activityLog.eventType.taskMerged", "Task Merged"),
|
||||||
"task:failed": t("activityLog.eventType.taskFailed", "Task Failed"),
|
"task:failed": t("activityLog.eventType.taskFailed", "Task Failed"),
|
||||||
|
"task:release-authorization-required": t("activityLog.eventType.releaseAuthorizationRequired", "Release Authorization Required"),
|
||||||
"task:duplicate-warning-overridden": t("activityLog.eventType.duplicateWarningOverridden", "Duplicate Warning Overridden"),
|
"task:duplicate-warning-overridden": t("activityLog.eventType.duplicateWarningOverridden", "Duplicate Warning Overridden"),
|
||||||
"task:auto-archived-ghost-bug": t("activityLog.eventType.autoArchivedGhostBug", "Task Auto-Archived (Ghost Bug)"),
|
"task:auto-archived-ghost-bug": t("activityLog.eventType.autoArchivedGhostBug", "Task Auto-Archived (Ghost Bug)"),
|
||||||
"task:auto-archived-duplicate": t("activityLog.eventType.autoArchivedDuplicate", "Task Auto-Archived (Duplicate)"),
|
"task:auto-archived-duplicate": t("activityLog.eventType.autoArchivedDuplicate", "Task Auto-Archived (Duplicate)"),
|
||||||
@@ -52,6 +53,11 @@ const EVENT_TYPE_ICONS: Record<ActivityEventType, React.ReactNode> = {
|
|||||||
"task:deleted": <X size={14} className="activity-icon deleted" />,
|
"task:deleted": <X size={14} className="activity-icon deleted" />,
|
||||||
"task:merged": <CheckCircle size={14} className="activity-icon merged" />,
|
"task:merged": <CheckCircle size={14} className="activity-icon merged" />,
|
||||||
"task:failed": <XCircle size={14} className="activity-icon failed" />,
|
"task:failed": <XCircle size={14} className="activity-icon failed" />,
|
||||||
|
/*
|
||||||
|
FNXC:ReleaseAuthorizationGate 2026-06-15-04:00:
|
||||||
|
The release gate parks unauthorized publish-class tasks; activity logs must expose that blocked state with warning styling so a human can authorize or revise the task.
|
||||||
|
*/
|
||||||
|
"task:release-authorization-required": <AlertCircle size={14} className="activity-icon updated" />,
|
||||||
"task:duplicate-warning-overridden": <AlertCircle size={14} className="activity-icon updated" />,
|
"task:duplicate-warning-overridden": <AlertCircle size={14} className="activity-icon updated" />,
|
||||||
"task:auto-archived-ghost-bug": <AlertCircle size={14} className="activity-icon failed" />,
|
"task:auto-archived-ghost-bug": <AlertCircle size={14} className="activity-icon failed" />,
|
||||||
"task:auto-archived-duplicate": <Trash2 size={14} className="activity-icon deleted" />,
|
"task:auto-archived-duplicate": <Trash2 size={14} className="activity-icon deleted" />,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
classifyReleaseTask,
|
||||||
|
evaluateReleaseAuthorizationGate,
|
||||||
|
isUserAuthoredSource,
|
||||||
|
parseReleaseAuthorizationMarker,
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
100
packages/engine/src/triage-release-authorization.ts
Normal file
100
packages/engine/src/triage-release-authorization.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 text = [input.title, input.description, input.promptText]
|
||||||
|
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
return { isReleaseClass: false, signals: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ import {
|
|||||||
isResearchToolSurfaceEnabled,
|
isResearchToolSurfaceEnabled,
|
||||||
} from "./tool-availability.js";
|
} from "./tool-availability.js";
|
||||||
import { runGhostBugPreflight } from "./triage-preflight.js";
|
import { runGhostBugPreflight } from "./triage-preflight.js";
|
||||||
|
import { evaluateReleaseAuthorizationGate } from "./triage-release-authorization.js";
|
||||||
import { archiveAsGhostBug } from "./self-healing.js";
|
import { archiveAsGhostBug } from "./self-healing.js";
|
||||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||||
import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
|
import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
|
||||||
@@ -2300,6 +2301,56 @@ export class TriageProcessor {
|
|||||||
planLog.warn(`${task.id}: failed to re-read task before approved-spec transition (${message}); proceeding with original task snapshot`);
|
planLog.warn(`${task.id}: failed to re-read task before approved-spec transition (${message}); proceeding with original task snapshot`);
|
||||||
latestTransitionTask = task;
|
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") {
|
||||||
|
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };
|
||||||
|
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 manual approval",
|
||||||
|
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 manual approval (${signals})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
planLog.warn(`${task.id}: release-authorization gate failed open: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (latestTransitionTask?.paused === true || latestTransitionTask?.userPaused === true) {
|
if (latestTransitionTask?.paused === true || latestTransitionTask?.userPaused === true) {
|
||||||
const restoreStatus = options.isReplan ? "needs-replan" : null;
|
const restoreStatus = options.isReplan ? "needs-replan" : null;
|
||||||
await this.store.updateTask(task.id, { status: restoreStatus });
|
await this.store.updateTask(task.id, { status: restoreStatus });
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.",
|
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.",
|
||||||
"entries": []
|
"entries": [
|
||||||
|
{
|
||||||
|
"file": "packages/core/src/__tests__/store-concurrent-writes.test.ts",
|
||||||
|
"reason": "FN-6481 pnpm test 2026-06-15 failed unrelated core SQLite lock recovery concurrency test with `SQLite BEGIN IMMEDIATE failed after 7 attempts: database is locked`; quarantine on sight per flaky-test policy.",
|
||||||
|
"quarantinedAt": "2026-06-15"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user