diff --git a/.changeset/fn-8987-release-gate-verdict.md b/.changeset/fn-8987-release-gate-verdict.md
new file mode 100644
index 0000000000..6d768f6f76
--- /dev/null
+++ b/.changeset/fn-8987-release-gate-verdict.md
@@ -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.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 632d45f82a..29258f099e 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -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.
diff --git a/docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md b/docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md
index 85a115ea48..e3769d4261 100644
--- a/docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md
+++ b/docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md
@@ -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 |
diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md
index f8cc99f185..04c071ecd1 100644
--- a/docs/workflow-steps.md
+++ b/docs/workflow-steps.md
@@ -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.
diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts
index 9ff9c6f04c..b144613c1f 100644
--- a/packages/core/src/index.gate.ts
+++ b/packages/core/src/index.gate.ts
@@ -43,7 +43,7 @@ etc. pulled in by production modules, not test files).
*/
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, ANTHROPIC_AUTH_PREFERENCES, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js";
-export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, MailKind, MailReportSection, MailReport, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js";
+export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskReleaseGateVerdict, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, MailKind, MailReportSection, MailReport, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError, normalizeAgentRoles } from "./types.js";
export {
resolveEntryPointBranchAssignment,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6ff944a23f..08960ef840 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, ANTHROPIC_AUTH_PREFERENCES, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, APPROVAL_REQUEST_PENDING_TTL_MS, APPROVAL_REQUEST_GRANT_TTL_MS, isApprovalRequestExpired, configureApprovalRequestTtls, getApprovalRequestGrantTtlMs, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, REVIEW_ARTIFACTS_MODES, LIVE_DEMO_ARTIFACT_MIME_TYPE, isReviewArtifact, parseReviewArtifactsModeOverride, resolveReviewArtifactsMode, classifyReviewArtifactTask, isReviewArtifactGenerationEligible, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js";
-export type { VoiceInputSettings, Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, ReviewArtifactsMode, ReviewArtifactTaskClassification, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, TaskDeleteClosureContext, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowReviewFinding, WorkflowReviewFindingSeverity, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, MailKind, MailReportSection, MailReport, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js";
+export type { VoiceInputSettings, Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskReleaseGateVerdict, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, ReviewArtifactsMode, ReviewArtifactTaskClassification, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, TaskDeleteClosureContext, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowReviewFinding, WorkflowReviewFindingSeverity, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, MailKind, MailReportSection, MailReport, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js";
export type { NativeStructureRef, NativeStructureEmbed, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult } from "./types.js";
export type {
SymbolLockStatus,
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 5893d7c9bc..576615be10 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -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,
diff --git a/packages/core/src/types/task/task-core.ts b/packages/core/src/types/task/task-core.ts
index aa14f8443a..85f30a9585 100644
--- a/packages/core/src/types/task/task-core.ts
+++ b/packages/core/src/types/task/task-core.ts
@@ -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. */
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index e78b3b38e4..8440d4b0f8 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -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
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index 1093322688..01383d5083 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -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();
+ expect(screen.getByTestId("card-promote-FN-8987-allowed")).toBeInTheDocument();
+ allowed.unmount();
+
+ const blocked = render();
+ 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();
+ expect(screen.queryByTestId("card-promote-FN-8987-fallback")).toBeNull();
+ fallback.unmount();
+ });
+
it("does not render a promote action when onPromote is omitted", () => {
render(
{
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" }));
diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts
index ebc6436d44..8d6b7b5907 100644
--- a/packages/dashboard/app/hooks/useTasks.ts
+++ b/packages/dashboard/app/hooks/useTasks.ts
@@ -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 | 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;
/** 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(
? 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(current: T, patch: Partial());
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) {
diff --git a/packages/dashboard/app/utils/__tests__/releaseGate.contract.test.ts b/packages/dashboard/app/utils/__tests__/releaseGate.contract.test.ts
new file mode 100644
index 0000000000..d595a6846c
--- /dev/null
+++ b/packages/dashboard/app/utils/__tests__/releaseGate.contract.test.ts
@@ -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);
+ });
+});
diff --git a/packages/dashboard/app/utils/__tests__/releaseGate.test.ts b/packages/dashboard/app/utils/__tests__/releaseGate.test.ts
new file mode 100644
index 0000000000..5fb31f36eb
--- /dev/null
+++ b/packages/dashboard/app/utils/__tests__/releaseGate.test.ts
@@ -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);
+ });
+});
diff --git a/packages/dashboard/app/utils/releaseGate.ts b/packages/dashboard/app/utils/releaseGate.ts
new file mode 100644
index 0000000000..822e83e97d
--- /dev/null
+++ b/packages/dashboard/app/utils/releaseGate.ts
@@ -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): 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;
+}
diff --git a/packages/dashboard/app/utils/reviewBudgetApproval.ts b/packages/dashboard/app/utils/reviewBudgetApproval.ts
index f448d348d1..29baa3deac 100644
--- a/packages/dashboard/app/utils/reviewBudgetApproval.ts
+++ b/packages/dashboard/app/utils/reviewBudgetApproval.ts
@@ -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, fallback: boolean): boolean {
+ return task.releaseGate?.promoteBlocked ?? fallback;
+}
diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts
index c1f1b8430c..abb79e2e93 100644
--- a/packages/dashboard/src/routes/register-task-workflow-routes.ts
+++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts
@@ -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();
+ const releaseGateByTask = new Map();
+ const irCache = new Map();
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 }) };
});
}
}
diff --git a/packages/engine/src/__tests__/release-gate-verdict.test.ts b/packages/engine/src/__tests__/release-gate-verdict.test.ts
new file mode 100644
index 0000000000..b6c0a1ea16
--- /dev/null
+++ b/packages/engine/src/__tests__/release-gate-verdict.test.ts
@@ -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 });
+ });
+});
diff --git a/packages/engine/src/execution/hold-release.ts b/packages/engine/src/execution/hold-release.ts
index 4df4b5ea38..9b3e4b0872 100644
--- a/packages/engine/src/execution/hold-release.ts
+++ b/packages/engine/src/execution/hold-release.ts
@@ -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 {
- /*
- 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;
+}
+
+/*
+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 {
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 {
+ 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 {
+ 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) ─────────────────────
/*
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
index 1a74b5f929..6a8935306d 100644
--- a/packages/engine/src/index.ts
+++ b/packages/engine/src/index.ts
@@ -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,