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 ## 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. 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 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 the aggregators do not — so it wants an opt-in
(`{ untraitedProject: "declared-columns" }`). Recorded at three call sites in `self-healing.ts`. (`{ 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. 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. `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, TaskRecommendation,
TaskRecommendationCategory, TaskRecommendationCategory,
Task, Task,
TaskReleaseGateVerdict,
TaskVerificationResultSummary, TaskVerificationResultSummary,
TaskVerificationRequest, TaskVerificationRequest,
TaskDetail, TaskDetail,
@@ -653,6 +654,7 @@ export type {
TaskRecommendation, TaskRecommendation,
TaskRecommendationCategory, TaskRecommendationCategory,
Task, Task,
TaskReleaseGateVerdict,
TaskVerificationResultSummary, TaskVerificationResultSummary,
TaskVerificationRequest, TaskVerificationRequest,
TaskDetail, TaskDetail,

View File

@@ -643,6 +643,19 @@ export interface TaskRecommendation {
createdTaskId?: string; 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 { export interface Task {
id: string; id: string;
/** Immutable lineage identity used for durable commit/task attribution. */ /** Immutable lineage identity used for durable commit/task attribution. */
@@ -1188,6 +1201,13 @@ export interface Task {
* step-count heuristic when the field is absent. * step-count heuristic when the field is absent.
*/ */
awaitingPlanning?: boolean; 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. */ /** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */
assignedAgentId?: string; 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. */ /** 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, isReviewBudgetExhaustedApproval,
isTaskAwaitingPlanApproval, isTaskAwaitingPlanApproval,
isTaskBlockedOnApprovalHold, isTaskBlockedOnApprovalHold,
resolvePromoteSuppressed,
} from "../utils/reviewBudgetApproval"; } from "../utils/reviewBudgetApproval";
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback"; import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
@@ -1615,7 +1616,7 @@ function TaskCardComponent({
|| planReviewGateUnsatisfied || planReviewGateUnsatisfied
|| isAwaitingApproval || isAwaitingApproval
|| isBlockedOnApprovalHold; || isBlockedOnApprovalHold;
const showPromoteAction = Boolean(onPromote) && !isStillInPlanning; const showPromoteAction = Boolean(onPromote) && !resolvePromoteSuppressed(task, isStillInPlanning);
const showIdleTodoBadge = !isPaused const showIdleTodoBadge = !isPaused
&& isHoldColumn && isHoldColumn
&& !visualStatus && !visualStatus

View File

@@ -8117,6 +8117,37 @@ describe("TaskCard mission badge", () => {
expect(pricedPromotable.container.querySelector(".card-promote-cost-row .card-cost-indicator")).not.toBeNull(); 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", () => { it("does not render a promote action when onPromote is omitted", () => {
render( render(
<TaskCard <TaskCard

View File

@@ -164,6 +164,32 @@ describe("useTasks", () => {
expect(result.current.tasks[0].id).toBe("FN-001"); 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", () => { it("hydrates per-project cached tasks synchronously", () => {
mockReadCache.mockReturnValueOnce([createMockTask({ id: "FN-CACHED" })]); mockReadCache.mockReturnValueOnce([createMockTask({ id: "FN-CACHED" })]);
const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); 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 { useState, useEffect, useCallback, useRef } from "react";
import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core"; 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 // 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 { function toCachedTaskRow(task: Task): Task {
const log = (task as Task & { log?: unknown }).log; const log = (task as Task & { log?: unknown }).log;
if (!Array.isArray(log) || log.length === 0) { if ((!Array.isArray(log) || log.length === 0) && task.releaseGate === undefined) return task;
return task; const { log: _droppedLog, releaseGate: _transientReleaseGate, ...rest } = task as Task & { log?: unknown };
} /*
const { log: _droppedLog, ...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; return rest as Task;
} }
@@ -246,6 +254,39 @@ function carryAwaitingPlanning(current: Task, incoming: Task): boolean | undefin
return stepCountUnchanged ? current.awaitingPlanning : undefined; 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: FNXC:TaskDetailStateStability 2026-08-05-02:55:
The scheduler can refresh the board while a task-detail host holds a newer queued dependency or 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. FN-8796 showed that an absent or equal local clock is not evidence of staleness.
*/ */
export interface TaskSnapshotMergeOptions { 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. */ /** A complete board/detail fetch can resolve an otherwise ambiguous legacy column clock. */
fullSnapshot?: boolean; fullSnapshot?: boolean;
/** A canonical task:moved SSE payload names its destination, even when its clock ties the visible row. */ /** 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) ? carryAwaitingPlanning(current, incoming)
: current.awaitingPlanning; : 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: FNXC:TaskStatusConsistency 2026-08-07-06:10:
`recentAgentActivityAt` is a client-only bridge from an agent-log event to the next task snapshot. `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 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; return merged as T;
} }
function mergeIncomingTask(current: Task, incoming: Task, options?: TaskSnapshotMergeOptions): Task {
return mergeTaskSnapshot(current, incoming, options);
}
export interface UseTasksOptions { export interface UseTasksOptions {
/* /*
FNXC:WorkflowResolvedColumns 2026-07-31-03:40: 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); 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 [isStale, setIsStale] = useState(true);
const [lastRefreshErrorAt, setLastRefreshErrorAt] = useState<number | null>(null); const [lastRefreshErrorAt, setLastRefreshErrorAt] = useState<number | null>(null);
@@ -511,7 +560,63 @@ export function useTasks(options?: UseTasksOptions) {
const includeArchived = false; const includeArchived = false;
const includeArchivedRef = useRef(includeArchived); const includeArchivedRef = useRef(includeArchived);
const tasksRef = useRef(tasks); 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 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: 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 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; return;
} }
const normalizedFetchedTasks = filterActiveTasks(fetchedTasks.map(normalizeTask)); 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: FNXC:ArchivePagination 2026-07-08-01:30:
A generic refresh (SSE reconnect resync, tab-visibility regain, delete- A generic refresh (SSE reconnect resync, tab-visibility regain, delete-
@@ -704,6 +817,10 @@ export function useTasks(options?: UseTasksOptions) {
const nextTasks = archivedCarryOver.length > 0 const nextTasks = archivedCarryOver.length > 0
? [...reconciledFetchedTasks, ...archivedCarryOver] ? [...reconciledFetchedTasks, ...archivedCarryOver]
: reconciledFetchedTasks; : 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; tasksRef.current = nextTasks;
setTasks(nextTasks); setTasks(nextTasks);
for (const [taskId, mutation] of liveTaskMutationsRef.current) { 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") return (task.paused === true && task.pausedReason === "awaiting-approval")
|| task.status === "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, planTaskWorktreePath,
promoteHeldTask, promoteHeldTask,
evaluateTaskReleaseGate,
performTaskRevert, performTaskRevert,
revertWorkspaceTask, revertWorkspaceTask,
TaskRevertError, TaskRevertError,
@@ -1479,12 +1480,14 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const enrichable = holdRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT); const enrichable = holdRows.slice(0, AWAITING_PLANNING_ENRICH_LIMIT);
if (holdRows.length > enrichable.length) { if (holdRows.length > enrichable.length) {
severityAuditLog.warn( 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)", "annotated (remaining cards fall back to the client step-count heuristic)",
); );
} }
if (enrichable.length > 0) { if (enrichable.length > 0) {
const flagByTask = new Map<string, boolean>(); 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) => { await Promise.all(enrichable.map(async (task) => {
let promptContent: string | null = null; let promptContent: string | null = null;
try { try {
@@ -1496,11 +1499,22 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") return; if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") return;
} }
flagByTask.set(task.id, isTaskAwaitingPlanning(task, promptContent)); 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) => { tasks = tasks.map((task) => {
const awaitingPlanning = flagByTask.get(task.id); 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, isPlanReviewSatisfied,
type TaskStore, type TaskStore,
type Task, type Task,
type TaskReleaseGateVerdict,
type WorkflowIr, type WorkflowIr,
type WorkflowIrNode, type WorkflowIrNode,
type WorkflowIrV2, type WorkflowIrV2,
@@ -217,110 +218,59 @@ export async function checkAndRecordUnplannedExecutionBlock(
} }
} }
export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<boolean> { export interface UnplannedForExecutionEvaluation {
/* unplanned: boolean;
FNXC:PlanReview 2026-07-19-00:40 (U3): reason: "plan-review-pending" | "planning-status" | "needs-replan" | "duplicate-prompt" | "seed-prompt" | null;
The graph is the SOLE Plan Review owner (triage's out-of-graph gate is deleted). readyAtCapacityBoundary: boolean;
When a workflow places the plan-review node in a PRE-RELEASE column (the planReview?: NonNullable<TaskReleaseGateVerdict["planReview"]>;
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: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.
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 export async function evaluateUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<UnplannedForExecutionEvaluation> {
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.
*/
const preReleaseReview = resolvePreReleasePlanReviewNode(ir); const preReleaseReview = resolvePreReleasePlanReviewNode(ir);
const preReleaseReviewEnabled = preReleaseReview const defaultOn = (preReleaseReview?.config as { defaultOn?: boolean } | undefined)?.defaultOn ?? false;
? isWorkflowOptionalGroupEnabled( const enabled = preReleaseReview ? isWorkflowOptionalGroupEnabled(task.enabledWorkflowSteps, preReleaseReview.id, defaultOn) : false;
task.enabledWorkflowSteps, const appliesToColumn = preReleaseReview?.column === task.column;
preReleaseReview.id, const satisfied = task.workflowStepResults?.some(isPlanReviewSatisfied) === true;
(preReleaseReview.config as { defaultOn?: boolean } | undefined)?.defaultOn ?? false, const planReview = preReleaseReview ? { nodeId: preReleaseReview.id, column: preReleaseReview.column!, defaultOn, enabled, appliesToColumn, satisfied } : undefined;
) let readyAtCapacityBoundary = false;
: false; if (preReleaseReview && enabled && appliesToColumn && !satisfied) {
if (preReleaseReview && preReleaseReviewEnabled && preReleaseReview.column === task.column) { if (typeof store.listWorkflowWorkItemsForTask !== "function") return { unplanned: true, reason: "plan-review-pending", readyAtCapacityBoundary, planReview };
// Compatibility for tasks planned before durable continuations existed and const active = (await store.listWorkflowWorkItemsForTask(task.id)).filter((item) => ACTIVE_WORKFLOW_WORK_ITEM_STATES.includes(item.state));
// for narrow store adapters that expose only the legacy review result. readyAtCapacityBoundary = active.some((item) => item.waitReason === "capacity" && item.sourceColumn === task.column);
const legacySatisfied = task.workflowStepResults?.some(isPlanReviewSatisfied); if (!readyAtCapacityBoundary) return { unplanned: true, reason: "plan-review-pending", readyAtCapacityBoundary, planReview };
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;
}
} }
// Still-live triage/executor statuses (kept, not triage-plan-review-owned): if (task.status === "planning") return { unplanned: true, reason: "planning-status", readyAtCapacityBoundary, planReview };
// `planning` = triage is actively writing PROMPT.md; `needs-replan` = the if (task.status === "needs-replan") return { unplanned: true, reason: "needs-replan", readyAtCapacityBoundary, planReview };
// executor's graph replan rebound parked the card for another planning pass. const flags = findColumn(ir, task.column) ? resolveColumnFlags(findColumn(ir, task.column)!) : {};
if (task.status === "planning") return true; if (flags.intake !== true && flags.hold !== true) return { unplanned: false, reason: null, readyAtCapacityBoundary, planReview };
/* /*
FNXC:WorkflowScheduling 2026-07-13-11:20: FNXC:DuplicateIntake 2026-08-11-20:53:
`needs-replan` is unplanned-by-decree: Plan Review rejected the current PROMPT.md and the A durable duplicate-only title is executable-state evidence even when a narrow store adapter
plan-in-place rebound parks the card in "todo" awaiting the triage service's replan. Without cannot expose PROMPT.md. Check it before filesystem access so every release surface preserves
this check the capacity-hold sweep read the real (rejected) prompt, judged the card planned, the duplicate redirect refusal rather than accidentally releasing the card.
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.
*/ */
if (task.status === "needs-replan") return true; 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 };
/*
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;
try { try {
const promptContent = await readFile(getPromptPath(store.getTasksDir(), task.id), "utf-8"); const prompt = await readFile(getPromptPath(store.getTasksDir(), task.id), "utf-8");
// isUnplannedSeedPrompt also matches the refineTask seed shape (no task-id prefix), if (isDuplicateRedirectOnlyPrompt(prompt, task.title)) return { unplanned: true, reason: "duplicate-prompt", readyAtCapacityBoundary, planReview };
// so an unplanned refinement promoted out of a manual intake is held for planning const unplanned = isUnplannedSeedPrompt(prompt, task.id, task.title, task.description);
// instead of releasing into execution with a feedback-only prompt. return { unplanned, reason: unplanned ? "seed-prompt" : null, readyAtCapacityBoundary, planReview };
/*
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);
} catch { } catch {
// Missing prompt is handled by filesystem validation elsewhere; do not block on it here. return { unplanned: false, reason: null, readyAtCapacityBoundary, planReview };
return false;
} }
} }
/** 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. * 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); 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) ───────────────────── // ── 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. // promote endpoint can release a manually-held card via the same authority.
export { export {
promoteHeldTask, promoteHeldTask,
evaluateTaskReleaseGate,
evaluateUnplannedForExecution,
isUnplannedForExecution,
releaseHeldTaskByEvent, releaseHeldTaskByEvent,
runHoldReleaseSweep, runHoldReleaseSweep,
type HoldReleaseDeps, type HoldReleaseDeps,