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:
7
.changeset/fn-8987-release-gate-verdict.md
Normal file
7
.changeset/fn-8987-release-gate-verdict.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
49
packages/dashboard/app/utils/__tests__/releaseGate.test.ts
Normal file
49
packages/dashboard/app/utils/__tests__/releaseGate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
packages/dashboard/app/utils/releaseGate.ts
Normal file
26
packages/dashboard/app/utils/releaseGate.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
40
packages/engine/src/__tests__/release-gate-verdict.test.ts
Normal file
40
packages/engine/src/__tests__/release-gate-verdict.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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) ─────────────────────
|
||||
|
||||
/*
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user