From a6ce7f89ef775871c385afa8523794b94f2df6e4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 14:20:12 -0700 Subject: [PATCH] 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) --- .changeset/fn-8987-release-gate-verdict.md | 7 + docs/dashboard-guide.md | 4 + .../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 +++ .../dashboard/app/components/TaskCard.tsx | 3 +- .../components/__tests__/TaskCard.test.tsx | 31 ++++ .../app/hooks/__tests__/useTasks.test.ts | 26 +++ packages/dashboard/app/hooks/useTasks.ts | 135 +++++++++++++- .../__tests__/releaseGate.contract.test.ts | 29 +++ .../app/utils/__tests__/releaseGate.test.ts | 49 ++++++ packages/dashboard/app/utils/releaseGate.ts | 26 +++ .../app/utils/reviewBudgetApproval.ts | 9 + .../routes/register-task-workflow-routes.ts | 20 ++- .../__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(-) create mode 100644 .changeset/fn-8987-release-gate-verdict.md create mode 100644 packages/dashboard/app/utils/__tests__/releaseGate.contract.test.ts create mode 100644 packages/dashboard/app/utils/__tests__/releaseGate.test.ts create mode 100644 packages/dashboard/app/utils/releaseGate.ts create mode 100644 packages/engine/src/__tests__/release-gate-verdict.test.ts 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,