FN-8987: expose release-gate verdicts for Promote visibility

Expose server-evaluated release-gate state so Promote controls accurately reflect approval readiness.

- Attach transient release-gate verdicts to task API responses.
- Preserve only fresh REST verdicts across task snapshots and render them in task cards.
- Align hold-release gating and document the Promote-state contract.

Files changed:
 .changeset/fn-8987-release-gate-verdict.md         |   7 +
 docs/dashboard-guide.md                            |   4 +
 docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md |   1 +
 docs/workflow-steps.md                             |   4 +
 packages/core/src/index.gate.ts                    |   2 +-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/types.ts                         |   2 +
 packages/core/src/types/task/task-core.ts          |  20 +++
 packages/dashboard/app/components/TaskCard.tsx     |   3 +-
 packages/dashboard/app/components/__tests__/TaskCard.test.tsx |  31 ++++
 packages/dashboard/app/hooks/__tests__/useTasks.test.ts |  26 ++++
 packages/dashboard/app/hooks/useTasks.ts           | 135 +++++++++++++++--
 packages/dashboard/app/utils/__tests__/releaseGate.contract.test.ts |  29 ++++
 packages/dashboard/app/utils/__tests__/releaseGate.test.ts |  49 ++++++
 packages/dashboard/app/utils/releaseGate.ts        |  26 ++++
 packages/dashboard/app/utils/reviewBudgetApproval.ts |   9 ++
 packages/dashboard/src/routes/register-task-workflow-routes.ts |  20 ++-
 packages/engine/src/__tests__/release-gate-verdict.test.ts |  40 +++++
 packages/engine/src/execution/hold-release.ts      | 166 +++++++++------------
 packages/engine/src/index.ts                       |   3 +
 20 files changed, 469 insertions(+), 110 deletions(-)

Fusion-Task-Id: FN-8987

Fusion-Task-Lineage: 6f1742bc-2b2b-4a32-9be5-92160335d90d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 14:20:12 -07:00
parent 6cc15fd73d
commit a6ce7f89ef
20 changed files with 469 additions and 110 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Promote now appears on every board card the server would actually release.
category: feature
dev: Adds evaluateTaskReleaseGate, transient Task.releaseGate API enrichment, and bounded client freshness handling.

View File

@@ -2434,3 +2434,7 @@ Activity includes durable and ephemeral agent sessions from heartbeat, executor,
## Plan alignment in Task Detail
The shared Task Detail Definition view shows the persisted spec alignment, latest lock/current-plan versions, and deterministic finding categories. `activeLock` is derived from the live approval fingerprint and current-plan hash; an unavailable or inactive lock is not presented as on-plan. A historical report from a prior lock or plan revision stays in retained history and displays as unavailable until a matching current report exists. Findings are structural; `mission-statement` identifies a changed Mission narrative hash without displaying or judging its prose. The same shared content is used by modal and right-dock task detail hosts.
### Promote release-gate enrichment
`GET /api/tasks` may attach a transient `releaseGate` verdict to hold-lane cards. It includes the resolved release target, pre-release Plan Review facts, and capacity-boundary state, so Promote visibility exactly matches the server while the verdict is fresh. SSE does not carry this field: `useTasks` retains it only while its visible-evidence fingerprint and task row clock match, and for at most `RELEASE_GATE_VERDICT_MAX_AGE_MS` (30 seconds). Otherwise the card uses the conservative client fallback because workflow IR, continuations, and prompt content are not browser-visible.

View File

@@ -78,3 +78,4 @@ nothing and its cards are invisible to every sweep that queries by role. The thr
project scope. The fix cannot be a changed default for the reason above — sweeps want the widening and
the aggregators do not — so it wants an opt-in
(`{ untraitedProject: "declared-columns" }`). Recorded at three call sites in `self-healing.ts`.
| `releaseGate` board enrichment | **union** | shares `awaitingPlanning`'s hold-lane union and request cap, with a per-request workflow-IR cache; it includes timestamped evidence because retaining a stale Promote decision is unsafe |

View File

@@ -1008,3 +1008,7 @@ A review node may persist `reviewerAgentId` in its IR. It is exact-node scoped a
Task creation resolves ownership once at the shared pre-insert boundary used by ordinary and reserved-ID creates. A durable owner must be a non-ephemeral, runtime-enabled executor not paused or errored and permitted by implementation assignment policy. A valid explicit owner wins; otherwise the first reachable execute-node column binding is used, then the deterministic executor pool. Planning and review principals remain work-item-scoped and never rewrite this owner.
`workflowId: null` disables workflow-step materialization only: it still resolves from the executor pool. The only internal exemption is an options-bag reason for terminal, historical, or fixture creation; no HTTP/tool/CLI payload can set it. Resolution outcomes are distinct: `selected` persists an owner; internal `exempt` deliberately persists null; `rejected` fails before insertion; and `unowned` succeeds only when no eligible executor exists, emitting `task:intake-owner-unresolved` for ordinary later assignment.
### Board visibility of pre-release Plan Review
Board hold-lane payloads can expose a transient `releaseGate` verdict. It makes the resolved pre-release Plan Review node, its column/default-on state, and a capacity-boundary continuation observable to Promote controls without duplicating workflow-gate rules in the browser.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -609,6 +609,7 @@ import type {
TaskRecommendation,
TaskRecommendationCategory,
Task,
TaskReleaseGateVerdict,
TaskVerificationResultSummary,
TaskVerificationRequest,
TaskDetail,
@@ -653,6 +654,7 @@ export type {
TaskRecommendation,
TaskRecommendationCategory,
Task,
TaskReleaseGateVerdict,
TaskVerificationResultSummary,
TaskVerificationRequest,
TaskDetail,

View File

@@ -643,6 +643,19 @@ export interface TaskRecommendation {
createdTaskId?: string;
}
export interface TaskReleaseGateVerdict {
promoteBlocked: boolean;
unplannedForExecution: boolean;
blockedOnApproval: boolean;
reason: "plan-review-pending" | "planning-status" | "needs-replan" | "duplicate-prompt" | "seed-prompt" | "awaiting-approval" | null;
readyAtCapacityBoundary: boolean;
planReview?: { nodeId: string; column: string; defaultOn: boolean; enabled: boolean; appliesToColumn: boolean; satisfied: boolean };
releaseTargetColumn?: string;
targetCountsTowardWip?: boolean;
evaluatedAt: string;
evaluatedForUpdatedAt?: string;
}
export interface Task {
id: string;
/** Immutable lineage identity used for durable commit/task attribution. */
@@ -1188,6 +1201,13 @@ export interface Task {
* step-count heuristic when the field is absent.
*/
awaitingPlanning?: boolean;
/**
* FNXC:PromoteVisibility 2026-08-11-20:38:
* GET /api/tasks attaches this best-effort hold-lane verdict only. It is transient: never store it
* in task.json or emit it over SSE; consumers must fall back when absent and must expire carried
* values under useTasks' evidence fingerprint, row-clock, and TTL contract.
*/
releaseGate?: TaskReleaseGateVerdict;
/** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */
assignedAgentId?: string;
/** Per-task node override. When set, this task routes to the specified node instead of the project's default node. Undefined means use the project default. Use empty string to explicitly clear. */

View File

@@ -63,6 +63,7 @@ import {
isReviewBudgetExhaustedApproval,
isTaskAwaitingPlanApproval,
isTaskBlockedOnApprovalHold,
resolvePromoteSuppressed,
} from "../utils/reviewBudgetApproval";
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
import type { ToastType } from "../hooks/useToast";
@@ -1615,7 +1616,7 @@ function TaskCardComponent({
|| planReviewGateUnsatisfied
|| isAwaitingApproval
|| isBlockedOnApprovalHold;
const showPromoteAction = Boolean(onPromote) && !isStillInPlanning;
const showPromoteAction = Boolean(onPromote) && !resolvePromoteSuppressed(task, isStillInPlanning);
const showIdleTodoBadge = !isPaused
&& isHoldColumn
&& !visualStatus

View File

@@ -8117,6 +8117,37 @@ describe("TaskCard mission badge", () => {
expect(pricedPromotable.container.querySelector(".card-promote-cost-row .card-cost-indicator")).not.toBeNull();
});
it("uses a fresh server release verdict before the conservative Promote fallback", () => {
const base = {
column: "todo",
status: null as any,
enabledWorkflowSteps: undefined,
workflowStepResults: undefined,
steps: [{ name: "Implement", status: "pending" }] as any,
};
const verdict = {
promoteBlocked: false,
unplannedForExecution: false,
blockedOnApproval: false,
reason: null,
readyAtCapacityBoundary: true,
evaluatedAt: "2026-08-11T20:00:00.000Z",
} as const;
const allowed = render(<TaskCard task={makeTask({ id: "FN-8987-allowed", ...base, releaseGate: verdict })} taskColumnFlags={{ hold: true }} onOpenDetail={noop} addToast={noop} onPromote={vi.fn()} />);
expect(screen.getByTestId("card-promote-FN-8987-allowed")).toBeInTheDocument();
allowed.unmount();
const blocked = render(<TaskCard task={makeTask({ id: "FN-8987-blocked", ...base, releaseGate: { ...verdict, promoteBlocked: true, unplannedForExecution: true, reason: "plan-review-pending" } })} taskColumnFlags={{ hold: true }} onOpenDetail={noop} addToast={noop} onPromote={vi.fn()} />);
expect(screen.queryByTestId("card-promote-FN-8987-blocked")).toBeNull();
expect(blocked.container.querySelector(".card-action-row")).toBeNull();
expect(blocked.container.querySelector(".card-promote-action")).toBeNull();
blocked.unmount();
const fallback = render(<TaskCard task={makeTask({ id: "FN-8987-fallback", ...base })} taskColumnFlags={{ hold: true }} onOpenDetail={noop} addToast={noop} onPromote={vi.fn()} />);
expect(screen.queryByTestId("card-promote-FN-8987-fallback")).toBeNull();
fallback.unmount();
});
it("does not render a promote action when onPromote is omitted", () => {
render(
<TaskCard

View File

@@ -164,6 +164,32 @@ describe("useTasks", () => {
expect(result.current.tasks[0].id).toBe("FN-001");
});
it("expires an idle release verdict without waiting for another snapshot", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const evaluatedAt = new Date().toISOString();
const task = createMockTask({
updatedAt: evaluatedAt,
releaseGate: {
promoteBlocked: false,
unplannedForExecution: false,
blockedOnApproval: false,
reason: null,
readyAtCapacityBoundary: false,
evaluatedAt,
evaluatedForUpdatedAt: evaluatedAt,
},
});
mockFetchTasks.mockResolvedValue([task]);
const { result } = renderHook(() => useTasks({ sseEnabled: false }));
await waitFor(() => expect(result.current.tasks[0]?.releaseGate).toBeDefined());
act(() => {
vi.advanceTimersByTime(30_001);
});
expect(result.current.tasks[0]?.releaseGate).toBeUndefined();
});
it("hydrates per-project cached tasks synchronously", () => {
mockReadCache.mockReturnValueOnce([createMockTask({ id: "FN-CACHED" })]);
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));

View File

@@ -1,3 +1,8 @@
import {
isReleaseGateVerdictFresh,
RELEASE_GATE_VERDICT_MAX_AGE_MS,
releaseGateEvidenceFingerprint,
} from "../utils/releaseGate";
import { useState, useEffect, useCallback, useRef } from "react";
import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
@@ -52,10 +57,13 @@ const TASK_CACHE_ROW_LIMITS = [500, 250, 100, 50] as const;
function toCachedTaskRow(task: Task): Task {
const log = (task as Task & { log?: unknown }).log;
if (!Array.isArray(log) || log.length === 0) {
return task;
}
const { log: _droppedLog, ...rest } = task as Task & { log?: unknown };
if ((!Array.isArray(log) || log.length === 0) && task.releaseGate === undefined) return task;
const { log: _droppedLog, releaseGate: _transientReleaseGate, ...rest } = task as Task & { log?: unknown };
/*
FNXC:PromoteVisibility 2026-08-11-21:06:
A cached release verdict has no hook-local evidence provenance and may already be expired. Persist
the task snapshot without it so cache hydration immediately takes the conservative Promote fallback.
*/
return rest as Task;
}
@@ -246,6 +254,39 @@ function carryAwaitingPlanning(current: Task, incoming: Task): boolean | undefin
return stepCountUnchanged ? current.awaitingPlanning : undefined;
}
/*
FNXC:PromoteVisibility 2026-08-11-20:38:
A wrong badge self-corrects, but a wrong Promote decision can start execution. SSE lacks the IR,
continuation, and prompt inputs, so retain a REST verdict only across identical visible evidence and
its server row clock, bounded by TTL; every doubt drops to the conservative fallback.
*/
function carryReleaseGate(
current: Task,
incoming: Task,
merged: Task,
provenance: Map<string, import("../utils/releaseGate").ReleaseGateProvenance> | undefined,
now = Date.now(),
): Task["releaseGate"] {
if (incoming.releaseGate !== undefined) {
const freshProvenance = { fingerprint: releaseGateEvidenceFingerprint(merged), capturedAt: now };
/*
FNXC:PromoteVisibility 2026-08-11-21:06:
A complete response may arrive after a newer row. Its verdict is evidence only when it was
evaluated for the row being rendered, rather than merely being a defined payload field.
*/
if (!isReleaseGateVerdictFresh(incoming.releaseGate, merged, freshProvenance, now)) {
provenance?.delete(current.id);
return undefined;
}
provenance?.set(current.id, freshProvenance);
return incoming.releaseGate;
}
if (!current.releaseGate) return undefined;
const retained = isReleaseGateVerdictFresh(current.releaseGate, merged, provenance?.get(current.id), now);
if (!retained) provenance?.delete(current.id);
return retained ? current.releaseGate : undefined;
}
/*
FNXC:TaskDetailStateStability 2026-08-05-02:55:
The scheduler can refresh the board while a task-detail host holds a newer queued dependency or
@@ -265,6 +306,8 @@ FNXC:TaskDetailStateStability 2026-08-09-07:13:
FN-8796 showed that an absent or equal local clock is not evidence of staleness.
*/
export interface TaskSnapshotMergeOptions {
/** Hook-local, non-persisted evidence captured when GET /api/tasks supplied a release verdict. */
releaseGateProvenance?: Map<string, import("../utils/releaseGate").ReleaseGateProvenance>;
/** A complete board/detail fetch can resolve an otherwise ambiguous legacy column clock. */
fullSnapshot?: boolean;
/** A canonical task:moved SSE payload names its destination, even when its clock ties the visible row. */
@@ -359,6 +402,14 @@ export function mergeTaskSnapshot<T extends Task>(
? carryAwaitingPlanning(current, incoming)
: current.awaitingPlanning;
/*
FNXC:PromoteVisibility 2026-08-11-21:06:
A delayed snapshot cannot attach a defined verdict over a newer task row.
*/
const acceptsReleaseGateSnapshot = acceptsIncomingSnapshot || acceptsEqualClockFields;
merged.releaseGate = acceptsReleaseGateSnapshot
? carryReleaseGate(current, incoming, merged as unknown as Task, options.releaseGateProvenance)
: current.releaseGate;
/*
FNXC:TaskStatusConsistency 2026-08-07-06:10:
`recentAgentActivityAt` is a client-only bridge from an agent-log event to the next task snapshot.
Preserve it while a stale payload is rejected so live Planning does not flash back to Queued. A
@@ -419,10 +470,6 @@ export function applyLocalTaskPatch<T extends Task>(current: T, patch: Partial<T
return merged as T;
}
function mergeIncomingTask(current: Task, incoming: Task, options?: TaskSnapshotMergeOptions): Task {
return mergeTaskSnapshot(current, incoming, options);
}
export interface UseTasksOptions {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-03:40:
@@ -495,7 +542,9 @@ export function useTasks(options?: UseTasksOptions) {
console.info("[swr-cache] hit tasks=", cachedTasks.length, "projectId=", projectId);
}
}
return Array.isArray(cachedTasks) ? filterActiveTasks(cachedTasks.map(normalizeTask)) : [];
return Array.isArray(cachedTasks)
? filterActiveTasks(cachedTasks.map(normalizeTask).map(({ releaseGate: _releaseGate, ...task }) => task as Task))
: [];
});
const [isStale, setIsStale] = useState(true);
const [lastRefreshErrorAt, setLastRefreshErrorAt] = useState<number | null>(null);
@@ -511,7 +560,63 @@ export function useTasks(options?: UseTasksOptions) {
const includeArchived = false;
const includeArchivedRef = useRef(includeArchived);
const tasksRef = useRef(tasks);
/*
FNXC:PromoteVisibility 2026-08-11-20:53:
This is deliberately hook-local rather than Task state or persistent cache. It records only the
browser-visible evidence paired with a REST verdict, so an SSE patch can retain that verdict only
while it remains provably valid; task removal and failed retention prune it.
*/
const releaseGateProvenanceRef = useRef(new Map<string, import("../utils/releaseGate").ReleaseGateProvenance>());
const fetchVersionRef = useRef(0);
const mergeIncomingTask = (current: Task, incoming: Task, mergeOptions?: TaskSnapshotMergeOptions): Task =>
mergeTaskSnapshot(current, incoming, { ...mergeOptions, releaseGateProvenance: releaseGateProvenanceRef.current });
/*
FNXC:PromoteVisibility 2026-08-11-21:06:
Freshness cannot be checked only when SSE or fetch merges a row: an idle board can receive no
further snapshots for longer than the verdict TTL. Wake at the earliest expiry and remove each
expired or unverifiable verdict, ensuring TaskCard never renders a stale server decision.
*/
useEffect(() => {
const now = Date.now();
let earliestExpiry = Number.POSITIVE_INFINITY;
let needsPrune = false;
for (const task of tasks) {
if (!task.releaseGate) continue;
if (!isReleaseGateVerdictFresh(task.releaseGate, task, releaseGateProvenanceRef.current.get(task.id), now)) {
needsPrune = true;
continue;
}
const evaluatedAt = Date.parse(task.releaseGate.evaluatedAt);
earliestExpiry = Math.min(earliestExpiry, evaluatedAt + RELEASE_GATE_VERDICT_MAX_AGE_MS);
}
const prune = () => {
setTasks((previous) => {
let changed = false;
const checkedAt = Date.now();
const next = previous.map((task) => {
if (!task.releaseGate || isReleaseGateVerdictFresh(task.releaseGate, task, releaseGateProvenanceRef.current.get(task.id), checkedAt)) {
return task;
}
changed = true;
releaseGateProvenanceRef.current.delete(task.id);
return { ...task, releaseGate: undefined };
});
if (changed) tasksRef.current = next;
return changed ? next : previous;
});
};
if (needsPrune) {
prune();
return;
}
if (!Number.isFinite(earliestExpiry)) return;
const timer = window.setTimeout(prune, Math.max(0, earliestExpiry - now) + 1);
return () => window.clearTimeout(timer);
}, [tasks]);
/*
FNXC:DashboardResume 2026-08-05-18:17:
A resumed list request is a point-in-time server snapshot, while task SSE is a later committed
@@ -649,6 +754,14 @@ export function useTasks(options?: UseTasksOptions) {
return;
}
const normalizedFetchedTasks = filterActiveTasks(fetchedTasks.map(normalizeTask));
for (const task of normalizedFetchedTasks) {
if (task.releaseGate !== undefined) {
releaseGateProvenanceRef.current.set(task.id, {
fingerprint: releaseGateEvidenceFingerprint(task),
capturedAt: Date.now(),
});
}
}
/*
FNXC:ArchivePagination 2026-07-08-01:30:
A generic refresh (SSE reconnect resync, tab-visibility regain, delete-
@@ -704,6 +817,10 @@ export function useTasks(options?: UseTasksOptions) {
const nextTasks = archivedCarryOver.length > 0
? [...reconciledFetchedTasks, ...archivedCarryOver]
: reconciledFetchedTasks;
const retainedTaskIds = new Set(nextTasks.map((task) => task.id));
for (const taskId of releaseGateProvenanceRef.current.keys()) {
if (!retainedTaskIds.has(taskId)) releaseGateProvenanceRef.current.delete(taskId);
}
tasksRef.current = nextTasks;
setTasks(nextTasks);
for (const [taskId, mutation] of liveTaskMutationsRef.current) {

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { evaluateTaskReleaseGate } from "@fusion/engine";
import type { WorkflowIr } from "@fusion/core";
import { resolvePromoteSuppressed } from "../reviewBudgetApproval";
import { isReleaseGateVerdictFresh, releaseGateEvidenceFingerprint } from "../releaseGate";
const ir: WorkflowIr = {
version: "v2", name: "release-gate-contract", columns: [
{ id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] },
],
nodes: [{ id: "start", kind: "start", column: "todo" }], edges: [],
} as WorkflowIr;
describe("release-gate client/server contract", () => {
it("uses the engine verdict verbatim and refuses stale evidence", async () => {
const task = {
id: "FN-8987-contract", title: "Specified task", description: "Specified task", column: "todo",
dependencies: [], status: null, enabledWorkflowSteps: undefined,
updatedAt: "2026-08-11T20:00:00.000Z",
} as any;
const verdict = await evaluateTaskReleaseGate({ getTasksDir: () => "/missing" } as any, task, { ir });
expect(verdict).toBeDefined();
expect(resolvePromoteSuppressed({ releaseGate: verdict }, true)).toBe(verdict!.promoteBlocked);
const provenance = { fingerprint: releaseGateEvidenceFingerprint(task), capturedAt: Date.now() };
expect(isReleaseGateVerdictFresh(verdict!, task, provenance, Date.parse(verdict!.evaluatedAt))).toBe(true);
expect(isReleaseGateVerdictFresh(verdict!, { ...task, updatedAt: "2026-08-11T20:00:00.001Z" }, provenance, Date.parse(verdict!.evaluatedAt))).toBe(false);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
RELEASE_GATE_VERDICT_MAX_AGE_MS,
isReleaseGateVerdictFresh,
releaseGateEvidenceFingerprint,
} from "../releaseGate";
const task = {
id: "FN-8987",
description: "test",
column: "todo",
status: null,
paused: false,
workflowIrPin: "builtin:coding",
enabledWorkflowSteps: undefined,
workflowStepResults: [],
steps: [{ name: "Plan", status: "pending" }],
updatedAt: "2026-08-11T20:00:00.000Z",
} as any;
const verdict = {
promoteBlocked: false,
unplannedForExecution: false,
blockedOnApproval: false,
reason: null,
readyAtCapacityBoundary: false,
evaluatedAt: "2026-08-11T20:00:00.000Z",
evaluatedForUpdatedAt: task.updatedAt,
} as const;
describe("release-gate freshness", () => {
it("fingerprints every browser-visible release input deterministically", () => {
const fingerprint = releaseGateEvidenceFingerprint(task);
expect(releaseGateEvidenceFingerprint({ ...task, enabledWorkflowSteps: ["code-review", "plan-review"] })).toBe(
releaseGateEvidenceFingerprint({ ...task, enabledWorkflowSteps: ["plan-review", "code-review"] }),
);
expect(releaseGateEvidenceFingerprint({ ...task, enabledWorkflowSteps: [] })).not.toBe(fingerprint);
expect(releaseGateEvidenceFingerprint({ ...task, pausedReason: "awaiting-approval" })).not.toBe(fingerprint);
expect(releaseGateEvidenceFingerprint({ ...task, workflowStepResults: [{ workflowStepId: "plan-review", status: "passed" }] })).not.toBe(fingerprint);
});
it("drops a verdict when its row clock, evidence, or bounded age becomes unsafe", () => {
const provenance = { fingerprint: releaseGateEvidenceFingerprint(task), capturedAt: 0 };
const now = Date.parse(verdict.evaluatedAt);
expect(isReleaseGateVerdictFresh(verdict, task, provenance, now)).toBe(true);
expect(isReleaseGateVerdictFresh(verdict, { ...task, status: "planning" }, provenance, now)).toBe(false);
expect(isReleaseGateVerdictFresh(verdict, { ...task, updatedAt: "2026-08-11T20:00:00.001Z" }, provenance, now)).toBe(false);
expect(isReleaseGateVerdictFresh(verdict, task, provenance, now + RELEASE_GATE_VERDICT_MAX_AGE_MS + 1)).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
import type { Task, TaskReleaseGateVerdict } from "@fusion/core";
export const RELEASE_GATE_VERDICT_MAX_AGE_MS = 30_000;
/** Stable browser-visible evidence; undefined and [] intentionally serialize differently. */
export function releaseGateEvidenceFingerprint(task: Pick<Task, "column" | "status" | "paused" | "pausedReason" | "workflowIrPin" | "workflowIrPinNodeId" | "enabledWorkflowSteps" | "workflowStepResults" | "steps">): string {
const review = (task.workflowStepResults ?? [])
.filter((entry) => entry.workflowStepId === "plan-review")
.map((entry) => [entry.workflowStepId, entry.status, entry.verdict ?? null, entry.supersededAt ?? null])
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
return JSON.stringify({
column: task.column, status: task.status ?? null, paused: task.paused ?? null, pausedReason: task.pausedReason ?? null,
workflowIrPin: task.workflowIrPin ?? null, workflowIrPinNodeId: task.workflowIrPinNodeId ?? null,
enabledWorkflowSteps: task.enabledWorkflowSteps === undefined ? "__undefined__" : [...task.enabledWorkflowSteps].sort(),
review, stepCount: task.steps?.length ?? 0,
});
}
export interface ReleaseGateProvenance { fingerprint: string; capturedAt: number; }
export function isReleaseGateVerdictFresh(verdict: TaskReleaseGateVerdict, task: Task, provenance: ReleaseGateProvenance | undefined, now: number): boolean {
if (!provenance || provenance.fingerprint !== releaseGateEvidenceFingerprint(task)) return false;
if (task.updatedAt && (!verdict.evaluatedForUpdatedAt || task.updatedAt > verdict.evaluatedForUpdatedAt)) return false;
const evaluatedAt = Date.parse(verdict.evaluatedAt);
return Number.isFinite(evaluatedAt) && now - evaluatedAt <= RELEASE_GATE_VERDICT_MAX_AGE_MS;
}

View File

@@ -63,3 +63,12 @@ export function isTaskBlockedOnApprovalHold(
return (task.paused === true && task.pausedReason === "awaiting-approval")
|| task.status === "awaiting-approval";
}
/*
FNXC:PromoteVisibility 2026-08-11-20:38:
useTasks owns freshness. TaskCard consumes a present verdict verbatim for exact server parity; absent
or expired payloads retain FN-8950's conservative fallback rather than inventing a second authority.
*/
export function resolvePromoteSuppressed(task: Pick<Task, "releaseGate">, fallback: boolean): boolean {
return task.releaseGate?.promoteBlocked ?? fallback;
}

View File

@@ -95,6 +95,7 @@ import {
planTaskWorktreePath,
promoteHeldTask,
evaluateTaskReleaseGate,
performTaskRevert,
revertWorkspaceTask,
TaskRevertError,
@@ -1479,12 +1480,14 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const enrichable = holdRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT);
if (holdRows.length > enrichable.length) {
severityAuditLog.warn(
`awaitingPlanning enrichment truncated: ${enrichable.length}/${holdRows.length} hold-lane tasks ` +
`awaitingPlanning/releaseGate enrichment truncated: ${enrichable.length}/${holdRows.length} hold-lane tasks ` +
"annotated (remaining cards fall back to the client step-count heuristic)",
);
}
if (enrichable.length > 0) {
const flagByTask = new Map<string, boolean>();
const releaseGateByTask = new Map<string, import("@fusion/core").TaskReleaseGateVerdict>();
const irCache = new Map<string, WorkflowIr>();
await Promise.all(enrichable.map(async (task) => {
let promptContent: string | null = null;
try {
@@ -1496,11 +1499,22 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") return;
}
flagByTask.set(task.id, isTaskAwaitingPlanning(task, promptContent));
/*
FNXC:PromoteVisibility 2026-08-11-20:38:
Share the hold-lane cap with awaitingPlanning and cache IR resolution per selected workflow:
exact Promote visibility must not turn a board load into unbounded workflow reads.
*/
const ir = await resolveWorkflowIrForTask(scopedStore, task.id, irCache);
const releaseGate = await evaluateTaskReleaseGate(scopedStore, task, { ir });
if (releaseGate) releaseGateByTask.set(task.id, releaseGate);
}));
if (flagByTask.size > 0) {
if (flagByTask.size > 0 || releaseGateByTask.size > 0) {
tasks = tasks.map((task) => {
const awaitingPlanning = flagByTask.get(task.id);
return awaitingPlanning === undefined ? task : { ...task, awaitingPlanning };
const releaseGate = releaseGateByTask.get(task.id);
return awaitingPlanning === undefined && releaseGate === undefined
? task
: { ...task, ...(awaitingPlanning === undefined ? {} : { awaitingPlanning }), ...(releaseGate === undefined ? {} : { releaseGate }) };
});
}
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { WorkflowIr } from "@fusion/core";
import { evaluateTaskReleaseGate, evaluateUnplannedForExecution, isUnplannedForExecution } from "../execution/hold-release.js";
function ir(withReview = true): WorkflowIr {
return {
version: "v2", name: "gate", columns: [
{ id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "in-progress", name: "Progress", traits: [{ trait: "wip" }] },
],
nodes: [{ id: "start", kind: "start", column: "todo" }, ...(withReview ? [{ id: "plan-review", kind: "optional-group" as const, column: "todo", config: { defaultOn: true, template: { nodes: [], edges: [] } } }] : [])],
edges: [],
} as WorkflowIr;
}
describe("release-gate verdict", () => {
it("reports an omitted plan-review node as releasable", async () => {
const task = { id: "T-1", description: "real", column: "todo", updatedAt: "2026-08-11T00:00:00.000Z" } as any;
const verdict = await evaluateTaskReleaseGate({ getTasksDir: () => "/missing" } as any, task, { ir: ir(false) });
expect(verdict).toMatchObject({ promoteBlocked: false, unplannedForExecution: false, readyAtCapacityBoundary: false, evaluatedForUpdatedAt: task.updatedAt });
expect(verdict?.planReview).toBeUndefined();
expect(Number.isFinite(Date.parse(verdict!.evaluatedAt))).toBe(true);
});
it("reports a plan-in-place default-on gate and preserves boolean equivalence", async () => {
const task = { id: "T-2", description: "real", column: "todo" } as any;
const store = {} as any;
const result = await evaluateUnplannedForExecution(store, task, ir());
const verdict = await evaluateTaskReleaseGate(store, task, { ir: ir() });
expect(result).toMatchObject({ unplanned: true, reason: "plan-review-pending", readyAtCapacityBoundary: false });
await expect(isUnplannedForExecution(store, task, ir())).resolves.toBe(result.unplanned);
expect(verdict).toMatchObject({ promoteBlocked: true, reason: "plan-review-pending", blockedOnApproval: false });
});
it("treats a capacity continuation as plan-review readiness", async () => {
const task = { id: "T-3", description: "real", column: "todo" } as any;
const store = { listWorkflowWorkItemsForTask: async () => [{ state: "held", waitReason: "capacity", sourceColumn: "todo" }] } as any;
await expect(evaluateTaskReleaseGate(store, task, { ir: ir() })).resolves.toMatchObject({ promoteBlocked: false, readyAtCapacityBoundary: true });
});
});

View File

@@ -55,6 +55,7 @@ import {
isPlanReviewSatisfied,
type TaskStore,
type Task,
type TaskReleaseGateVerdict,
type WorkflowIr,
type WorkflowIrNode,
type WorkflowIrV2,
@@ -217,110 +218,59 @@ export async function checkAndRecordUnplannedExecutionBlock(
}
}
export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<boolean> {
/*
FNXC:PlanReview 2026-07-19-00:40 (U3):
The graph is the SOLE Plan Review owner (triage's out-of-graph gate is deleted).
When a workflow places the plan-review node in a PRE-RELEASE column (the
benchmark's Plan Review in the Todo hold column — i.e. NOT a wip column), the
card must not release into execution until the graph has reached its durable
capacity boundary. Releasing first would skip the gate. This does not fire
when Plan Review already lives in a WIP column.
*/
/*
FNXC:PlanReview 2026-07-26-14:05:
The gate is PLAN-IN-PLACE only: it applies when Plan Review runs in the very column the card is
held in (Coding (Ideas) / the benchmark's Plan Review in Todo), which is the same condition the
other two consumers of this resolver already require (`seedPreReleasePlanReviewContinuation`,
`evaluateStrandedHoldContinuation`). Without the column check, moving the default workflow's Plan
Review out of the wip column into the planning column turned "non-wip" into "pre-release" for every
card in Todo — including cards whose graph never routes through Todo at all — and the capacity
sweep stopped releasing them (no continuation for a boundary they never reach). A review node in an
upstream column the card has already left is not something this sweep gates on.
*/
/*
FNXC:PlanReview 2026-07-26-17:10:
The gate also requires Plan Review to be ENABLED for this task. It exists to stop a card entering
implementation before its plan gate ran; a task whose plan-review group is toggled OFF has no such
gate, and holding it produced a deadlock — nothing would ever record the evidence the hold was
waiting for.
*/
export interface UnplannedForExecutionEvaluation {
unplanned: boolean;
reason: "plan-review-pending" | "planning-status" | "needs-replan" | "duplicate-prompt" | "seed-prompt" | null;
readyAtCapacityBoundary: boolean;
planReview?: NonNullable<TaskReleaseGateVerdict["planReview"]>;
}
/*
FNXC:PromoteVisibility 2026-08-11-20:38:
Release dispatch and board enrichment consume one structured decision so the browser does not keep a
second gate. Continuations, PROMPT.md, and workflow IR are invisible to the browser, so verdicts carry expiry evidence.
*/
export async function evaluateUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<UnplannedForExecutionEvaluation> {
const preReleaseReview = resolvePreReleasePlanReviewNode(ir);
const preReleaseReviewEnabled = preReleaseReview
? isWorkflowOptionalGroupEnabled(
task.enabledWorkflowSteps,
preReleaseReview.id,
(preReleaseReview.config as { defaultOn?: boolean } | undefined)?.defaultOn ?? false,
)
: false;
if (preReleaseReview && preReleaseReviewEnabled && preReleaseReview.column === task.column) {
// Compatibility for tasks planned before durable continuations existed and
// for narrow store adapters that expose only the legacy review result.
const legacySatisfied = task.workflowStepResults?.some(isPlanReviewSatisfied);
if (!legacySatisfied) {
if (typeof store.listWorkflowWorkItemsForTask !== "function") return true;
// FNXC:StrandedHoldContinuation 2026-07-26-15:45:
// FN-8592 defines graph idleness over every active continuation kind;
// filtering to task continuations would allow a live non-task run to be
// mistaken for an idle graph and receive a duplicate plan-review seed.
const continuations = await store.listWorkflowWorkItemsForTask(task.id);
const active = continuations.filter((item) => ACTIVE_WORKFLOW_WORK_ITEM_STATES.includes(item.state));
// Readiness is represented by the graph's durable boundary continuation,
// not by a special-case review result. Optional groups that are disabled
// are still traversed and therefore reach the same capacity boundary.
const readyAtCapacityBoundary = active.some(
(item) => item.waitReason === "capacity" && item.sourceColumn === task.column,
);
if (!readyAtCapacityBoundary) return true;
}
const defaultOn = (preReleaseReview?.config as { defaultOn?: boolean } | undefined)?.defaultOn ?? false;
const enabled = preReleaseReview ? isWorkflowOptionalGroupEnabled(task.enabledWorkflowSteps, preReleaseReview.id, defaultOn) : false;
const appliesToColumn = preReleaseReview?.column === task.column;
const satisfied = task.workflowStepResults?.some(isPlanReviewSatisfied) === true;
const planReview = preReleaseReview ? { nodeId: preReleaseReview.id, column: preReleaseReview.column!, defaultOn, enabled, appliesToColumn, satisfied } : undefined;
let readyAtCapacityBoundary = false;
if (preReleaseReview && enabled && appliesToColumn && !satisfied) {
if (typeof store.listWorkflowWorkItemsForTask !== "function") return { unplanned: true, reason: "plan-review-pending", readyAtCapacityBoundary, planReview };
const active = (await store.listWorkflowWorkItemsForTask(task.id)).filter((item) => ACTIVE_WORKFLOW_WORK_ITEM_STATES.includes(item.state));
readyAtCapacityBoundary = active.some((item) => item.waitReason === "capacity" && item.sourceColumn === task.column);
if (!readyAtCapacityBoundary) return { unplanned: true, reason: "plan-review-pending", readyAtCapacityBoundary, planReview };
}
// Still-live triage/executor statuses (kept, not triage-plan-review-owned):
// `planning` = triage is actively writing PROMPT.md; `needs-replan` = the
// executor's graph replan rebound parked the card for another planning pass.
if (task.status === "planning") return true;
if (task.status === "planning") return { unplanned: true, reason: "planning-status", readyAtCapacityBoundary, planReview };
if (task.status === "needs-replan") return { unplanned: true, reason: "needs-replan", readyAtCapacityBoundary, planReview };
const flags = findColumn(ir, task.column) ? resolveColumnFlags(findColumn(ir, task.column)!) : {};
if (flags.intake !== true && flags.hold !== true) return { unplanned: false, reason: null, readyAtCapacityBoundary, planReview };
/*
FNXC:WorkflowScheduling 2026-07-13-11:20:
`needs-replan` is unplanned-by-decree: Plan Review rejected the current PROMPT.md and the
plan-in-place rebound parks the card in "todo" awaiting the triage service's replan. Without
this check the capacity-hold sweep read the real (rejected) prompt, judged the card planned,
and released it into execution — re-running the plan the reviewer just rejected and racing
triage, which only flips the dispatch-blocking `planning` status after acquiring its
semaphore slot.
FNXC:DuplicateIntake 2026-08-11-20:53:
A durable duplicate-only title is executable-state evidence even when a narrow store adapter
cannot expose PROMPT.md. Check it before filesystem access so every release surface preserves
the duplicate redirect refusal rather than accidentally releasing the card.
*/
if (task.status === "needs-replan") return true;
/*
FNXC:WorkflowScheduling 2026-07-19-02:10 (U4):
Gate the bootstrap-stub check on the TRAIT, not the literal "todo" id. An
unplanned card rests in a pre-wip column — an `intake` column (Ideas / the
renamed "Planning") OR a `hold` column (the default workflow's `todo` is
hold+reset-on-entry). Keying the OR-branch on `task.column === "todo"` both
hard-coded the default id and missed a renamed intake column (FN-7648); the
trait predicate covers every variant, so the literal-todo branch is removed.
*/
const currentColumn = findColumn(ir, task.column);
const currentFlags = currentColumn ? resolveColumnFlags(currentColumn) : {};
if (currentFlags.intake !== true && currentFlags.hold !== true) return false;
if (typeof store.getTasksDir !== "function") return false;
if (isDuplicateRedirectOnlyPrompt(undefined, task.title)) return { unplanned: true, reason: "duplicate-prompt", readyAtCapacityBoundary, planReview };
if (typeof store.getTasksDir !== "function") return { unplanned: false, reason: null, readyAtCapacityBoundary, planReview };
try {
const promptContent = await readFile(getPromptPath(store.getTasksDir(), task.id), "utf-8");
// isUnplannedSeedPrompt also matches the refineTask seed shape (no task-id prefix),
// so an unplanned refinement promoted out of a manual intake is held for planning
// instead of releasing into execution with a feedback-only prompt.
/*
FNXC:DuplicateIntake 2026-08-01-19:24:
A DUPLICATE-only PROMPT is unplanned for execution (FN-8704). Hold capacity release
until triage writes a real plan — filesystem validation is the twin of this check.
*/
if (isDuplicateRedirectOnlyPrompt(promptContent)) return true;
return isUnplannedSeedPrompt(promptContent, task.id, task.title, task.description);
const prompt = await readFile(getPromptPath(store.getTasksDir(), task.id), "utf-8");
if (isDuplicateRedirectOnlyPrompt(prompt, task.title)) return { unplanned: true, reason: "duplicate-prompt", readyAtCapacityBoundary, planReview };
const unplanned = isUnplannedSeedPrompt(prompt, task.id, task.title, task.description);
return { unplanned, reason: unplanned ? "seed-prompt" : null, readyAtCapacityBoundary, planReview };
} catch {
// Missing prompt is handled by filesystem validation elsewhere; do not block on it here.
return false;
return { unplanned: false, reason: null, readyAtCapacityBoundary, planReview };
}
}
/** Compatibility wrapper retained for scheduler and release callers. */
export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<boolean> {
return (await evaluateUnplannedForExecution(store, task, ir)).unplanned;
}
/**
* Resolve the release target column for a held card.
*
@@ -371,6 +321,32 @@ function resolveReleaseTarget(ir: WorkflowIr, fromColumn: string, preferCapacity
return neighbors.find((n) => n !== fromColumn);
}
/** Evaluate the exact hold-to-target refusal without dispatch side effects. */
export async function evaluateTaskReleaseGate(store: TaskStore, task: Task, options: { ir?: WorkflowIr } = {}): Promise<TaskReleaseGateVerdict | undefined> {
const ir = options.ir ?? await resolveWorkflowIrForTask(store, task.id);
if (!isHeldTask(ir, task)) return undefined;
const releaseTargetColumn = resolveReleaseTarget(ir, task.column, true);
if (!releaseTargetColumn) return undefined;
const targetColumn = findColumn(ir, releaseTargetColumn);
const targetCountsTowardWip = targetColumn ? resolveColumnFlags(targetColumn).countsTowardWip === true : false;
const result = targetCountsTowardWip
? await evaluateUnplannedForExecution(store, task, ir)
: { unplanned: false, reason: null, readyAtCapacityBoundary: false };
const blockedOnApproval = targetCountsTowardWip && isTaskBlockedOnApproval(task);
return {
promoteBlocked: result.unplanned || blockedOnApproval,
unplannedForExecution: result.unplanned,
blockedOnApproval,
reason: result.reason ?? (blockedOnApproval ? "awaiting-approval" : null),
readyAtCapacityBoundary: result.readyAtCapacityBoundary,
...(result.planReview ? { planReview: result.planReview } : {}),
releaseTargetColumn,
targetCountsTowardWip,
evaluatedAt: new Date().toISOString(),
...(task.updatedAt ? { evaluatedForUpdatedAt: task.updatedAt } : {}),
};
}
// ── Dependency satisfaction (KTD-5 + FN-5719 dual-accept) ─────────────────────
/*

View File

@@ -1050,6 +1050,9 @@ export { RemoteNodeRuntime, type RemoteNodeRuntimeConfig } from "./runtimes/remo
// promote endpoint can release a manually-held card via the same authority.
export {
promoteHeldTask,
evaluateTaskReleaseGate,
evaluateUnplannedForExecution,
isUnplannedForExecution,
releaseHeldTaskByEvent,
runHoldReleaseSweep,
type HoldReleaseDeps,