diff --git a/.changeset/fn-094-multi-repository-lifecycle.md b/.changeset/fn-094-multi-repository-lifecycle.md new file mode 100644 index 0000000000..54fad09629 --- /dev/null +++ b/.changeset/fn-094-multi-repository-lifecycle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep multi-repository tasks from reviewing or recovering clean unrelated repositories. +category: fix +dev: Adds explicit task repository scope and lifecycle parity fencing. diff --git a/docs/agents.md b/docs/agents.md index 23d7eec354..30fa97c8a8 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1723,6 +1723,14 @@ Seven coordination tools support spawning, provisioning, discovery, delegation, - `delegate_task` — Create + assign task to a specific agent. Implementation tasks require executor-role target unless `override: true`. Cannot target ephemeral agents (use `spawn_agent`). - `get_agent_config` / `update_agent_config` — Read/write soul, instructions, heartbeat interval/timeout, max concurrent runs, message response mode. **Authorization**: caller can only act on agents where `target.reportsTo === caller.id`. Cannot operate on ephemeral agents. +## Workspace repository scope tools + +`fn_task_create` accepts an optional `repository_scope` list for an explicit operator-selected workspace scope. Omit it only when the planner will confirm the proposed scope in `## Repository Scope` of the task plan; a workspace plan without a valid non-empty heading is rejected before it is persisted. + +`fn_acquire_repo_worktree` acquires a configured member checkout; it does not make that member task intent merely by acquiring it. When the member is outside the current pre-land scope, provide a reason: Fusion records one accepted scope-extension history entry so later review and landing can include that member deliberately. An acquisition after review or landing has started is refused with follow-up-task guidance. + +For workspace tasks, `fn_task_file_scope_add` must use qualified paths such as `repo-a/src/file.ts`. A relative entry is only unambiguous when the task has one active repository; never use an unqualified path to imply every acquired workspace repository. + ## Checkout leasing - 409 Conflict = ownership contention. Response: `{ error, currentHolder, taskId }`. **Never auto-retry 409.** diff --git a/docs/architecture.md b/docs/architecture.md index 0fbfc5b348..fb36160136 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2439,3 +2439,9 @@ Structured merge sentinels emitted with `data.status:"failed"` are preserved ver AI merge review findings are durable task-owned reconciliation state, not task-log prose. The state binds the source head, integration tip, candidate commit/tree, exact findings, confirmation count, and corrective-pass budget. A changed source or integration tip clears all of those values together; interruption of the same identity preserves them. A blocking finding can consume a corrective pass only when its exact ID/text is supplied to the merger. An approval remains an approval. A clean approval is reviewed a second time against the identical candidate without re-running the merger or spending budget; two consecutive clean approvals land it. Unconfirmed prior findings are displayed with the approval rather than being rewritten as a rejection. Explicit `still-present` findings remain blocking, while advisory concerns remain landable. Operator dismissal requires a nonblank reason, is audit logged, and clears the candidate/state/budget together so the next merge starts clean. + +## Workspace repository scope + +Workspace acquisition prepares Git worktrees for every configured repository before planning; it does not declare task intent. A task's explicit repository scope is the authority for downstream work. Planning confirms that scope, and a pre-land extension is recorded with its acceptance or refusal history. A late extension after any repository has landed is refused so operators can create a follow-up rather than mutate an integration episode. + +Review and landing use the intersection of confirmed scope and qualified changed-file evidence. Clean scoped repositories are recorded as **No changes — not reviewed** and have no reviewer verdict. Acquired out-of-scope repositories are not opened by reviewers or selected as land/recovery targets. Workspace tasks keep their worktree and branch state per repository; an absent singular `task.worktree` is normal and must not trigger root-worktree recovery. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6652e4401b..2faf0d986b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2563,3 +2563,7 @@ The Memory view also includes a fourth **Knowledge Graph** tab. It provides capp ### Workspace per-repository land status Task Detail shows each acquired workspace repository as **landed**, **pending**, or **failed**. Landed repositories include a short commit SHA; a partial land shows the aggregate landed count and retained task failure detail. The compact TaskCard chip deliberately remains count-only. The engine records a durable per-repository failure for both `landWorkspaceTask` failed-result branches and self-healing's unrecoverable partial-land park; busy leases, aborts, persist-after-advance recovery, and empty merges intentionally do not record one. Older partial-land rows and empty repositories remain pending, with any available failure detail shown only in the aggregate block. + +### Workspace task scope status + +Task Detail's workspace repository summary distinguishes acquired repositories from explicit task scope. Each repository shows whether it is modified, out of scope, or **No changes — not reviewed**, alongside landed, pending, or failed land state. An acquired repository outside the selected scope is informational only; it does not receive a review verdict or block task completion. The same summary remains readable in compact task-card presentation without adding empty controls on mobile. diff --git a/docs/multi-project.md b/docs/multi-project.md index c292d8709f..7c288ab67b 100644 --- a/docs/multi-project.md +++ b/docs/multi-project.md @@ -441,3 +441,9 @@ Each project persists its canonical central identity in `.fusion/project.json` a Dashboard `POST /api/projects` now surfaces this mismatch as `409` with `error: "orphan-identity"` and recovery metadata, and callers can opt into recovery flows with `acceptRecovery: true` behavior at the route layer. Back up PostgreSQL with the deployment's PostgreSQL backup tooling; `.fusion/project.json` is identity metadata, not a substitute for a database backup. + +## Workspace task repository scope + +For a workspace project, configured repositories may all be acquired before planning, but acquisition is not task scope. Fusion persists an explicit per-task repository scope that planning confirms. Only repositories both in that scope and evidenced by qualified modified files are reviewed, landed, or considered by partial-land recovery. A clean scoped repository appears as **No changes — not reviewed** and is neither a failed review nor a partial land. + +Before the first land, an accepted extension is recorded in task scope history. After a land begins, scope extension is refused and should be handled as a follow-up task, preserving the existing integration boundary and per-repository leases. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 57ac4c730c..b71f333763 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -651,7 +651,7 @@ Default notes: | `validatorFallbackProvider` | `string` | `undefined` | Fallback provider for reviewers; also used by reviewer UNAVAILABLE/error recovery retry before returning terminal UNAVAILABLE. | | `validatorFallbackModelId` | `string` | `undefined` | Fallback model ID for reviewers; paired with `validatorFallbackProvider` for reviewer recovery retry. | | `validatorFallbackThinkingLevel` | `ThinkingLevel` | `undefined` | Optional workflow reviewer-fallback thinking override. Inherits the validator/default thinking level when unset. | -| `workflowStepTimeoutMs` | `number` | `900000` | Maximum time in milliseconds a single workflow step may run before it is timed out. | +| `workflowStepTimeoutMs` | `number` | `900000` | Maximum time in milliseconds a single workflow step may run before it is timed out. For review nodes this is a per-reviewer-session attempt limit: an expired primary is disposed before the configured validator fallback starts a distinct fenced session. | | `modelPresets` | `ModelPreset[]` | `[]` | Reusable executor/reviewer model presets. | | `autoSelectModelPreset` | `boolean` | `false` | Auto-select presets by task size. | | `defaultPresetBySize` | `{ S?: string; M?: string; L?: string }` | `{}` | Mapping for `S`/`M`/`L` → preset ID. | diff --git a/docs/testing.md b/docs/testing.md index 61f9ce0e59..a22b9a2fc0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -941,3 +941,15 @@ FN-9136 evaluated and rejected per-fork `TRUNCATE` database reuse: its two-sided FN-9130 evaluated a reusable advisory admission primitive: server session locks coordinate forks while a process-local ledger coordinates counted same-session locks within a fork; async context allows true nesting only. It uses one maintenance-database connection per fork, holds a slot for one statement only, and reports fail-open degradation. `FUSION_PG_TEST_DDL_MAX_CONCURRENCY` and `FUSION_PG_TEST_DDL_ADMISSION_ACQUIRE_TIMEOUT_MS` configure that primitive for its deterministic coverage. The harness does **not** currently wire the primitive into `CREATE DATABASE` or `DROP DATABASE`. Uniform CREATE/DROP pooling regressed the 12-worker lane (49 watchdogs / 5,068ms versus a 4–5 / 3,284ms baseline); drop-only wiring also regressed (27 / 3,361ms). A bounded off-hook reaper (R=2/Q=8, flush, and dead-pid sweep) was then measured and reverted: its watchdog zero was structural, but green runs took 117.2s and 122.4s against a 108.1s baseline maximum and a later run timed out. FN-9136 subsequently evaluated and rejected candidate C (per-fork reuse plus `TRUNCATE`) because its seven-pair campaign leaked dead-fork pooled databases. The pg-gate's four-worker cap remains a separate lane-shape policy. + +### Workspace lifecycle parity regression + +Use focused workspace tests for a two-repository task with one explicitly scoped, modified repository: + +```bash +pnpm --filter @fusion/core exec vitest run src/__tests__/postgres/workspace-worktrees-concurrent-merge.pg.test.ts --silent=passed-only --reporter=dot +pnpm --filter @fusion/engine exec vitest run src/__tests__/reviewer-workspace.test.ts src/__tests__/self-healing-workspace.test.ts src/__tests__/workspace-merger.test.ts --silent=passed-only --reporter=dot +pnpm --filter @fusion/dashboard exec vitest run app/components/__tests__/WorkspaceWorktreesSummary.test.tsx --silent=passed-only --reporter=dot +``` + +The parity invariant is that a mono-repository task and a workspace task changing one scoped repository have identical review, completion, and landing outcomes. The acquired clean peer must be displayed as **No changes — not reviewed**, must not get a blocking verdict, and must not become a partial-land target. diff --git a/docs/workspaces.md b/docs/workspaces.md index eb82d5c046..4a37dcc038 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -159,3 +159,9 @@ Fusion writes `.fusion-workspace-root` only while acquiring an external shared r ### JIRA-derived branch names Workspace tasks retain the operator-supplied shared branch-name flow. When JIRA integration is enabled, enter an issue key and choose **Derive** to fill the editable branch field using `feature/{key}-{summary}` by default. Failed lookup or authentication leaves the existing branch untouched, so manual branch naming remains available. + +## Task repository scope + +Configured repositories are acquired before planning for availability, but acquisition is not task intent. Each workspace task starts with an explicit repository-scope proposal (an operator selection is already confirmed); planning confirms the final scope in `## Repository Scope`. A clean scoped repository is reported as **No changes — not reviewed** and creates no review, landing, or partial-land obligation. Reviews and landing use only scoped repositories with qualified modified-file evidence. + +An executor that needs another repository before landing records a scope extension and its reason. Once a land intent or landing exists, new acquisition remains refused and should be handled by a follow-up task. A workspace task normally has no singular `task.worktree`: member worktrees are the only routing authority, so Fusion never creates a worktree at the non-Git workspace root. diff --git a/packages/core/src/__tests__/postgres/workspace-worktrees-concurrent-merge.pg.test.ts b/packages/core/src/__tests__/postgres/workspace-worktrees-concurrent-merge.pg.test.ts index b08183111a..e00e2e8292 100644 --- a/packages/core/src/__tests__/postgres/workspace-worktrees-concurrent-merge.pg.test.ts +++ b/packages/core/src/__tests__/postgres/workspace-worktrees-concurrent-merge.pg.test.ts @@ -54,6 +54,25 @@ pgTest("workspace worktree per-repo atomic merge (PostgreSQL)", () => { }); }); + it("persists explicit scope without treating acquired entries as intent or clobbering them", async () => { + const first = h.store(); + const second = h.store(); + const task = await first.createTask({ description: "repository scope is explicit" }); + await first.mergeWorkspaceWorktreeEntry(task.id, "repo-a", { worktreePath: "/tmp/repo-a", branch: "fusion/a" }); + + await Promise.all([ + first.updateTaskRepositoryScope(task.id, { repositories: ["repo-b", "repo-a", "repo-a"], confirmedBy: "operator" }), + second.mergeWorkspaceWorktreeEntry(task.id, "repo-b", { worktreePath: "/tmp/repo-b", branch: "fusion/b" }), + ]); + + const current = await first.getTask(task.id); + expect(current.repositoryScope).toMatchObject({ repositories: ["repo-a", "repo-b"], confirmedBy: "operator" }); + expect(current.workspaceWorktrees).toEqual({ + "repo-a": { worktreePath: "/tmp/repo-a", branch: "fusion/a" }, + "repo-b": { worktreePath: "/tmp/repo-b", branch: "fusion/b" }, + }); + }); + it("clears singular state in the same per-key update", async () => { const store = h.store(); const task = await store.createTask({ description: "workspace singular state" }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3ecfb95684..8c7246342c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,7 @@ export { resolveTaskOutputLanguage, isTaskOutputLanguage } from "./ai/ai-output-language.js"; export type { TaskOutputLanguage, ResolvedTaskOutputLanguage } from "./ai/ai-output-language.js"; 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, 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, 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, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowReviewFinding, WorkflowRepositoryReviewOutcome, 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/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index 3779724ef4..a9c9780b66 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -154,6 +154,7 @@ CREATE TABLE IF NOT EXISTS project.tasks ( source_issue_closed_at text, merge_details jsonb, workspace_worktrees jsonb, + repository_scope jsonb, break_into_subtasks integer DEFAULT 0, no_commits_expected integer DEFAULT 0, enabled_workflow_steps jsonb DEFAULT '[]', diff --git a/packages/core/src/postgres/migrations/0064_fn_094_task_repository_scope.sql b/packages/core/src/postgres/migrations/0064_fn_094_task_repository_scope.sql new file mode 100644 index 0000000000..19d07cb72f --- /dev/null +++ b/packages/core/src/postgres/migrations/0064_fn_094_task_repository_scope.sql @@ -0,0 +1,2 @@ +-- FNXC:RepositoryScope 2026-08-20-23:07: Persist explicit workspace task intent independently of acquired checkout metadata. +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS repository_scope jsonb; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index fe905e4981..e3d91a0cda 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -66,7 +66,7 @@ capacity-model table drop that landed while this PR was open. /* FNXC:TaskRecommendations 2026-08-13-22:23: upgrades must install the source-agent index before duplicate intake queries it. */ /* FNXC:WorkspaceLease 2026-08-15-12:00: the baseline ceiling must include durable coordination tables so an upgraded database is never rejected by the current binary. */ /* FNXC:ActivityLogTaskSearch 2026-08-20-04:17: advance the schema ceiling so durable central task-ID lookups receive their indexed upgrade. */ -export const SCHEMA_BASELINE_VERSION = "0063"; +export const SCHEMA_BASELINE_VERSION = "0064"; /** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -231,6 +231,8 @@ export const WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION = "0060"; export const ACTIVITY_LOG_TASK_ID_INDEX_VERSION = "0061"; export const REMOVE_TASK_SUBTASK_SPLITTING_VERSION = "0062"; export const AI_MERGE_REVIEW_RECONCILIATION_VERSION = "0063"; +/** FNXC:RepositoryScope 2026-08-20-23:07: upgraded projects need explicit task repository intent before workspace lifecycle readers use it. */ +export const TASK_REPOSITORY_SCOPE_VERSION = "0064"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -468,6 +470,7 @@ const WORKSPACE_COORDINATION_LEASES_MIGRATION_PATH = join(MIGRATIONS_DIR, "0060_ const ACTIVITY_LOG_TASK_ID_INDEX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0061_fn_066_activity_log_task_id_index.sql"); const REMOVE_TASK_SUBTASK_SPLITTING_MIGRATION_PATH = join(MIGRATIONS_DIR, "0062_remove_task_subtask_splitting.sql"); const AI_MERGE_REVIEW_RECONCILIATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0063_fn_090_ai_merge_review_reconciliation.sql"); +const TASK_REPOSITORY_SCOPE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0064_fn_094_task_repository_scope.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -601,6 +604,7 @@ export async function applySchemaBaseline( const activityLogTaskIdIndexAlreadyApplied = applied.includes(ACTIVITY_LOG_TASK_ID_INDEX_VERSION); const removeTaskSubtaskSplittingAlreadyApplied = applied.includes(REMOVE_TASK_SUBTASK_SPLITTING_VERSION); const aiMergeReviewReconciliationAlreadyApplied = applied.includes(AI_MERGE_REVIEW_RECONCILIATION_VERSION); + const taskRepositoryScopeAlreadyApplied = applied.includes(TASK_REPOSITORY_SCOPE_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -1339,6 +1343,13 @@ export async function applySchemaBaseline( await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${AI_MERGE_REVIEW_RECONCILIATION_VERSION}) ON CONFLICT (version) DO NOTHING`); schemaChanged = true; } + /* FNXC:RepositoryScope 2026-08-20-23:07: migrations are explicitly registered so upgrade paths cannot silently omit task intent. */ + if (!taskRepositoryScopeAlreadyApplied) { + const migrationSql = await readFile(TASK_REPOSITORY_SCOPE_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_REPOSITORY_SCOPE_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 86e7bcda43..f3e6afa74d 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -229,6 +229,8 @@ export const tasks = projectSchema.table("tasks", { sourceIssueClosedAt: text("source_issue_closed_at"), mergeDetails: jsonb("merge_details"), workspaceWorktrees: jsonb("workspace_worktrees"), + // FNXC:RepositoryScope 2026-08-20-23:07: explicit task intent must survive PostgreSQL reads independently of acquired worktrees. + repositoryScope: jsonb("repository_scope"), noCommitsExpected: integer("no_commits_expected").default(0), enabledWorkflowSteps: jsonb("enabled_workflow_steps").default([]), modifiedFiles: jsonb("modified_files").default([]), diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 771bbff9cb..a7bed948f5 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -124,7 +124,7 @@ import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssoci import { findRecentTasksBySourceParentTaskIdImpl } from "./task-store/branch-and-pr-entities.js"; import { addTaskCommentImpl, applyBuiltInPromptOverridesAsyncImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, resolveOriginWorkflowOverrideIdImpl, type TaskOriginWorkflowKind, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthAsyncImpl, refreshDatabaseHealthImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/task-store-helpers.js"; import { getTaskSelectClauseImpl2, createTaskPersistSerializationContextImpl, getTaskPersistValuesImpl, getTaskPatchDescriptorsImpl, normalizeTaskFromDiskImpl, writeTaskJsonFileImpl, rowToPrEntityImpl, generatePrEntityIdImpl, readTaskForMoveImpl, rowToMergeQueueEntryImpl, rowToMergeRequestRecordImpl, rowToCompletionHandoffMarkerImpl, rowToWorkflowWorkItemImpl, rowToRunAuditEventImpl } from "./task-store/task-row-mappers.js"; -import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, linkTaskRecommendationImpl, normalizeWorkspaceTaskWorktreeMetadataImpl, mergeWorkspaceWorktreeEntryImpl, resolveTaskWedgeNotificationEpisodeImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, rollbackConfigurationImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/task-mutation-ops.js"; +import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, linkTaskRecommendationImpl, normalizeWorkspaceTaskWorktreeMetadataImpl, mergeWorkspaceWorktreeEntryImpl, updateTaskRepositoryScopeImpl, resolveTaskWedgeNotificationEpisodeImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, rollbackConfigurationImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/task-mutation-ops.js"; import { getOrCreateForProjectImpl, listGoalCitationsImpl, atomicWriteTaskJsonWithAuditImpl, type PlanningDependencyInvalidation, duplicateTaskImpl, listStrandedRefinementsImpl, tryClaimCheckoutImpl, evaluateWorkflowMovePoliciesImpl, recordRunAuditEventImpl, getRunAuditEventsImpl, dequeueMergeQueueOnColumnExitImpl, updateIssueInfoImpl, listWorkflowStepsImpl, getWorkflowStepImpl, createWorkflowDefinitionImpl, countActiveInCapacitySlotSyncImpl, countActiveInCapacitySlotAsyncImpl, generateSpecifiedPromptImpl, recordActivityImpl, getEvalStoreImpl } from "./task-store/project-store-ops.js"; import { markLegacyAutoMergeStampsOnceImpl, appendAgentLogImpl, importLegacyAgentLogsImpl, cleanupNoOpTaskMovedActivityRowsOnceImpl, backfillCommitAssociationDiffStatsImpl } from "./task-store/workflow-integrity.js"; import { saveWorkflowRunBranchImpl, clearNearDuplicateReferencesToImpl, selectNextTaskForAgentImpl, pauseTaskImpl, clearLinkedAgentTaskIdsImpl, listArtifactsImpl, rehomeOccupantImpl, type RehomeOccupantResult } from "./task-store/branch-group-ops.js"; @@ -1769,7 +1769,7 @@ export class TaskStore extends EventEmitter { } async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; branchWriteOrigin?: "operator" | "engine"; branchContext?: import("./types.js").TaskBranchContext | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; aiMergeReviewReconciliation?: import("./types.js").Task["aiMergeReviewReconciliation"] | null; log?: import("./types.js").TaskLogEntry[]; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; recommendations?: import("./types.js").TaskRecommendation[]; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; repositoryScope?: import("./types.js").Task["repositoryScope"] | null; status?: string | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; branchWriteOrigin?: "operator" | "engine"; branchContext?: import("./types.js").TaskBranchContext | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; aiMergeReviewReconciliation?: import("./types.js").Task["aiMergeReviewReconciliation"] | null; log?: import("./types.js").TaskLogEntry[]; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; recommendations?: import("./types.js").TaskRecommendation[]; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, ): Promise { /* FNXC:SpecLock 2026-08-09-20:34: @@ -2084,6 +2084,23 @@ export class TaskStore extends EventEmitter { ): Promise { return mergeWorkspaceWorktreeEntryImpl(this, id, repoRelPath, patch, options); } + async updateTaskRepositoryScope( + id: string, + repositoryScope: import("./types.js").TaskRepositoryScope | undefined, + ): Promise { + return updateTaskRepositoryScopeImpl(this, id, repositoryScope); + } + /** + * FNXC:RepositoryScope 2026-08-21-01:53: + * Operator and executor changes are deltas so their repository history is merged from the + * planning-locked durable row rather than replacing a stale dashboard or tool snapshot. + */ + async mutateTaskRepositoryScope( + id: string, + mutation: import("./task-store/task-mutation-ops.js").TaskRepositoryScopeMutation, + ): Promise { + return updateTaskRepositoryScopeImpl(this, id, mutation); + } async resolveTaskWedgeNotificationEpisode(id: string, episodeId: string): Promise<{ task: Task; resolved: boolean }> { return resolveTaskWedgeNotificationEpisodeImpl(this, id, episodeId); } diff --git a/packages/core/src/task-store/branch-and-pr-entities.ts b/packages/core/src/task-store/branch-and-pr-entities.ts index 37674ed7ac..45eb912d2c 100644 --- a/packages/core/src/task-store/branch-and-pr-entities.ts +++ b/packages/core/src/task-store/branch-and-pr-entities.ts @@ -659,7 +659,7 @@ export async function updateTaskImpl(store: TaskStore, async function updateTaskWithTaskLockImpl(store: TaskStore, id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; repositoryScope?: import("../types.js").Task["repositoryScope"] | null; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext, ): Promise { /* FNXC:StateMachine 2026-07-07-12:00: diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 71fe8e5e7a..5c32929ce1 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -146,6 +146,7 @@ export interface TaskRow { sourceIssueClosedAt: string | null; mergeDetails: string | null; workspaceWorktrees: string | null; + repositoryScope: string | null; noCommitsExpected: number | null; enabledWorkflowSteps: string | null; modifiedFiles: string | null; @@ -228,7 +229,7 @@ PostgreSQL task JSONB conversion must use one registry for both descriptor write export const TASK_JSONB_COLUMNS: ReadonlySet = new Set([ "dependencies", "steps", "customFields", "log", "attachments", "steeringComments", "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", - "issueInfo", "githubTracking", "gitlabTracking", "mergeDetails", "workspaceWorktrees", "enabledWorkflowSteps", + "issueInfo", "githubTracking", "gitlabTracking", "mergeDetails", "workspaceWorktrees", "repositoryScope", "enabledWorkflowSteps", "modifiedFiles", "declaredSymbols", "scopeAutoWiden", "sourceMetadata", "tokenUsagePerModel", "tokenBudgetOverride", "columnDwellMs", "workflowTransitionNotification", "recommendations", ]); @@ -397,6 +398,7 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("sourceIssueClosedAt", (task) => task.sourceIssue?.closedAt ?? null), defineTaskColumn("mergeDetails", (task) => toJsonNullable(task.mergeDetails)), defineTaskColumn("workspaceWorktrees", (task) => toJsonNullable(task.workspaceWorktrees)), + defineTaskColumn("repositoryScope", (task) => toJsonNullable(task.repositoryScope)), defineTaskColumn("noCommitsExpected", (task) => task.noCommitsExpected ? 1 : 0), defineTaskColumn("enabledWorkflowSteps", (task) => toJson(task.enabledWorkflowSteps || [])), defineTaskColumn("modifiedFiles", (task) => toJson(task.modifiedFiles || [])), diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 7df4bbee55..e22e5caac6 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -267,6 +267,8 @@ export function rowToTask(row: TaskRow): Task { const w = fromJson(row.workspaceWorktrees); return w && Object.keys(w).length > 0 ? w : undefined; })(), + // FNXC:RepositoryScope 2026-08-20-23:07: legacy null remains absent; hydration must not convert acquired worktrees into intent. + repositoryScope: fromJson(row.repositoryScope) ?? undefined, noCommitsExpected: row.noCommitsExpected ? true : undefined, // FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty optional-step // selection must hydrate back as [], not undefined — "all disabled" and "not diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 45f5405695..764b24727e 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -44,6 +44,7 @@ import type {DbTransaction} from "../postgres/data-layer.js"; import { resolveTaskPrefix } from "./task-prefix.js"; import {assertValidProviderInstanceId} from "../provider-instance.js"; import {validateTaskBranchName} from "../branch/branch-assignment.js"; +import {loadWorkspaceConfig} from "../git/git-repository.js"; import { getInternalIntakeOwnershipExemptionReason, resolveTaskIntakeOwner, @@ -76,6 +77,33 @@ function normalizeCreateBranchProvenance(input: TaskCreateInput): TaskCreateInpu return {...input, branchContext: Object.keys(branchContext).length > 0 ? branchContext : undefined}; } +/* +FNXC:RepositoryScope 2026-08-20-23:40: +Workspace acquisition deliberately obtains every configured checkout after creation. Seed task intent +before that acquisition so checkout membership cannot become review or landing authority. Explicit +operator selection is confirmed; description/default evidence remains a planner-confirmable proposal. +*/ +async function resolveInitialRepositoryScope(store: TaskStore, input: TaskCreateInput, now: string): Promise { + const configured = (await loadWorkspaceConfig(store.getRootDir()))?.repos ?? []; + if (configured.length === 0) return undefined; + const explicit = input.repositoryScope?.map((repo) => repo.trim()).filter(Boolean); + const unknown = explicit?.filter((repo) => !configured.includes(repo)) ?? []; + if (unknown.length > 0) throw new Error(`Unknown workspace repository scope: ${unknown.join(", ")}`); + const mentions = configured.filter((repo) => input.description.toLowerCase().includes(repo.toLowerCase())); + const repositories = explicit && explicit.length > 0 + ? [...new Set(explicit)].sort() + : [mentions.length === 1 ? mentions[0] : configured[0]]; + const explicitSelection = Boolean(explicit?.length); + return { + repositories, + state: explicitSelection ? "confirmed" : "proposed", + revision: 1, + confirmedBy: explicitSelection ? "operator" : "inferred", + ...(explicitSelection ? { confirmedAt: now } : {}), + }; +} +} + type CreateTaskWithAfterInsert = TaskCreateInput & { /** Internal transaction hook; never persisted in task source metadata. */ afterTaskInsert?: (tx: DbTransaction, task: Task) => Promise; @@ -686,6 +714,7 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta ? { manual: false as boolean, intake: undefined as string | undefined, hold: undefined as string | undefined } : await resolveWorkflowIntakeFacts(store, input.workflowId ?? undefined); const declaredSymbols = resolveCreateDeclaredSymbols(input, options?.promptOverride); + const repositoryScope = await resolveInitialRepositoryScope(store, input, now); const task: Task = { id, lineageId: input.lineageId ?? generateTaskLineageId(), @@ -716,6 +745,7 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage", dependencies: input.dependencies || [], noCommitsExpected: input.noCommitsExpected === true ? true : undefined, + repositoryScope, enabledWorkflowSteps: resolvedWorkflowSteps, modelPresetId: input.modelPresetId, assignedAgentId: ownership.status === "selected" ? ownership.agentId : undefined, @@ -1237,6 +1267,7 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat ? { manual: false as boolean, intake: undefined as string | undefined, hold: undefined as string | undefined } : await resolveWorkflowIntakeFacts(store, input.workflowId ?? undefined); const declaredSymbols = resolveCreateDeclaredSymbols(input, options?.promptOverride); + const repositoryScope = await resolveInitialRepositoryScope(store, input, now); const task: Task = { id, lineageId: input.lineageId ?? generateTaskLineageId(), @@ -1267,6 +1298,7 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage", dependencies: input.dependencies || [], noCommitsExpected: input.noCommitsExpected === true ? true : undefined, + repositoryScope, enabledWorkflowSteps: resolvedWorkflowSteps, modelPresetId: input.modelPresetId, assignedAgentId: input.assignedAgentId, diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index ef2353f8e7..09e5f3cf8b 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -21,7 +21,7 @@ import {randomUUID} from "node:crypto"; import {mkdir, readFile, writeFile, rename, unlink} from "node:fs/promises"; import {join} from "node:path"; import {existsSync} from "node:fs"; -import type {Task, TaskCreateInput, TaskAttachment, BoardConfig, ActivityLogEntry, ActivityEventType, Artifact, ArtifactCreateInput, RunMutationContext, MergeQueueEntry, BranchGroup, BranchGroupUpdate, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemKind, PrEntity, PrEntityUpdate, TaskRecommendation, WorkspaceWorktreeEntry} from "../types.js"; +import type {Task, TaskCreateInput, TaskAttachment, BoardConfig, ActivityLogEntry, ActivityEventType, Artifact, ArtifactCreateInput, RunMutationContext, MergeQueueEntry, BranchGroup, BranchGroupUpdate, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemKind, PrEntity, PrEntityUpdate, TaskRecommendation, WorkspaceWorktreeEntry, TaskRepositoryScope} from "../types.js"; import { CONFIG_CHANGED_BY_SYSTEM } from "../types.js"; import {validateSettingValuePatch, WorkflowSettingRejectionError} from "../workflows/workflow-settings.js"; import "../builtin-traits.js"; @@ -46,10 +46,12 @@ import {insertArtifactRow as insertArtifactRowAsync} from "../task-store/async/a import {appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration} from "../async-stores/async-configuration-revision-store.js"; import {readProjectConfig, writeProjectConfig} from "./async/async-settings.js"; import {publishSettingsUpdated} from "./settings-ops.js"; +import {loadWorkspaceConfig} from "../git/git-repository.js"; import { mergeRestoredProjectSettings } from "../config/settings-schema.js"; import type {ConfigChangedBy, ConfigurationRevision} from "../types.js"; import { resolveArchivedLanes } from "../project-lane-vocabulary.js"; import { acquireTaskAdvisoryXactLock } from "./task-advisory-lock.js"; +import { invalidateSupersededRepositoryScopeReviews } from "../tasks/repository-scope.js"; export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, limit: number): string { const columns = [ @@ -68,7 +70,7 @@ export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, li "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "cumulativePlanningMs", "planningStartedAt", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", - "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", + "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "repositoryScope", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "declaredSymbols", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", @@ -574,6 +576,131 @@ export async function mergeWorkspaceWorktreeEntryImpl( }); } +/* +FNXC:RepositoryScope 2026-08-20-23:07: +Scope changes are a project-scoped read-modify-write under the task advisory lock. This updates only +repository_scope, so an operator scope decision cannot overwrite concurrent per-repository acquisition +or landing entries. +*/ +export type TaskRepositoryScopeMutation = { + action: "add" | "remove" | "refuse"; + repositories: string[]; + reason: string; + actor: string; +}; + +export async function updateTaskRepositoryScopeImpl( + store: TaskStore, + id: string, + requestedScope: TaskRepositoryScope | TaskRepositoryScopeMutation | undefined, +): Promise { + const configuredRepositories = (await loadWorkspaceConfig(store.getRootDir()))?.repos ?? []; + const isMutation = requestedScope !== undefined && "action" in requestedScope; + const requestedRepositories = requestedScope && "repositories" in requestedScope + ? requestedScope.repositories + : undefined; + const normalizedRepositories = requestedRepositories + ? [...new Set(requestedRepositories.map((repo) => repo.trim()).filter(Boolean))].sort() + : undefined; + if (normalizedRepositories && configuredRepositories.length > 0) { + const unknown = normalizedRepositories.filter((repo) => !configuredRepositories.includes(repo)); + if (unknown.length > 0) throw new Error(`Unknown workspace repository scope: ${unknown.join(", ")}`); + } + /* + FNXC:RepositoryScope 2026-08-21-01:53: + Scope intent shares planning lifecycle serialization with plan confirmation. Acquire that lock + before the task/advisory transaction so a delta always merges the current scope and appends its + event instead of restoring a stale client snapshot over a planner or executor extension. + */ + return store.withPlanningLifecycleLock(id, () => store.withTaskLock(id, async () => { + const layer = store.asyncLayer!; + const outcome = await layer.transactionImmediate(async (tx) => { + await acquireTaskAdvisoryXactLock(tx, layer.projectId, id); + const row = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); + if (!row) throw new TaskNotFoundError(id); + if (row.deletedAt) throw new TaskDeletedError(id, row.deletedAt as string); + const current = store.rowToTask(store.pgRowToTaskRow(row)); + const hasLandedRepository = Object.values(current.workspaceWorktrees ?? {}).some((entry) => Boolean(entry.landedSha)); + const [pendingIntent] = await tx.select({ taskId: schema.project.workspaceLandIntents.taskId }) + .from(schema.project.workspaceLandIntents) + .where(and( + eq(schema.project.workspaceLandIntents.projectId, layer.projectId?.trim() || "__legacy_unscoped__"), + eq(schema.project.workspaceLandIntents.taskId, id), + eq(schema.project.workspaceLandIntents.status, "pending"), + )) + .limit(1); + /* + FNXC:RepositoryScope 2026-08-21-00:12: + Repository intent becomes immutable once a land intent is pending or any repository has + landed. This transaction-level fence prevents an already-acquired clean checkout from + changing review or landing obligations after integration begins. + */ + const now = new Date().toISOString(); + const currentRepositories = current.repositoryScope?.repositories ?? []; + const mutation = isMutation ? requestedScope as TaskRepositoryScopeMutation : undefined; + const nextRepositories = mutation?.action === "add" + ? [...new Set([...currentRepositories, ...normalizedRepositories!])].sort() + : mutation + ? currentRepositories.filter((repository) => !normalizedRepositories!.includes(repository)) + : normalizedRepositories; + const scopeChanged = JSON.stringify([...currentRepositories].sort()) !== JSON.stringify(nextRepositories ?? []); + if ((pendingIntent || hasLandedRepository) && scopeChanged) { + throw new Error(`Repository scope for ${id} cannot change after workspace landing has started`); + } + const priorExtensions = current.repositoryScope?.extensions ?? []; + const mutationEvents = mutation + ? normalizedRepositories!.map((repository) => ({ + repository, + requestedAt: now, + requestedBy: mutation.actor, + reason: mutation.reason, + status: mutation.action === "refuse" ? "refused" as const : "accepted" as const, + ...(mutation.action === "refuse" ? { refusedAt: now, refusedBy: mutation.actor, refusalReason: mutation.reason } : {}), + })) + : []; + const replacement = isMutation + ? { + ...(current.repositoryScope ?? {}), + repositories: nextRepositories ?? [], + state: "confirmed" as const, + confirmedAt: now, + confirmedBy: mutation!.actor === "operator" ? "operator" as const : current.repositoryScope?.confirmedBy, + extensions: [...priorExtensions, ...mutationEvents], + } + : requestedScope; + /* + FNXC:RepositoryScope 2026-08-21-02:48: + A repository-scope mutation invalidates approvals captured for the prior intent + generation. Never carry review evidence into a new revision: landing may only + accept fingerprints reviewed against the current confirmed repository set. + */ + const normalized = nextRepositories && replacement && { + ...replacement, + repositories: nextRepositories, + revision: Math.max((current.repositoryScope?.revision ?? 0) + 1, replacement.revision ?? 0), + ...(scopeChanged ? { reviewEvidence: undefined } : {}), + }; + const [updatedRow] = await tx + .update(schema.project.tasks) + .set({ + repositoryScope: normalized ?? null, + workflowStepResults: scopeChanged + ? invalidateSupersededRepositoryScopeReviews(current.workflowStepResults, normalized?.revision) + : current.workflowStepResults, + updatedAt: new Date().toISOString(), + }) + .where(and(eq(schema.project.tasks.id, id), taskProjectScope(layer))) + .returning(); + if (!updatedRow) throw new TaskNotFoundError(id); + return store.rowToTask(store.pgRowToTaskRow(updatedRow)); + }); + await store.writeTaskJsonFile(store.taskDir(id), outcome); + if (store.isWatching) store.taskCache.set(id, { ...outcome }); + store.emitTaskLifecycleEventSafely("task:updated", [outcome]); + return outcome; + })); +} + export async function resolveTaskWedgeNotificationEpisodeImpl( store: TaskStore, id: string, diff --git a/packages/core/src/task-store/task-row-mappers.ts b/packages/core/src/task-store/task-row-mappers.ts index 4b76b22a23..2d37b6384f 100644 --- a/packages/core/src/task-store/task-row-mappers.ts +++ b/packages/core/src/task-store/task-row-mappers.ts @@ -42,7 +42,7 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "cumulativePlanningMs", "planningStartedAt", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", - "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", + "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "repositoryScope", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "declaredSymbols", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", "proposalClaimId", diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 190367317c..2059c5b0ba 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -16,7 +16,7 @@ import { } from "../workflows/workflow-lifecycle-traits.js"; import {resolveWorkflowIrForTask} from "../workflows/workflow-ir-resolver.js"; import {InvalidFileScopeError} from "./errors.js"; -import {mkdir, readFile, writeFile} from "node:fs/promises"; +import {mkdir, readFile, stat, writeFile} from "node:fs/promises"; import {join} from "node:path"; import {existsSync} from "node:fs"; import type {Task, Column, TaskLogEntry, RunMutationContext, TaskRecommendation} from "../types.js"; @@ -38,6 +38,7 @@ import {supersedePlanReviewResults} from "../planner/plan-approval.js"; import {PLAN_REVIEW_GROUP_ID} from "../workflows/builtin-plan-review-group.js"; import {validateTaskBranchName} from "../branch/branch-assignment.js"; import {withTaskBranchContextInSourceMetadata} from "./branch-context.js"; +import {invalidateSupersededRepositoryScopeReviews} from "../tasks/repository-scope.js"; /* FNXC:TaskRecommendations 2026-08-08-07:06: @@ -236,6 +237,31 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat if (updates.workspaceWorktrees !== undefined) { task.workspaceWorktrees = updates.workspaceWorktrees; } + /* + FNXC:RepositoryScope 2026-08-21-01:18: + A validated workspace plan commits its prompt and repository intent through this one + task-row write. Do not route that paired publication through updateTaskRepositoryScope: + a second transaction would expose a new ## Repository Scope heading with stale intent. + */ + if (updates.repositoryScope === null) { + task.repositoryScope = undefined; + } else if (updates.repositoryScope !== undefined) { + /* + FNXC:RepositoryScope 2026-08-21-02:48: + Prompt confirmation writes scope in the same task mutation. Its new revision + cannot inherit Code Review evidence captured for the old repository intent. + */ + const scopeRevisionChanged = task.repositoryScope?.revision !== updates.repositoryScope.revision; + task.repositoryScope = scopeRevisionChanged + ? { ...updates.repositoryScope, reviewEvidence: undefined } + : updates.repositoryScope; + if (scopeRevisionChanged) { + task.workflowStepResults = invalidateSupersededRepositoryScopeReviews( + task.workflowStepResults, + task.repositoryScope.revision, + ); + } + } // New dependencies re-seed hold-lane tasks and exhausted Plan Review cap parks. let movedToTriage = false; let respecifyFromColumn: string | undefined; @@ -1130,20 +1156,22 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } task.updatedAt = new Date().toISOString(); - // FNXC:TaskDetailPromptResilience 2026-07-10-17:00 (merge port from main): - // Perform the explicit PROMPT.md write (and its File Scope validation) - // BEFORE committing the task row, so a failed write (EACCES/EISDIR/ - // disk-full) or an invalid File Scope aborts the whole update atomically. - // Previously this ran AFTER the row/task.json commit, so a failed prompt - // write returned an error while the field changes stayed committed and - // PROMPT.md went stale — a partial commit. + /* + FNXC:RepositoryScopePublication 2026-08-21-01:36: + A plan's Repository Scope is authoritative only after its task-row generation commits. Validate + the PROMPT.md destination before that commit, but do not expose a new Repository Scope heading + while the old durable scope is still visible to review, completion, or land readers. + */ + const promptPath = join(dir, "PROMPT.md"); if (updates.prompt !== undefined) { const validation = validateFileScopeInPromptContent(updates.prompt); if (validation.invalid.length > 0) { throw new InvalidFileScopeError(id, validation.invalid); } await mkdir(dir, { recursive: true }); - await writeFile(join(dir, "PROMPT.md"), updates.prompt); + if (existsSync(promptPath) && (await stat(promptPath)).isDirectory()) { + throw new Error(`Cannot write PROMPT.md for ${id}: destination is a directory`); + } /* FNXC:SpecLock 2026-08-09-12:34: An explicit full-spec write is an authoritative plan revision, not cosmetic title sync. @@ -1174,6 +1202,15 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat await store.atomicWriteTaskJsonWithAudit(dir, task, undefined, planningInvalidation, updates.prompt); } + /* + FNXC:RepositoryScopePublication 2026-08-21-01:36: + The task-row commit is the observable scope-generation fence. Publish PROMPT.md only after it, + so no reader can dispatch work from a new heading paired with the preceding scope generation. + */ + if (updates.prompt !== undefined) { + await writeFile(promptPath, updates.prompt); + } + if (store.isBackendMode() && updates.prompt !== undefined) { /* FNXC:SpecLock 2026-08-09-19:01: diff --git a/packages/core/src/tasks/repository-scope.ts b/packages/core/src/tasks/repository-scope.ts new file mode 100644 index 0000000000..4bc748e0d1 --- /dev/null +++ b/packages/core/src/tasks/repository-scope.ts @@ -0,0 +1,29 @@ +import type { Task } from "../types.js"; + +/** + * FNXC:RepositoryScope 2026-08-21-03:05: + * A repository-scope revision invalidates Code Review results from every older generation. + * Keep an explicit failed record rather than deleting it: absence could accidentally satisfy a + * merge gate, while the diagnostic prevents an old approval from admitting a graph edge. + */ +export function invalidateSupersededRepositoryScopeReviews( + results: Task["workflowStepResults"], + revision: number | undefined, +): Task["workflowStepResults"] { + if (revision === undefined) return results; + return results?.map((result) => ( + result.reviewKind === "code" + && typeof result.repositoryScopeRevision === "number" + && result.repositoryScopeRevision !== revision + ? { + ...result, + status: "failed" as const, + verdict: undefined, + findings: undefined, + repositoryReviewOutcomes: undefined, + output: "Code Review result superseded by a repository scope change.", + notes: undefined, + } + : result + )); +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 03b8ce69f0..9b0291efcd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -280,6 +280,7 @@ import type { WorkflowReviewFindingSeverity, WorkflowReviewFindingResolution, WorkflowReviewFinding, + WorkflowRepositoryReviewOutcome, WorkflowStep, NtfyNotificationEvent, NotificationEvent, @@ -303,6 +304,7 @@ export type { WorkflowReviewFindingSeverity, WorkflowReviewFindingResolution, WorkflowReviewFinding, + WorkflowRepositoryReviewOutcome, WorkflowStep, NtfyNotificationEvent, NotificationEvent, @@ -604,6 +606,7 @@ import type { TaskRecommendationListItem, TaskRecommendationListPage, WorkspaceWorktreeEntry, + TaskRepositoryScope, Task, TaskReleaseGateVerdict, TaskVerificationResultSummary, @@ -652,6 +655,7 @@ export type { TaskRecommendationListItem, TaskRecommendationListPage, WorkspaceWorktreeEntry, + TaskRepositoryScope, Task, TaskReleaseGateVerdict, TaskVerificationResultSummary, diff --git a/packages/core/src/types/task/task-core.ts b/packages/core/src/types/task/task-core.ts index bf1c42d33d..744fa266ac 100644 --- a/packages/core/src/types/task/task-core.ts +++ b/packages/core/src/types/task/task-core.ts @@ -703,6 +703,34 @@ base missing in one repo falls back to that repo's integration branch and record name in baseBranchFallbackFrom. Legacy entries without these fields remain pinned to their own integration branch and ignore task.baseBranch. Ref names live here and in task logs, never audit metadata. */ +/* +FNXC:RepositoryScope 2026-08-20-23:07: +Repository acquisition is an implementation detail, not task intent. This durable scope is the +sole authority for workspace review, landing, and recovery; unprefixed file scope can seed it but +must never expand it to every acquired checkout. +*/ +export interface TaskRepositoryScope { + repositories: string[]; + /** A proposal is visible before the planner confirms the repository intent. */ + state?: "proposed" | "confirmed"; + /** Monotonic intent generation used to fence stale review callbacks. */ + revision?: number; + confirmedAt?: string; + confirmedBy?: "operator" | "plan" | "inferred"; + /** FNXC:RepositoryScope 2026-08-21-01:18: Fresh landing accepts only the exact repository diff approved by Code Review. */ + reviewEvidence?: Record; + extensions?: Array<{ + repository: string; + requestedAt: string; + requestedBy: string; + reason: string; + status: "accepted" | "refused"; + refusedAt?: string; + refusedBy?: string; + refusalReason?: string; + }>; +} + export interface WorkspaceWorktreeEntry { worktreePath: string; branch: string; @@ -793,6 +821,8 @@ export interface Task { * it alongside `landedSha`. */ workspaceWorktrees?: Record; + /** Explicit repository intent. Missing legacy scope is intentionally not inferred from checkouts. */ + repositoryScope?: TaskRepositoryScope; steps: TaskStep[]; currentStep: number; /** @@ -1236,7 +1266,8 @@ export interface Task { * mislabel a completed implementation as a plan awaiting approval. * Undefined means either no hold or a routine manual plan-approval hold. */ - awaitingApprovalReason?: "release-authorization" | "plan-review-replan-cap" | "merge-blocked-by-policy"; + /** FNXC:RepositoryScope 2026-08-21-01:53: repeated unchanged Code Review revisions park for an operator before a third remediation loop. */ + awaitingApprovalReason?: "release-authorization" | "plan-review-replan-cap" | "merge-blocked-by-policy" | "code-review-non-convergence"; /* * FNXC:PlanApproval 2026-07-04-22:41: * FN-7569 — records the computePlanApprovalFingerprint (packages/core/src/plan-approval.ts) @@ -1566,6 +1597,8 @@ export interface TaskCreateInput { dependencies?: string[]; /** When true, this task is expected to complete without creating git commits. */ noCommitsExpected?: boolean; + /** Explicit workspace repository intent selected by the operator at creation. */ + repositoryScope?: string[]; /** IDs of workflow steps to enable for this task */ enabledWorkflowSteps?: string[]; /** diff --git a/packages/core/src/types/workflow/workflow-steps.ts b/packages/core/src/types/workflow/workflow-steps.ts index 05429f2b38..c2951a6fa7 100644 --- a/packages/core/src/types/workflow/workflow-steps.ts +++ b/packages/core/src/types/workflow/workflow-steps.ts @@ -52,6 +52,23 @@ export interface WorkflowReviewFinding { resolution?: WorkflowReviewFindingResolution; } +/* +FNXC:RepositoryScope 2026-08-21-02:17: +Workspace review aggregates must retain repository identity, review input, and verdict data as structured task state. Rendered prose is not a remediation or merge authority because clean repositories have no verdict and identical feedback can describe different diffs. +*/ +export interface WorkflowRepositoryReviewOutcome { + repository: string; + status: "REVIEWED" | "NOT_REVIEWED"; + verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE" | "RETHINK" | "UNAVAILABLE"; + findings?: WorkflowReviewFinding[]; + output?: string; + fingerprint?: string; + /** Stable identifier for the single reviewer session that produced this record. */ + episodeId: string; + scopeRevision?: number; + reviewedAt: string; +} + /** Lifecycle phase for workflow step execution. */ export type WorkflowStepPhase = "pre-merge" | "post-merge"; @@ -280,6 +297,10 @@ export interface WorkflowStepResult { output?: string; /** Normalized structured advisory findings from an explicitly classified review node. */ findings?: WorkflowReviewFinding[]; + /** Per-repository review records for workspace code-review nodes; clean peers carry NOT_REVIEWED without a verdict. */ + repositoryReviewOutcomes?: WorkflowRepositoryReviewOutcome[]; + /** Confirmed repository-scope generation that supplied the workspace review input. */ + repositoryScopeRevision?: number; /** Prior result containing the finding IDs this review step explicitly declared superseded. */ supersededFindingSourceWorkflowStepId?: string; /** Prior-lane finding IDs this review step explicitly declared superseded; audit-only. */ diff --git a/packages/dashboard/app/api/tasks/tasks.ts b/packages/dashboard/app/api/tasks/tasks.ts index a562b0c584..b81f146857 100644 --- a/packages/dashboard/app/api/tasks/tasks.ts +++ b/packages/dashboard/app/api/tasks/tasks.ts @@ -316,6 +316,7 @@ export async function createTask( sessionAdvisorEnabled, acknowledgedDuplicates, bypassDuplicateCheck, + repositoryScope, } = input; try { @@ -359,6 +360,7 @@ export async function createTask( sessionAdvisorEnabled, acknowledgedDuplicates, bypassDuplicateCheck, + repositoryScope, }), }); } catch (error) { @@ -372,6 +374,18 @@ export async function createTask( } } +/** Update explicit workspace repository intent before any land intent or landed SHA exists. */ +export function updateTaskRepositoryScope( + id: string, + input: { repositories: string[]; reason: string; action?: "add" | "remove" | "refuse" }, + projectId?: string, +): Promise { + return api(withProjectId(`/tasks/${encodeURIComponent(id)}/repository-scope`, projectId), { + method: "POST", + body: JSON.stringify(input), + }); +} + export interface RepairOverlapBlockerResult { taskId: string; dryRun: boolean; diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 3d4998dd2a..7aed5c3141 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -19,6 +19,7 @@ import { fetchGitRemotes, uploadAttachment, fetchBoardWorkflows, + fetchWorkspaceRepos, type BoardWorkflowsPayload, type CreateTaskInput, type DuplicateMatch, @@ -369,6 +370,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const isFloating = viewportMode !== "mobile"; const [dependencies, setDependencies] = useState([]); + const [workspaceRepositories, setWorkspaceRepositories] = useState([]); + const [selectedRepositoryScope, setSelectedRepositoryScope] = useState([]); const [branchMode, setBranchMode] = useState("project-default"); const [branch, setBranch] = useState(""); const [baseBranch, setBaseBranch] = useState(""); @@ -639,10 +642,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, branchMode !== "project-default" || branch !== "" || baseBranch !== "" || + selectedRepositoryScope.length > 0 || githubTrackingEnabled !== initialDefaultValues.githubTrackingEnabled || githubRepoOverrideTrimmed !== ""; setHasDirtyState(isDirty); - }, [description, dependencies, pendingImages, selectedWorkflowId, hasUserSelectedEnabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, plannerOversightLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, initialDefaultValues]); + }, [description, dependencies, pendingImages, selectedWorkflowId, hasUserSelectedEnabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, plannerOversightLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, branch, baseBranch, selectedRepositoryScope, githubTrackingEnabled, githubRepoOverrideTrimmed, initialDefaultValues]); const resetForm = useCallback(() => { // Clean up object URLs @@ -651,6 +655,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, setPendingImages([]); setDescription(""); setDependencies([]); + setSelectedRepositoryScope([]); setExecutorModel(""); setCredentialInstanceId(undefined); setValidatorModel(""); @@ -683,6 +688,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, githubGeneratedDescriptionRef.current = ""; }, [pendingImages]); + useEffect(() => { + if (!isOpen) return; + void fetchWorkspaceRepos(projectId).then(({ repos }) => setWorkspaceRepositories(repos)).catch(() => setWorkspaceRepositories([])); + }, [isOpen, projectId]); + const handleClose = useCallback(async () => { if (hasDirtyState) { const shouldDiscard = await confirm({ @@ -723,7 +733,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, title: undefined, description: trimmedDesc, dependencies: dependencies.length ? dependencies : undefined, - // U6/R3: forward the workflow selection only when the user changed it. + ...(selectedRepositoryScope.length > 0 ? { repositoryScope: selectedRepositoryScope } : {}), // U6/R3: forward the workflow selection only when the user changed it. // - undefined → omit (store inherits the project default, today's behavior) // - null → explicit "No workflow" (store skips default materialization) // - string → that workflow, materialized atomically at create time. @@ -1246,7 +1256,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, onStartSubmit={canStartTask && (Boolean(description.trim()) || isSubmitting) ? handleStartSubmit : undefined} startSubmitLabel={isSubmitting ? t("newTaskModal.starting", "Starting...") : t("newTaskModal.startTask", "Start")} startSubmitDisabled={!canStartTaskNow} - renderBelowPrimary={quickFields} + renderBelowPrimary={<>{quickFields}{workspaceRepositories.length > 0 &&
{t("newTaskModal.repositoryScope", "Repository scope")}

{t("newTaskModal.repositoryScopeHint", "Select the repositories this task intends to change.")}

{workspaceRepositories.map((repository) => )}
}} hideDependencies={true} autoExpandMoreOptionsOnSelection={false} /> diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index bdd72cedf1..badf8fc10e 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -4039,3 +4039,30 @@ reuse source-grid spacing so long structural finding labels and immutable proven @media (max-width: 768px) { .ai-merge-review-reconciliation { margin-inline: var(--space-2); } } + +/* +FNXC:RepositoryScope 2026-08-21-00:29: +Task Detail exposes pre-land scope corrections beside acquired repository evidence. The controls +use existing form and spacing tokens so desktop and compact modal layouts retain keyboard-accessible +operator recovery without treating acquisition count as task intent. +*/ +.workspace-repository-scope-controls { + display: grid; + gap: var(--space-sm); + margin-block: var(--space-md); +} +.workspace-repository-scope-controls label { + display: grid; + gap: var(--space-xs); + color: var(--text-muted); +} +.workspace-repository-scope-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} +.workspace-repository-scope-error { color: var(--color-danger-text); } +.workspace-repository-scope-history { + margin: var(--space-sm) 0; + padding-inline-start: var(--space-lg); +} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 459fa9d664..d18707b4dd 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -37,6 +37,7 @@ import { } from "../utils/columnRoles"; import { resolveEffectiveAutoMerge } from "../../../core/src/merge/task-merge"; import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, fetchTaskDetail, fetchTaskPrompt, fetchSpecLock, fetchTaskVerificationRequest, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api"; +import { updateTaskRepositoryScope } from "../api/tasks/tasks"; import type { RevertTaskOptions, RevertTaskResult, ModelInfo, NodeInfo, SpecLockResponse } from "../api"; import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import { WorkflowIcon } from "./WorkflowIcon"; @@ -998,6 +999,18 @@ export function TaskDetailContent({ } as TaskDetail) : ({ ...task, prompt: "" } as TaskDetail); const activityLog = workingTask.log ?? []; + /* + FNXC:RepositoryScope 2026-08-21-00:29: + Scope edits must replace the local snapshot with the server's authoritative, land-fenced task. + This keeps an operator from making a second edit against stale intent after another session has + started a repository land. + */ + const handleRepositoryScopeChange = useCallback(async (input: { repositories: string[]; reason: string; action: "add" | "remove" | "refuse" }) => { + const updated = await updateTaskRepositoryScope(workingTask.id, input, projectId); + setFullDetail((previous) => previous ? ({ ...previous, ...updated } as TaskDetail) : (updated as TaskDetail)); + onTaskUpdated?.(updated); + }, [onTaskUpdated, projectId, workingTask.id]); + const handleCopyActivityLogs = useCallback(async () => { if (detailLoading || activityLog.length === 0) return; const copied = await copyTextToClipboard(serializeTaskActivityLogs(activityLog)); @@ -5669,7 +5682,7 @@ export function TaskDetailContent({ workingTask, not the sparse task row. workspaceWorktrees is only present in fetched detail, so keying off task renders blank on the optimistic-open path before the detail fetch resolves. */} - {isWorkspaceTask(workingTask) && } + {isWorkspaceTask(workingTask) && } )} {shouldShowTaskFailureAlert && ( diff --git a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx index 2034008f6f..3c1e04ee2d 100644 --- a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx +++ b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { Task } from "@fusion/core"; @@ -32,17 +33,71 @@ export function deriveWorkspaceRepoStatus( } interface WorkspaceWorktreesSummaryProps { - task: Pick; + task: Pick; compact?: boolean; + onScopeChange?: (input: { repositories: string[]; reason: string; action: "add" | "remove" | "refuse" }) => Promise; } -export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWorktreesSummaryProps) { +function RepositoryScopeControls({ + onScopeChange, +}: Pick) { + const { t } = useTranslation("app"); + const [repository, setRepository] = useState(""); + const [reason, setReason] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + if (!onScopeChange) return null; + const submit = async (action: "add" | "remove" | "refuse") => { + const selected = repository.trim(); + if (!selected || !reason.trim()) { + setError(t("tasks.workspaceScopeReasonRequired", "Repository and reason are required.")); + return; + } + setSaving(true); + setError(null); + try { + await onScopeChange({ repositories: [selected], reason: reason.trim(), action }); + setRepository(""); + setReason(""); + } catch (cause) { + setError(cause instanceof Error ? cause.message : t("tasks.workspaceScopeUpdateFailed", "Repository scope could not be updated.")); + } finally { + setSaving(false); + } + }; + return
+ + +
+ + + +
+ {error &&
{error}
} +
; +} + +export function WorkspaceWorktreesSummary({ task, compact = false, onScopeChange }: WorkspaceWorktreesSummaryProps) { const { t } = useTranslation("app"); const entries = task.workspaceWorktrees; if (!isWorkspaceTask(task) || !entries) return null; const repos = Object.entries(entries).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); - const statuses = repos.map(([repoRelPath, entry]) => ({ repoRelPath, entry, ...deriveWorkspaceRepoStatus(entry, repoRelPath, task.mergeDetails) })); + /* FNXC:RepositoryScope 2026-08-20-23:07: task detail distinguishes acquired checkout state from explicit intent and diff evidence. */ + const scopedRepos = new Set(task.repositoryScope?.repositories ?? []); + const statuses = repos.map(([repoRelPath, entry]) => ({ + repoRelPath, + entry, + scopeState: task.repositoryScope ? (scopedRepos.has(repoRelPath) ? "scoped" : "out-of-scope") : "legacy", + modified: (task.modifiedFiles ?? []).some((file) => file.startsWith(`${repoRelPath}/`)), + ...deriveWorkspaceRepoStatus(entry, repoRelPath, task.mergeDetails), + })); const landedCount = statuses.filter(({ status }) => status === "landed").length; const hasStatusEvidence = statuses.some(({ status }) => status !== "pending"); const fullyLanded = landedCount === repos.length; @@ -62,10 +117,15 @@ export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWo return
{placeholder}
{!fullyLanded && task.error &&
{task.error}
} + + {task.repositoryScope?.extensions?.length ?
    + {task.repositoryScope.extensions.map((event, index) =>
  • {event.repository}: {event.status} — {event.reason}
  • )} +
: null}
    - {statuses.map(({ repoRelPath, entry, status, landedSha, failureMessage }) =>
  • + {statuses.map(({ repoRelPath, entry, status, scopeState, modified, landedSha, failureMessage }) =>
  • {repoRelPath} {status} + {scopeState === "out-of-scope" ? t("tasks.workspaceRepoOutOfScope", "Out of scope") : modified ? t("tasks.workspaceRepoModified", "Modified") : t("tasks.workspaceRepoNotReviewed", "No changes — not reviewed")} {landedSha && {landedSha.slice(0, 8)}} {entry.worktreePath} {entry.branch} diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 1859771422..32743cd937 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -46,6 +46,7 @@ vi.mock("../../api", () => ({ checkDuplicateTasks: vi.fn().mockResolvedValue([]), fetchGitRemotes: vi.fn().mockResolvedValue([]), fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: true, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }), + fetchWorkspaceRepos: vi.fn().mockResolvedValue({ repos: [] }), apiFetchGitHubIssues: vi.fn().mockResolvedValue([]), apiFetchGitHubPulls: vi.fn().mockResolvedValue([]), fetchModels: vi.fn().mockResolvedValue({ models: [ diff --git a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx index cc4627ae51..1f3353c570 100644 --- a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx @@ -1,4 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; import { render, screen } from "@testing-library/react"; import { WorkspaceWorktreesSummary, deriveWorkspaceRepoStatus, isWorkspaceTask } from "../WorkspaceWorktreesSummary"; @@ -65,6 +66,30 @@ describe("WorkspaceWorktreesSummary", () => { expect(screen.getByText("fusion/fn-1-b")).toBeTruthy(); }); + it("distinguishes a modified scoped repository from a clean acquired peer", () => { + render(); + expect(screen.getAllByTestId("workspace-repo-scope-scoped")[0]).toHaveTextContent("Modified"); + expect(screen.getByText("No changes — not reviewed")).toBeTruthy(); + }); + + it("sends an attributed pre-land scope correction and renders its history", async () => { + const user = userEvent.setup(); + const onScopeChange = vi.fn(async () => undefined); + render(); + expect(screen.getByText(/repo-b: accepted/i)).toBeTruthy(); + const inputs = screen.getAllByRole("textbox"); + await user.type(inputs[0]!, "repo-b"); + await user.type(inputs[1]!, "needed for shared API"); + await user.click(screen.getByRole("button", { name: "Add" })); + expect(onScopeChange).toHaveBeenCalledWith({ repositories: ["repo-b"], reason: "needed for shared API", action: "add" }); + }); + it("renders recorded bases and fallback markers only in the full per-repo list", () => { render( typeof repo === "string" && repo.trim().length > 0))) { + throw badRequest("repositoryScope must be an array of non-empty repository names"); + } if (Object.hasOwn(req.body as object, "breakIntoSubtasks")) { throw badRequest("breakIntoSubtasks is no longer supported; create one detailed task instead"); } @@ -2090,12 +2094,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } const normalizedTaskSource = normalizedSource as TaskSource; + /* + FNXC:RepositoryScope 2026-08-21-00:12: + The dashboard forwards explicit create-time repository intent unchanged to the guarded + TaskStore boundary. Server validation keeps a browser payload from naming a checkout that + is not configured for this project. + */ const createInput = { title: normalizedTitle, description: normalizedDescription, column, dependencies, enabledWorkflowSteps, + ...(repositoryScope !== undefined ? { repositoryScope: repositoryScope.map((repo: string) => repo.trim()) } : {}), // U6/R3: forward only when the client set it (string | null). Leaving it // absent preserves the project-default inheritance behavior. ...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}), @@ -2284,6 +2295,43 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork await createTaskThroughGuardedIntake(req, res); }); + /* + FNXC:RepositoryScope 2026-08-21-00:12: + Operators can correct, extend, or refuse repository intent before landing. The core mutation + re-checks pending intents and landed SHA state under its advisory transaction, so this route + returns the authoritative snapshot instead of trusting a stale dashboard copy. + */ + router.post("/tasks/:id/repository-scope", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const id = req.params.id; + const { repositories, reason, action } = req.body ?? {}; + if (!Array.isArray(repositories) || !repositories.every((repo: unknown) => typeof repo === "string" && repo.trim().length > 0)) { + throw badRequest("repositories must be an array of non-empty repository names"); + } + if (typeof reason !== "string" || reason.trim().length === 0) throw badRequest("reason is required"); + if (action !== undefined && action !== "add" && action !== "remove" && action !== "refuse") throw badRequest("action must be add, remove, or refuse"); + const task = await scopedStore.getTask(id); + if (!task) throw new ApiError(404, `Task ${id} not found`); + /* + FNXC:RepositoryScope 2026-08-21-01:53: + Send an operator delta, never a replacement assembled from this read. The store takes the + planning lifecycle lock and appends the event to the current durable scope with plan and + executor changes serialized ahead of the task advisory transaction. + */ + const updated = await scopedStore.mutateTaskRepositoryScope(id, { + action: action ?? "add", + repositories: repositories.map((repository: string) => repository.trim()), + reason: reason.trim(), + actor: "operator", + }); + res.json(updated); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + /* FNXC:TaskRecommendations 2026-08-08-05:27: A recommendation key must serialize BEFORE the guarded intake takes its content-fingerprint lock. diff --git a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts index dd5bac654e..7cb768d9a6 100644 --- a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts +++ b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts @@ -155,6 +155,26 @@ describeIfGit("U2 KTD4 — per-repo scope-leak guard in fn_task_done", () => { expect(result.message).toContain("OFFSCOPE.md"); }); + it("blocks an unreadable acquired out-of-scope checkout instead of treating lossy capture as clean", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const store = createStore([]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 1 }, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: path.join(fx.repoPath("repo-b"), ".worktrees", "missing"), branch: BRANCH }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result).toMatchObject({ blocked: true }); + expect(result.message).toContain("repo-b"); + expect(result.message).toContain("cannot establish out-of-scope change evidence"); + }); + it("accepts repo-local scope for a one-repository workspace", async () => { fx = await createWorkspaceFixture(["repo-a"]); const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); diff --git a/packages/engine/src/__tests__/reviewer-workspace.test.ts b/packages/engine/src/__tests__/reviewer-workspace.test.ts index 1375664d4b..be9eee7ecb 100644 --- a/packages/engine/src/__tests__/reviewer-workspace.test.ts +++ b/packages/engine/src/__tests__/reviewer-workspace.test.ts @@ -28,6 +28,16 @@ vi.mock("../execution/reviewer.js", async (importOriginal) => { return { ...actual, reviewStep: vi.fn() }; }); +vi.mock("../executor/worktree-capture-modified-files.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // FNXC:RepositoryScope 2026-08-21-00:58: workspace review must use fresh capture; + // this narrow Git seam supplies deterministic per-checkout evidence to the aggregation tests. + captureModifiedFiles: vi.fn(async (cwd: string) => capturedFilesByCwd[cwd] ?? []), + }; +}); + import { reviewStep as mockedReviewStepFn } from "../execution/reviewer.js"; import { TaskExecutor } from "../executor.js"; import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflows/workflow-node-handlers.js"; @@ -39,6 +49,7 @@ const ROOT = "/tmp/ws-root"; // NON-git workspace root — must never be a revie const WT_A = "/tmp/ws-root/repo-a/.worktrees/fn-1"; const WT_B = "/tmp/ws-root/repo-b/.worktrees/fn-1"; const cleanupDirs: string[] = []; +let capturedFilesByCwd: Record = {}; function makeGitCheckout(): string { const dir = mkdtempSync(join(tmpdir(), "fusion-review-checkout-")); @@ -53,6 +64,11 @@ function makeStore(task: Task): TaskStore & EventEmitter { getTask: vi.fn().mockResolvedValue(task), getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), updateStep: vi.fn().mockResolvedValue(undefined), + updateTaskAtomic: vi.fn(async (_id: string, updater: (current: Task) => Partial | null) => { + const patch = updater(task); + if (patch) Object.assign(task, patch); + return task; + }), logEntry: vi.fn().mockResolvedValue(undefined), getRunContextFor: vi.fn(), // mergeEffectiveSettings degrades to base on any resolver error; these reject → base used. @@ -78,6 +94,11 @@ function makeTask(overrides: Partial = {}): Task { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, + // FNXC:RepositoryScope 2026-08-20-23:07: existing conjunction fixtures represent two modified, explicitly scoped repos. + repositoryScope: overrides.repositoryScope ?? (overrides.workspaceWorktrees && Object.keys(overrides.workspaceWorktrees).length > 0 + ? { repositories: Object.keys(overrides.workspaceWorktrees), state: "confirmed", revision: 1 } + : undefined), + modifiedFiles: overrides.modifiedFiles ?? (overrides.workspaceWorktrees && Object.keys(overrides.workspaceWorktrees).flatMap((repo) => [`${repo}/src/changed.ts`])), } as Task; } @@ -103,6 +124,7 @@ function workspaceExecutor(store: TaskStore & EventEmitter): TaskExecutor { } beforeEach(() => { + capturedFilesByCwd = { [WT_A]: ["src/changed.ts"], [WT_B]: ["src/changed.ts"] }; mockedReviewStep.mockReset(); }); afterEach(() => { @@ -176,8 +198,63 @@ describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared l expect(result.summary).toMatch(/^repo-a:/); }); + it("reviews only the modified scoped repository and records a clean peer as not reviewed", async () => { + capturedFilesByCwd = { [WT_A]: ["src/changed.ts"], [WT_B]: [] }; + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES, repositoryScope: { repositories: ["repo-a", "repo-b"], state: "confirmed", revision: 1 }, modifiedFiles: ["repo-a/src/changed.ts"] }); + const executor = workspaceExecutor(makeStore(task)); + const seen: string[] = []; + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { + seen.push(cwd); + return { verdict: "APPROVE", review: "reviewed change", summary: "approved" }; + }); + expect(seen).toEqual([WT_A]); + expect(result.verdict).toBe("APPROVE"); + expect(result.review).toContain("[repo-b] NOT_REVIEWED"); + expect(result.repositoryReviewOutcomes).toEqual(expect.arrayContaining([ + expect.objectContaining({ repository: "repo-a", status: "REVIEWED", verdict: "APPROVE" }), + expect.objectContaining({ repository: "repo-b", status: "NOT_REVIEWED", output: "No changes — not reviewed." }), + ])); + expect(result.repositoryScopeRevision).toBe(1); + }); + + /* + FNXC:RepositoryScope 2026-08-20-23:23: + MRG-041 acquires every workspace repository before planning, but only explicit intent plus + diff evidence authorizes review. An acquired modified checkout outside that intent cannot + consume a reviewer session or contribute a verdict. + */ + it("never reviews an acquired modified repository outside explicit scope", async () => { + capturedFilesByCwd = { [WT_A]: ["src/changed.ts"], [WT_B]: ["src/unrelated.ts"] }; + const task = makeTask({ + workspaceWorktrees: TWO_REPO_WORKTREES, + repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 1 }, + modifiedFiles: ["repo-a/src/changed.ts", "repo-b/src/unrelated.ts"], + }); + const executor = workspaceExecutor(makeStore(task)); + const seen: string[] = []; + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { + seen.push(cwd); + return { verdict: "APPROVE", review: "reviewed intended change", summary: "approved" }; + }); + + expect(result.verdict).toBe("APPROVE"); + expect(seen).toEqual([WT_A]); + expect(result.review).not.toContain("repo-b"); + }); + + it("clean scoped repository is recorded as not reviewed without invoking a reviewer", async () => { + capturedFilesByCwd = { [WT_A]: [], [WT_B]: [] }; + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES, repositoryScope: { repositories: ["repo-a", "repo-b"], state: "confirmed", revision: 1 }, modifiedFiles: [] }); + const executor = workspaceExecutor(makeStore(task)); + const invoke = vi.fn(); + const result = await (executor as any).reviewWorkspacePerRepo(task, invoke); + expect(result.verdict).toBe("UNAVAILABLE"); + expect(result.review).toContain("No changes — not reviewed"); + expect(invoke).not.toHaveBeenCalled(); + }); + it("unproven zero-acquire workspace task → non-retryable UNAVAILABLE without invoking a reviewer", async () => { - const task = makeTask({ workspaceWorktrees: {} }); + const task = makeTask({ workspaceWorktrees: {}, repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 1 } }); const executor = workspaceExecutor(makeStore(task)); const invoke = vi.fn(); const result = await (executor as any).reviewWorkspacePerRepo(task, invoke); @@ -188,7 +265,7 @@ describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared l }); it("commit-free zero-acquire workspace task approves honestly without invoking a reviewer", async () => { - const task = makeTask({ workspaceWorktrees: {}, noCommitsExpected: true }); + const task = makeTask({ workspaceWorktrees: {}, noCommitsExpected: true, repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 1 } }); const executor = workspaceExecutor(makeStore(task)); const invoke = vi.fn(); const result = await (executor as any).reviewWorkspacePerRepo(task, invoke); @@ -219,11 +296,100 @@ describe("U2 KTD3 — step-inversion review seam (executor.ts:5668) loops per su expect(result.verdict).toBe("APPROVE"); }); + /* + FNXC:RepositoryScope 2026-08-20-23:40: + Plan Review is a task-document gate, not a per-repository diff gate. Even after workspace + acquisition, one scoped coordinator produces exactly one review session before implementation. + */ + it("discards a stale step-review callback after a repository scope revision", async () => { + const task = makeTask({ + workspaceWorktrees: { "repo-a": TWO_REPO_WORKTREES["repo-a"] }, + repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 2 }, + modifiedFiles: ["repo-a/src/changed.ts"], + }); + const store = makeStore(task); + const executor = workspaceExecutor(store); + /* + FNXC:RepositoryScope 2026-08-21-02:48: + Model the P0 race precisely: evidence commits at revision 2, then an operator + scope mutation wins before the step-inversion graph can mark its APPROVE done. + */ + vi.mocked(store.updateTaskAtomic).mockImplementationOnce(async (_id, updater) => { + const patch = await updater(task); + if (patch) Object.assign(task, patch); + task.repositoryScope = { ...task.repositoryScope!, repositories: ["repo-a", "repo-b"], revision: 3, reviewEvidence: undefined }; + return task; + }); + mockedReviewStep.mockImplementation(async () => + ({ verdict: "APPROVE", review: "approved before scope mutation", summary: "approved" })); + const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any); + const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: WT_A, baselineSha: "base" } } as any; + + const result = await seams.stepReview!(task as any, context, { type: "code", advisory: false } as any); + + expect(result.verdict).toBe("UNAVAILABLE"); + expect(result.summary).toContain("scope changed during review"); + expect(store.updateStep).not.toHaveBeenCalled(); + expect(task.repositoryScope?.reviewEvidence).toBeUndefined(); + }); + + it("discards a stale custom-node callback after a repository scope revision", async () => { + const task = makeTask({ + workspaceWorktrees: { "repo-a": TWO_REPO_WORKTREES["repo-a"] }, + repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 2 }, + modifiedFiles: ["repo-a/src/changed.ts"], + }); + const store = makeStore(task); + const executor = workspaceExecutor(store); + /* FNXC:RepositoryScope 2026-08-21-02:48: The custom-node route receives the same post-evidence/pre-completion scope mutation. */ + vi.mocked(store.updateTaskAtomic).mockImplementationOnce(async (_id, updater) => { + const patch = await updater(task); + if (patch) Object.assign(task, patch); + task.repositoryScope = { ...task.repositoryScope!, repositories: ["repo-a", "repo-b"], revision: 3, reviewEvidence: undefined }; + return task; + }); + vi.spyOn(executor as any, "ensureGraphCustomNodeWorktree").mockResolvedValue(task); + vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, verdict: "APPROVE", output: "approved before scope mutation" }); + + const result = await (executor as any).runGraphCustomNode( + { id: "custom-code-review", kind: "prompt", config: { reviewKind: "code", prompt: "review" } }, + task, + {}, + undefined, + ); + + /* FNXC:RepositoryScope 2026-08-21-03:05: Advisory Code Review cannot convert scope supersession into a passing edge. */ + expect(result).toMatchObject({ outcome: "failure", value: "workspace-review-unavailable" }); + expect(task.repositoryScope?.reviewEvidence).toBeUndefined(); + }); + + it("dispatches workspace Plan Review once through the scoped coordinator", async () => { + const task = makeTask({ + workspaceWorktrees: TWO_REPO_WORKTREES, + repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 2 }, + modifiedFiles: [], + worktree: ROOT, + }); + const store = makeStore(task); + const executor = workspaceExecutor(store); + const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "plan is sound", summary: "approved plan" } }); + const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any); + const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: ROOT } } as any; + + const result = await seams.stepReview!(task as any, context, { type: "plan", advisory: true } as any); + + expect(result.verdict).toBe("APPROVE"); + expect(seen).toEqual([WT_A]); + expect(mockedReviewStep).toHaveBeenCalledTimes(1); + expect(mockedReviewStep.mock.calls[0]?.[5]).toContain("Repository scope (task-level; review this plan once): repo-a"); + }); + it("preserves fn_task_done's persisted no-op eligibility through the production step-review seam", async () => { // fn_task_done persists this flag before it schedules the graph handoff; the // later review must not reclassify the same zero-acquire task as unproven. const task = makeTask({ workspaceWorktrees: {}, + repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 1 }, noCommitsExpected: true, summary: "PREMISE STALE: implementation already exists on HEAD", }); diff --git a/packages/engine/src/__tests__/self-healing-workspace.test.ts b/packages/engine/src/__tests__/self-healing-workspace.test.ts index df40468bcc..19795cedef 100644 --- a/packages/engine/src/__tests__/self-healing-workspace.test.ts +++ b/packages/engine/src/__tests__/self-healing-workspace.test.ts @@ -202,6 +202,10 @@ function workspaceTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Pa log: [], paused: false, workspaceWorktrees, + // FNXC:RepositoryScope 2026-08-21-01:18: partial-land recovery admits only confirmed + // repository intent plus qualified modified evidence; fixtures model that production contract. + repositoryScope: { repositories: Object.keys(workspaceWorktrees ?? {}).sort(), state: "confirmed", revision: 1 }, + modifiedFiles: Object.keys(workspaceWorktrees ?? {}).sort().map((repo) => `${repo}/feature.txt`), createdAt: new Date().toISOString(), updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), ...extra, diff --git a/packages/engine/src/__tests__/workflow-graph-optional-group.test.ts b/packages/engine/src/__tests__/workflow-graph-optional-group.test.ts index f414339e81..5d39e87cc8 100644 --- a/packages/engine/src/__tests__/workflow-graph-optional-group.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-optional-group.test.ts @@ -281,6 +281,38 @@ describe("WorkflowGraphExecutor optional-group", () => { expect(result.outcome).toBe("success"); }); + it("fences a Code Review edge when scope changes after terminal result persistence", async () => { + const calls: string[] = []; + const records: WorkflowStepResult[] = []; + const edgeAdmission = vi.fn(async () => false); + const executor = new WorkflowGraphExecutor({ + handlers: { + prompt: async (node) => { + calls.push(node.id); + return node.id === "review" + ? { outcome: "success", value: "APPROVE", contextPatch: { repositoryScopeRevision: 2 } } + : { outcome: "success" }; + }, + }, + recordWorkflowStepResult: async (_taskId, result) => { records.push(result); return true; }, + isRepositoryScopeReviewEdgeCurrent: edgeAdmission, + }); + const ir = reviseGroupIr(); + const group = ir.nodes.find((node) => node.id === "group"); + if (!group) throw new Error("review group missing"); + group.config = { ...group.config, reviewKind: "code" }; + + const result = await executor.run(taskWith(["group"]), settingsOn(), ir); + + /* FNXC:RepositoryScope 2026-08-21-03:05: The callback models an operator scope mutation after terminal CAS. */ + expect(records).toEqual(expect.arrayContaining([ + expect.objectContaining({ workflowStepId: "group", status: "passed", repositoryScopeRevision: 2 }), + ])); + expect(edgeAdmission).toHaveBeenCalledWith("FN-OG", "group", 2); + expect(calls).not.toContain("after"); + expect(result.outcome).toBe("failure"); + }); + it("pre-merge advisory REVISE requests a bounded fix and aborts forward traversal when scheduled", async () => { const calls: string[] = []; const records: unknown[] = []; diff --git a/packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts b/packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts index 812b8e8fcd..e82d7cbd33 100644 --- a/packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts @@ -38,6 +38,27 @@ const reviseInfo = { verdict: "REVISE", }; +function workspaceReviseResult(fingerprint: string, output: string, priorAttempts?: NonNullable[number]["priorAttempts"]) { + return { + workflowStepId: "code-review", + workflowStepName: "Code Review", + status: "advisory_failure" as const, + verdict: "REVISE" as const, + output, + repositoryScopeRevision: 3, + repositoryReviewOutcomes: [{ + repository: "repo-a", + status: "REVIEWED" as const, + verdict: "REVISE" as const, + fingerprint, + findings: [{ id: "finding-1", title: "Missing guard", body: "Validate repository scope." }], + episodeId: "episode-1", + reviewedAt: "2026-08-21T02:17:00.000Z", + }], + priorAttempts, + }; +} + function revisionLog(stepName: string, key: string, attempt: number) { return { timestamp: new Date().toISOString(), @@ -1179,6 +1200,43 @@ describe("TaskExecutor pre-merge optional-step fix seam", () => { expect(sendBack).toHaveBeenCalledOnce(); }); + it("parks two identical Code Review revisions before scheduling a third remediation", async () => { + const store = createMockStore(); + const prior = workspaceReviseResult("same-diff", "Earlier reviewer prose"); + const liveTask = task({ + workflowStepResults: [workspaceReviseResult("same-diff", reviseInfo.feedback, [prior])], + }); + store.getTask.mockResolvedValue(liveTask); + store.getSettings.mockResolvedValue({ maxPostReviewFixes: 9 }); + store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined); + const executor = new TaskExecutor(store, "/tmp/test"); + const sendBack = vi.spyOn(executor as any, "sendTaskBackForFix").mockResolvedValue(undefined); + + await expect((executor as any).requestPreMergeOptionalStepFix(liveTask.id, liveTask, reviseInfo)).resolves.toBe(false); + + expect(sendBack).not.toHaveBeenCalled(); + expect(store.updateTask).toHaveBeenCalledWith(liveTask.id, expect.objectContaining({ + status: "awaiting-approval", + awaitingApprovalReason: "code-review-non-convergence", + }), undefined); + expect(store.logEntry).toHaveBeenCalledWith(liveTask.id, expect.stringContaining("did not converge"), expect.any(String), undefined); + }); + + it("does not park a changed workspace review input that repeats reviewer prose", async () => { + const store = createMockStore(); + const prior = workspaceReviseResult("old-diff", reviseInfo.feedback); + const liveTask = task({ workflowStepResults: [workspaceReviseResult("new-diff", reviseInfo.feedback, [prior])] }); + store.getTask.mockResolvedValue(liveTask); + store.getSettings.mockResolvedValue({ maxPostReviewFixes: 9 }); + const executor = new TaskExecutor(store, "/tmp/test"); + const sendBack = vi.spyOn(executor as any, "sendTaskBackForFix").mockResolvedValue(undefined); + + await expect((executor as any).requestPreMergeOptionalStepFix(liveTask.id, liveTask, reviseInfo)).resolves.toBe(true); + + expect(sendBack).toHaveBeenCalledOnce(); + expect(store.updateTask).not.toHaveBeenCalledWith(liveTask.id, expect.objectContaining({ awaitingApprovalReason: "code-review-non-convergence" }), undefined); + }); + it("honors unbounded and zero per-step maxRevisions states", async () => { const unboundedStore = createMockStore(); const exhaustedTask = task({ diff --git a/packages/engine/src/__tests__/workspace-e2e.test.ts b/packages/engine/src/__tests__/workspace-e2e.test.ts index 6f8cfce390..f3e602e934 100644 --- a/packages/engine/src/__tests__/workspace-e2e.test.ts +++ b/packages/engine/src/__tests__/workspace-e2e.test.ts @@ -28,6 +28,7 @@ Surfaces (FN-5893): import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { writeFileSync } from "node:fs"; import path from "node:path"; import type { Settings, Task, TaskStore } from "@fusion/core"; @@ -82,6 +83,15 @@ function createStore(rows: Task[], settings: Partial = {}): TaskStore if (cur) tasks.set(id, { ...cur, ...patch } as Task); return tasks.get(id) as Task; }), + // FNXC:AiMergeReconciliation 2026-08-20-23:40: merge review persists durable + // reconciliation through the atomic seam; this fixture must mutate the same row getTask reads. + updateTaskAtomic: vi.fn(async (id: string, mutate: (task: Task) => Partial | undefined) => { + const current = tasks.get(id); + if (!current) throw new Error(`Missing task ${id}`); + const patch = mutate(current); + if (patch) tasks.set(id, { ...current, ...patch } as Task); + return tasks.get(id) as Task; + }), moveTask: vi.fn(async (id: string, column: string) => { moveTaskCalls.push({ id, column }); const cur = tasks.get(id); @@ -105,7 +115,15 @@ function createStore(rows: Task[], settings: Partial = {}): TaskStore } function makeTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial = {}): Task { - return { + const scopedRepositories = extra.repositoryScope?.repositories ?? Object.keys(workspaceWorktrees ?? {}); + const reviewEvidence = Object.fromEntries(Object.entries(workspaceWorktrees ?? {}) + .filter(([repo]) => scopedRepositories.includes(repo)) + .map(([repo, entry]) => { + const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim(); + const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" }); + return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }]; + })); + const task = { id: TASK_ID, title: "Workspace merge task", description: "", @@ -118,10 +136,16 @@ function makeTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial log: [], paused: false, workspaceWorktrees, + // FNXC:RepositoryScope 2026-08-21-01:36: workspace e2e fixtures model the + // exact fingerprint that the production Code Review episode must approve before land. + repositoryScope: { repositories: Object.keys(workspaceWorktrees ?? {}).sort(), state: "confirmed" as const, revision: 1, reviewEvidence }, + modifiedFiles: Object.keys(workspaceWorktrees ?? {}).sort().map((repo) => `${repo}/feature.txt`), createdAt: new Date().toISOString(), updatedAt: new Date(Date.now() - 30 * 60_000).toISOString(), ...extra, } as unknown as Task; + if (task.repositoryScope) task.repositoryScope.reviewEvidence ??= reviewEvidence; + return task; } /** A merge agent that performs the real squash in the clean room (no AI). */ @@ -277,6 +301,51 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase expect(fx.git("repo-b", "git for-each-ref refs/remotes")).toBe(remotesBBefore); }); + /* + FNXC:RepositoryScope 2026-08-20-23:40: + MRG-041's landing half: acquisition retained both repositories, but only the scoped repository + with qualified diff evidence may create a review/merge obligation or advance its integration ref. + */ + it("MRG-041: one scoped modified repository lands while its clean acquired peer is untouched", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + /* + FNXC:RepositoryScope 2026-08-21-02:35: + Acquisition creates a task branch in every checkout before planning, including a clean peer. + Keep that branch in the MRG-041 fixture so merge-boundary evidence validates the production + acquisition shape instead of treating an impossible missing branch as a clean repository. + */ + fx.git("repo-b", `git branch ${BRANCH}`); + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const tipBBefore = fx.git("repo-b", "git rev-parse refs/heads/main"); + const reviewAgent = vi.fn(approveReviewAgent); + const store = createStore([ + makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }, { + repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 2 }, + modifiedFiles: ["repo-a/feature.txt"], + }), + ]); + + const result = await landWorkspaceTask(store, store.tasks.get(TASK_ID)!, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent, + }); + + expect(result.allLanded).toBe(true); + expect(result.finalized).toBe(true); + expect(result.repos).toEqual([expect.objectContaining({ repo: "repo-a", status: "landed" })]); + // FNXC:RepositoryScope 2026-08-20-23:40: existing merge reconciliation requires two clean + // confirmations for the one land target; the clean peer creates no independent review episode. + expect(reviewAgent).toHaveBeenCalledTimes(2); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore); + expect(fx.git("repo-b", "git rev-parse refs/heads/main")).toBe(tipBBefore); + expect(store.tasks.get(TASK_ID)!.workspaceWorktrees?.["repo-a"].landedSha).toBeTruthy(); + expect(store.tasks.get(TASK_ID)!.workspaceWorktrees?.["repo-b"].landedSha).toBeUndefined(); + }); + it("e2e partial-land recovery: A lands, task not done → U1 reconciler lands B, no double-land of A", async () => { fx = await createWorkspaceFixture(["repo-a", "repo-b"]); addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); @@ -284,12 +353,14 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); - const store = createStore([ - makeTask({ - "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, - "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, - }), - ]); + const partialTask = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + // FNXC:RepositoryScope 2026-08-21-00:58: the fixture's reviewed snapshot must + // describe the conflict branch's README change before the merge boundary admits it. + partialTask.modifiedFiles = ["repo-a/feature.txt", "repo-b/README.md"]; + const store = createStore([partialTask]); // First pass: repo B conflicts → repo A lands, task NOT finalized. const first = await landWorkspaceTask(store, store.tasks.get(TASK_ID)!, fx.rootDir, {}, { @@ -309,6 +380,16 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase // Resolve repo B's conflict so a retry can land it. resolveConflictingRepo(fx, "repo-b"); + // FNXC:RepositoryScope 2026-08-21-00:58: resolution adds feature.txt, so model + // the intervening Code Review that records the new merge boundary before recovery. + const recoveringTask = store.tasks.get(TASK_ID)!; + recoveringTask.modifiedFiles = ["repo-a/feature.txt", "repo-b/README.md", "repo-b/feature.txt"]; + const repoB = recoveringTask.workspaceWorktrees!["repo-b"]; + const repoBMergeBase = execSync(`git merge-base HEAD ${repoB.branch}`, { cwd: repoB.worktreePath, encoding: "utf8" }).trim(); + recoveringTask.repositoryScope!.reviewEvidence!["repo-b"] = { + fingerprint: createHash("sha256").update(execSync(`git diff --binary ${repoB.baseCommitSha ?? repoBMergeBase}..HEAD`, { cwd: repoB.worktreePath, encoding: "utf8" })).digest("hex"), + approvedAt: new Date().toISOString(), + }; // Wire enqueueMerge to the REAL in-process route: re-run landWorkspaceTask (idempotent — A is // skipped via isRepoLanded). Capture the routed promise so the test can await completion. diff --git a/packages/engine/src/__tests__/workspace-lifecycle-parity.test.ts b/packages/engine/src/__tests__/workspace-lifecycle-parity.test.ts new file mode 100644 index 0000000000..7498d763ab --- /dev/null +++ b/packages/engine/src/__tests__/workspace-lifecycle-parity.test.ts @@ -0,0 +1,61 @@ +/* +FNXC:WorkspaceLifecycleParity 2026-08-21-00:12: +A workspace task that changes one explicitly scoped repository must make the same review decision +as its mono-repository equivalent. Acquisition of a clean peer is deliberately included here to +prove it neither receives a reviewer session nor changes the approval outcome. +*/ +import { describe, expect, it, vi } from "vitest"; +import type { Task } from "@fusion/core"; +import { reviewWorkspacePerRepo } from "../executor/workspace-review-per-repo.js"; + +function task(overrides: Partial): Task { + return { + id: "FN-094", title: "parity", description: "", column: "in-review", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-08-21T00:00:00.000Z", updatedAt: "2026-08-21T00:00:00.000Z", ...overrides, + } as Task; +} + +describe("FN-094 workspace lifecycle parity", () => { + it("reviews one scoped modified repository exactly like the mono-repository case", async () => { + const review = vi.fn(async () => ({ verdict: "APPROVE" as const, review: "approved", summary: "approved" })); + const workspace = task({ + repositoryScope: { repositories: ["repo-a", "repo-b"], state: "confirmed", revision: 2 }, + modifiedFiles: ["repo-a/src/changed.ts"], + workspaceWorktrees: { + "repo-a": { worktreePath: "/workspace/repo-a/.worktrees/fn-094", branch: "fusion/fn-094" }, + "repo-b": { worktreePath: "/workspace/repo-b/.worktrees/fn-094", branch: "fusion/fn-094" }, + }, + }); + const result = await reviewWorkspacePerRepo(workspace, review, { workspaceRepos: ["repo-a", "repo-b"], workspaceRootDir: "/workspace", captureModifiedFiles: async (repoRel) => repoRel === "repo-a" ? ["src/changed.ts"] : [] }); + + expect(result.verdict).toBe("APPROVE"); + expect(review).toHaveBeenCalledTimes(1); + expect(review).toHaveBeenCalledWith("/workspace/repo-a/.worktrees/fn-094"); + expect(result.review).toContain("[repo-b] NOT_REVIEWED"); + expect(result.review).toContain("No changes — not reviewed"); + }); + + it("does not dispatch Code Review from a proposed scope", async () => { + const review = vi.fn(); + const result = await reviewWorkspacePerRepo(task({ + repositoryScope: { repositories: ["repo-a"], state: "proposed", revision: 1 }, + workspaceWorktrees: { "repo-a": { worktreePath: "/workspace/repo-a/.worktrees/fn-094", branch: "fusion/fn-094" } }, + }), review, { workspaceRepos: ["repo-a"], workspaceRootDir: "/workspace", captureModifiedFiles: async () => ["src/changed.ts"] }); + + expect(result.verdict).toBe("UNAVAILABLE"); + expect(result.retryable).toBe(false); + expect(review).not.toHaveBeenCalled(); + }); + + it("refuses an ordinary all-clean scoped implementation without inventing a blocking clean-peer verdict", async () => { + const review = vi.fn(); + const result = await reviewWorkspacePerRepo(task({ + repositoryScope: { repositories: ["repo-a"], state: "confirmed", revision: 1 }, + workspaceWorktrees: { "repo-a": { worktreePath: "/workspace/repo-a/.worktrees/fn-094", branch: "fusion/fn-094" } }, + }), review, { workspaceRepos: ["repo-a"], workspaceRootDir: "/workspace", captureModifiedFiles: async () => [] }); + + expect(result.verdict).toBe("UNAVAILABLE"); + expect(result.retryable).toBe(false); + expect(review).not.toHaveBeenCalled(); + expect(result.review).toContain("No changes — not reviewed"); + }); +}); diff --git a/packages/engine/src/__tests__/workspace-merger-lease.test.ts b/packages/engine/src/__tests__/workspace-merger-lease.test.ts index 08137e195f..eeeafd9150 100644 --- a/packages/engine/src/__tests__/workspace-merger-lease.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-lease.test.ts @@ -24,6 +24,7 @@ Coverage (FN-5893 surfaces): import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { writeFileSync } from "node:fs"; import path from "node:path"; import type { Task, TaskStore, WorkspaceLeaseHandle } from "@fusion/core"; @@ -60,6 +61,14 @@ function createStore(task: Task): TaskStore & RecordingStore { Object.assign(store.task, patch); return undefined; }), + updateTaskAtomic: vi.fn(async ( + _id: string, + updater: (current: Task) => Partial | null | undefined | Promise | null | undefined>, + ) => { + const patch = await updater(store.task); + if (patch) Object.assign(store.task, patch); + return store.task; + }), mergeWorkspaceWorktreeEntry: vi.fn(async ( _id: string, repoRelPath: string, @@ -130,6 +139,22 @@ function makeTask(id: string, workspaceWorktrees: Task["workspaceWorktrees"]): T currentStep: 0, log: [], workspaceWorktrees, + /* + FNXC:RepositoryScope 2026-08-21-02:05: + Lease scenarios must model the Code Review fingerprint that production requires before a + scoped repository can land; confirmed membership and a file list alone are not approval. + */ + repositoryScope: { + repositories: Object.keys(workspaceWorktrees ?? {}), + state: "confirmed", + revision: 1, + reviewEvidence: Object.fromEntries(Object.entries(workspaceWorktrees ?? {}).map(([repoRel, entry]) => { + const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim(); + const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" }); + return [repoRel, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }]; + })), + }, + modifiedFiles: Object.keys(workspaceWorktrees ?? {}).map((repoRel) => `${repoRel}/feature.txt`), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } as Task; diff --git a/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts b/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts index bc94a51311..13b74f33ce 100644 --- a/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Task, TaskStore } from "@fusion/core"; @@ -35,6 +36,11 @@ function storeFor(task: Task, scope: string[]): TaskStore & { updates: Array ({ autoMerge: false, merger: { mode: "ai", maxReviewPasses: 0 } })), parseFileScopeFromPrompt: vi.fn(async () => scope), updateTask: vi.fn(async (_id: string, patch: Record) => { updates.push(patch); Object.assign(task, patch); return task; }), + updateTaskAtomic: vi.fn(async (_id: string, updater: (current: Task) => Record | null | undefined | Promise | null | undefined>) => { + const patch = await updater(task); + if (patch) Object.assign(task, patch); + return task; + }), appendAgentLog: vi.fn(async () => undefined), logEntry: vi.fn(async () => undefined), moveTask: vi.fn(async () => task), @@ -44,6 +50,14 @@ function storeFor(task: Task, scope: string[]): TaskStore & { updates: Array>; audit: any[] }; } +function reviewEvidence(workspaceWorktrees: NonNullable): NonNullable["reviewEvidence"] { + return Object.fromEntries(Object.entries(workspaceWorktrees).map(([repo, entry]) => { + const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim(); + const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" }); + return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }]; + })); +} + function squashAgent(branch: string) { return async (cwd: string): Promise => { execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); @@ -80,6 +94,16 @@ describeIfGit("landWorkspaceTask file-scope gates", () => { "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, }, + repositoryScope: { + repositories: ["repo-a", "repo-b"], state: "confirmed", revision: 1, + // FNXC:RepositoryScope 2026-08-21-01:36: merge gate fixtures carry + // the Code Review fingerprint required for each fresh land candidate. + reviewEvidence: reviewEvidence({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }), + }, + modifiedFiles: ["repo-a/feature.txt", "repo-b/repo-a/feature.txt"], } as Task; const store = storeFor(task, ["repo-a/feature.txt"]); const beforeA = fx.git("repo-a", "git rev-parse main"); @@ -97,6 +121,35 @@ describeIfGit("landWorkspaceTask file-scope gates", () => { expect(store.audit.some((event) => event.mutationType === "merge:file-scope-violation")).toBe(true); }); + it("refuses landing when an acquired repository changed outside confirmed scope", async () => { + policy.mockResolvedValue({ fileScope: "strict", fileScopeRules: [] }); + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addBranch(fx, "repo-a"); + addBranch(fx, "repo-b", "unapproved.ts"); + const task = { + id: TASK_ID, title: "workspace scope", description: "", column: "in-review", branch: BRANCH, + comments: [], steeringComments: [], dependencies: [], steps: [], log: [], currentStep: 0, + workspaceWorktrees: { + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }, + repositoryScope: { + repositories: ["repo-a"], state: "confirmed", revision: 1, + reviewEvidence: reviewEvidence({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }), + }, + modifiedFiles: ["repo-a/feature.txt"], + } as Task; + const store = storeFor(task, ["repo-a/feature.txt"]); + const beforeA = fx.git("repo-a", "git rev-parse main"); + + await expect(landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashAgent(BRANCH), reviewAgent: async () => "REVIEW_VERDICT: approve", + })).rejects.toThrow("modified outside confirmed scope"); + + expect(fx.git("repo-a", "git rev-parse main")).toBe(beforeA); + expect(store.updates).not.toContainEqual(expect.objectContaining({ modifiedFiles: expect.anything() })); + }); + it("uses unprefixed scope as a repo-local fallback instead of blocking every workspace repo", async () => { policy.mockResolvedValue({ fileScope: "strict", fileScopeRules: [] }); fx = await createWorkspaceFixture(["repo-a"]); @@ -105,6 +158,11 @@ describeIfGit("landWorkspaceTask file-scope gates", () => { id: TASK_ID, title: "workspace scope", description: "", column: "in-review", branch: BRANCH, comments: [], steeringComments: [], dependencies: [], steps: [], log: [], currentStep: 0, workspaceWorktrees: { "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }, + repositoryScope: { + repositories: ["repo-a"], state: "confirmed", revision: 1, + reviewEvidence: reviewEvidence({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }), + }, + modifiedFiles: ["repo-a/feature.txt"], } as Task; const store = storeFor(task, ["feature.txt"]); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index cf707a781d..dcad9d8736 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -26,11 +26,13 @@ extraction is byte-for-byte; runAiMerge is landOneRepo's single-repo caller). import { afterEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { writeFileSync } from "node:fs"; import path from "node:path"; import type { Task, TaskStore } from "@fusion/core"; import { assertNotWorkspaceTaskMerge } from "@fusion/core"; import { RESOLVED_PRIOR_FINDINGS_MARKER, landWorkspaceTask, runAiMerge } from "../merge/merger-ai.js"; +import { PRIOR_FINDING_DISPOSITIONS_MARKER } from "../merge/merger-ai-prompts.js"; import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; const describeIfGit = hasGit ? describe : describe.skip; @@ -58,6 +60,13 @@ function createStore(settings: Record = {}): TaskStore & Record emitted, getSettings: vi.fn().mockResolvedValue({ autoMerge: false, ...settings }), updateTask: vi.fn().mockResolvedValue(undefined), + /* FNXC:WorkspaceMergeTests 2026-08-20-23:23: the durable merge-review reconciliation path atomically records its episode before a workspace repository can land. Keep this narrow store double compatible with that production contract. */ + updateTaskAtomic: vi.fn(async (_id: string, mutate: (task: Task) => Partial | undefined) => { + const current = await store.getTask(TASK_ID) as Task; + const patch = mutate(current); + if (patch) Object.assign(current, patch); + return current; + }), mergeWorkspaceWorktreeEntry: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined), appendAgentLog: vi.fn().mockResolvedValue(undefined), @@ -166,6 +175,20 @@ function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task { currentStep: 0, log: [], workspaceWorktrees, + // FNXC:RepositoryScope 2026-08-21-00:12: merge fixtures model planner-confirmed intent and fresh qualified diff evidence; acquisition alone is deliberately insufficient to land. + repositoryScope: { + repositories: Object.keys(workspaceWorktrees ?? {}).sort(), + state: "confirmed", + revision: 1, + // FNXC:RepositoryScope 2026-08-21-01:36: fixtures persist the same + // merge-boundary fingerprint that production requires from Code Review. + reviewEvidence: Object.fromEntries(Object.entries(workspaceWorktrees ?? {}).map(([repo, entry]) => { + const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim(); + const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" }); + return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }]; + })), + }, + modifiedFiles: Object.keys(workspaceWorktrees ?? {}).sort().map((repo) => `${repo}/feature.txt`), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } as Task; @@ -233,12 +256,13 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { reviews++; return reviews === 1 ? `${finding}\nSEVERITY: blocking\nREVIEW_VERDICT: reject` - : `${RESOLVED_PRIOR_FINDINGS_MARKER} ${finding}\nREVIEW_VERDICT: approve`; + : `${RESOLVED_PRIOR_FINDINGS_MARKER} ${finding}\n${PRIOR_FINDING_DISPOSITIONS_MARKER}\nfinding-1-1: corrected\nREVIEW_VERDICT: approve`; }, }); expect(result.allLanded).toBe(true); - expect(reviewPrompts).toHaveLength(2); + /* FNXC:WorkspaceMergeTests 2026-08-20-23:23: FN-090 requires two clean confirmations after a corrected finding, so the final approval pass is intentionally a second independent reviewer session. */ + expect(reviewPrompts).toHaveLength(3); expect(reviewPrompts[1]).toContain(finding); }); @@ -288,6 +312,34 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { expect(fx.git("repo-b", "git rev-parse refs/heads/release")).toBe(byRepo["repo-b"].landedSha); }); + it("rejects a modified repository with no approving review fingerprint", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "review evidence is mandatory\n"); + const store = createStore(); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + task.repositoryScope!.reviewEvidence = {}; + + await expect(landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + })).rejects.toThrow("changed after review"); + expect(store.mergeWorkspaceWorktreeEntry).not.toHaveBeenCalled(); + }); + + it("returns a fresh repository file to Code Review instead of landing it without review evidence", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "review this newly discovered file\n"); + const store = createStore(); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + task.modifiedFiles = []; + + await expect(landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + })).rejects.toThrow("changed after review"); + expect(store.mergeWorkspaceWorktreeEntry).not.toHaveBeenCalled(); + }); + it("partial: repo B conflict → repo A lands, B reports failure, task NOT moved done", async () => { fx = await createWorkspaceFixture(["repo-a", "repo-b"]); addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); @@ -300,6 +352,9 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, }); + // FNXC:RepositoryScope 2026-08-21-00:58: the merge boundary only admits fresh + // changes that were present in the persisted Code Review evidence. + task.modifiedFiles = ["repo-a/feature.txt", "repo-b/README.md"]; (store.getTask as ReturnType).mockResolvedValue(task); const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index dc4f2cf449..eb8a6ef9ec 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -78,6 +78,13 @@ export const taskCreateParams = Type.Object({ "Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.", }), ), + repository_scope: Type.Optional( + Type.Array(Type.String(), { + description: + "Explicit workspace repositories this task may modify. Names must match configured workspace repositories; " + + "omitting this lets the planner confirm a proposed scope.", + }), + ), mission_lineage: Type.Optional(missionLineageParams), }); @@ -128,6 +135,7 @@ export const acquireRepoWorktreeParams = Type.Object({ "(e.g. 'wolf-server'). Must be one of the repos listed in the workspace. " + "If already acquired, returns the existing worktree path immediately.", }), + reason: Type.Optional(Type.String({ minLength: 1, description: "Why this repository is needed when it is outside the current task scope." })), }); export const taskDocumentWriteParams = Type.Object({ @@ -1674,6 +1682,7 @@ export function createTaskCreateTool( description: params.description, dependencies: params.dependencies, priority: params.priority, + ...(params.repository_scope ? { repositoryScope: params.repository_scope } : {}), ...(workflowId ? { workflowId } : {}), ...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}), ...definedFeatureBootstrapInput(store, lineage), @@ -2121,6 +2130,22 @@ export function createTaskDocumentReadTool(store: TaskStore, taskId: string): To * FNXC:WorkflowReviewers 2026-07-01-13:22: * Plan Review inline fixes must be able to rewrite the task's authoritative PROMPT.md, but that pre-execution reviewer should not need general source-file write tools. Route the write through TaskStore so existing PROMPT.md validation, task directory placement, and task.json sync remain the single persistence path. */ +/* +FNXC:RepositoryScope 2026-08-20-23:40: +A workspace plan confirms a task-level intent once, rather than turning each acquired checkout into +an independent plan. The heading is mandatory for a workspace plan: retaining a creation proposal +when a planner omitted or misspelled it would let review approve unconfirmed repository intent. +*/ +function parsePlanRepositoryScope(content: string, configured: readonly string[]): string[] | undefined { + const match = content.match(/^##\s+Repository Scope\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/m); + if (!match) return undefined; + const repositories = [...match[1].matchAll(/^\s*[-*]\s+`?([^`\n]+?)`?\s*$/gm)] + .map((entry) => entry[1].trim()) + .filter(Boolean); + if (repositories.length === 0 || repositories.some((repo) => !configured.includes(repo))) return undefined; + return [...new Set(repositories)].sort(); +} + export function createTaskPromptWriteTool(store: TaskStore, taskId: string, runContext?: RunMutationContext): ToolDefinition { return { name: "fn_task_prompt_write", @@ -2130,9 +2155,53 @@ export function createTaskPromptWriteTool(store: TaskStore, taskId: string, runC "Use during fresh triage planning, replanning, or Plan Review repair; provide the complete final PROMPT.md content.", parameters: taskPromptWriteParams, execute: async (_id: string, params: Static) => { + /* + FNXC:RepositoryScope 2026-08-21-00:58: + updateTask's authoritative prompt path owns the planning lifecycle lock. Do not wrap this + tool in that non-reentrant lock: prompt publication would wait on itself before scope + confirmation could run, so the validated prompt-then-scope compensation remains sequential. + */ try { - await store.updateTask(taskId, { prompt: params.content }, runContext); - const persisted = await store.getTask(taskId); + const rootDir = typeof (store as unknown as { getRootDir?: unknown }).getRootDir === "function" + ? store.getRootDir() + : undefined; + const configured = rootDir ? (await fusionCore.loadWorkspaceConfig(rootDir))?.repos ?? [] : []; + const plannedScope = parsePlanRepositoryScope(params.content, configured); + /* + FNXC:RepositoryScope 2026-08-20-23:57: + Workspace plans cannot persist before their Repository Scope is validated. This blocks a + Plan Review from approving a stale creation proposal when the planner omitted an empty, or + unknown scope heading; non-workspace plans retain their existing prompt-only contract. + */ + if (configured.length > 0 && !plannedScope) { + throw new Error("Workspace PROMPT.md must include a non-empty ## Repository Scope with configured repository names"); + } + const current = await store.getTask(taskId); + const hasStartedLanding = Object.values(current?.workspaceWorktrees ?? {}).some((entry) => Boolean(entry.landedSha)); + if (plannedScope && hasStartedLanding && JSON.stringify(current?.repositoryScope?.repositories ?? []) !== JSON.stringify(plannedScope)) { + throw new Error(`Repository scope for ${taskId} cannot change after workspace landing has started`); + } + /* + FNXC:RepositoryScope 2026-08-21-01:18: + PROMPT.md and confirmed task intent are one observable generation. The authoritative + updateTask path holds the planning lifecycle lock and writes both fields in one task-row + transaction, so review, completion, and land readers never observe a new scope heading + paired with the prior repository_scope value. File projection follows the committed row. + */ + const repositoryScope = plannedScope + ? { + repositories: plannedScope, + state: "confirmed" as const, + revision: (current?.repositoryScope?.revision ?? 0) + 1, + confirmedAt: new Date().toISOString(), + confirmedBy: "plan" as const, + extensions: current?.repositoryScope?.extensions, + } + : undefined; + const persisted = await store.updateTask(taskId, { + prompt: params.content, + ...(repositoryScope ? { repositoryScope } : {}), + }, runContext); if (persisted?.prompt !== params.content) { throw new Error("authoritative PROMPT.md read-back did not match the requested content; persistence could not be verified"); } @@ -6479,6 +6548,25 @@ export function createAcquireRepoWorktreeTool(opts: { isError: true, }; } + /* + FNXC:RepositoryScope 2026-08-20-23:40: + A successful pre-land acquisition outside explicit intent is an extension request, not evidence + that every acquired repository belongs to the task. Persist the accepted extension once so the + next review and land pass can deliberately include it. + */ + if (!freshTask.repositoryScope?.repositories.includes(repo)) { + /* + FNXC:RepositoryScope 2026-08-21-01:53: + Acquisition extends intent as a durable delta after the checkout succeeds. A fresh + planning-locked read preserves a concurrent plan confirmation or operator decision. + */ + await store.mutateTaskRepositoryScope(task.id, { + action: "add", + repositories: [repo], + reason: params.reason ?? "Executor acquired a repository required for implementation.", + actor: runContext?.agentId ?? "executor", + }); + } // FNXC:Workspace 2026-06-21-22:30: F2 — register a freshly-acquired sub-repo worktree in the executor's activeWorktrees Set (KTD2) so owner/liveness checks see live per-repo worktrees, not just the browse-only root. // FNXC:Workspace 2026-06-22-09:00: register UNCONDITIONALLY, including the // already-acquired short-circuit. After an executor restart activeWorktrees is an diff --git a/packages/engine/src/execution/reviewer.ts b/packages/engine/src/execution/reviewer.ts index e8cc6c0461..1022b9f3e1 100644 --- a/packages/engine/src/execution/reviewer.ts +++ b/packages/engine/src/execution/reviewer.ts @@ -9,7 +9,7 @@ * - Verdict + feedback is returned to the worker */ -import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core"; +import type { TaskStore, TaskComment, AgentPromptsConfig, Settings, WorkflowRepositoryReviewOutcome, WorkflowReviewFinding } from "@fusion/core"; import { buildReviewerMemoryInstructions, hasConfiguredFallbackLane, @@ -88,6 +88,14 @@ export interface ReviewResult { * ReviewerProviderError rather than being converted into UNAVAILABLE. */ retryable?: boolean; + /** Immutable Git-content fingerprints for workspace repositories approved in this review episode. */ + repositoryDiffFingerprints?: Record; + /** Structured findings when the review caller parsed a machine-readable response. */ + findings?: WorkflowReviewFinding[]; + /** Structured workspace outcomes preserve clean-peer non-review and blocking review attribution. */ + repositoryReviewOutcomes?: WorkflowRepositoryReviewOutcome[]; + /** Confirmed scope generation used to build those workspace outcomes. */ + repositoryScopeRevision?: number; } export interface ReviewOptions { diff --git a/packages/engine/src/executor/create-authoritative-workflow-seams.ts b/packages/engine/src/executor/create-authoritative-workflow-seams.ts index fccffa769e..e04b398e0d 100644 --- a/packages/engine/src/executor/create-authoritative-workflow-seams.ts +++ b/packages/engine/src/executor/create-authoritative-workflow-seams.ts @@ -355,7 +355,9 @@ export function createAuthoritativeWorkflowSeams( : deps.workspaceConfig; const workspaceCoordinator = workspaceConfig ? Object.keys(detail.workspaceWorktrees ?? {}) - .filter((repoRelPath) => workspaceConfig.repos.includes(repoRelPath)) + // FNXC:RepositoryScope 2026-08-20-23:40: Plan Review needs one real scoped checkout for + // task-document context; acquisition alone never authorizes a reviewer cwd. + .filter((repoRelPath) => workspaceConfig.repos.includes(repoRelPath) && detail.repositoryScope?.repositories.includes(repoRelPath)) .sort() .map((repoRelPath) => detail.workspaceWorktrees?.[repoRelPath]?.worktreePath) .find((path): path is string => typeof path === "string" && path.length > 0) @@ -378,6 +380,9 @@ export function createAuthoritativeWorkflowSeams( if (reviewCwd) logReviewCheckoutRouting(seamTask.id, detail, reviewCwd, worktreePath); const stepName = detail.steps[stepIndex]?.name ?? `Step ${stepIndex}`; const promptContent = detail.prompt ?? ""; + const planScopeContext = workspaceConfig && config.type === "plan" + ? `\n\nRepository scope (task-level; review this plan once): ${detail.repositoryScope?.repositories.join(", ") || "unconfirmed"}.` + : ""; const userComments = selectUserCommentsForAgentContext(detail, { limit: null }); // Merge per-task effective workflow settings (U3, KTD-3) so the validator // model-lane reads below pick up workflow values. Behavior-inert by default. @@ -404,7 +409,7 @@ export function createAuthoritativeWorkflowSeams( stepIndex, stepName, type: config.type, - promptContent, + promptContent: `${promptContent}${planScopeContext}`, // Code reviews diff against the per-step baseline captured at // step-execute; plan reviews pass no baseline (advisory). baselineSha: config.type === "code" ? active.baselineSha : undefined, @@ -459,13 +464,20 @@ export function createAuthoritativeWorkflowSeams( return sem ? sem.runNested(invoke) : invoke(); }; /* - FNXC:WorkspaceRootRouting 2026-08-19-12:15: - Every workspace step-review is aggregated across the acquired repository set. Prompt-node - review routing is handled by runGraphCustomNode's matching per-repository coordinator loop; - no review form may use the singular detail.worktree or workspace root as a fallback. + FNXC:RepositoryScope 2026-08-20-23:40: + Plan Review is one task-document session even in a workspace. Code Review alone aggregates + modified scoped repositories. This prevents a clean acquired checkout from producing either + an extra plan session or an unavailable verdict before implementation starts. */ const invokeReviewer = () => - workspaceConfig + workspaceConfig && config.type === "code" && detail.repositoryScope?.state !== "confirmed" + ? Promise.resolve({ + verdict: "UNAVAILABLE" as const, + retryable: false, + review: "Workspace Code Review requires a confirmed repository scope.", + summary: "Unavailable: repository scope is not confirmed", + }) + : workspaceConfig && config.type === "code" ? deps.reviewWorkspacePerRepo(detail, (cwd: string) => runForCwd(cwd), { workspaceRepos: workspaceConfig.repos, workspaceRootDir: deps.rootDir, @@ -479,7 +491,9 @@ export function createAuthoritativeWorkflowSeams( noOpCompletion: detail.noCommitsExpected === true, noOpCompletionReason: "verified no-op completion persisted by fn_task_done", }) - : runForCwd(reviewCwd); + : workspaceConfig && !reviewCwd + ? Promise.resolve({ verdict: "UNAVAILABLE" as const, retryable: false, review: "Workspace Plan Review requires a confirmed scoped repository checkout.", summary: "Unavailable: no scoped repository checkout" }) + : runForCwd(reviewCwd); let review: ReviewResult; try { @@ -492,6 +506,43 @@ export function createAuthoritativeWorkflowSeams( return { verdict: "UNAVAILABLE", review: `reviewer error: ${message}` }; } + /* + FNXC:RepositoryScope 2026-08-21-02:35: + A workspace Code Review callback belongs to the scope generation used to capture its + per-repository diff evidence. Check that generation under the task lock before persisting + approval or advancing the graph: an operator scope change supersedes the whole callback. + */ + let reviewSuperseded = false; + if (workspaceConfig && config.type === "code" && review.repositoryScopeRevision !== undefined) { + const approvedAt = new Date().toISOString(); + await deps.store.updateTaskAtomic(seamTask.id, (current) => { + const currentScope = current.repositoryScope; + if (!currentScope || currentScope.revision !== review.repositoryScopeRevision) { + reviewSuperseded = true; + return null; + } + if (review.verdict !== "APPROVE" || !review.repositoryDiffFingerprints || Object.keys(review.repositoryDiffFingerprints).length === 0) { + return null; + } + return { + repositoryScope: { + ...currentScope, + reviewEvidence: Object.fromEntries(Object.entries(review.repositoryDiffFingerprints).map(([repo, fingerprint]) => [repo, { fingerprint, approvedAt }])), + }, + }; + }); + } + if (reviewSuperseded) { + review = { + verdict: "UNAVAILABLE", + retryable: false, + review: "Workspace Code Review result superseded by a repository scope change.", + summary: "Unavailable: repository scope changed during review", + repositoryReviewOutcomes: review.repositoryReviewOutcomes, + repositoryScopeRevision: review.repositoryScopeRevision, + }; + } + await deps.store.logEntry( seamTask.id, `${config.type} step-review Step ${stepIndex}: ${review.verdict}${config.advisory ? " (advisory)" : ""}`, @@ -515,6 +566,24 @@ export function createAuthoritativeWorkflowSeams( if (review.verdict === "APPROVE" && !config.advisory) { try { const cur = await deps.store.getTask(seamTask.id); + /* + FNXC:RepositoryScope 2026-08-21-02:48: + The step-inversion path has its own graph-advance projection. Re-read + the scope generation after evidence persistence and before marking the + step done, so an intervening scope mutation routes this callback as + unavailable instead of allowing its old APPROVE edge to advance. + */ + if (workspaceConfig && config.type === "code" && review.repositoryScopeRevision !== undefined + && cur.repositoryScope?.revision !== review.repositoryScopeRevision) { + return { + verdict: "UNAVAILABLE", + retryable: false, + review: "Workspace Code Review result superseded by a repository scope change.", + summary: "Unavailable: repository scope changed during review before graph advancement", + repositoryReviewOutcomes: review.repositoryReviewOutcomes, + repositoryScopeRevision: review.repositoryScopeRevision, + }; + } const status = cur.steps[stepIndex]?.status; if (stepIndex >= 0 && stepIndex < cur.steps.length && status !== "done" && status !== "skipped") { await deps.updateStepGraph(seamTask.id, stepIndex, "done"); @@ -530,7 +599,15 @@ export function createAuthoritativeWorkflowSeams( } } - return { verdict: review.verdict, review: review.review, summary: review.summary, retryable: review.retryable }; + return { + verdict: review.verdict, + review: review.review, + summary: review.summary, + retryable: review.retryable, + repositoryDiffFingerprints: review.repositoryDiffFingerprints, + repositoryReviewOutcomes: review.repositoryReviewOutcomes, + repositoryScopeRevision: review.repositoryScopeRevision, + }; }, }; } diff --git a/packages/engine/src/executor/execute-workflow-graph.ts b/packages/engine/src/executor/execute-workflow-graph.ts index 30dff6e468..580800ed8f 100644 --- a/packages/engine/src/executor/execute-workflow-graph.ts +++ b/packages/engine/src/executor/execute-workflow-graph.ts @@ -144,10 +144,14 @@ export async function persistWorkflowStepResult( & Partial>, taskId: string, result: CoreWorkflowStepResult, -): Promise { - if (typeof deps.store.updateTask !== "function") return; +): Promise { + if (typeof deps.store.updateTask !== "function") return true; try { const live = await deps.store.getTask(taskId); + const repositoryScopeRevision = typeof result.repositoryScopeRevision === "number" + ? result.repositoryScopeRevision + : undefined; + let scopeSuperseded = false; const isPlanReviewResult = result.workflowStepId === PLAN_REVIEW_GROUP_ID || result.workflowStepName === "Plan Review"; const resultToPersist = isPlanReviewResult @@ -195,6 +199,26 @@ export async function persistWorkflowStepResult( }, deps.getRunContextFor(taskId)); await deps.store.reconcileSpecDriftWhilePlanningLocked(accepted); }); + } else if (repositoryScopeRevision !== undefined && typeof deps.store.updateTaskAtomic === "function") { + /* + FNXC:RepositoryScope 2026-08-21-02:48: + Terminal Code Review state is the graph's edge-admission record. Persist it + under the callback's scope-generation CAS so a superseding scope mutation + cannot leave an old approval eligible to advance the graph. + */ + await deps.store.updateTaskAtomic(taskId, (current) => { + if (current.repositoryScope?.revision !== repositoryScopeRevision) { + scopeSuperseded = true; + return null; + } + const currentUpserted = upsertWorkflowStepResult(current.workflowStepResults, resultToPersist); + const currentResults = applySupersededFindingIds(currentUpserted, resultToPersist.supersededFindingIds ?? [], { + excludeWorkflowStepId: resultToPersist.workflowStepId, + sourceWorkflowStepId: resultToPersist.supersededFindingSourceWorkflowStepId ?? "", + }) ?? currentUpserted; + return { workflowStepResults: currentResults }; + }, deps.getRunContextFor(taskId)); + if (scopeSuperseded) return false; } else { await deps.store.updateTask(taskId, { workflowStepResults: existing }, deps.getRunContextFor(taskId)); } @@ -261,6 +285,7 @@ export async function persistWorkflowStepResult( executorLog.warn(`[agent-activity] ${taskId}: failed to record workflow gate activity: ${error instanceof Error ? error.message : String(error)}`); } } + return true; } catch (error) { /* FNXC:AgentActivityStream 2026-08-09-13:43: @@ -268,6 +293,7 @@ export async function persistWorkflowStepResult( failed persistence attempt without converting an otherwise valid graph run into a failure. */ executorLog.warn(`[agent-activity] ${taskId}: failed to persist workflow step result: ${error instanceof Error ? error.message : String(error)}`); + return true; } } @@ -622,6 +648,13 @@ export async function executeWorkflowGraph( }, recordWorkflowStepResult: (taskId: string, result: CoreWorkflowStepResult) => persistWorkflowStepResult(deps, taskId, result), + isRepositoryScopeReviewEdgeCurrent: async (taskId: string, workflowStepId: string, revision: number): Promise => { + const current = await deps.store.getTask(taskId); + const result = current.workflowStepResults?.find((entry) => entry.workflowStepId === workflowStepId); + return current.repositoryScope?.revision === revision + && result?.repositoryScopeRevision === revision + && result?.status === "passed"; + }, requestPreMergeOptionalStepFix: (taskId, info) => deps.requestPreMergeOptionalStepFix(taskId, task, info), // U5c (U1 KTD-1/2/3/12): wire the production lifecycle-move hooks so the // graph interpreter owns the card's column moves (was reverted in U5a diff --git a/packages/engine/src/executor/request-pre-merge-optional-step-fix.ts b/packages/engine/src/executor/request-pre-merge-optional-step-fix.ts index a906c426c0..bc81ffa5f2 100644 --- a/packages/engine/src/executor/request-pre-merge-optional-step-fix.ts +++ b/packages/engine/src/executor/request-pre-merge-optional-step-fix.ts @@ -55,6 +55,43 @@ import { } from "../plan-review-feedback-history.js"; import { executorLog } from "../logger.js"; import type { EngineRunContext } from "../util/run-audit.js"; +import { emitBoundedRunAudit } from "./emit-bounded-run-audit.js"; + +function normalizeConvergenceText(value: string | undefined): string { + return (value ?? "").replace(/\s+/g, " ").trim().toLowerCase(); +} + +/* +FNXC:RepositoryScope 2026-08-21-02:17: +R10 convergence is keyed by the actual review input: review node, repository, confirmed scope generation, exact diff fingerprint, blocking verdict, and normalized findings. Reviewer prose is presentation only; it may change without a new defect or remain unchanged after the underlying diff changes. +*/ +function reviewInputSignature(result: CoreWorkflowStepResult): string | undefined { + const blocking = (result.repositoryReviewOutcomes ?? []) + .filter((outcome) => outcome.status === "REVIEWED" && (outcome.verdict === "REVISE" || outcome.verdict === "RETHINK")) + .map((outcome) => { + const findings = (outcome.findings ?? []) + .map((finding) => `${finding.id}:${normalizeConvergenceText(finding.title)}:${normalizeConvergenceText(finding.body)}`) + .sort() + .join("|"); + return `${outcome.repository}\u0000${outcome.fingerprint ?? ""}\u0000${outcome.verdict}\u0000${findings}`; + }) + .sort(); + if (blocking.length === 0 || result.repositoryScopeRevision === undefined) return undefined; + return `${result.workflowStepId}\u0000${result.repositoryScopeRevision}\u0000${blocking.join("\u0001")}`; +} + +function hasRepeatedUnchangedCodeReview(task: Task, info: RequestPreMergeOptionalStepFixInfo): boolean { + if (info.nodeId !== "code-review" && info.stepName !== "Code Review") return false; + const current = (task.workflowStepResults ?? []).find((result) => + (result.workflowStepId === info.nodeId || result.workflowStepName === info.stepName) + && result.verdict === "REVISE", + ); + if (!current) return false; + const currentSignature = reviewInputSignature(current); + if (!currentSignature) return false; + const previous = current.priorAttempts?.[0]; + return previous?.verdict === "REVISE" && reviewInputSignature(previous) === currentSignature; +} export type RequestPreMergeOptionalStepFixInfo = { stepName: string; @@ -328,6 +365,39 @@ export async function requestPreMergeOptionalStepFix( } const revisionKey = optionalStepRevisionKey(info.nodeId, info.stepName); + /* + FNXC:RepositoryScope 2026-08-21-01:53: + Two identical Code Review rejections with unchanged durable review input cannot be repaired by + another executor bounce. Park the task for an operator before a third session; new review output, + a changed diff, or a scope revision naturally produces a different durable result and reopens it. + */ + if (hasRepeatedUnchangedCodeReview(liveTask, info)) { + const runContext = deps.getRunContextFor(taskId); + await deps.store.logEntry( + taskId, + "Code Review did not converge — awaiting operator action", + `The same Code Review revision was returned twice without a new review result. Fusion stopped automatic remediation before a third review session. Latest feedback:\n${info.feedback}`, + runContext, + ); + await deps.store.updateTask(taskId, { + status: "awaiting-approval", + awaitingApprovalReason: "code-review-non-convergence", + error: null, + nextRecoveryAt: null, + }, runContext); + if (runContext) { + await emitBoundedRunAudit(deps.store, { + taskId, + agentId: runContext.agentId, + runId: runContext.runId, + domain: "database", + mutationType: "task:code-review-non-convergence", + target: taskId, + metadata: { nodeId: info.nodeId ?? "code-review", outcome: "parked", repeatedResults: 2 }, + }); + } + return false; + } const currentCount = countOptionalStepRevisionAttempts(liveTask, revisionKey, info.stepName); if (!budget.unbounded && currentCount >= budget.max) { // Budget exhaustion is a legitimate terminal outcome, but it must be visible: the card stays diff --git a/packages/engine/src/executor/run-graph-custom-node.ts b/packages/engine/src/executor/run-graph-custom-node.ts index bdd9f5f5e2..adcf3ff21b 100644 --- a/packages/engine/src/executor/run-graph-custom-node.ts +++ b/packages/engine/src/executor/run-graph-custom-node.ts @@ -34,6 +34,8 @@ import { } from "./workflow-step-verdict.js"; import { parseAwaitInputSentinel } from "./await-input-parse.js"; import { buildAgentPersona } from "./agent-binding-pure.js"; +import { reviewWorkspacePerRepo } from "./workspace-review-per-repo.js"; +import type { ReviewResult } from "../execution/reviewer.js"; const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet = new Set(THINKING_LEVELS); @@ -477,54 +479,97 @@ export async function runGraphCustomNode( ? graphContext["workflow:principal-agent-id"] : undefined; let outcome: WorkflowStepOutcome; - const workspaceReviewPaths = workspaceConfig && declaredReviewKind - ? Object.keys(executionTarget.workspaceWorktrees ?? {}) - .filter((repoRelPath) => workspaceConfig.repos.includes(repoRelPath)) - .sort() - .map((repoRelPath) => executionTarget.workspaceWorktrees?.[repoRelPath]?.worktreePath) - .filter((path): path is string => typeof path === "string" && path.length > 0) + const scopedWorkspacePaths = workspaceConfig && declaredReviewKind + ? (live.repositoryScope?.state === "confirmed" + ? live.repositoryScope.repositories + .map((repoRelPath) => executionTarget.workspaceWorktrees?.[repoRelPath]?.worktreePath) + .filter((path): path is string => typeof path === "string" && path.length > 0) + : []) : []; - if (workspaceConfig && declaredReviewKind) { + if (workspaceConfig && declaredReviewKind === "code") { /* - FNXC:WorkspaceRootRouting 2026-08-19-12:15: - Explicit graph plan/code review nodes are per-repository reviews, not a single review against - the coordinator checkout. Evaluate declared worktrees deterministically, stop at the first real - non-approval, and preserve that repository's actual outcome for the graph's existing edges. + FNXC:RepositoryScope 2026-08-21-00:44: + Graph custom code-review nodes share the authoritative scoped fresh-diff aggregator with + step-review. Acquisition is never review intent: clean peers are NOT_REVIEWED and only + modified confirmed repositories can produce a blocking verdict. */ - if (workspaceReviewPaths.length === 0) { - outcome = { success: false, error: "No acquired declared-repository worktree was available for workspace review", failureValue: "workspace-review-no-worktrees" }; + if (live.repositoryScope?.state !== "confirmed") { + outcome = { success: false, error: "Workspace Code Review requires confirmed repository scope", failureValue: "workspace-review-scope-unresolved" }; } else { - const perRepoOutcomes: WorkflowStepOutcome[] = []; - for (const repoWorktreePath of workspaceReviewPaths) { + let aggregate = await reviewWorkspacePerRepo(live, async (repoWorktreePath): Promise => { const repoEnv = mode === "prompt" ? (await deps.buildInjectedRuntimeEnv(live.id, repoWorktreePath, undefined)).env : nodeEnv; const repoOutcome = mode === "script" ? await deps.executeScriptWorkflowStep(live, step, repoWorktreePath, settings, repoEnv) : await deps.executeWorkflowStep(live, step, repoWorktreePath, settings, repoEnv, { unattended, principalAgentId, outputLanguage }); - perRepoOutcomes.push(repoOutcome); - const approval = repoOutcome.verdict === undefined - || repoOutcome.verdict === "APPROVE" - || repoOutcome.verdict === "APPROVE_WITH_NOTES" - || repoOutcome.verdict === "CLOSE_NO_OP"; - if (!repoOutcome.success || !approval) break; + return { + verdict: (repoOutcome.verdict ?? (repoOutcome.success ? "APPROVE" : "UNAVAILABLE")) as ReviewResult["verdict"], + review: repoOutcome.output ?? repoOutcome.error ?? "", + summary: repoOutcome.output ?? repoOutcome.error ?? "", + retryable: !repoOutcome.success, + }; + }, { workspaceRepos: workspaceConfig.repos, workspaceRootDir: deps.rootDir }); + /* + FNXC:RepositoryScope 2026-08-21-02:35: + Custom review nodes use the same generation fence as step-review. A callback from an + older scope must be unavailable rather than contribute approval evidence or a graph edge. + */ + let reviewSuperseded = false; + if (aggregate.repositoryScopeRevision !== undefined) { + const approvedAt = new Date().toISOString(); + await deps.store.updateTaskAtomic(live.id, (current) => { + const currentScope = current.repositoryScope; + if (!currentScope || currentScope.revision !== aggregate.repositoryScopeRevision) { + reviewSuperseded = true; + return null; + } + if (aggregate.verdict !== "APPROVE" || !aggregate.repositoryDiffFingerprints || Object.keys(aggregate.repositoryDiffFingerprints).length === 0) { + return null; + } + return { + repositoryScope: { + ...currentScope, + reviewEvidence: Object.fromEntries(Object.entries(aggregate.repositoryDiffFingerprints).map(([repo, fingerprint]) => [repo, { fingerprint, approvedAt }])), + }, + }; + }); + /* FNXC:RepositoryScope 2026-08-21-02:48: Fence the return handed to graph-result persistence as well as the evidence write. */ + const afterEvidence = await deps.store.getTask(live.id); + if (afterEvidence.repositoryScope?.revision !== aggregate.repositoryScopeRevision) reviewSuperseded = true; + } + if (reviewSuperseded) { + aggregate = { + verdict: "UNAVAILABLE", + retryable: false, + review: "Workspace Code Review result superseded by a repository scope change.", + summary: "Unavailable: repository scope changed during review", + repositoryReviewOutcomes: aggregate.repositoryReviewOutcomes, + repositoryScopeRevision: aggregate.repositoryScopeRevision, + }; } - const firstFailure = perRepoOutcomes.find((candidate) => { - const approval = candidate.verdict === undefined - || candidate.verdict === "APPROVE" - || candidate.verdict === "APPROVE_WITH_NOTES" - || candidate.verdict === "CLOSE_NO_OP"; - return !candidate.success || !approval; - }); - const selected = firstFailure ?? perRepoOutcomes[perRepoOutcomes.length - 1]; outcome = { - ...selected, - output: perRepoOutcomes.map((candidate, index) => `[${workspaceReviewPaths[index]}]\n${candidate.output ?? ""}`).join("\n\n"), - ...(perRepoOutcomes.some((candidate) => candidate.findings?.length) - ? { findings: perRepoOutcomes.flatMap((candidate) => candidate.findings ?? []) } - : {}), + success: aggregate.verdict === "APPROVE", + verdict: aggregate.verdict as WorkflowStepOutcome["verdict"], + output: aggregate.review, + repositoryReviewOutcomes: aggregate.repositoryReviewOutcomes, + repositoryScopeRevision: aggregate.repositoryScopeRevision, + ...(aggregate.verdict === "UNAVAILABLE" ? { failureValue: "workspace-review-unavailable" } : {}), }; } + } else if (workspaceConfig && declaredReviewKind === "plan") { + /* FNXC:RepositoryScope 2026-08-21-00:44: Plan Review is one task-document session using a scoped coordinator, never a per-checkout fan-out. */ + const coordinator = scopedWorkspacePaths.sort()[0]; + if (!coordinator) { + outcome = { success: false, error: "Workspace Plan Review requires a confirmed scoped repository checkout", failureValue: "workspace-review-no-worktrees" }; + } else { + const coordinatorEnv = mode === "prompt" + ? (await deps.buildInjectedRuntimeEnv(live.id, coordinator, undefined)).env + : nodeEnv; + outcome = mode === "script" + ? await deps.executeScriptWorkflowStep(live, step, coordinator, settings, coordinatorEnv) + : await deps.executeWorkflowStep(live, step, coordinator, settings, coordinatorEnv, { unattended, principalAgentId, outputLanguage }); + } } else { outcome = mode === "script" ? await deps.executeScriptWorkflowStep(live, step, worktreePath, settings, nodeEnv) @@ -583,6 +628,9 @@ export async function runGraphCustomNode( if (typeof stepNotes === "string" && stepNotes) contextPatch.notes = stepNotes; const stepFindings = outcome.findings; if (stepFindings?.length) contextPatch.findings = stepFindings; + const repositoryReviewOutcomes = outcome.repositoryReviewOutcomes; + if (repositoryReviewOutcomes?.length) contextPatch.repositoryReviewOutcomes = repositoryReviewOutcomes; + if (outcome.repositoryScopeRevision !== undefined) contextPatch.repositoryScopeRevision = outcome.repositoryScopeRevision; if (outcome.supersededFindingIds?.length && outcome.supersededFindingSourceWorkflowStepId) { contextPatch.supersededFindingSourceWorkflowStepId = outcome.supersededFindingSourceWorkflowStepId; contextPatch.supersededFindingIds = outcome.supersededFindingIds; @@ -611,7 +659,13 @@ export async function runGraphCustomNode( Malformed review output (no parseable verdict, even after the fallback-model retry in executeWorkflowStep) is treated as a NON-BLOCKING advisory rather than a hard gate failure. Operators asked that an unparseable reviewer response not block a task in review — a genuine REVISE (parsed verdict) still blocks, and the advisory_failure value keeps the malformed result visible on the Workflow tab. Only `malformed` relaxes a gate; every parsed non-pass verdict continues to block exactly as before. */ return { - outcome: outcome.success || !blocking || malformed ? "success" : "failure", + /* + FNXC:RepositoryScope 2026-08-21-03:05: + An advisory review may tolerate malformed reviewer text, but a scope-superseded + UNAVAILABLE result is never a pass. Returning success here would persist it as passed + and admit an obsolete Code Review edge. + */ + outcome: outcome.success || ((!blocking || malformed) && verdict !== "UNAVAILABLE") ? "success" : "failure", value: (outcome as WorkflowStepOutcome).failureValue ?? verdict ?? (outcome.success ? "passed" : advisoryFailureValue), ...(Object.keys(contextPatch).length > 0 ? { contextPatch } : {}), }; diff --git a/packages/engine/src/executor/workflow-step-verdict.ts b/packages/engine/src/executor/workflow-step-verdict.ts index 468dee5376..7c04703292 100644 --- a/packages/engine/src/executor/workflow-step-verdict.ts +++ b/packages/engine/src/executor/workflow-step-verdict.ts @@ -7,7 +7,7 @@ * review groups and prose cannot open a terminal lifecycle path. */ import { proseSignalsClearApproval, extractJsonObjectCandidates, textHasStructuredVerdictKey } from "../execution/reviewer.js"; -import { normalizeSupersededFindingIds, normalizeWorkflowReviewFindings, PLAN_REVIEW_GROUP_ID, type WorkflowReviewFinding } from "@fusion/core"; +import { normalizeSupersededFindingIds, normalizeWorkflowReviewFindings, PLAN_REVIEW_GROUP_ID, type WorkflowReviewFinding, type WorkflowRepositoryReviewOutcome } from "@fusion/core"; /** Machine-readable workflow-step verdicts, including Plan Review CLOSE_NO_OP. */ export type WorkflowStepVerdict = "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE" | "CLOSE_NO_OP"; @@ -58,6 +58,10 @@ export interface WorkflowStepOutcome { notes?: string; /** Normalized independently actionable feedback from a review-kind node. */ findings?: WorkflowReviewFinding[]; + /** Structured workspace review outcomes for durable node-result persistence. */ + repositoryReviewOutcomes?: WorkflowRepositoryReviewOutcome[]; + /** Confirmed scope generation used for the workspace review. */ + repositoryScopeRevision?: number; /** Specific prior result containing the findings this review step claims are superseded. */ supersededFindingSourceWorkflowStepId?: string; /** Explicit prior-lane finding IDs this review step claims are now superseded. */ diff --git a/packages/engine/src/executor/workspace-review-per-repo.ts b/packages/engine/src/executor/workspace-review-per-repo.ts index 80b382accb..0708f79efa 100644 --- a/packages/engine/src/executor/workspace-review-per-repo.ts +++ b/packages/engine/src/executor/workspace-review-per-repo.ts @@ -8,8 +8,9 @@ * `reviewStep` single-cwd; the CALLERS loop. This helper is the shared loop+aggregate so both review entry points * (historically the deleted in-session review tool, now only the step-inversion `stepReview` seam) iterate * identically: it invokes the caller's - * own `invokeForCwd(cwd)` once per acquired worktree (cwd = repo.worktreePath) and aggregates the repo-tagged - * verdicts as a CONJUNCTION — the task is "reviewed" only if EVERY repo passes; the FIRST non-APPROVE repo's + * own `invokeForCwd(cwd)` only for an explicitly scoped repository with diff evidence. Acquired + * worktrees are never task intent: clean scoped repositories are recorded as not-reviewed and + * out-of-scope worktrees are not opened. Modified in-scope verdicts aggregate as a conjunction. * verdict becomes the aggregate verdict (mirroring verifyWorktreeInvariants' first-failing-repo return), and its * findings are repo-tagged. A zero-acquire workspace task is classified with the completion invariant: proven * commit-free work approves honestly, while unproven work returns non-retryable UNAVAILABLE. @@ -19,10 +20,24 @@ * verdict so the caller's existing verdict→edge mapping (APPROVE done-marking, REVISE block, RETHINK reset, * UNAVAILABLE retry) is unchanged. */ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; import { resolve, sep } from "node:path"; -import type { Task } from "@fusion/core"; +import type { Task, WorkflowRepositoryReviewOutcome } from "@fusion/core"; import type { ReviewResult } from "../execution/reviewer.js"; import { classifyWorkspaceZeroAcquire, type WorkspaceZeroAcquireOptions } from "./workspace-zero-acquire.js"; +import { captureModifiedFiles } from "./worktree-capture-modified-files.js"; + +const execFileAsync = promisify(execFile); + +/** FNXC:RepositoryScope 2026-08-21-01:18: path membership is insufficient after review; hash the exact Git diff that the reviewer approved. */ +async function captureReviewFingerprint(worktreePath: string, baseCommitSha: string | undefined): Promise { + if (!baseCommitSha) throw new Error("workspace review cannot fingerprint a repository without its base commit"); + const { stdout } = await execFileAsync("git", ["diff", "--binary", `${baseCommitSha}..HEAD`], { cwd: worktreePath, encoding: "utf8" }); + return createHash("sha256").update(stdout).digest("hex"); +} export async function reviewWorkspacePerRepo( // FNXC:Workspace 2026-06-21-15:00: F7 — drop the dead `repoRel` callback param. @@ -35,16 +50,55 @@ export async function reviewWorkspacePerRepo( workspaceMode?: boolean; workspaceRepos?: readonly string[]; workspaceRootDir?: string; + captureModifiedFiles?: (repoRel: string, worktreePath: string, baseCommitSha?: string) => Promise; } = {}, ): Promise { const workspaceWorktrees = task.workspaceWorktrees ?? {}; const declaredRepos = options.workspaceRepos ? new Set(options.workspaceRepos) : undefined; + /* + FNXC:RepositoryScope 2026-08-21-01:53: + A proposed creation default is not review authority. Code review fails closed until the planner + confirms repository intent, so no approval can be persisted for a scope that may be replaced. + */ + if (task.repositoryScope?.state !== "confirmed") { + return { + verdict: "UNAVAILABLE", + retryable: false, + review: "Workspace Code Review requires a confirmed repository scope.", + summary: "Unavailable: repository scope is not confirmed", + }; + } + const repositoryScope = new Set(task.repositoryScope.repositories); + const repositoryScopeRevision = task.repositoryScope.revision; + /* + FNXC:RepositoryScope 2026-08-21-00:29: + Persisted modifiedFiles is a historical task snapshot, not review authority. Re-read each + acquired repository at the review boundary so a commit made after the last executor capture + cannot be mislabeled clean and bypass its required approval. Diff capture is deliberately + per-repository because workspace roots are not Git worktrees. + */ + const freshModifiedFiles: string[] = []; + const repositoryDiffFingerprints: Record = {}; + for (const repoRel of Object.keys(workspaceWorktrees).sort()) { + const repo = workspaceWorktrees[repoRel]; + const files = options.captureModifiedFiles + ? await options.captureModifiedFiles(repoRel, repo.worktreePath, repo.baseCommitSha ?? undefined) + : await captureModifiedFiles(repo.worktreePath, repo.baseCommitSha ?? undefined, task.id, undefined, "workspace-review-boundary"); + freshModifiedFiles.push(...files.map((file) => `${repoRel}/${file}`)); + if (files.length > 0 && repositoryScope.has(repoRel) && !options.captureModifiedFiles && existsSync(repo.worktreePath)) { + repositoryDiffFingerprints[repoRel] = await captureReviewFingerprint(repo.worktreePath, repo.baseCommitSha ?? undefined); + } + } + const modifiedFiles = freshModifiedFiles; + const hasDiffEvidence = (repoRel: string) => modifiedFiles.some((file) => file === repoRel || file.startsWith(`${repoRel}/`)); const seenPaths = new Set(); // FNXC:WorkspaceRootRouting 2026-08-19-12:15: Only declared repository entries are reviewable; // stale root-keyed metadata and duplicate paths cannot become reviewer cwd values. const repoKeys = Object.keys(workspaceWorktrees) .filter((repoRel) => { if (declaredRepos && !declaredRepos.has(repoRel)) return false; + // FNXC:RepositoryScope 2026-08-20-23:07: acquisition grants a checkout, never review authority. + if (!repositoryScope.has(repoRel) || !hasDiffEvidence(repoRel)) return false; const worktreePath = workspaceWorktrees[repoRel]?.worktreePath; if (typeof worktreePath !== "string" || worktreePath.length === 0) return false; const canonical = resolve(worktreePath); @@ -58,6 +112,24 @@ export async function reviewWorkspacePerRepo( }) .sort(); if (repoKeys.length === 0) { + const cleanScopedRepos = [...repositoryScope].filter((repoRel) => declaredRepos?.has(repoRel) !== false); + if (cleanScopedRepos.length > 0 && Object.keys(workspaceWorktrees).length > 0) { + return { + verdict: "UNAVAILABLE", + retryable: false, + review: `No changes — not reviewed: ${cleanScopedRepos.map((repo) => `\`${repo}\``).join(", ")}. No scoped repository has diff evidence; this is not a blocking reviewer verdict.`, + summary: `Not reviewed: no changes in ${cleanScopedRepos.join(", ")}`, + repositoryReviewOutcomes: cleanScopedRepos.map((repository) => ({ + repository, + status: "NOT_REVIEWED" as const, + output: "No changes — not reviewed.", + episodeId: new Date().toISOString(), + scopeRevision: task.repositoryScope?.revision, + reviewedAt: new Date().toISOString(), + })), + repositoryScopeRevision: task.repositoryScope?.revision, + }; + } /* FNXC:Workspace 2026-08-15-04:21: This is the review-side consumer of classifyWorkspaceZeroAcquire. A proven @@ -85,12 +157,36 @@ export async function reviewWorkspacePerRepo( }; } - const reviewSections: string[] = []; - const summarySections: string[] = []; + // FNXC:RepositoryScope 2026-08-20-23:07: clean scoped repositories remain visible as informational non-verdicts. + const notReviewedRepos = [...repositoryScope] + .filter((repoRel) => declaredRepos?.has(repoRel) !== false && !hasDiffEvidence(repoRel)) + .sort(); + const reviewedAt = new Date().toISOString(); + const repositoryReviewOutcomes: WorkflowRepositoryReviewOutcome[] = notReviewedRepos.map((repository) => ({ + repository, + status: "NOT_REVIEWED", + output: "No changes — not reviewed.", + episodeId: reviewedAt, + scopeRevision: repositoryScopeRevision, + reviewedAt, + })); + const reviewSections: string[] = notReviewedRepos.map((repoRel) => `### [${repoRel}] NOT_REVIEWED\nNo changes — not reviewed.`); + const summarySections: string[] = notReviewedRepos.map((repoRel) => `[${repoRel}] NOT_REVIEWED: no changes`); let firstFailing: { repo: string; result: ReviewResult } | undefined; for (const repoRel of repoKeys) { const repo = workspaceWorktrees[repoRel]; const result = await invokeForCwd(repo.worktreePath); + repositoryReviewOutcomes.push({ + repository: repoRel, + status: "REVIEWED", + verdict: result.verdict, + output: result.review, + findings: result.findings, + fingerprint: repositoryDiffFingerprints[repoRel], + episodeId: reviewedAt, + scopeRevision: repositoryScopeRevision, + reviewedAt, + }); // Tag every per-repo finding with its sub-repo so downstream readers attribute it correctly. reviewSections.push(`### [${repoRel}] ${result.verdict}\n${result.review}`); summarySections.push(`[${repoRel}] ${result.verdict}: ${result.summary}`); @@ -112,15 +208,21 @@ export async function reviewWorkspacePerRepo( // FNXC:Workspace 2026-06-22-00:00: the conjunction BREAKS on the first non-APPROVE repo, // so reviewSections holds only the repos evaluated up to (and including) the failure — not // every sub-repo. Label it honestly so operators don't read a partial list as exhaustive. - review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts (evaluation stopped at first failure; later repos not reviewed):\n\n${reviewSections.join("\n\n")}`, + review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts (evaluation stopped at first failure; later modified repos not reviewed):\n\n${reviewSections.join("\n\n")}`, summary: `${firstFailing.repo}: ${firstFailing.result.verdict} — ${summarySections.join(" | ")}`, + repositoryDiffFingerprints, + repositoryReviewOutcomes, + repositoryScopeRevision: repositoryScopeRevision, }; } // Every sub-repo approved → the task is reviewed (conjunction satisfied). return { verdict: "APPROVE", - review: `All ${repoKeys.length} sub-repo(s) approved. Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, - summary: `APPROVE across ${repoKeys.length} sub-repo(s): ${summarySections.join(" | ")}`, + review: `All ${repoKeys.length} modified in-scope sub-repo(s) approved. Per-repo outcomes:\n\n${reviewSections.join("\n\n")}`, + summary: `APPROVE across ${repoKeys.length} modified in-scope sub-repo(s): ${summarySections.join(" | ")}`, + repositoryDiffFingerprints, + repositoryReviewOutcomes, + repositoryScopeRevision: repositoryScopeRevision, }; } diff --git a/packages/engine/src/executor/worktree-task-done-scope-leak.ts b/packages/engine/src/executor/worktree-task-done-scope-leak.ts index 7ab4625e9b..b9ebf1b5a1 100644 --- a/packages/engine/src/executor/worktree-task-done-scope-leak.ts +++ b/packages/engine/src/executor/worktree-task-done-scope-leak.ts @@ -3,6 +3,9 @@ * evaluateTaskDoneScopeLeak peeled from TaskExecutor (U4 Slice B). * fn_task_done File Scope leak guard (workspace multi-repo + singular checkout). */ +import { execFile } from "node:child_process"; +import { access } from "node:fs/promises"; +import { promisify } from "node:util"; import type { Settings, Task, TaskStore } from "@fusion/core"; import { resolveRepoDeclaredScope } from "../worktree/workspace-paths.js"; import { executorLog } from "../logger.js"; @@ -13,6 +16,53 @@ import { workflowPathMatchesDeclaredScope, } from "./workflow-feedback-paths.js"; +const execFileAsync = promisify(execFile); + +/** + * FNXC:RepositoryScope 2026-08-21-00:58: + * Scope capture helpers intentionally degrade Git errors to an empty list for ordinary telemetry. + * Completion cannot use that lossy result for an acquired out-of-scope checkout: establish that + * the path is readable and Git-addressable first, or fail closed instead of treating unknown work + * as clean. + */ +async function verifyRepositoryCaptureEvidence(worktreePath: string): Promise { + await access(worktreePath); + const { stdout } = await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: worktreePath, + encoding: "utf8", + }); + if (stdout.trim() !== "true") throw new Error("path is not a Git worktree"); +} + +/** + * FNXC:RepositoryScope 2026-08-21-01:18: + * Completion must distinguish a clean checkout from a lossy capture failure. The historical + * capture helpers intentionally return [] for telemetry continuity, so this direct Git evidence + * probe executes every diff needed by the scope guard and throws when a base or diff is unreadable. + */ +async function captureRepositoryChangeEvidence( + worktreePath: string, + baseCommitSha: string | undefined, +): Promise { + const commands: string[][] = [ + ["diff", "--name-only"], + ["diff", "--name-only", "--cached"], + ]; + if (baseCommitSha) { + commands.push(["merge-base", baseCommitSha, "HEAD"]); + commands.push(["diff", "--name-only", `${baseCommitSha}..HEAD`]); + } else { + commands.push(["rev-parse", "--verify", "HEAD"]); + } + const results = await Promise.all(commands.map((args) => execFileAsync("git", args, { + cwd: worktreePath, + encoding: "utf8", + }))); + return [...new Set(results + .filter((_, index) => !commands[index]?.includes("merge-base") && !commands[index]?.includes("rev-parse")) + .flatMap(({ stdout }) => stdout.split("\n").map((file) => file.trim()).filter(Boolean)))]; +} + export type TaskDoneScopeLeakDeps = { store: TaskStore; workspaceConfig: unknown | null | undefined; @@ -43,9 +93,39 @@ export async function evaluateTaskDoneScopeLeak( } const declaredScope = await deps.store.parseFileScopeFromPrompt(task.id).catch(() => [] as string[]); - if (declaredScope.length === 0) { + // FNXC:RepositoryScope 2026-08-21-00:44: + // Empty File Scope disables only declared-path matching. It must not bypass the independent + // completion fence for dirty acquired repositories outside confirmed task intent. + const workspaceConfig = deps.ensureWorkspaceConfig + ? await deps.ensureWorkspaceConfig() + : deps.workspaceConfig; + if (declaredScope.length === 0 && workspaceConfig) { + const scope = new Set(task.repositoryScope?.repositories ?? []); + for (const repoRel of Object.keys(task.workspaceWorktrees ?? {}).sort()) { + if (scope.has(repoRel)) continue; + const repo = task.workspaceWorktrees?.[repoRel]; + if (!repo) continue; + try { + await verifyRepositoryCaptureEvidence(repo.worktreePath); + } catch (_error) { + const message = `workspace repository ${repoRel} cannot establish out-of-scope change evidence; completion is blocked until its checkout is readable`; + await deps.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, deps.getRunContextFor(task.id)); + return { blocked: true, message }; + } + const [uncommitted, committed, strictEvidence] = await Promise.all([ + deps.captureUncommittedModifiedFiles(repo.worktreePath), + deps.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha ?? undefined, task.id, audit, "scope-leak-out-of-scope"), + captureRepositoryChangeEvidence(repo.worktreePath, repo.baseCommitSha ?? undefined), + ]); + if (uncommitted.length > 0 || committed.length > 0 || strictEvidence.length > 0) { + const message = `workspace repository ${repoRel} has modified out-of-scope work; approve the repository scope or clean the checkout before completing`; + await deps.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, deps.getRunContextFor(task.id)); + return { blocked: true, message }; + } + } return { blocked: false }; } + if (declaredScope.length === 0) return { blocked: false }; const reviewLevel = parseReviewLevelFromPrompt(promptContent); const configuredMode = settings.planOnlyScopeLeakEnforcement ?? "warn"; @@ -84,13 +164,18 @@ export async function evaluateTaskDoneScopeLeak( // off-scope files and would silently pass; we block it (scope is declared but unverifiable). // F6 (deterministic ordering): iterate sorted repo keys so the reported offending repo is stable // across runs/rehydrate. - const workspaceConfig = deps.ensureWorkspaceConfig - ? await deps.ensureWorkspaceConfig() - : deps.workspaceConfig; let touchedFiles: string[]; let offendingRepo: string | undefined; if (workspaceConfig) { const workspaceWorktrees = task.workspaceWorktrees ?? {}; + /* + FNXC:RepositoryScope 2026-08-21-00:29: + Review authority is limited to explicit scope, but completion must inspect every acquired + checkout. A dirty acquired-out-of-scope repository is evidence that cannot be silently + delivered or ignored; it blocks until the operator approves it into scope or the work is + cleaned. Clean acquired-out-of-scope repositories remain non-reviewable. + */ + const scope = new Set(task.repositoryScope?.repositories ?? []); const repoKeys = Object.keys(workspaceWorktrees).sort(); // F2: declaredScope is non-empty here (the `declaredScope.length === 0` early-return above // handled the unscoped case). A scoped task that acquired no sub-repo worktrees cannot have its @@ -105,15 +190,25 @@ export async function evaluateTaskDoneScopeLeak( for (const repoRel of repoKeys) { const repo = workspaceWorktrees[repoRel]; try { - const [repoUncommitted, repoCommitted] = await Promise.all([ + await verifyRepositoryCaptureEvidence(repo.worktreePath); + const [repoUncommitted, repoCommitted, strictEvidence] = await Promise.all([ deps.captureUncommittedModifiedFiles(repo.worktreePath), deps.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha ?? undefined, task.id, audit, "scope-leak-guard"), + captureRepositoryChangeEvidence(repo.worktreePath, repo.baseCommitSha ?? undefined), ]); // Repo-LOCAL touched files (no `${repoRel}/` prefix) so the always-allowed `.changeset/` // carve-out and the scope match operate as the reviewer/cwd=repo sees them (F5). - const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted])]; + // The direct evidence cannot degrade a later Git failure to [] like telemetry capture does. + const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted, ...strictEvidence])]; + if (scope.size > 0 && !scope.has(repoRel) && repoTouched.length > 0) { + const message = `workspace repository ${repoRel} has modified out-of-scope work; approve the repository scope or clean the checkout before completing`; + executorLog.warn(`${task.id}: [scope-leak] ${message}`); + await deps.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, deps.getRunContextFor(task.id)); + return { blocked: true, message }; + } // Repo-LOCAL declared-scope subset for THIS repo (prefix stripped). Same filter as the - // non-workspace branch below — one surface. + // non-workspace branch below — one surface. Clean acquired-out-of-scope repositories have + // no declared scope and are intentionally informational only. const repoScopeSubset = resolveRepoDeclaredScope(declaredScope, repoRel, repoKeys).scope; const repoOffScope = repoTouched .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, repoScopeSubset)) diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index e974ef57f9..9b97073c33 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -35,6 +35,7 @@ import { commitIdentityArgs, resolveCommitIdentity } from "../git-identity.js"; * Pure helpers (prompt builders, verdict parser) are exported for unit testing; * the orchestrator accepts injectable agent functions for the same reason. */ +import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { realpathSync, readdirSync } from "node:fs"; @@ -2094,9 +2095,92 @@ export async function landWorkspaceTask( const trailers = taskTrailers(taskId, task.lineageId, settings); const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; + /* + FNXC:RepositoryScope 2026-08-21-00:44: + Landing captures qualified per-repository diffs at the merge boundary, after all execution and + review work has finished. Persisting this snapshot prevents a stale executor capture from + omitting a newly changed scoped repository from leases, review obligations, or land intents. + */ + const liveMergeBoundaryTask = await store.getTask(taskId).catch(() => undefined); + const mergeBoundaryTask = liveMergeBoundaryTask?.workspaceWorktrees ? liveMergeBoundaryTask : task; + const confirmedScope = mergeBoundaryTask.repositoryScope?.state === "confirmed" + ? new Set(mergeBoundaryTask.repositoryScope.repositories) + : undefined; + const mergeBoundaryModifiedFiles: string[] = []; + const mergeBoundaryFingerprints: Record = {}; + const modifiedOutOfScopeRepositories = new Set(); + const netZeroBranchRepositories = new Set(); + for (const [repoRel, entry] of Object.entries(mergeBoundaryTask.workspaceWorktrees ?? {}) + // FNXC:RepositoryScope 2026-08-21-02:17: every acquired checkout is inspected for dirty + // out-of-scope work before land selection. Acquisition is not intent, but it is still a + // completion safety surface; filtering it out here could erase unapproved changes on update. + .filter(([repoRel, entry]) => !entry.landedSha || !confirmedScope?.has(repoRel)) + .sort(([left], [right]) => left.localeCompare(right))) { + if (!entry.branch) throw new Error(`Workspace repository ${repoRel} has no task branch for fresh merge evidence`); + try { + const { stdout: mergeBase } = await execFileAsync("git", ["merge-base", "HEAD", entry.branch], { cwd: entry.worktreePath, encoding: "utf8" }); + const { stdout } = await execFileAsync("git", ["diff", "--name-only", `${mergeBase.trim()}..${entry.branch}`], { cwd: entry.worktreePath, encoding: "utf8" }); + const files = stdout.split("\n").map((file) => file.trim()).filter(Boolean); + if (files.length > 0) { + const { stdout: diffContent } = await execFileAsync("git", ["diff", "--binary", `${entry.baseCommitSha ?? mergeBase.trim()}..HEAD`], { cwd: entry.worktreePath, encoding: "utf8" }); + mergeBoundaryFingerprints[repoRel] = createHash("sha256").update(diffContent).digest("hex"); + } + if (files.length > 0 && !confirmedScope?.has(repoRel)) { + modifiedOutOfScopeRepositories.add(repoRel); + } else if (confirmedScope?.has(repoRel)) { + mergeBoundaryModifiedFiles.push(...files.map((file) => `${repoRel}/${file}`)); + if (files.length === 0) { + const { stdout: aheadCount } = await execFileAsync("git", ["rev-list", "--count", `HEAD..${entry.branch}`], { cwd: entry.worktreePath, encoding: "utf8" }); + if (Number(aheadCount.trim()) > 0) netZeroBranchRepositories.add(repoRel); + } + } + } catch (error) { + throw new Error(`Cannot capture fresh merge evidence for workspace repository ${repoRel}: ${getErrorMessage(error)}`); + } + } + if (modifiedOutOfScopeRepositories.size > 0) { + throw new Error(`Workspace repositories modified outside confirmed scope for ${taskId}: ${[...modifiedOutOfScopeRepositories].sort().join(", ")}`); + } + const normalizedMergeBoundaryFiles = [...new Set(mergeBoundaryModifiedFiles)].sort(); + const persistedReviewFiles = [...new Set(mergeBoundaryTask.modifiedFiles ?? [])].sort(); + /* + FNXC:RepositoryScope 2026-08-21-00:58: + Landing must not convert fresh evidence into approved evidence. A changed repository/file set after + Code Review has no matching reviewer episode, so return it through the normal review path instead + of silently persisting the new snapshot and landing it. Persist failure is likewise a hard fence: + a later recovery must never infer an unrecorded merge boundary. + */ + const approvedReviewEvidence = mergeBoundaryTask.repositoryScope?.reviewEvidence ?? {}; + const missingOrStaleReviewApproval = Object.entries(mergeBoundaryFingerprints) + .some(([repoRel, fingerprint]) => approvedReviewEvidence[repoRel]?.fingerprint !== fingerprint); + if ( + normalizedMergeBoundaryFiles.some((file) => !persistedReviewFiles.includes(file)) + || missingOrStaleReviewApproval + ) { + /* + FNXC:RepositoryScope 2026-08-21-01:36: + Every fresh land-required repository needs the exact fingerprint persisted by its approving + Code Review episode. An empty reviewEvidence map is missing approval evidence, not a legacy + exemption: otherwise a modified repository could publish without any reviewer authorization. + */ + throw new Error(`Workspace merge evidence changed after review for ${taskId}; return the task to Code Review before landing`); + } + await store.updateTask(taskId, { modifiedFiles: normalizedMergeBoundaryFiles }); + task = { ...mergeBoundaryTask, modifiedFiles: normalizedMergeBoundaryFiles }; const workspaceWorktrees = task.workspaceWorktrees ?? {}; - // SORTED keys for deterministic land order (KTD1). - const repoKeys = Object.keys(workspaceWorktrees).sort(); + /* + FNXC:RepositoryScope 2026-08-20-23:57: + Landing obligations come from confirmed task intent plus actual qualified diff evidence. An + ambiguous legacy row must park for operator confirmation rather than treating every acquired + checkout as intent, because that recreates the clean-peer partial-land livelock. + */ + const explicitScope = task.repositoryScope?.state === "confirmed" ? task.repositoryScope.repositories : undefined; + if (!explicitScope) { + throw new Error(`Workspace repository scope is unresolved for ${taskId}; operator confirmation is required before landing`); + } + const repoKeys = Object.keys(workspaceWorktrees) + .filter((repoRel) => explicitScope.includes(repoRel) && ((task.modifiedFiles ?? []).some((file) => file.startsWith(`${repoRel}/`)) || netZeroBranchRepositories.has(repoRel))) + .sort(); const repos: WorkspaceRepoLandResult[] = []; let allLanded = true; diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 24c3b38530..cbc4e8ec26 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -10504,9 +10504,22 @@ const movedTask = await this.store.moveTask(task.id, completeLane); continue; } - // Classify each acquired sub-repo: landed / retryable / unrecoverable / unreadable (FORK-A). + // Classify each confirmed, modified sub-repo: landed / retryable / unrecoverable / unreadable (FORK-A). const workspaceWorktrees = task.workspaceWorktrees ?? {}; - const repoKeys = Object.keys(workspaceWorktrees); + /* + FNXC:RepositoryScope 2026-08-20-23:57: + Recovery must fail closed for an unconfirmed legacy scope. Acquired worktrees prove only + checkout policy, never repository intent, so re-enqueueing them would restart FN-094's + clean-peer land loop instead of waiting for an operator-confirmed scope. + */ + const explicitScope = task.repositoryScope?.state === "confirmed" ? task.repositoryScope.repositories : undefined; + if (!explicitScope) { + await this.emitWorkspacePartialLandNoAction(task, "scope-unresolved", []); + continue; + } + const repoKeys = Object.keys(workspaceWorktrees).filter((repoRel) => + explicitScope.includes(repoRel) && (task.modifiedFiles ?? []).some((file) => file.startsWith(`${repoRel}/`)), + ); const landedRepos: string[] = []; const unlandedRepos: string[] = []; const unrecoverableRepos: string[] = []; @@ -10672,7 +10685,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane); private async emitWorkspacePartialLandNoAction( task: Task, - reason: "auto-merge-off" | "user-paused" | "live-worktree" | "merge-pending" | "evidence-unavailable", + reason: "auto-merge-off" | "user-paused" | "live-worktree" | "merge-pending" | "evidence-unavailable" | "scope-unresolved", livePaths: string[], ): Promise { try { diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 18a29acdb3..89fb8ceada 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -2907,12 +2907,21 @@ export class TriageProcessor { // this a no-op there while still guaranteeing no dangling token leaks. const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, triagePolicySettings); const duplicatePolicyInstruction = buildPlanningDuplicatePolicyInstruction(); + /* + FNXC:RepositoryScope 2026-08-21-00:12: + Workspace plan persistence rejects a missing Repository Scope, so every planner that sees + repository intent must be explicitly instructed to emit the confirmed heading rather than + repeatedly producing a plan the authoritative writer cannot publish. + */ const triageLayers = buildPromptLayers({ basePrompt: renderedBasePrompt, goalContext: triageGoalResolution.goalContext, agentInstructions: [ triageIdentitySection, duplicatePolicyInstruction, + task.repositoryScope + ? `## Workspace repository intent\nThis is a multi-repository workspace task. Your final PROMPT.md MUST include a non-empty \`## Repository Scope\` heading with a markdown bullet list of configured repository names. Confirm only repositories the task concerns; do not infer intent from acquired checkouts. File Scope entries must be qualified as \`repository/path\` when more than one repository is in scope.` + : "", triageInstructions, isResearchToolSurfaceEnabled(settings) ? getResearchGuidanceForSurface("triage") diff --git a/packages/engine/src/workflows/workflow-graph-executor.ts b/packages/engine/src/workflows/workflow-graph-executor.ts index b9ac05c137..9edaeab5d9 100644 --- a/packages/engine/src/workflows/workflow-graph-executor.ts +++ b/packages/engine/src/workflows/workflow-graph-executor.ts @@ -334,7 +334,10 @@ export interface WorkflowGraphExecutorDeps { * `store.updateTask({workflowStepResults})` wiring lives in the executor adapter; * this seam only forwards the terminal/pending entry. */ - recordWorkflowStepResult?: (taskId: string, result: WorkflowStepResult) => void | Promise; + /** Returns false only when a repository-scope CAS supersedes a review result before edge admission. */ + recordWorkflowStepResult?: (taskId: string, result: WorkflowStepResult) => boolean | void | Promise; + /** Atomically-adjacent admission check for Code Review edges after terminal result persistence. */ + isRepositoryScopeReviewEdgeCurrent?: (taskId: string, workflowStepId: string, revision: number) => boolean | Promise; /* * FNXC:WorkflowOptionalStepFix 2026-06-26-16:20: * Enabled PRE-merge optional workflow steps that return REVISE must offer the executor one remediation path before normal advisory/gate fall-through. The graph forwards the optional-group node id and per-step `maxRevisions` override so the executor can resolve the budget against workflow-value caps, `maxPostReviewFixes`, or `"unbounded"`; absent or false preserves prior byte-inert behavior for in-memory tests and exhausted budgets. @@ -1075,6 +1078,12 @@ export class WorkflowGraphExecutor { const stepFindings = this.workflowReviewKind(node) && Array.isArray(exitContextPatch?.findings) ? exitContextPatch.findings as WorkflowStepResult["findings"] : undefined; + const repositoryReviewOutcomes = this.workflowReviewKind(node) && Array.isArray(exitContextPatch?.repositoryReviewOutcomes) + ? exitContextPatch.repositoryReviewOutcomes as WorkflowStepResult["repositoryReviewOutcomes"] + : undefined; + const repositoryScopeRevision = this.workflowReviewKind(node) && typeof exitContextPatch?.repositoryScopeRevision === "number" + ? exitContextPatch.repositoryScopeRevision + : undefined; const supersededFindingSourceWorkflowStepId = this.workflowReviewKind(node) && typeof exitContextPatch?.supersededFindingSourceWorkflowStepId === "string" ? exitContextPatch.supersededFindingSourceWorkflowStepId : undefined; @@ -1105,7 +1114,7 @@ export class WorkflowGraphExecutor { : undefined, }); } - await this.recordOptionalGroupStepResult(task.id, { + const scopeCurrent = await this.recordOptionalGroupStepResult(task.id, { workflowStepId: node.id, workflowStepName: groupName, phase: stepPhase, @@ -1116,10 +1125,18 @@ export class WorkflowGraphExecutor { ...(stepOutput !== undefined ? { output: stepOutput } : {}), ...(stepNotes !== undefined ? { notes: stepNotes } : {}), ...(stepFindings?.length ? { findings: stepFindings } : {}), + ...(repositoryReviewOutcomes?.length ? { repositoryReviewOutcomes } : {}), + ...(repositoryScopeRevision !== undefined ? { repositoryScopeRevision } : {}), ...(supersededFindingSourceWorkflowStepId && supersededFindingIds?.length ? { supersededFindingSourceWorkflowStepId, supersededFindingIds } : {}), startedAt: stepStartedAt, completedAt: new Date().toISOString(), }); + if (!scopeCurrent) { + /* FNXC:RepositoryScope 2026-08-21-02:48: Optional-group Code Review cannot route an approval once its terminal scope CAS is superseded. */ + context[`node:${node.id}:outcome`] = "failure"; + context[`node:${node.id}:value`] = "workspace-review-superseded"; + return { outcome: "failure", value: "workspace-review-superseded" }; + } // `[pre-merge]`/`[post-merge]` terminal logs at parity with the legacy path // (executor.ts runWorkflowSteps: "completed" / "requested revision" / // "failed" + the advisory variant). @@ -1133,6 +1150,7 @@ export class WorkflowGraphExecutor { } else { this.deps.logTaskEntry?.(`${logPrefix} Workflow step failed: ${groupName}`, stepOutput); } + if (repositoryScopeRevision !== undefined) context[`node:${node.id}:repositoryScopeRevision`] = repositoryScopeRevision; visitedNodeIds.push(...groupResult.visitedNodeIds); const result: WorkflowNodeResult = { outcome: groupResult.outcome, @@ -1454,6 +1472,18 @@ export class WorkflowGraphExecutor { node: WorkflowIrNode, sourceResult: WorkflowNodeResult, ): Promise => { + const reviewRevision = context[`node:${node.id}:repositoryScopeRevision`]; + if (this.workflowReviewKind(node) === "code" && typeof reviewRevision === "number" + && this.deps.isRepositoryScopeReviewEdgeCurrent + && !await this.deps.isRepositoryScopeReviewEdgeCurrent(task.id, node.id, reviewRevision)) { + /* + FNXC:RepositoryScope 2026-08-21-03:05: + Terminal-result persistence and graph traversal are separate operations. Re-check the + durable review record at edge admission so a scope change in that interval cannot let an + obsolete approval traverse an advisory or success edge. + */ + return { outcome: "failure", value: "workspace-review-superseded" }; + } const edges = outgoingMap.get(node.id) ?? []; if (edges.length === 0) { return sourceResult; @@ -1696,12 +1726,13 @@ export class WorkflowGraphExecutor { * Recording is additive visibility bookkeeping — a sink failure (or absent sink) * must NEVER affect graph execution, so swallow errors and no-op when unwired. */ - private async recordOptionalGroupStepResult(taskId: string, result: WorkflowStepResult): Promise { - if (!this.deps.recordWorkflowStepResult) return; + private async recordOptionalGroupStepResult(taskId: string, result: WorkflowStepResult): Promise { + if (!this.deps.recordWorkflowStepResult) return true; try { - await this.deps.recordWorkflowStepResult(taskId, result); + return (await this.deps.recordWorkflowStepResult(taskId, result)) !== false; } catch { - // Result recording is additive — a failure must not affect the run. + // Result recording is additive — a sink failure must not affect the run. + return true; } } @@ -1865,8 +1896,13 @@ export class WorkflowGraphExecutor { if (signal?.aborted || this.isAbortNodeResult(projected)) { return this.withEnginePauseAbortContext(node, projected); } - if (progressRecord) { - await this.recordNodeProgressFinish(task.id, node, progressRecord, projected); + if (progressRecord && !await this.recordNodeProgressFinish(task.id, node, progressRecord, projected)) { + /* + FNXC:RepositoryScope 2026-08-21-02:48: + A terminal review record rejected by the scope-generation CAS cannot + traverse its APPROVE edge. Return an unavailable outcome instead. + */ + return { outcome: "failure", value: "workspace-review-superseded" }; } return projected; } @@ -1878,8 +1914,9 @@ export class WorkflowGraphExecutor { if (signal?.aborted || this.isAbortNodeResult(projected)) { return this.withEnginePauseAbortContext(node, projected); } - if (progressRecord) { - await this.recordNodeProgressFinish(task.id, node, progressRecord, projected); + if (progressRecord && !await this.recordNodeProgressFinish(task.id, node, progressRecord, projected)) { + /* FNXC:RepositoryScope 2026-08-21-02:48: See the plugin-node path above; both graph dispatch routes share this edge fence. */ + return { outcome: "failure", value: "workspace-review-superseded" }; } return projected; } catch (error) { @@ -2009,7 +2046,7 @@ export class WorkflowGraphExecutor { node: WorkflowIrNode, started: WorkflowStepResult | null, nodeResult: WorkflowNodeResult, - ): Promise { + ): Promise { const status: WorkflowStepResult["status"] = nodeResult.outcome === "success" ? "passed" : "failed"; const contextPatch = nodeResult.contextPatch ?? {}; let output = typeof contextPatch.output === "string" ? contextPatch.output : undefined; @@ -2017,6 +2054,16 @@ export class WorkflowGraphExecutor { const findings = this.workflowReviewKind(node) && Array.isArray(contextPatch.findings) ? contextPatch.findings as WorkflowStepResult["findings"] : undefined; + /* + FNXC:RepositoryScope 2026-08-21-02:17: + Graph custom review nodes must persist the same structured repository outcomes as step-review seams. The remediation and Review-tab readers cannot recover scope revision or clean-peer state from formatted output. + */ + const repositoryReviewOutcomes = this.workflowReviewKind(node) && Array.isArray(contextPatch.repositoryReviewOutcomes) + ? contextPatch.repositoryReviewOutcomes as WorkflowStepResult["repositoryReviewOutcomes"] + : undefined; + const repositoryScopeRevision = this.workflowReviewKind(node) && typeof contextPatch.repositoryScopeRevision === "number" + ? contextPatch.repositoryScopeRevision + : undefined; /* FNXC:WorkflowReviewFindings 2026-08-11-19:39: This ordinary writer and the optional-group exit writer above carry explicit review supersession claims to the shared persistence sink. */ const supersededFindingSourceWorkflowStepId = this.workflowReviewKind(node) && typeof contextPatch.supersededFindingSourceWorkflowStepId === "string" ? contextPatch.supersededFindingSourceWorkflowStepId @@ -2039,7 +2086,7 @@ export class WorkflowGraphExecutor { failureValue: nodeResult.value, }); } - await this.recordOptionalGroupStepResult(taskId, { + return this.recordOptionalGroupStepResult(taskId, { workflowStepId: node.id, workflowStepName: this.workflowNodeProgressName(node), phase: started?.phase ?? (node.config?.phase === "post-merge" ? "post-merge" : "pre-merge"), @@ -2049,6 +2096,8 @@ export class WorkflowGraphExecutor { ...(output !== undefined ? { output } : {}), ...(notes !== undefined ? { notes } : {}), ...(findings?.length ? { findings } : {}), + ...(repositoryReviewOutcomes?.length ? { repositoryReviewOutcomes } : {}), + ...(repositoryScopeRevision !== undefined ? { repositoryScopeRevision } : {}), ...(supersededFindingSourceWorkflowStepId && supersededFindingIds?.length ? { supersededFindingSourceWorkflowStepId, supersededFindingIds } : {}), startedAt: started?.startedAt ?? new Date().toISOString(), completedAt: new Date().toISOString(), diff --git a/packages/engine/src/workflows/workflow-node-handlers.ts b/packages/engine/src/workflows/workflow-node-handlers.ts index eb0dadbe95..c9c1ff4c34 100644 --- a/packages/engine/src/workflows/workflow-node-handlers.ts +++ b/packages/engine/src/workflows/workflow-node-handlers.ts @@ -1,5 +1,5 @@ import { WorkflowIrError, instanceNodeId } from "@fusion/core"; -import type { TaskDetail, WorkflowIrNode } from "@fusion/core"; +import type { TaskDetail, WorkflowIrNode, WorkflowRepositoryReviewOutcome } from "@fusion/core"; import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js"; import { createPrNodeHandlers, createAutoMergeGateHandler, type PrNodeDeps } from "../merge/pr-nodes.js"; @@ -137,6 +137,12 @@ export interface StepReviewSeamResult { review?: string; summary?: string; retryable?: boolean; + /** Workspace code-review evidence used to fence merge against later same-path edits. */ + repositoryDiffFingerprints?: Record; + /** Structured per-repository review state for the active workspace review episode. */ + repositoryReviewOutcomes?: WorkflowRepositoryReviewOutcome[]; + /** Confirmed scope generation that supplied the workspace review input. */ + repositoryScopeRevision?: number; } /** The reserved context key carrying the active foreach instance (KTD-3, U3). @@ -567,6 +573,8 @@ export function createStepReviewHandler(seams: WorkflowLegacySeams): WorkflowNod const patch: Record = { [FOREACH_ACTIVE_CONTEXT_KEY]: active, [`node:${node.id}:verdict`]: result.verdict, + ...(result.repositoryReviewOutcomes ? { repositoryReviewOutcomes: result.repositoryReviewOutcomes } : {}), + ...(result.repositoryScopeRevision !== undefined ? { repositoryScopeRevision: result.repositoryScopeRevision } : {}), }; const value = @@ -626,6 +634,8 @@ export function createPrimitiveStepReviewHandler(primitives: WorkflowRuntimePrim ...(primitivePatch ?? {}), [FOREACH_ACTIVE_CONTEXT_KEY]: active, [`node:${node.id}:verdict`]: result.verdict, + ...(result.repositoryReviewOutcomes ? { repositoryReviewOutcomes: result.repositoryReviewOutcomes } : {}), + ...(result.repositoryScopeRevision !== undefined ? { repositoryScopeRevision: result.repositoryScopeRevision } : {}), }; const value =