diff --git a/.changeset/fix-workspace-land-mechanics-phase-c-review.md b/.changeset/fix-workspace-land-mechanics-phase-c-review.md new file mode 100644 index 0000000000..f24a7921ba --- /dev/null +++ b/.changeset/fix-workspace-land-mechanics-phase-c-review.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Harden the workspace per-repo land loop against partial-failure races. A lost `landedSha` DB write after a sub-repo's integration ref already advanced no longer silently continues — it escalates to a retryable partial-land error, and the landed predicate now recognizes an already-landed repo via its `Fusion-Task-Id` trailer on retry, so a re-run never produces a second squash commit. The land lease is now taskId-aware across registry kinds: a merging task can no longer clobber an executing task's acquire lease on a shared sub-repo (any foreign-task holder is treated as contention), and the active-session registry rejects foreign-task overwrites instead of silently clobbering. The transient `merging` status is always reset before any throw escapes the land loop (no stuck-`merging` leak), and finalize re-reads the latest task and no longer swallows the merge-details persist failure (no finalizing on a stale row). + +Harden the workspace merge dispatch and user-facing merge doors. The partial-land retry catch now fails closed when the task row can't be read (DB outage no longer triggers an indefinite retry storm). The merge-confirmed reachability fast-path skips workspace tasks (whose recorded commitSha lives in a sub-repo, not the workspace root) so a fully-landed workspace task is no longer demoted/parked. The dashboard and CLI merge doors now report `merged: true` (and `mergeConfirmed`/`commitSha`) when a workspace fully lands, mirroring the engine result. Transient sub-repo land-lease contention (`WorkspaceRepoLandBusyError`) is re-enqueued with capped backoff on a separate bounded counter instead of burning the merge-retry quota, so pure contention can't park a never-failed task. Retry backoff is capped at 60s. diff --git a/.changeset/fix-workspace-phase-c-review-round-2.md b/.changeset/fix-workspace-phase-c-review-round-2.md new file mode 100644 index 0000000000..7eba89c430 --- /dev/null +++ b/.changeset/fix-workspace-phase-c-review-round-2.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Address Phase C workspace merge-loop review feedback. A sub-repo recognized as already-landed via the `Fusion-Task-Id` trailer fallback (when its `landedSha` persist was lost) now resolves and re-records a concrete `landedSha`, so finalize no longer drops it and mis-reports a fully-landed workspace task as a no-op (`mergeConfirmed:false`). A manual merge that hits sub-repo land-lease contention now surfaces the busy error to the user without consuming the persisted `mergeRetries` quota (matching the auto path's separate busy counter). The partial-land retry persists the incremented retry count before arming the backoff timer — a failed write now fails closed instead of looping without consuming budget — and clears the stale busy-contention counter when a real partial land supersedes transient busy failures. The CLI and dashboard merge doors use the shared `isWorkspaceTask` predicate instead of re-inlining the workspace check, and integration-branch shell interpolation in base-commit capture uses POSIX single-quote escaping. diff --git a/.changeset/fn-6939-dev-server-narrow-preview-modal.md b/.changeset/fn-6939-dev-server-narrow-preview-modal.md new file mode 100644 index 0000000000..2133d343fb --- /dev/null +++ b/.changeset/fn-6939-dev-server-narrow-preview-modal.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix narrow right-sidebar Dev Server preview overlap by replacing the inline preview with an accessible modal launcher when the dock is very narrow, while keeping inline preview for full-page, mobile viewport, and expanded pop-out hosts. diff --git a/.changeset/fn-6953-ntfy-test-unsaved-config.md b/.changeset/fn-6953-ntfy-test-unsaved-config.md new file mode 100644 index 0000000000..6371518d61 --- /dev/null +++ b/.changeset/fn-6953-ntfy-test-unsaved-config.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix ntfy test notifications to honor unsaved Settings form config so users can enable ntfy, enter a valid topic/server/token, and send a test notification before saving. diff --git a/.changeset/merger-unification-runaimerge-sole-path.md b/.changeset/merger-unification-runaimerge-sole-path.md new file mode 100644 index 0000000000..fbd226966a --- /dev/null +++ b/.changeset/merger-unification-runaimerge-sole-path.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Merger unification (master-plan U0): `runAiMerge` (the FN-5633 clean-room AI merge path) is now the **sole** merge path. The engine dispatch, the `fn task merge` CLI command, and the UI-only (`--no-engine`) dashboard merge all route through `runAiMerge`; the legacy `aiMergeTask` pipeline is soft-deprecated (body retained, `@deprecated`). The `merger.mode` setting is now **inert and deprecated** — the type and field are retained as published surface, but the `"deterministic"` value no longer selects a different pipeline; observing it logs a one-time deprecation warning and proceeds via the unified AI merge path. A new shared `assertNotWorkspaceTaskMerge` guard rejects workspace-mode tasks (populated `workspaceWorktrees`) at every merge entry point with a clear error until per-repo merge support (master-plan U6) lands. diff --git a/.changeset/retire-optional-steps-declaration.md b/.changeset/retire-optional-steps-declaration.md new file mode 100644 index 0000000000..3ce5035347 --- /dev/null +++ b/.changeset/retire-optional-steps-declaration.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": major +--- + +**Breaking:** the `WorkflowOptionalStep` type, previously exported from `@runfusion/fusion`, is removed — any consumer importing it must migrate to `optional-group` nodes / `ResolvedWorkflowOptionalStep`. + +Retire the legacy optional-step DECLARATION model now that optional steps are graph-native `optional-group` nodes. Remove the `WorkflowOptionalStep` type and the `WorkflowIrV2.optionalSteps` IR field, drop the workflow node editor's optional-step declaration authoring panel (sidebar section, mobile tab, and collapse state), and stop threading an `optionalSteps` array through `flowToIr`/`serializeGraph`. A legacy persisted `optionalSteps` key on an old v2 workflow row is now tolerated (ignored, not validated) at parse so old rows still load as v2, and the rollback-downgrade heuristic still treats such a row as v2. The per-task optional-step toggle surfaces are unchanged — they continue to list and toggle optional steps sourced from `optional-group` nodes via `resolveWorkflowOptionalSteps` (`ResolvedWorkflowOptionalStep`). diff --git a/.changeset/session-routing-headers.md b/.changeset/session-routing-headers.md new file mode 100644 index 0000000000..decbd699ad --- /dev/null +++ b/.changeset/session-routing-headers.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add `X-Session-Id` and `X-Session-Affinity` request headers to all LLM chat completion requests. These let LLM gateways sticky-route consecutive requests from the same conversation to the same backend, and let observability tools (Langfuse, Arize, etc.) group the otherwise-stateless API calls of a session into a single multi-turn trace. Both headers carry the same stable identifier — the task id when available (stable across pause/resume), otherwise the pi session id. (#1675) diff --git a/.changeset/workflow-node-help.md b/.changeset/workflow-node-help.md new file mode 100644 index 0000000000..29a99275c9 --- /dev/null +++ b/.changeset/workflow-node-help.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workflow editor: add a Help section to the node detail pane. Every node now documents what it does, how to configure it, and its inputs/outputs/edges — including the engine-managed merge-lifecycle nodes (auto-merge gate, branch-group member integration, branch-group promotion, PR and recovery nodes), which are surfaced read-only with an "Engine-managed" badge. diff --git a/.changeset/workflow-optional-group-subgraphs.md b/.changeset/workflow-optional-group-subgraphs.md new file mode 100644 index 0000000000..c60dad2060 --- /dev/null +++ b/.changeset/workflow-optional-group-subgraphs.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workflow editor: optional steps are now graph-native. A new `optional-group` container node (foreach/loop-style) holds a subgraph the executor runs once when the group is enabled for a task (per-task `enabledWorkflowSteps` + workflow `defaultOn`) and bypasses when disabled. All seven built-in add-ons (documentation-review, qa-check, security-audit, performance-review, accessibility-check, browser-verification, frontend-ux-design) are insertable from the node-editor palette as a node or wrapped in an optional-group. The built-in coding and stepwise-coding workflows now express `browser-verification` as an optional-group. Optional-group enable resolution correctly handles id collisions with add-on template ids, so a group's enable state is not silently bypassed during task creation/update. (The legacy declaration-based optional-steps model is retired in a sibling changeset; only the `workflow-step` seam infrastructure removal remains a follow-up.) diff --git a/.changeset/workspace-dashboard-floor.md b/.changeset/workspace-dashboard-floor.md new file mode 100644 index 0000000000..47db598dea --- /dev/null +++ b/.changeset/workspace-dashboard-floor.md @@ -0,0 +1,8 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace tasks no longer render blank in the dashboard. Task cards and the task +detail view now surface a workspace task's acquired per-sub-repo worktrees as a +read-only "N repos acquired" placeholder and flat repo → worktree/branch list, +instead of an empty branch area (no `task.worktree`/`task.branch`). diff --git a/.changeset/workspace-per-repo-acquisition-hardening.md b/.changeset/workspace-per-repo-acquisition-hardening.md new file mode 100644 index 0000000000..3ce549f618 --- /dev/null +++ b/.changeset/workspace-per-repo-acquisition-hardening.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase A / U2): harden per-repo worktree acquisition. Each sub-repo worktree now gets the task identity guard installed (single-repo parity), a per-repo base commit SHA captured local-first against that sub-repo's resolved integration branch (shared `integrationBranch` override stripped so each repo falls through to its own `origin/HEAD`), and same-sub-repo acquisition exclusivity registered in the path-keyed active-session registry. Re-acquiring an already-acquired `(taskId, repo)` is idempotent, and acquisition failures surface an error plus an audit event instead of silently stalling. diff --git a/.changeset/workspace-per-repo-land-lease.md b/.changeset/workspace-per-repo-land-lease.md new file mode 100644 index 0000000000..8d9fe58642 --- /dev/null +++ b/.changeset/workspace-per-repo-land-lease.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase C U3): serialize concurrent same-sub-repo lands with a per-repo file-scope lease. When two workspace tasks try to land onto the SAME sub-repo's local integration ref at the same time, the merge phase now registers the sub-repo's absolute path in the path-keyed active-session registry under a distinct `workspace-repo-land` kind before each land and releases it in a `finally` (on land success or failure — no stuck lock). A second task contending for the same sub-repo fast-fails with a retryable `WorkspaceRepoLandBusyError`, which the existing partial-land auto-retry-then-park dispatch handles (consume a `mergeRetry`, re-enqueue with backoff, then operator-park). Disjoint sub-repos lease different paths and never serialize against each other. The lease prevents clean-room ai-merge worktree collisions; ref correctness is already guaranteed by `advanceIntegrationBranchRef`'s CAS (concurrent-advance → rebuild). diff --git a/.changeset/workspace-phase-a-u1-executor-session-scoping.md b/.changeset/workspace-phase-a-u1-executor-session-scoping.md new file mode 100644 index 0000000000..6fc9911756 --- /dev/null +++ b/.changeset/workspace-phase-a-u1-executor-session-scoping.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase A (U1): executor session scoping. In workspace mode the executor now skips the root worktree acquisition and every rootDir git preflight (base-commit capture, contamination, worktree-liveness), runs the agent session rooted at the browse-only workspace root, and tracks acquired sub-repo worktrees as a per-task set. Single-repo tasks are unchanged (one-element set, byte-for-byte preflight parity). diff --git a/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md b/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md new file mode 100644 index 0000000000..e4a27b33dc --- /dev/null +++ b/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase B, U1): per-repo post-session change capture, contamination detection, and worktree-invariant verification. In workspace mode the executor now loops `task.workspaceWorktrees`, reusing `captureModifiedFiles` per sub-repo (diffing each against its own `baseCommitSha`, with a merge-base fallback when undefined) to aggregate repo-prefixed `task.modifiedFiles` and surface per-repo contamination, and un-stubs `verifyWorktreeInvariants` to assert each acquired worktree's git toplevel and `fusion/` branch. Single-repo behavior is unchanged. diff --git a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md new file mode 100644 index 0000000000..bdb9b95252 --- /dev/null +++ b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase B, U2): per-repo review at both review entry points plus per-repo `fn_task_done` completion + scope-leak verification. In workspace mode both review call sites (the in-session `fn_review_step` tool and the step-inversion review seam) now loop the single-cwd `reviewStep` once per acquired sub-repo (cwd = each repo's worktree) and aggregate the repo-tagged verdicts as a conjunction — the task is reviewed only when every sub-repo approves, and the first failing sub-repo's verdict (with repo-tagged findings) drives the existing verdict→edge mapping. `fn_task_done` now verifies worktree invariants per acquired repo and iterates the scope-leak guard per sub-repo (cwd = repo worktree, repo `baseCommitSha`), blocking completion on any sub-repo carrying off-scope changes and naming the repo. Adds a minimal shared repo-prefix-derivation helper (`workspace-paths.ts`). Single-repo behavior is unchanged. + +Phase-B hardening: the per-repo scope-leak guard now fails CLOSED — a thrown capture/diff error in any sub-repo refuses `fn_task_done` (naming the repo) instead of failing open, and a scoped task that acquired zero sub-repo worktrees is blocked rather than silently passing. A legitimate per-repo `.changeset/` file is no longer falsely flagged off-scope (the always-allowed carve-out now runs against the repo-local path). Per-repo review stops at the first non-APPROVE sub-repo so a later repo's reviewer error can't mask an already-determined REVISE/RETHINK. Per-repo capture failures are isolated (one repo's error no longer drops the whole modified-files write), and the reported offending/failing repo is now deterministic (sorted repo iteration). Single-repo behavior remains unchanged. diff --git a/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md b/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md new file mode 100644 index 0000000000..ec87d1eb94 --- /dev/null +++ b/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md @@ -0,0 +1,12 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase C (U1): per-repo merge loop. Extract `landOneRepo` from the +`runAiMerge` clean-room land closure (single-repo behavior unchanged) and add +`landWorkspaceTask`, which lands each acquired sub-repo's `fusion/` branch onto +that repo's OWN local integration ref (re-resolved per repo with overrides stripped), +land-as-you-go with no remote push. The engine merge dispatch and the user-facing +CLI/dashboard merge doors now route workspace tasks through this loop instead of +throwing; `store.mergeTask`, `aiMergeTask`, and the `runAiMerge` chokepoint keep +throwing `WorkspaceTaskMergeError` as defense-in-depth. diff --git a/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md new file mode 100644 index 0000000000..1f7f837c80 --- /dev/null +++ b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md @@ -0,0 +1,15 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase C (U2): per-repo landed predicate, finalize-once, and idempotent +auto-retry-then-park. `landWorkspaceTask` now records each sub-repo's `landedSha` after +its branch advances that repo's local integration ref, and on a re-run SKIPS any repo +whose recorded `landedSha` is an ancestor of (or equals) its current integration tip — so +an interrupted multi-repo land retries only the un-landed repos and never re-advances an +already-landed ref. When every acquired repo's landed predicate holds, the task moves to +`done` EXACTLY ONCE via the task-global finalize path with an aggregate `mergeDetails` +(representative `commitSha` + a `workspaceLandedShas` map). A partial land (some repos +unlanded) does not move the task done; the engine merge dispatch surfaces it as a +retryable failure that consumes a `mergeRetry` and auto-retries the merge (skipping landed +repos) up to the configured max, then operator-parks the task as failed. diff --git a/.changeset/workspace-phase-d-self-healing.md b/.changeset/workspace-phase-d-self-healing.md new file mode 100644 index 0000000000..1bcdb8fa71 --- /dev/null +++ b/.changeset/workspace-phase-d-self-healing.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase D (U1): workspace-aware self-healing. The existing merging-status reconcilers no longer mis-finalize a partial-landed workspace task (recoverInterruptedMergingTasks now clears the transient `merging` status and re-enqueues the idempotent per-repo land instead of running the single-commit finalize over the non-git workspace root), and recoverMergeableReviewTasks now admits workspace tasks (task.worktree is null). Adds three reconcilers: partial-land recovery (re-enqueue via enqueueMerge, FORK-A unrecoverable → park failed; guarded by autoMerge:false + user-pause + workspace-aware liveness), phantom `workspace-repo-land` lease reclaim (new `entriesByKind` registry seam), and per-repo worktree cleanup from stored paths (no temp walk). New run-audit events: `task:reconcile-workspace-partial-land`(`-no-action`), `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`. + +Phase D P1 TOCTOU fix (merge-queue dispatch blind spot): the workspace partial-land and phantom-land-lease reconcilers now consult a new `ProjectEngine.isMergePending(taskId)` seam (true if the task is in the engine's in-memory `mergeQueue` or `mergeActive`). This closes the dequeue→rawMerge window where a workspace task is being merged but no other liveness signal fires yet (the id is shifted out of `mergeQueue` while `activeMergeTaskId` / `merging` status / the `workspace-repo-land` lease are not yet set inside `landWorkspaceTask`). The partial-land reconciler skips a merge-pending candidate (emitting `task:reconcile-workspace-partial-land-no-action` with reason `merge-pending`) instead of launching a second concurrent `landWorkspaceTask` (double-squash risk, since a same-task land lease is not contention), and lease reclaim leaves a merge-pending owner's not-yet-registered lease alone. Wired via `InProcessRuntime.setMergePendingProvider`; undefined (unwired) is treated as not-pending so existing guards still apply. + +Phase D review hardening: every single-commit-finalize self-healing site is now workspace-gated so a partial-landed workspace task can never be marked fully merged on one repo's commit — `recoverStuckMergeDeadlocks` (the twin of recoverInterruptedMergingTasks), `recoverOrphanOnlyScopeViolations`, `recoverAlreadyMergedReviewTasks`, `recoverBranchMisboundInReviewTasks`, and `recoverDoneTaskMergeMetadata` all skip workspace tasks and defer recovery to the workspace partial-land reconciler. The partial-land reconciler now bounds its `enqueueMerge` re-enqueue (parks `failed` after repeated queue rejections instead of looping forever) and treats a branch-gone-and-not-landed sub-repo as unrecoverable even when a stale unreachable `landedSha` is present. Phantom land-lease reclaim now only reclaims a demonstrably TERMINAL owner (never an `in-progress` executing task that registered its lease early). Orphan per-repo worktree removal failures are now engine-logged and retry-bounded. The canonical `isRepoLanded` predicate moved to a new dependency-free `workspace-land-predicate` module, dissolving the self-healing ↔ merger-ai import cycle (public export preserved). diff --git a/AGENTS.md b/AGENTS.md index 3cc121a95b..3dfc2a2d53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,6 +195,9 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs. - FN-6782/FN-6796: self-healing emits `task:auto-recover-paused-abort-park` when it clears a benign pause-abort operator park, requeueing safe `todo`/`in-progress` rows or preserving a clean auto-merge-eligible `in-review` row for review progression. - FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move. +- Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` when a sub-repo's `fusion/` branch is gone with no `landedSha`), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, or a live sub-repo worktree (workspace-aware liveness) blocks that backward move. +- Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor (a live merging owner is left untouched). +- Workspace (Phase D U1): self-healing emits `task:reconcile-orphaned-workspace-worktree` when it removes a done/dead workspace task's recorded per-repo worktree from its stored `worktreePath` (guarded by `isPathActive`; no temp-root walk). ## Reference docs (deeper detail) diff --git a/CONCEPTS.md b/CONCEPTS.md index 1abe3a8db7..2afdad7e97 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -61,6 +61,10 @@ sub-directories. Fusion discovers sub-repos at init time and records them in single root-level worktree; instead, the agent acquires per-repo worktrees on demand via `fn_acquire_repo_worktree`. +Workspace-task merges are **non-atomic**: each sub-repo lands on its own local +integration ref independently, so a partial-land window (some sub-repos merged, +others not) is possible mid-task — this state is local and operator-resettable. + ### Project Identity The durable identity a registered Project carries locally so it can be reattached to the central registry after central state is lost or rebuilt, preserving rows keyed by the same project id instead of minting a replacement. @@ -260,6 +264,11 @@ A workflow graph node that reads a declared Artifact and runs a registry parser ### Custom task field A workflow-declared, typed task field (`string | text | number | boolean | enum | multi-enum | date | url`, with enum options and render hints) whose values live in `tasks.customFields`, keyed by field id. The task model is thereby recast as core fields (title, description) + standard metadata + these workflow-defined fields. Writes pass through a single store authority (`updateTaskCustomFields`) that validates each value against the resolving workflow's schema and returns typed rejections (offending `fieldId` + `code`); agents write them via `fn_task_update`'s `custom_fields` patch. Editing a workflow's fields or switching a task's workflow orphans (never destroys) values for removed or type-incompatible ids — orphans are retained and surfaced under a detail disclosure, excluded from cards. Same id means the same field within a project; there is no cross-workflow shared field namespace. +### Optional step group +A workflow graph container node (alongside `foreach`/`loop`) whose template subgraph runs once when a task has enabled it and is bypassed otherwise — the graph-native way to make a step optional per task. Enablement is a per-task toggle set seeded from the group's workflow-level default; the group's own node id is the toggle key. It replaces the earlier execution-inert *declaration* model (a separate optional-step list run through a hidden seam), so optional steps are now real, placeable nodes rather than an out-of-graph facet. + +Single pass — no iteration or rework inside the template (this is what distinguishes it from `foreach`/`loop`). Because the toggle key is the node id, renaming or recreating a group resets its per-task enablement; and because that id may deliberately equal a built-in step-template id, the per-task enable set must keep group ids identity-stable rather than round-tripping them through legacy step-template materialization (which would remap the key and silently bypass the group). + ## Persistence & migrations ### Schema-Version Sweep diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index de1e9379d9..0ce4b15586 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -866,6 +866,9 @@ Features: - Start, stop, and restart the current server session - Manage preview URLs with embedded preview and **Open in new tab** fallback - Tail live logs, load older history, and refresh session status +- When Dev Server is hosted in a very narrow right sidebar, open the preview from the compact **Open preview** launcher; the modal keeps preview actions available while configuration and logs stay usable in the sidebar. + + For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.md). diff --git a/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md b/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md new file mode 100644 index 0000000000..03d9c3650d --- /dev/null +++ b/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md @@ -0,0 +1,626 @@ +--- +title: "feat: Optional-group container nodes + add-ons as insertable subgraphs" +status: active +date: 2026-06-21 +type: feat +plan_id: 2026-06-21-002-feat-workflow-optional-group-subgraphs +--- + +# feat: Optional-group container nodes + add-ons as insertable subgraphs + +## Summary + +Today "optional steps" are **execution-inert declarations**: a workflow lists `optionalSteps: +[{ templateId, defaultOn }]`, the create/edit UI seeds a per-task `enabledWorkflowSteps` set, and a +single hidden `workflow-step` seam node runs every enabled step *after* the graph finishes. Nothing about +optionality is visible in the graph, and the steps cannot be placed, ordered, or composed. + +This plan makes optionality **graph-native**. A new `optional-group` container node — modeled on the +existing `foreach`/`loop` container nodes — holds a `template:{ nodes, edges }` subgraph. The graph +executor runs that subgraph **once** when the group is enabled for the task and **passes through +(skips)** it when disabled. Enable state reuses the existing per-task `enabledWorkflowSteps` facet plus a +workflow-level `defaultOn`, keyed by the group. Separately, every prior pre-workflow **add-on** (the seven +`WORKFLOW_STEP_TEMPLATES`: documentation-review, qa-check, security-audit, performance-review, +accessibility-check, browser-verification, frontend-ux-design) becomes **insertable from the editor's +template palette as a node subgraph**, and can be inserted already wrapped in an `optional-group` so an +author can drop in "Security Audit (optional)" in one action. + +Finally, the plan **replaces** the declaration-based system per the confirmed scope decision: the built-in +**coding** and **stepwise-coding** workflows migrate `browser-verification` onto an `optional-group`, and +the now-dead `WorkflowOptionalStep` / `optionalSteps` declaration, `resolveWorkflowOptionalSteps` source, +the `workflow-step` seam node, and its compiler seam-anchor are retired — without breaking the create/edit +toggle surfaces, which re-point to the new source. + +**Plan depth:** Deep. Cross-cutting across core IR + validation, the engine graph executor, per-task +persistence/seeding, the visual node editor, the built-in workflows, and a behavior-affecting removal of +the legacy execution path. + +--- + +## Problem Frame + +The declaration model has three structural limits this plan removes: + +- **Invisible & unplaceable.** `optionalSteps` never appears in the graph + (`packages/core/src/workflow-ir-types.ts:314-339`, marked "Execution-inert; the graph executor ignores + this facet"). All enabled steps run in one lump at the `workflow-step` seam + (`packages/engine/src/executor.ts` `runWorkflowSteps`, gated on `enabledWorkflowSteps`), so an author + cannot put an optional step *between* two graph nodes, order multiple optional steps, or branch on one. +- **Add-ons are flat, not composable.** A `WorkflowStepTemplate` + (`packages/core/src/types.ts:868-899`) is a flat prompt/script config. The seven built-in add-ons appear + in the editor palette today only as **single "Built-in steps"** entries + (`WorkflowNodeEditor.tsx:1002` `stepEntries`), not as subgraphs you can compose or gate. +- **Two ways to express "run this sometimes."** The graph already has real conditional routing + (`shouldTraverseEdge` in `packages/engine/src/workflow-graph-executor.ts:657`, edge `condition` of + `success`/`failure`/`outcome:`) and container nodes (`foreach`/`loop`), yet optionality lives in a + parallel, execution-inert declaration channel. Converging optionality onto the graph removes the split. + +The graph already provides every seam this needs: container nodes compile/execute via a `template` +subgraph (`WorkflowForeachConfig`/`WorkflowLoopConfig`, `workflow-ir-types.ts:129-165`), the executor +dispatches them in `runNodeAndTraverse` (`workflow-graph-executor.ts:431-485`), the editor renders them as +React Flow group nodes with `parentId` children (`workflow-flow-mapping.ts:46-82,316-403,431-550`), and the +palette can already insert multi-node subgraphs via `insertFragment` (`WorkflowNodeEditor.tsx:1425-1430`). +The work is to add one new container kind that branches on a per-task toggle, project the add-on catalog +into the palette as subgraphs, and migrate the built-ins off the legacy path. + +--- + +## Requirements + +- **R1 — Optional-group container kind.** Add an `optional-group` node kind to the IR carrying a + `template:{ nodes, edges }` subgraph, mirroring `WorkflowForeachConfig`. Parse + validate it. +- **R2 — Run-or-bypass execution.** The graph executor runs the group's template **once** when the group + is enabled for the task and **passes through** (skips the subgraph, continues to the group's children) + when disabled. No rework budget; a single pass. +- **R3 — Enable state reuses the per-task facet.** Whether a group runs is driven by the existing per-task + `enabledWorkflowSteps` set plus a workflow-level `defaultOn` on the group, keyed by the group's stable + id. New tasks seed their enabled set from each group's `defaultOn` at creation. +- **R4 — Author optional groups in the node editor.** An author can add an `optional-group` container, + name it, set `defaultOn`, and place nodes inside it — reusing the foreach/loop group UX. The node type is + registered so it renders (not as `react-flow__node-default`). +- **R5 — Every add-on is an insertable subgraph.** All seven `WORKFLOW_STEP_TEMPLATES` add-ons are + insertable from the editor palette as a node subgraph, and offered with an "insert as optional group" + variant that drops the add-on wrapped in an `optional-group` (seeded `defaultOn`). +- **R6 — Built-ins migrated, behavior preserved.** The coding and stepwise-coding built-ins express + `browser-verification` as an `optional-group` (default OFF). A task with it enabled runs the step; a task + with it disabled does not — proven by an execution-level (not traversal-only) test. +- **R7 — Legacy path retired without surface breakage.** The `WorkflowOptionalStep`/`optionalSteps` + declaration, `resolveWorkflowOptionalSteps` as the toggle source, the `workflow-step` seam node, and its + compiler seam-anchor are removed. The create/edit toggle surfaces (inline card, New Task modal, Workflow + tab, steps dropdown) keep working by resolving their toggle list from `optional-group` nodes instead. +- **R8 — Validation invariants hold.** Optional-group templates are validated by walking the subgraph + (children are not in `ir.nodes`): all template nodes reachable, no illegal (non-rework) cycles, no seam + nodes inside a group. Graphs with no optional groups serialize byte-identically (R9 of prior art). +- **R9 — Additive serialization.** A workflow with no optional groups round-trips through the node editor + byte-identically; `optional-group` introduces no new top-level IR keys (it is just a node kind). + +--- + +## High-Level Technical Design + +### Execution: container branches on the per-task toggle + +The new dispatch slots into `runNodeAndTraverse` beside `foreach`/`loop`. Enabled → run the template +sub-walk once (reuse the loop/foreach template-walk machinery, no rework budget); disabled → return success +and traverse the group's children, skipping the body entirely. + +```mermaid +flowchart TD + Prev["upstream node"] --> OG{{"optional-group node\n(id, defaultOn, template)"}} + OG -->|"enabled = task.enabledWorkflowSteps.includes(group.id)"| CHK{enabled?} + CHK -->|yes| RUN["run template sub-walk ONCE\n(template.nodes/edges, single pass)"] + CHK -->|no| SKIP["pass through\noutcome=success, value=bypassed"] + RUN --> CHILD["traverseChildren(OG, result)"] + SKIP --> CHILD + CHILD --> Next["downstream node"] +``` + +Key boundary: the **decision** (run vs skip) is read from per-task state at the trigger seam, exactly like +the existing per-task auto-merge override — so it must be consulted wherever the run is gated, not only in +the executor branch (see Risks R-2). The **body** is an ordinary subgraph the executor already knows how to +walk. + +### Authoring + add-on projection: catalog → palette → graph + +```mermaid +flowchart LR + CAT["WORKFLOW_STEP_TEMPLATES\n(7 add-ons: flat prompt/script config)"] + CAT -->|"project to subgraph entry"| PAL["editor template palette\n(Built-in steps → subgraph entries)"] + PAL -->|"insert as node"| N["prompt/script node\n(add-on config)"] + PAL -->|"insert as optional group"| OGW["optional-group{ template:[ add-on node ], defaultOn }"] + N --> CANVAS["canvas IR"] + OGW --> CANVAS + CANVAS -->|"flowToIr / irToFlow\n(group children via parentId)"| IR["WorkflowIrV2"] +``` + +The add-on→subgraph projection reuses the existing `insertFragment` subgraph-insertion path +(`WorkflowNodeEditor.tsx:1425`), which already remaps ids and rewires internal edges — so "insert as +optional group" is a wrap-then-insert, not a new insertion engine. + +--- + +## Key Technical Decisions + +- **KTD-1 — `optional-group` is a container node mirroring `WorkflowForeachConfig`, not a bypass edge.** + Per the confirmed scope decision, optionality is encapsulated in a container (foreach/loop style) holding + a `template:{ nodes, edges }`, rather than inline nodes plus an explicit bypass edge. This reuses the + entire group-node toolchain (IR config, validation, React Flow `parentId` children, `insertFragment`), + and the "skip" is the container passing through rather than a visible routed edge. + +- **KTD-2 — Enable state reuses `enabledWorkflowSteps`, keyed by the group's stable node id.** No new + persistence. The per-task `tasks.enabledWorkflowSteps` column (`db.ts:324`, + `store.ts:424,2140`) holds the ids of enabled groups; `defaultOn` on the group seeds it at task creation + via the existing materialization path. Keying on the **node id** (stable for built-ins and preserved + across editor round-trips, like foreach/loop ids) means renaming/recreating a group resets its per-task + state — acceptable and identical to today's `templateId` keying. + +- **KTD-3 — Single pass, no rework budget.** Unlike `foreach` (per-step) and `loop` (bounded repeat), an + optional-group runs its template exactly once when enabled. Reuse the loop/foreach template-walk helper + but disable rework/iteration. Rework edges are **forbidden inside** an optional-group template (validation + rejects them) to keep the single-pass guarantee unambiguous. + +- **KTD-4 — Re-point the toggle resolver, don't keep two sources.** `resolveWorkflowOptionalSteps` + (`workflow-optional-steps.ts`) currently maps `ir.optionalSteps` → display metadata for the create/edit + UI. Replace its source with a scan of `optional-group` nodes (id, group name, `defaultOn`), preserving its + output shape (`ResolvedWorkflowOptionalStep[]`) so the inline card, New Task modal, Workflow tab, and + steps dropdown keep consuming it unchanged. This is what lets R7 retire the declaration without breaking + the four toggle surfaces. + +- **KTD-5 — Add-ons stay flat configs; the palette projects them to subgraphs at insert time.** Do **not** + rewrite `WorkflowStepTemplate` into a nodes+edges shape. A template projects to a single `prompt`/`script` + node (carrying its `prompt`/`scriptName`/`toolMode`/`gateMode`/`phase`/model), and the "optional" variant + wraps that node in an `optional-group`. Keeping the catalog flat avoids migrating plugin-contributed + templates and keeps the resolver/seeding logic simple. + +- **KTD-6 — `workflow-step` seam removal is a compiler change, not just an IR edit.** `workflow-step` is a + registered seam anchor in the compiler's canonical pipeline + (`workflow-compiler.ts:45,170` `planning → execute → workflow-step → review → merge`). Retiring it + requires removing it from `SEAM_NAMES`/`expectedSeamOrder` and updating the built-in IRs + their + byte-identity parity oracles together, or the compiler will reject (or mis-order) the migrated graphs. + +--- + +## Implementation Units + +Grouped into three phases: **A — core/engine** (the construct runs), **B — editor** (authoring + add-on +palette), **C — migration/cleanup** (built-ins on the new model, legacy path retired). + +### Phase A — Core construct + execution + +### U1. `optional-group` IR type, parse, and validation + +**Goal:** Introduce the `optional-group` node kind and its `template` config, and validate it by walking +the subgraph (R1, R8). + +**Requirements:** R1, R8, R9 + +**Dependencies:** none + +**Files:** +- `packages/core/src/workflow-ir-types.ts` (modify — add `"optional-group"` to `WorkflowIrNodeKind`; add + `WorkflowOptionalGroupConfig { defaultOn?: boolean; template: { nodes; edges } }` mirroring + `WorkflowForeachConfig` at `:129`) +- `packages/core/src/workflow-ir.ts` (modify — `validateV2` calls a new `validateOptionalGroup(node, ...)`; + extend cycle/reachability/seam walks to descend into the template subgraph) +- `packages/core/src/__tests__/workflow-ir.test.ts` (modify/create — validation cases) + +**Approach:** +- Add the kind + config type. The group's stable enable key is its node `id` (KTD-2); `defaultOn` lives on + the node `config`. +- In validation, treat the template like a foreach template: its nodes are **not** in `ir.nodes`, so every + reader/validator must walk the subgraph explicitly (learning: per-entity blast-radius `:37`). Validate: + all template nodes reachable from the template's entry; endpoints reference template-local nodes; **no + rework edges** inside (KTD-3); **no seam nodes** inside (mirror `validateParallelism`'s seam-in-branch + rule, `workflow-ir.ts:190+`). +- `parseWorkflowIr` descends into optional-group templates the same way it clamps foreach/loop configs. + +**Patterns to follow:** `WorkflowForeachConfig` type + `validateV2`/foreach template validation in +`workflow-ir.ts`; the seam-in-branch check in `validateParallelism`. + +**Test scenarios:** +- A v2 IR with one `optional-group` (valid template) parses and validates. +- A template referencing an undefined template-local node throws `WorkflowIrError`. +- A rework edge inside an optional-group template is rejected. +- A seam node (e.g. `merge-gate`) inside an optional-group template is rejected. +- An unreachable template node is rejected. +- A graph with **no** optional-group serializes/parses byte-identically (R9). + +**Verification:** `pnpm --filter @fusion/core test workflow-ir` green; no change to graphs without optional +groups. + +--- + +### U2. Executor: run-once-or-bypass dispatch for `optional-group` + +**Goal:** Make the graph executor run an enabled group's template once and pass through a disabled group +(R2), reading enable state from per-task `enabledWorkflowSteps` (R3). + +**Requirements:** R2, R3 + +**Dependencies:** U1 + +**Files:** +- `packages/engine/src/workflow-graph-executor.ts` (modify — add an `optional-group` branch in + `runNodeAndTraverse` at `:389-485`, beside `foreach`/`loop`) +- `packages/engine/src/workflow-graph-loop.ts` or a shared helper (modify/extract — reuse the + single-template-walk without iteration/rework for the enabled path) +- `packages/engine/src/__tests__/workflow-graph-optional-group.test.ts` (create — execution-level tests) + +**Approach:** +- Resolve `enabled = currentTask.enabledWorkflowSteps?.includes(node.id) ?? false`. **Read the freshest + task state** at the seam (the executor already re-reads the task in `runWorkflowSteps`); ensure + `enabledWorkflowSteps` is in whatever projection the gate reads (learning: per-task override slim-SELECT + trap `:105`). +- Enabled → walk `node.config.template` once via the extracted helper, threading the same + `runTemplateNode`/`shouldTraverseEdge` deps the foreach/loop handlers pass; collect `visitedNodeIds`; + set `context[node:id:outcome]`. Disabled → `return await traverseChildren(node, { outcome: "success", + value: "optional-group-bypassed" })`. +- Namespaced child ids: reuse the foreach instance-id scheme defensively — parse candidate-style and + validate the template node exists, not just the container (learning `:56`). + +**Execution note:** Start with the failing **two-task divergence** execution test (below) — it is the +contract that guards the dead-toggle failure mode. + +**Test scenarios:** +- **Two-task divergence (critical):** two tasks identical except `enabledWorkflowSteps` — the one including + the group id records the template's node execution; the sibling records none and reaches the same + downstream node. (Shape from per-task-auto-merge learning `:106`.) +- Enabled group runs its template exactly **once** (not per-step, not looped) even when the graph has a + foreach elsewhere. +- Disabled group is byte-inert: downstream context/outcome identical to a graph with the group removed + (kill-switch inertness, learning `:55`). +- A template node failure inside an enabled group surfaces as the group's outcome and routes the group's + `failure`/`outcome:` edges. + +**Verification:** new engine test green; existing graph-executor tests unaffected. + +--- + +### U3. Per-task enable resolution from optional-group nodes + `defaultOn` seeding + +**Goal:** Re-point the per-task toggle source from `ir.optionalSteps` to `optional-group` nodes and seed +new tasks' enabled set from each group's `defaultOn` (R3, R7-prep). + +**Requirements:** R3, R7 + +**Dependencies:** U1 + +**Files:** +- `packages/core/src/workflow-optional-steps.ts` (modify — `resolveWorkflowOptionalSteps` scans + `optional-group` nodes instead of `ir.optionalSteps`, preserving `ResolvedWorkflowOptionalStep[]` output) +- `packages/core/src/` task-creation/materialization path that seeds `enabledWorkflowSteps` from `defaultOn` + (modify — seed from optional-group `defaultOn`; today's `materializeDefaultWorkflowSteps`, see + `types.ts:2668-2675`) +- `packages/core/src/__tests__/workflow-optional-steps.test.ts` (modify — group-sourced resolution) + +**Approach:** +- Resolver: walk `ir` (v2) nodes, collect `optional-group` nodes → `{ id, name (from config), defaultOn }`. + Keep output shape identical so the four UI surfaces (inline card, modal, Workflow tab, dropdown) need no + change beyond what U7/U-editor already covers. Unknown/stale ids in `enabledWorkflowSteps` are ignored + (defensive, as today). +- Seeding: at task creation, the enabled set is the ids of optional-group nodes whose effective + `defaultOn` is true — mirroring the prior `optionalStep.defaultOn ?? false` precedence. +- Grep every reader of `enabledWorkflowSteps`/`optionalSteps` and confirm each now resolves from groups + (learning: consult the override at every trigger seam `:100,:102`). + +**Test scenarios:** +- `resolveWorkflowOptionalSteps` over a workflow with two optional-group nodes returns both, with names and + `defaultOn` from node config. +- A new task created against that workflow seeds `enabledWorkflowSteps` to exactly the `defaultOn: true` + group ids. +- A workflow with no optional-group nodes resolves to `[]` and seeds an empty set. +- Stale id in `enabledWorkflowSteps` (group since removed) does not crash resolution or execution. + +**Verification:** core optional-steps tests green; creation seeding covered. + +--- + +### Phase B — Editor authoring + add-on palette + +### U4. Render & author the `optional-group` container in the node editor + +**Goal:** Let an author add, name, configure (`defaultOn`), and fill an `optional-group` container, +reusing the foreach/loop group UX, with the node type registered so it renders (R4). + +**Requirements:** R4 + +**Dependencies:** U1 (kind exists) + +**Files:** +- `packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx` (modify — add `optional-group` to the + node-type registry + icon, render as a group container like `foreach`/`loop`) +- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — treat `optional-group` as a group + kind everywhere foreach/loop are special-cased: `groupTemplateConfigOf` `:266`, group child + reassembly `:441-480`, intra-template edge handling `:517`, group delete `:611,:636`, + condition-editability `:663`) +- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — inspector controls: group name + + `defaultOn` toggle; help entry) +- `packages/dashboard/app/components/nodes/node-help.ts` (modify — add an `optional-group` help entry) +- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (modify — round-trip group + children) +- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — add/name/toggle/fill) + +**Approach:** +- Mirror the `foreach`/`loop` group node: a React Flow `type: "group"` with `parentId` children using the + existing `foreachChildFlowId` namespacing (`workflow-flow-mapping.ts:75`). `irToFlow` renders the + template as children; `flowToIr` reassembles it (the `:454,:480` kind checks gain `optional-group`). +- Inspector: a `defaultOn` checkbox (labeled, focus ring) and the group name; the body is authored by + dropping nodes inside, identical to foreach. +- **Register the node type** in `WorkflowNodeTypes.tsx` — an unregistered kind renders as + `react-flow__node-default` with missing children (learning: worktree bundle `:24`). Verify against a + fresh `FUSION_CLIENT_DIR` bundle, non-4040 port. + +**Test scenarios:** +- Adding an optional-group, dropping a prompt node inside, and saving yields IR with an `optional-group` + node whose `template.nodes` contains the inner node (round-trip). +- Toggling `defaultOn` marks the editor dirty and persists on save. +- Deleting the group removes its `parentId` children (no orphans) — mirrors the foreach delete test. +- The node renders with its registered type (not `react-flow__node-default`) — asserted via node-type + registry presence. + +**Verification:** mapping + editor tests green; real-browser check that the container renders, accepts +child nodes, and the `defaultOn` toggle works (fresh worktree bundle; verify on a mobile viewport per +Risks R-4). + +--- + +### U5. Add-ons as insertable subgraphs (plus "insert as optional group") + +**Goal:** Make all seven `WORKFLOW_STEP_TEMPLATES` add-ons insertable from the palette as node subgraphs, +each also offerable wrapped in an `optional-group` (R5). + +**Requirements:** R5, KTD-5 + +**Dependencies:** U4 (optional-group authoring exists) + +**Files:** +- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — the "Built-in steps" palette + section `:1002,:2741`, the existing `stepTemplateToNode()` projector `:249-275`, and the + `handleInsertStepTemplate`/`handleInsertFragment` handlers `:1425-1453`: add an "insert as optional + group" variant per add-on) +- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — `insertFragment` `:1110-1219` + already expands subgraphs incl. group `parentId` children; add the wrap-in-`optional-group` helper that + builds the fragment IR from a projected add-on node) +- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — insert-as-node and + insert-as-optional-group for an add-on) + +**Approach:** +- Project each add-on to a node using the **existing `stepTemplateToNode()`** (`WorkflowNodeEditor.tsx: + 249-275`), which already maps a `WorkflowStepTemplate` → a `prompt`/`script` node carrying its + `prompt`/`scriptName`/`toolMode`/`gateMode`/model (KTD-5). "Insert as node" is today's behavior. +- "Insert as optional group" wraps that projected node in an `optional-group{ template:{ nodes:[node], + edges:[] }, defaultOn }` (seeded from the template's `defaultOn`) and inserts it through the existing + `insertFragment` path (`workflow-flow-mapping.ts:1110-1219`), which already remaps ids, rewires internal + edges, and expands group `parentId` children — so no new insertion engine is needed. +- Surface both variants in the palette's existing "Built-in steps" group; keep `data-testid` conventions. + +**Test scenarios:** +- Each of the seven add-ons appears in the palette and inserts a node carrying its template config. +- "Insert as optional group" for `security-audit` yields an `optional-group` whose `template` holds the + security-audit node and whose `defaultOn` matches the template default. +- Inserting an add-on subgraph remaps ids so two insertions of the same add-on do not collide. +- A plugin-contributed template (if present) still inserts as a node (catalog stays flat, KTD-5). + +**Verification:** editor tests green; real-browser insert of an add-on and an optional-group-wrapped add-on, +then save → reopen round-trip. + +--- + +### Phase C — Migrate built-ins, retire the legacy path + +### U6. Migrate coding + stepwise-coding built-ins to `optional-group` browser-verification + +**Goal:** Express `browser-verification` as an `optional-group` (default OFF) in both built-ins, preserving +runtime behavior, and update parity oracles (R6). + +**Requirements:** R6, R8 + +**Dependencies:** U2, U3 (execution + seeding), U1 (kind) + +**Files:** +- `packages/core/src/builtin-coding-workflow-ir.ts` (modify — replace `optionalSteps:[{templateId: + "browser-verification"}]` `:123` + the `workflow-step` seam node `:69` with a `browser-verification` + `optional-group` on the pre-merge path) +- `packages/core/src/builtin-stepwise-coding-workflow-ir.ts` (modify — add the `browser-verification` + optional-group on the pre-merge path; stepwise had no `workflow-step` seam at all) +- `packages/core/src/__tests__/` built-in IR snapshot/parity fixtures (modify — update byte-identity + oracles deliberately) +- `packages/engine/src/__tests__/` stepwise/coding execution parity test (modify — enabled-runs/disabled- + skips at the built-in level) + +**Approach:** +- Place the optional-group on the success path where `workflow-step` sat (pre-merge, after execute/steps, + before review), so an enabled task runs browser-verification pre-merge exactly as before. +- The stepwise IR is a documented byte-identity parity oracle — adding the construct shifts its snapshot; + update the fixture deliberately and confirm the group runs **once** post-foreach, not per step-instance + (learning `:55`, prior plan R-5). + +**Test scenarios:** +- `resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)` returns one `browser-verification` group, + `defaultOn: false`; same for stepwise. +- Execution: a coding task with the group enabled runs browser-verification pre-merge; disabled does not + (two-task divergence at the built-in level). +- Stepwise: same divergence; the group runs once after the foreach completes. +- Both built-ins parse and pass `validateV2`. + +**Verification:** core + engine built-in tests green; parity oracles updated and passing. + +--- + +### U7. Retire the declaration-based optional-steps path + +**Goal:** Remove the now-dead `WorkflowOptionalStep`/`optionalSteps` declaration, the `workflow-step` seam +node + handler, and its compiler seam-anchor, keeping the four toggle surfaces working via U3's resolver +(R7). + +**Requirements:** R7 + +**Dependencies:** U3 (resolver re-pointed), U6 (built-ins migrated — nothing still declares `optionalSteps`) + +**Files:** +- `packages/core/src/workflow-ir-types.ts` (modify — remove `WorkflowOptionalStep` + `WorkflowIrV2. + optionalSteps`) +- `packages/core/src/workflow-compiler.ts` (modify — remove `workflow-step` from `SEAM_NAMES` `:45` and + `expectedSeamOrder` `:170`) +- `packages/engine/src/executor.ts` (modify — remove `runWorkflowSteps` + the `workflow-step` seam + dispatch, now unreachable) +- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — drop `optionalSteps` threading in + `flowToIr`/`optionalStepsOf` if present from the prior plan) +- `packages/core/src/__tests__/`, `packages/engine/src/__tests__/` (modify — delete/replace tests asserting + the legacy path; keep the resolver/seeding tests now backed by groups) + +**Approach:** +- This is a **behavior-affecting removal** — follow the codebase's Surface Enumeration discipline (see the + section below). Enumerate every reader of `optionalSteps`/`workflow-step`/`runWorkflowSteps` and confirm + each is migrated or removed; do not leave a mock-masked dead path (learning: branch-group dead-wiring). +- Removing the seam anchor changes the compiler's accepted pipeline — confirm no remaining built-in or + fragment references `workflow-step`, then drop it from both anchor lists together with the built-in edits + from U6. + +**Test scenarios:** +- Grep proves zero remaining references to `optionalSteps`, `WorkflowOptionalStep`, `workflow-step` seam, + and `runWorkflowSteps` in non-test source. +- The compiler accepts the migrated built-ins with `workflow-step` removed from the seam order. +- The four toggle surfaces (inline card, New Task modal, Workflow tab, steps dropdown) still render and + submit `enabledWorkflowSteps` — now sourced from optional-group nodes (regression). +- A pre-existing workflow JSON that still carries `optionalSteps` (legacy persisted) does not crash parse — + the key is ignored, not fatal (back-compat decision: tolerate-and-drop). *(Confirm this stance in review; + alternative is a one-time parse upgrade.)* + +**Verification:** full core + engine + dashboard suites green; `pnpm lint`, `pnpm typecheck`, `pnpm build`, +`pnpm test:gate` pass. + +--- + +## Surface Enumeration + +Behavior-affecting change (new execution construct + removal of the legacy path) and a UI-affordance change +(new node kind + retired toggle source), so per AGENTS.md ("Fix the Invariant, Not the Repro", FN-5893) the +surfaces are enumerated: + +- **Workflow providers/graphs:** built-in **coding** and **stepwise-coding** (both migrated, U6); any + fragment or user workflow that declared `optionalSteps` (tolerated-and-dropped, U7). +- **Execution states:** group **enabled** (runs once), **disabled** (passes through), **stale id** in + `enabledWorkflowSteps` (ignored), **template failure** (routes group failure edge). +- **Editor breakpoints:** desktop and **mobile** node editor — container render, child placement, + `defaultOn` toggle, palette insert (both variants). +- **Per-task toggle surfaces:** inline quick-create card, New Task modal, task-detail Workflow tab, steps + dropdown — all re-sourced from optional-group nodes (U3/U7). +- **Validation:** subgraph walked (not just `ir.nodes`); no rework/seam inside a group; graphs without + optional groups byte-identical. +- **Compiler:** seam-anchor list with `workflow-step` removed; canonical pipeline still valid for migrated + built-ins. + +## Symptom Verification + +- **Original symptom:** optionality is invisible and unplaceable — enabled steps run in one lump at a + hidden seam, and add-ons cannot be composed or gated in the graph. +- **Exact reproduction:** build/inspect the coding workflow; `browser-verification` appears only as an + `optionalSteps` declaration, runs at the `workflow-step` seam, and is absent from the graph; add-ons + appear in the palette only as flat single steps. +- **Assertion it is gone:** an enabled optional-group runs its placed template once at its graph position + and a disabled one is inert (two-task divergence execution test, U2/U6); every add-on inserts as a + node/optional-group subgraph (U5); no `workflow-step`/`optionalSteps` path remains (U7 greps). + +--- + +## Scope Boundaries + +**In scope:** +- `optional-group` IR kind + validation (U1), executor run/bypass (U2), per-task resolution + seeding (U3). +- Editor container authoring + node-type registration (U4); add-ons as insertable subgraphs incl. + optional-group wrapping (U5). +- Built-in coding + stepwise migration (U6); retiring the declaration/seam/compiler-anchor path (U7). + +**Already built (reuse, not rebuilt):** +- Container-node toolchain: `foreach`/`loop` config, group rendering, `parentId` children, + `insertFragment` subgraph insertion. +- Per-task `enabledWorkflowSteps` column, create-surface toggles, the four toggle UIs, and + `resolveWorkflowOptionalSteps`'s output shape (source re-pointed in U3). +- Step→node projection (`workflow-steps-to-ir.ts`) reused to project add-ons (U5). + +### Delivered cohort (this PR) vs. Deferred +This PR delivers **U1–U6 plus U7a** (10 commits). U7a retired the legacy declaration *model*: the core +`WorkflowOptionalStep` type + `WorkflowIrV2.optionalSteps` field + `validateOptionalSteps`, and the editor's +declaration **authoring** surface (`WorkflowOptionalStepsPanel`, `optionalStepsOf`, the `flowToIr` +`optionalSteps` threading). A code-review pass also fixed a P1 (the optional-group toggle-id collision in +enable resolution) — captured in the commit history and in +`docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md`. The per-task toggle +surfaces (`WorkflowOptionalStepsDropdown`, inline card, modal, Workflow tab) stayed — they consume the +distinct `ResolvedWorkflowOptionalStep`. + +- **Deferred: the `workflow-step` seam infrastructure removal.** What remains of "full U7" is excising the + `workflow-step` seam itself — a shared `WorkflowSeam` union member woven through ~9 engine runtime files + (`runtime-primitives`, `step-session-executor`, `workflow-node-handlers`, `active-session-registry`, + `workflow-graph-task-runner`, `executor.runWorkflowSteps`, the compiler seam-anchor). It is now orphaned + (no built-in graph reaches it) but inert; excising it is its own focused refactor with its own blast radius. +- **Nested/conditional groups** (an optional-group inside a split/foreach, or gated by a workflow field + rather than the per-task toggle) — single-level, per-task-toggle only for now. +- **Plugin-contributed add-ons as optional-group presets** beyond inserting them as flat nodes. +- The prior plan's deferred **unified per-task workflow-facet override** abstraction (optional steps + + auto-merge + column-agent overrides) — strong `/ce-compound` candidate once this lands. + +**Out of scope:** +- New persistence/migrations. Reuses `tasks.enabledWorkflowSteps`; `optional-group` is just a node kind. +- Rewriting `WorkflowStepTemplate` into a nodes+edges shape (KTD-5 keeps it flat). +- Changing unrelated execution (merge lifecycle, branch groups, PR nodes). + +--- + +## Risks & Dependencies + +- **R-1 — Subgraph-walking validation/readers miss template children.** Optional-group template nodes are + not in `ir.nodes`; any validator, reachability pass, or id parser that only sees top-level nodes will be + wrong. *Mitigation:* walk the subgraph explicitly and parse namespaced child ids candidate-style, + validating the template node exists, not just the container. + (`docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md:37,:56`) +- **R-2 — Per-task enable consulted only in the executor branch.** If skip-vs-run is read only where the + group executes and not at every gate/trigger that decides whether to run it, the toggle silently no-ops; + a slim SELECT omitting `enabledWorkflowSteps` reads `undefined`. *Mitigation:* grep every gate; ensure the + column is selected; ship the two-task divergence test. + (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md:100,:102,:105,:106`) +- **R-3 — Seam-anchor / parity-oracle drift on built-in migration.** `workflow-step` is a compiler seam + anchor and the stepwise IR is a byte-identity oracle; removing the seam and adding the construct shifts + snapshots and can break compile-order validation. *Mitigation:* edit built-ins, seam-anchor lists, and + parity fixtures together (KTD-6); confirm the group runs once post-foreach. + (`docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md:117`; prior plan R-5) +- **R-4 — Editor node-type registration + mobile.** An unregistered `optional-group` renders as + `react-flow__node-default` with missing children, looking like a source bug; per-task toggle controls have + a real-browser-only mobile failure history. *Mitigation:* register the node type and verify against a + fresh `FUSION_CLIENT_DIR` worktree bundle on a non-4040 port; real-browser-check the `defaultOn` toggle on + a mobile viewport. + (`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md:24,:44`; + `docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md:32,:53`) +- **R-5 — Mock-masked dead wiring on removal.** Retiring the legacy path risks a green suite over a feature + whose new path is never actually exercised (the branch-group failure class). *Mitigation:* execution-level + (not traversal-only) tests for enabled-runs/disabled-skips at both the construct and built-in levels; + grep-prove the old path is gone. + (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`) + +--- + +## Sources & Research + +- **Execution seam:** `packages/engine/src/workflow-graph-executor.ts:389-485` (`runNodeAndTraverse` + foreach/loop dispatch), `:657` (`shouldTraverseEdge`); `workflow-graph-foreach.ts`, + `workflow-graph-loop.ts` (template sub-walk to reuse). +- **IR + validation:** `packages/core/src/workflow-ir-types.ts:41-165` (node/edge/container types), + `:314-339` (legacy `optionalSteps`); `packages/core/src/workflow-ir.ts:190+` (seam-in-branch), + `:1218-1298` (`validateV2`, cycles/endpoints), `:1327` (`parseWorkflowIr`). +- **Per-task facet:** `packages/core/src/types.ts:2421,2663-2680` (`enabledWorkflowSteps`, `workflowId` + precedence), `db.ts:324`, `store.ts:424,2140`; `workflow-optional-steps.ts` (resolver to re-point); + `executor.ts` `runWorkflowSteps` (seam consumer to retire). +- **Add-on catalog:** `packages/core/src/types.ts:868-899` (`WorkflowStepTemplate`), `:902-1150` + (seven `WORKFLOW_STEP_TEMPLATES`: documentation-review, qa-check, security-audit, performance-review, + accessibility-check, browser-verification, frontend-ux-design); `WorkflowNodeEditor.tsx:249-275` + (`stepTemplateToNode` add-on→node projector to reuse). +- **Editor:** `packages/dashboard/app/components/workflow-flow-mapping.ts:46-82,266-269,316-403,431-550` + (group children, `groupTemplateConfigOf`), `:1110-1219` (`insertFragment` subgraph insertion); + `WorkflowNodeEditor.tsx:985-1010` (palette: fragments/steps/plugins, sourced from + `/api/workflow-step-templates`), `:1425-1453` (insert handlers); `nodes/WorkflowNodeTypes.tsx` (node-type + registry to extend). +- **Compiler:** `packages/core/src/workflow-compiler.ts:45,165-196` (seam anchors + canonical order). +- **Built-ins:** `packages/core/src/builtin-coding-workflow-ir.ts:69,123`, + `builtin-stepwise-coding-workflow-ir.ts:132`. +- **Prior art:** `docs/plans/2026-06-20-001-feat-workflow-optional-steps-node-editor-modal-plan.md` (the + declaration-based system this replaces); institutional learnings cited inline under Risks. diff --git a/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md b/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md new file mode 100644 index 0000000000..c5b50e1d03 --- /dev/null +++ b/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md @@ -0,0 +1,589 @@ +--- +title: "feat: Workspace mode execution model — make multi-repo tasks run end-to-end" +status: active +date: 2026-06-21 +deepened: 2026-06-21 +decided: 2026-06-21 +type: feat +origin: none (solo planning from PR #1710) +pr: https://github.com/Runfusion/Fusion/pull/1710 +branch: pr-1710 (feat/workspace-multi-repo) +depth: deep +--- + +# feat: Workspace mode execution model — make multi-repo tasks run end-to-end + +## Summary + +PR #1710 lays a clean, additive foundation for **workspace mode**: a Project whose `rootDir` is a non-git parent directory containing multiple git sub-repos. The foundation adds a separate `task.workspaceWorktrees` field, an `fn_acquire_repo_worktree` agent tool, `acquireWorkspaceRepoWorktree()`, workspace config detection, and a validation bypass — without mutating any existing single-worktree invariant. + +This plan covers the **deeper execution-model work** the PR deferred: making one task that spans multiple sub-repos run end-to-end through acquisition → capture → review → merge → self-healing. Architecture (user-confirmed): **one task spans repos** — a single task/session holds N per-repo worktrees in `task.workspaceWorktrees`, the merger merges each sub-repo's branch into that repo's own integration branch, and completion is a branch-anchored **conjunction** across all worktrees. + +**Decisions settled this session** (see Decisions Made; previously the open forks): +- **Merger unification (U0, lands first):** `aiMergeTask` is soft-deprecated; `runAiMerge` (the FN-5633 clean-room path, already the default) becomes the **sole** merge path. Workspace mode is built on `runAiMerge` only — no dual-path branching. +- **Merge atomicity = land-as-you-go (local integration ref)** + an unconditional operator revert/force-complete escape hatch. Each repo's clean-room **advances that repo's local integration branch ref via `update-ref` CAS** as it passes — `runAiMerge` does **not** push to any remote (verified: `merger-ai.ts:817/847`; the only `git push` is the separate PR-mode path). Remote push is a separate existing mechanism (PR flow / pull-integration-worktree), **out of scope** here — workspace mode matches `runAiMerge`'s local-ref behavior. Consequence: a partial land is a transient **local** integration-state window, operator-resettable with a clean local reset (not a compensate-forward remote revert). Two-phase was rejected (see KTD8). +- **Scope = full N>1 end-to-end** in one plan. + +**The dominant engineering theme that survived review:** the single-worktree assumption is `cwd: rootDir`-bound git execution threaded through the most invariant-dense code in the repo — now concentrated, post-unification, in `runAiMerge`'s clean-room model (`merger-ai.ts`), the self-healing reconcilers, and `store.mergeTask`. A missed site silently strands or loses work — a documented incident class (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`). The hardest piece is reworking `runAiMerge`'s single-terminal clean-room pipeline into a per-repo loop (U6). + +**Scope out:** the brand `kb→fn` rename; a full dashboard workspace-registration UI (a minimal "doesn't look broken" floor is in — U10); `branchContext`/shared-branch-group reuse (a distinct axis from branch groups). + +--- + +## Problem Frame + +Today every task carries exactly one `(rootDir, branch, worktree)` triple, the lifecycle runs git in `rootDir`, and merge dispatches between two functions: + +- **Executor** acquires one worktree at `executor.ts:~7430` (`acquireTaskWorktree({ rootDir })`), binds the agent session cwd to it, then captures base-commit SHA, modified files, contamination base, identity-guard hooks, review, and `verifyWorktreeInvariants` against that single path. The foundation's workspace guard at `executor.ts:7414-7418` only suppresses the `isGitRepository` *error message* — the acquisition itself is **not yet gated** on `pr-1710`, so the first workspace task crashes there today. +- **Merger** dispatches at `project-engine.ts:2280-2282` (`mergerMode === "ai" ? runAiMerge(...) : aiMergeTask(...)`). `"ai"` is the **FN-5633 default**; the code labels `aiMergeTask` the **"legacy pipeline."** `runAiMerge` (`merger-ai.ts`) does a **clean-room temp worktree** (prefix `fusion-ai-merge--`), AI merge + AI review, then a single terminal `finalizeMerged → finalizeTask → store.moveTask(taskId,'done')` (`merger-ai.ts:1174/1194/1285/1384`). It already has an `{ empty: true }` finalize path (`:1174`) for empty squashes. +- **`store.mergeTask` is a *third merge path*, not just cleanup** (corrected, round 3): `store.mergeTask` (`store.ts:11150`, called from `executor.ts:1742` `finalizeAlreadyInReviewTask` + `self-healing.ts:5830` the no-`enqueueMerge`-queue UI-only fallback) does a full `git checkout ` / `git merge --squash` / `git commit` (`store.ts:11256-11266`) **and then** `git worktree remove` / `git branch -d` — all via `runGitCommand` pinned to rootDir, keyed on singular `task.worktree`/`task.branch`. For a workspace task it would `git checkout` against the non-git root and fail. It is **not** unified by U0's dispatch change, so it must be made workspace-aware or gated (U6). +- **Self-healing** reconcilers read scalar `task.worktree`/`task.branch` and run `git for-each-ref`/`git show-ref` against `rootDir`; the in-review rebind *deliberately skips* ambiguous multi-branch candidates and dedups by resolved SHA within one rootDir. +- **Scheduler** file-scope leases key on `taskId` alone; the worktree pool is a recycle cache (`recycleWorktrees`-gated), **not** a cross-task lock. + +In workspace mode `rootDir` is a **non-git** parent, so none of this works as-is. This plan (a) unifies merge onto `runAiMerge` (U0), then (b) re-targets the lifecycle from "one worktree, git in rootDir" to "N per-repo worktrees, git in each `repoAbsPath`," preserving every existing single-repo task's behavior. + +--- + +## Key Technical Decisions + +### KTD0 — `runAiMerge` is the sole merge path (U0, lands before all workspace work) +Soft-deprecate `aiMergeTask`: collapse the `project-engine.ts:2280-2282` dispatch to always-`runAiMerge`, mark `aiMergeTask` + its now-dead helpers `@deprecated` (body retained, deleted in a later pass), and retire/alias the `settings.merger.mode` setting. Low blast radius — `"ai"` is already the default, so default-config projects are unaffected; only projects explicitly on the (effectively unused) `"deterministic"` mode change behavior. Workspace mode then targets one canonical merge function — no dual-path forks, no "keep the legacy path working" regression burden. + +### KTD1 — Session cwd = browse-only workspace root; skip root acquisition (resolved; not yet implemented on pr-1710) +In workspace mode the main `acquireTaskWorktree({ rootDir })` (`executor.ts:~7430`) is **skipped** (cannot run against a non-git dir). Session cwd becomes the workspace root for browsing; **all edits happen inside per-repo worktrees** via `fn_acquire_repo_worktree`. On `pr-1710` the acquisition and the preflights between the workspace guard (`:7414`) and session create (`~:8443`) are **not yet gated** — U1 must gate the acquisition *and* every intervening preflight (identity guard, `resolveContaminationBaseRef` `:7536`, base-commit capture `:7525`, `verifyWorktreeInvariants`). Direction resolved; the gating is real work. + +### KTD2 — One task spans repos; merge-boundary coherence is session-time only (accepted) +A single task/session holds N per-repo worktrees. Merge runs **per repo** — each sub-repo's `fusion/` branch lands into *that repo's own* local integration branch ref — and completion is the **conjunction**. Coherence is **session-time only**: repos land independently (land-as-you-go), so a task can briefly have repo A landed on its local integration ref while repo B is still in-flight. **This is accepted** (KTD8/D3 decision): a transient incoherent window in **local** integration state, resettable via the operator escape hatch, is acceptable for this tool. Because the merge advances a local ref (not a remote push — KTD8), the window is local-only: no shared remote is mutated until the separate, out-of-scope push step runs, so other developers don't pull a half-applied change from the merge itself. The rejected alternative (parent + per-repo child tasks) is in Alternatives Considered. + +### KTD3 — Per-repo `baseCommitSha`, captured at each acquisition against that repo's resolved integration branch +Per `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`, base/fork-point must be measured against the **local** integration branch first (`merge-base HEAD || origin/`). `resolveCapturedBaseCommitSha` (`base-commit-capture.ts:26-55`) **hardcodes `main`** and takes no branch param — U2 must extend it to accept the per-repo integration branch from `resolveIntegrationBranch(repoAbsPath, settings)`, or any sub-repo whose integration branch is not `main` re-introduces the diff-inflation bug R3 guards against. Stored as `workspaceWorktrees[repo].baseCommitSha`; the singular `task.baseCommitSha` is unused in workspace mode. + +### KTD4 — Shared `@fusion/engine` "landed" predicate + `merged`-flag integrity +A branch-anchored conjunction predicate (`isWorkspaceTaskLanded(task)`) in a shared `@fusion/engine` helper (`packages/engine/src/workspace-completion.ts`), imported by route logic, merger, and self-healing — **not** in published `@fusion/core` (no non-engine caller today). Per `docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`, a column-only / any-one-branch check is the data-loss hazard; the predicate verifies *each* repo's merge landed on *that repo's* integration branch and reads stored row data only — never a re-derived string. **`merged`-flag integrity:** the operator `revert-landed-repo`/`force-complete` must clear `merged=false` + `mergeTargetBranch` **in the same atomic op** as the revert (or the flag drifts and the task reports landed forever); U6's crash re-entry skip relies **only** on the persisted per-repo flag (no live "landing evidence" re-derivation — that would contradict the row-only rule). The flag is honest at write time (set on land, cleared on revert), not by re-checking the tip at read time. + +### KTD5 — File scope declared with repo-prefixed paths; per-repo filtering strips the prefix; leases skip cross-repo at compare time +Workspace tasks declare `## File Scope` with workspace-relative prefixed paths (`wolf-server/src/**`). The repo prefix is derived from the first path segment matching a configured repo, **after canonicalizing** (strip leading/trailing slashes, resolve `.`); a non-matching first segment routes to an explicit `unscoped` fallback (logged, never silently no-leased). Consequences: +- **Squash overlap (U6):** `assertSquashOverlapsFileScope` reads staged paths via `git diff --cached --name-only` with cwd = the sub-repo, so they are **repo-relative** (`src/foo.ts`). Per-repo filtering must both *select* the repo's scope entries **and strip the repo prefix** (`wolf-server/src/**` → `src/**`) or every per-repo merge throws `FileScopeViolationError` (verified against `merger.ts:4935-5099`). +- **Leases (U7):** keep `activeScopes` as `Map` (no map-shape refactor); skip comparison at *overlap-check time* when two entries derive to different repo prefixes. Lease lifecycle (set/clear) untouched for existing tasks. + +### KTD6 — Per-repo identity-guard hooks, init/setup at acquisition; same-sub-repo exclusivity is the lease's job (not the pool) +`installTaskWorktreeIdentityGuard` and configured init/setup install/run **in each sub-repo worktree** at acquisition. The foundation already passes `runInitCommand: true`; U2 adds identity-guard install + per-repo base-commit capture. **Same-sub-repo concurrency:** the first draft's "per-repo pool lease" misread `WorktreePool` — it's a *recycle cache* gated on `settings.recycleWorktrees` (`acquire(taskId)` returns an arbitrary idle path; `assertNotDoubleLeased` only fires on same-*path* reuse; never consulted with recycling off — `worktree-acquisition.ts:279`), with **no** repo-keyed cross-task exclusivity. So same-sub-repo serialization comes from the **file-scope lease** (overlapping in-repo scopes) and, for the disjoint-scope case, a dedicated **repo-path exclusivity registry** on `activeSessionRegistry` path-keying (which `runAiMerge` already uses) — implemented in **U2 (Phase A)**, at acquisition, so the guard never lags acquisition by a phase. + +### KTD7 — Aggregated, repo-tagged `modifiedFiles`, review, and a per-repo `MergeResult` breakdown +`captureModifiedFiles`, contamination, `verifyWorktreeInvariants`, and `reviewStep` iterate `task.workspaceWorktrees` and run inner git with cwd = each sub-repo (not rootDir). Modified-file lists carry repo prefixes. Review runs per-repo and aggregates verdicts. The aggregated `MergeResult` (today single-repo-shaped) must carry a **per-repo results array** so retry counters, audit, and the dashboard attribute failure to the right sub-repo; no consumer reads a scalar `merged` for completion — only `isWorkspaceTaskLanded`. + +### KTD8 — Cross-repo merge atomicity = **land-as-you-go (local integration ref) + unconditional escape hatch** (DECIDED) +**The merge advances a LOCAL ref, not a remote push (verified — feasibility + adversarial, round 3).** `runAiMerge`/`landSquash` advance the repo's local integration branch ref via `update-ref` CAS (`merger-ai.ts:817/847`); there is **no `git push` in the merge path** (the only engine `git push` is the separate PR-mode `pr-response-run-ops.ts`). Workspace mode matches this: each sub-repo's clean-room **lands on that repo's local integration ref** as it passes. Remote push is a separate, existing per-repo mechanism (PR flow / pull-integration-worktree), **out of scope** for U6. + +Each repo lands independently; the task reaches done only when `isWorkspaceTaskLanded` is true. A forever-unmergeable repo (or a bad half-landed change) is handled by an operator **revert-landed-repo / force-complete** affordance with an audit event, which clears the per-repo `merged` flag atomically (KTD4); because landing is a local ref advance, this is a **clean local reset**, not a compensate-forward remote revert. **The escape hatch is unconditional.** Two-phase (dry-run-all-then-land) was rejected: it adds real cost (holding N clean-rooms through a barrier; `runAiMerge` has no dry-run-without-landing primitive) for a coherence guarantee that the local-ref model already makes cheap to reset. Land-as-you-go is the natural fit for the clean-room model. + +> **Merge order:** with local-ref-only landing, order is **low-stakes** — a partial state is local and operator-resettable, and nothing reaches a shared remote from the merge. The loop may iterate `workspaceWorktrees` in arbitrary (key) order for v1. Dependency-aware ordering (callee/API repos before callers) is an *optional* future refinement, relevant only if/when a remote-push step is added; recorded as a non-blocking note, not v1 work. + +--- + +## High-Level Technical Design + +### Workspace task lifecycle (one task, two sub-repos; push-as-you-go on the sole `runAiMerge` path) + +```mermaid +sequenceDiagram + participant Ex as TaskExecutor + participant WS as workspace root (non-git, browse-only) + participant A as wolf-server worktree + participant B as wolf-frontend worktree + participant Mg as runAiMerge (sole path, per-repo clean-room) + participant Core as @fusion/engine landed predicate + + Ex->>Ex: loadWorkspaceConfig(rootDir) → present + Ex->>WS: session cwd = workspace root (SKIP root acquire + all rootDir preflights) + Note over Ex: agent browses, decides it needs repo A + Ex->>A: fn_acquire_repo_worktree("wolf-server") + A-->>A: acquireTaskWorktree(repoAbs) + identity guard + baseSha_A(localIntegration) + repo-path exclusivity + Ex->>B: fn_acquire_repo_worktree("wolf-frontend") + B-->>B: acquireTaskWorktree(repoAbs) + identity guard + baseSha_B(localIntegration) + repo-path exclusivity + Note over Ex: agent commits in A and B; fn_task_done + Ex->>A: captureModifiedFiles(baseSha_A, cwd=A) + review(A) + Ex->>B: captureModifiedFiles(baseSha_B, cwd=B) + review(B) + loop each entry in workspaceWorktrees (land-as-you-go, local ref) + Mg->>A: landOneRepo(wolf-server): clean-room(repoAbs) + file-scope(strip prefix) + squash → advance wolf-server LOCAL integration ref (CAS) + Note over Mg: persist workspaceWorktrees[A].merged=true (atomic), DON'T finalize task + Mg->>B: landOneRepo(wolf-frontend): clean-room(repoAbs) + squash → advance wolf-frontend LOCAL integration ref (CAS) + Note over Mg: persist workspaceWorktrees[B].merged=true (atomic) + end + Mg->>Core: isWorkspaceTaskLanded(task)? + Core-->>Mg: true only if ALL entries merged on their target → finalize task → done + Note over Mg: stuck repo → operator revert/force-complete (clean LOCAL reset; clears merged atomically) + Note over Mg: remote push = separate existing per-repo step, OUT OF SCOPE +``` + +### Single-worktree → multi-repo invariant inventory + +The surface-enumeration spine (FN-5893). Every row is a single-worktree / `cwd:rootDir` assumption that must become per-repo; the U-ID column maps each to the unit that fixes it. + +| Surface | Location | Today (singular) | Workspace behavior | Unit | +|---|---|---|---|---| +| Merge dispatch | `project-engine.ts:2280-2282` | `mergerMode==="ai" ? runAiMerge : aiMergeTask` | always `runAiMerge` (aiMergeTask `@deprecated`) | U0 | +| Extra `aiMergeTask` callers | `cli/.../dashboard.ts:~1330` (`--no-engine` `onMergeImpl`), `cli/.../task.ts:~854` (`fn task merge`) | call `aiMergeTask` directly, bypassing dispatch | route to `runAiMerge` or workspace-guard | U0 | +| Main acquisition | `executor.ts:~7430` | `acquireTaskWorktree({rootDir})` always | Skip when workspaceConfig | U1 | +| Intervening preflights | `executor.ts:7414→8443` | identity guard, contamination `:7536`, base capture `:7525`, verify | all gated off in workspace mode | U1 | +| Session cwd | `executor.ts:8443-8494` | `cwd: worktreePath` | `cwd: rootDir` (browse-only) | U1 | +| `activeWorktrees` map (+~15 consumers) | `executor.ts:7667`, `:1585`,`:14491`,`:14518`, FN-6736 reclaim `:2055` | `taskId → one path`; `===` liveness | `taskId → set`; membership semantics at each consumer | U1 | +| Identity-guard hooks | `executor.ts:14034` | installed in root worktree | installed per sub-repo at acquire | U2 | +| Init/setup + same-repo exclusivity | `worktree-acquisition.ts:~633` | once at root; no exclusivity | per sub-repo at acquire; repo-path exclusivity registry (KTD6) | U2 | +| Base-commit capture | `base-commit-capture.ts:26` (hardcodes `main`) | one `baseCommitSha` vs `main` | per-repo `baseSha` vs resolved integration branch (KTD3) | U2 | +| Same-sub-repo exclusivity | `activeSessionRegistry` path-keying (NOT `worktree-pool.ts` — recycle cache, not a lock) | none for sub-repos | repo-path exclusivity registry at acquisition (KTD6) | U2 | +| Contamination base | `executor.ts:7536` `assertCleanBranchAtBase(rootDir,…)` | one base, cwd rootDir | per-repo, cwd sub-repo | U3 | +| Modified-files capture | `executor.ts:7853`, `:12198` | one diff | iterate worktrees, repo-tagged, cwd sub-repo | U3 | +| `verifyWorktreeInvariants` (called by `fn_task_done`) | `executor.ts:10830` (one call site), `:12498` | one worktree | per acquired worktree | U3/U4 | +| Review | `executor.ts:11169`, `reviewer.ts` | one worktree diff | per-repo passes, aggregated | U4 | +| Landed predicate | route + merger + self-healing | column / one branch | `@fusion/engine` conjunction (KTD4) | U5 | +| **Merge entry (sole path)** | `merger-ai.ts` `runAiMerge`: clean-room `:172` prefix, `finalizeMerged`/`finalizeTask` `:1194/1285/1384`, `{empty:true}` `:1174` | single-repo clean-room, terminal finalize | per-repo clean-room via `landOneRepo` seam; loop+finalize gated on predicate | U6 | +| Clean-room parent dir | `merger-ai.ts` `finalizeMerged`/`landSquash` take `projectRootDir` | clean-room + local-sync at rootDir | pass `repoAbsPath` per repo; temp-prefix made repo-aware | U6 | +| File-scope squash overlap | `merger.ts:4935-5099` | one staged set vs unified scope | per-repo filtered scope, **prefix stripped** (KTD5) | U6 | +| `store.mergeTask` (3rd merge path + cleanup) | `store.ts:11150` checkout+squash+commit+remove at rootDir `:11256`; called `executor.ts:1742`/`self-healing.ts:5830` | full merge in rootDir, remove one worktree/branch | gate/convert per-repo, cwd sub-repo (or block workspace tasks from both callers) | U6 | +| File-scope leases | `scheduler.ts:1373-1450` | `Map` | compare-time repo-prefix skip (KTD5) | U7 | +| `reconcileTaskWorktreeMetadata` | `self-healing.ts:3974` | rebind one worktree | reconcile each entry, per-repo cwd | U8 | +| `reclaimStaleActiveBranches` | `self-healing.ts:3291` | one `fusion/` branch | per sub-repo, keyed `(repo, fusion/)` | U8 | +| `reconcileInReviewBranchRebind` | `self-healing.ts:3786` | skips ambiguous; SHA-dedup in one rootDir | per-repo rebind; scope dedup to correct sub-repo | U8 | +| `reclaimSelfOwnedBranchConflicts` | `self-healing.ts:2739` | one worktree usability | per sub-repo | U8 | +| `reclaimPrConflicts` | `self-healing.ts:2515` | one worktree | per sub-repo | U8 | +| `reconcileCompletedTask` | `self-healing.ts:3555` | one worktree on complete | conjunction-aware | U8 | + +--- + +## Output / Field Additions + +Additive only — no migration to existing single-repo tasks: + +```ts +Task.workspaceWorktrees: Record +``` + +`@fusion/engine` new export: `isWorkspaceTaskLanded(task): boolean` (and the shared repo-prefix-derivation helper). `MergeResult` gains an optional `perRepo: Array<{ repo, merged, branch, error? }>` breakdown (KTD7). + +--- + +## Implementation Units + +> **Standing requirements for every unit:** add `FNXC:Workspace ` comments (jsdoc-preferred) at each non-obvious decision point. Add a `.changeset/*.md` (`@runfusion/fusion: minor`). Per-repo work must emit **persisted** audit events on every acquisition/reconcile/merge failure path. Update the AGENTS.md **Run Audit** section with every new `task:*-workspace-*` event (enumerate exact names — the FN-6230 auto-close gate matches on these strings). All git execution that today targets `cwd: rootDir` must be re-targeted to the per-repo `repoAbsPath` — a per-repo loop wrapper is insufficient if inner git calls still target rootDir. + +### U0. Merger unification — make `runAiMerge` the sole path, soft-deprecate `aiMergeTask` + +**Goal:** Collapse merge onto `runAiMerge` so all downstream workspace work targets one canonical path. + +**Requirements:** KTD0. + +**Dependencies:** none (lands first, Phase 0). + +> **Standalone-decision framing (review):** U0 is a system-wide merge change — it routes **every** task in **every** project through `runAiMerge` (clean-room + AI merge + AI reviewer), not just workspace tasks. It is worth doing on its own merits (single canonical merge path) even if workspace mode were cancelled, and it ships as its own Phase 0 PR with its own review and rollback story. Reviewers should evaluate "all merges become clean-room" as its own decision, not as workspace-mode plumbing. + +**Files:** +- `packages/engine/src/project-engine.ts` (`:2275-2282` — drop the `mergerMode` ternary; always `runAiMerge`) +- `packages/cli/src/commands/dashboard.ts` (`~:1330` `onMergeImpl`, the `--no-engine` UI-only merge — currently calls `aiMergeTask` directly, `const`, despite the stale `:1299` comment; route to `runAiMerge` or workspace-guard) +- `packages/cli/src/commands/task.ts` (`~:854` `runTaskMerge`, the `fn task merge` CLI command — calls `aiMergeTask` directly; route to `runAiMerge` or workspace-guard) +- `packages/engine/src/merger.ts` (`aiMergeTask` + now-dead helpers → `@deprecated`; body retained for a later deletion pass) +- `packages/core/src/types.ts` (`:505` `settings.merger.mode` — retire/alias; this is published `@runfusion/fusion` surface, needs a changeset) +- `packages/engine/src/__tests__/` (update/retire `aiMergeTask`-specific tests; assert all entry points route to `runAiMerge`) + +**Approach:** Replace the dispatch with an unconditional `runAiMerge` call, **and** route the two direct CLI/dashboard callers (`onMergeImpl`, `runTaskMerge`) the same way — collapsing only the engine dispatch leaves two live production `aiMergeTask` callers. Mark `aiMergeTask` `@deprecated` with a pointer to `runAiMerge`; do **not** delete the body yet (soft delete). For `merger.mode`: keep accepting it, ignore `"deterministic"`, log a one-time deprecation warning. + +**Deterministic-mode blast-radius audit (do this, don't assert):** before claiming low blast radius, grep test fixtures, CI configs, and seeded project settings for `merger.mode === "deterministic"` (and `testMode`/mock interactions that may depend on `aiMergeTask`'s non-AI deterministic output) and enumerate which suites assert that behavior. Cite the result. Expectation is "effectively unused" (the user confirmed this for their projects), but the audit must confirm it rather than the plan asserting it. + +**R7 merge-boundary guard lands here (moved from U1, review):** because U0 is Phase 0 and collapses the dispatch *before* U1, add the merge-boundary guard in U0 — reject any workspace task (populated `workspaceWorktrees`) from entering any merge path (`runAiMerge`, `store.mergeTask`, the CLI callers) with a clear error naming U6 as required. Otherwise a workspace task reaching `in-review` in the U0→U1 window crashes at `git rev-parse refs/heads/` against the non-git root. **U6 removes the guard** when the per-repo loop lands. + +**Test scenarios:** +- Every entry point (engine dispatch, `onMergeImpl`, `runTaskMerge`), any `mergerMode` value → routes to `runAiMerge`. (behavior unification across all callers) +- A project previously on `"deterministic"` → routed to `runAiMerge` with a deprecation warning, not an error. (migration) +- A workspace task reaching merge before U6 → held with a clear error naming U6 (R7 guard, all entry points). (safety floor in the U0→U1 window) +- Existing `runAiMerge` single-repo behavior unchanged. (regression) + +**Verification:** All merge entry points route to `runAiMerge`; `aiMergeTask` is unreachable in production and marked deprecated; the deterministic-mode audit is cited; the R7 guard blocks workspace tasks from every merge path until U6. + +--- + +### U1. Workspace-mode session scoping — skip root acquisition + all rootDir preflights, browse-only root cwd + +**Goal:** In workspace mode, skip the main `acquireTaskWorktree` *and every preflight between the workspace guard and session create*, run the session with cwd = workspace root, and tolerate no singular `task.worktree`. + +**Requirements:** KTD1, KTD2. + +**Dependencies:** U0 (the R7 merge-boundary guard lands in U0; U1 builds on the unified single merge path). + +**Files:** +- `packages/engine/src/executor.ts` (acquisition `~:7430`, preflights `:7525`/`:7536`/identity guard, session create `~:8443-8494`, `activeWorktrees` `:7667` + consumers `:1585`/`:14491`/`:14518`/`:2055`, retry session `~:8935`) +- `packages/engine/src/__tests__/executor-workspace.test.ts` (**rewrite** — currently `vi.mock`s the functions under test; build the real two-repo fixture harness here so Phase A and all later units use it) +- `packages/engine/src/__tests__/executor-workspace-session.test.ts` (new) + +**Approach:** Gate the `~:7430` acquisition behind `!this.workspaceConfig`, and gate each intervening preflight (identity guard install, `resolveContaminationBaseRef`, `captureBaseCommitSha`, `verifyWorktreeInvariants`) so none runs against the non-git root. Set session cwd = `this.rootDir`; do not set `task.worktree`. Convert `activeWorktrees` to `taskId → Set` and update each enumerated consumer (`findActiveWorktreeOwner`, `hasActiveWorktreeBinding`, `getActiveWorktreeHolders`, FN-6736 phantom-binding reclaim) to membership semantics. Make `scopePromptToWorktree` a no-op in workspace mode. Leave the singular path byte-for-byte unchanged when `workspaceConfig` is absent. + +> **R7 guard:** the merge-boundary guard now lands in **U0** (Phase 0, before this unit) so the U0→U1 window is covered; U1 must not reintroduce a path around it. + +**Patterns to follow:** the existing `this.workspaceConfig === undefined` lazy-load guard at `executor.ts:7413-7418`. + +**Execution note:** Build the real-fixture harness (two temp git repos) here — do not extend the foundation's self-mocking pattern. + +**Test scenarios:** +- Workspace config present → main `acquireTaskWorktree` NOT called; no preflight runs git against rootDir; session `cwd === rootDir`. (happy path) +- Non-workspace task → acquisition + all preflights called exactly as before; `cwd === worktreePath`. (regression) +- Each enumerated `activeWorktrees` consumer returns correct results when a task holds two sub-repo paths. (integration) +- Retry session in workspace mode uses `cwd === rootDir`. (edge) +- Workspace task acquiring zero sub-repos reaches `fn_task_done` without throwing on missing `task.worktree`; completion boundary defined (see U5). (edge/empty) +- (R7 merge-boundary guard is tested in U0, where it now lives.) + +**Verification:** A workspace task starts a session rooted at the workspace dir with no root worktree and no rootDir git preflight; a single-repo task is unchanged. + +--- + +### U2. Per-repo acquisition hardening — identity guard, init/setup, same-repo exclusivity, base-commit capture + +**Goal:** Make `acquireWorkspaceRepoWorktree` install identity-guard hooks, register same-sub-repo exclusivity, and capture a per-repo `baseCommitSha` against the repo's **resolved** integration branch. + +**Requirements:** KTD3, KTD6. + +**Dependencies:** U1. + +**Files:** +- `packages/engine/src/worktree-acquisition.ts` (`acquireWorkspaceRepoWorktree` `~:598-650`) +- `packages/engine/src/base-commit-capture.ts` (**extend `resolveCapturedBaseCommitSha` to accept the integration branch** — it currently hardcodes `main`) +- `packages/engine/src/worktree-hooks.ts` (`installTaskWorktreeIdentityGuard`) +- `activeSessionRegistry` path-keying (repo-path exclusivity registry — KTD6; NOT `worktree-pool.ts`) +- `packages/core/src/types.ts` (extend `workspaceWorktrees` entry with `baseCommitSha`) +- `packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts` (new — real git fixture) + +**Approach:** After `acquireTaskWorktree` returns for a sub-repo: (1) install the identity guard; (2) resolve the repo's integration branch via `resolveIntegrationBranch(repoAbsPath, settings)` and capture `baseCommitSha` via the **extended** `resolveCapturedBaseCommitSha(worktreePath, integrationBranch)`; (3) persist `baseCommitSha`; (4) register same-sub-repo exclusivity in the repo-path registry (KTD6) at Phase A, where the contention is created. Idempotent across `(taskId, repo)` and any global branch-name/worktree-path uniqueness. + +> **Integration-branch caveat:** `resolveIntegrationBranch(rootDir, settings)` resolves `settings.integrationBranch` first, then the dir's `origin/HEAD`. Per-repo resolution must let each sub-repo fall through to its own `origin/HEAD` rather than inheriting a shared `settings.integrationBranch` override, unless the workspace genuinely shares one integration branch name. + +**Execution note:** Real two-repo git fixture; commit-without-pushing to exercise local-ahead-of-origin. + +**Test scenarios:** +- Acquiring repo A captures `baseSha_A` = local integration tip even when `origin/` is behind. (happy path + R3 regression) +- A sub-repo whose integration branch is **not** `main` captures against that branch and does not inherit a shared `settings.integrationBranch`. (KTD3 + caveat) +- Identity-guard hook present; a commit on a non-`fusion/` branch is rejected. (integration) +- Two concurrent workspace tasks acquiring the same sub-repo (even with disjoint in-repo scopes) are serialized by the repo-path exclusivity registry. (concurrency — KTD6) +- Re-acquiring repo A returns the existing entry without re-capture/re-install. (idempotency) +- Acquisition failure persists an audit event and surfaces an error. (error path) + +**Verification:** Each sub-repo worktree has identity hooks, a correct per-repo base SHA (local-first, right branch), and same-sub-repo concurrency protection registered at acquisition. + +--- + +### U3. Per-repo modified-files capture, contamination, worktree-invariant verification + +**Goal:** Iterate `workspaceWorktrees` for modified-files capture, contamination, and `verifyWorktreeInvariants`, running inner git with cwd = each sub-repo. + +**Requirements:** KTD7. + +**Dependencies:** U2. + +**Files:** +- `packages/engine/src/executor.ts` (`captureModifiedFiles` `~:7853`/`:12198`, contamination `assertCleanBranchAtBase` `:7539` — **rewire cwd to sub-repo**, `verifyWorktreeInvariants` `:10830`/`:12498`) +- `packages/core/src/types.ts` (`modifiedFiles` carries repo-prefixed paths) +- `packages/engine/src/__tests__/executor-workspace-capture.test.ts` (new — real git fixture) + +**Approach:** Loop over `workspaceWorktrees`; for each repo run `git diff ..HEAD` with cwd = that worktree, collect repo-prefixed files, aggregate into `task.modifiedFiles`. Run contamination + `verifyWorktreeInvariants` per worktree (cwd sub-repo). Skip the singular path in workspace mode. + +**Test scenarios:** +- Edits in repo A and B → `modifiedFiles` carries repo-prefixed paths from both. (happy path) +- A worktree HEAD drifted off `fusion/` → verify reports the offending repo. (error path) +- Contamination check runs against the sub-repo, not rootDir. (the cwd correction) +- Repo acquired, no edits → zero files, no error. (empty) +- Single-repo task → identical to today. (regression) + +**Verification:** Capture/verify cover all acquired worktrees with repo context and correct cwd. + +--- + +### U4. Per-repo review and `fn_task_done` completion verification + +**Goal:** Review per sub-repo and verify completion invariants across all acquired worktrees before `fn_task_done` succeeds. + +**Requirements:** KTD7. + +**Dependencies:** U3. + +**Files:** +- `packages/engine/src/executor.ts` (`reviewStep` `:11169`, `createReviewStepTool` `:8296`, `createTaskDoneTool` `:8279`/`:10830`) +- `packages/engine/src/reviewer.ts` (per-repo worktree/diff context, aggregate verdicts) +- `packages/engine/src/__tests__/reviewer-workspace.test.ts` (new) + +**Approach:** In workspace mode `reviewStep` iterates `workspaceWorktrees`, one reviewer pass per repo with that repo's diff and prefix-stripped File Scope subset; aggregate repo-tagged verdicts. `fn_task_done` calls `verifyWorktreeInvariants` for every acquired worktree and blocks on any dirty/misbound repo or uncommitted in-scope change. + +**Test scenarios:** +- Two-repo task → two reviewer passes; reviewed only when both pass. (conjunction) +- One repo has an uncommitted in-scope change at `fn_task_done` → blocked, naming the repo. (error path) +- Reviewer finding in repo B is repo-tagged. (integration) +- Single-repo task → one pass, unchanged. (regression) + +**Verification:** Reviewed/complete only when every sub-repo passes review and invariant checks. + +--- + +### U5. Shared `@fusion/engine` "landed" conjunction predicate + repo-prefix helper + +**Goal:** Define the multi-repo completion predicate and the shared repo-prefix helper once in `@fusion/engine`. + +**Requirements:** KTD4, KTD5. + +**Dependencies:** U2. + +**Files:** +- `packages/engine/src/workspace-completion.ts` (new — `isWorkspaceTaskLanded` + the repo-prefix-derivation helper, so U6 and U7 both import from one home) + export from the engine index +- `packages/core/src/types.ts` (extend entry with `merged`/`mergeTargetBranch`) +- `packages/engine/src/__tests__/workspace-completion.test.ts` (new) + +**Approach:** `isWorkspaceTaskLanded(task)` returns true only when **every** entry has `merged === true` and `mergeTargetBranch === `. Reads stored row data only. + +**Empty / no-op resolution (two cases, one rule):** (a) *zero acquisitions* → no-op done, consistent with U1's zero-acquire edge. (b) *acquired-but-unedited entry* (acquire repo A, edit nothing) → the entry exists with `merged=undefined`, so a naive conjunction returns `false` forever, stranding a fresh worktree+branch+registration; the rule: an acquired entry whose merge produces no net change resolves to `merged=true` (no-op) and its worktree/branch/registration is reclaimed. + +> **Empty authority = tip-relative, not `baseSha..HEAD` (review).** `runAiMerge` computes "empty" as `!squashSha` — no net change vs the **current local integration tip** (`merger-ai.ts:1054/1126`), and `mergeAndReview` rebuilds the clean-room on the *new* tip if another task advanced it (`:1188`). U3/KTD3's `baseSha..HEAD` per-repo diff can disagree (e.g. HEAD==baseSha but the tip moved). **The tip-relative `!squashSha` result is the authority**; U5's orphan resolution must defer to U6's tip-relative outcome, not to the stale `baseSha..HEAD` diff — so the short-circuit holds even when another task advanced the integration tip (it rebuilds on the new tip and re-lands nothing). U6 owns the actual short-circuit; U5's predicate reads the resulting `merged` flag. (Note: U6 cannot reuse `finalizeMerged({empty:true})` directly — it finalizes the whole task; see U6.) + +**Test scenarios:** +- All entries `merged` on the right target → `true`. (happy path) +- One `merged`, one not → `false`. The lost-work case. (critical) +- `merged` but wrong `mergeTargetBranch` → `false`. (anchor correctness) +- Zero `workspaceWorktrees` → no-op-done, consistent with U1. (empty state) +- Acquired-but-unedited entry (empty diff) → resolves `merged=true` (no-op), not stranded `false`. (orphan-prevention — joint with U6) +- Non-workspace task → delegating caller uses the scalar check, unchanged. (regression) + +**Verification:** One source of truth for completion; both empty cases resolve consistently across U1/U5/U6 with no orphans. + +--- + +### U6. Workspace-aware `runAiMerge` — per-repo clean-room loop, `landOneRepo` seam, push-as-you-go, escape hatch + +**Goal:** Rework the sole merge path (`runAiMerge`) so each sub-repo's clean-room lands on that repo's **local** integration ref independently (no remote push — KTD8), the task finalizes only on the conjunction, with crash-safe re-entry and an operator escape hatch. Also gate the third merge path (`store.mergeTask`) for workspace tasks. + +**Requirements:** KTD2, KTD4, KTD5, KTD7, KTD8. + +**Dependencies:** U0, U5. + +**Files:** +- `packages/engine/src/merger-ai.ts` (`runAiMerge`, clean-room prefix `:172`, `finalizeMerged`/`finalizeTask` `:1194/1285/1384`, `{empty:true}` `:1174`) +- `packages/engine/src/merger.ts` (file-scope check `:4935-5099`) +- `packages/engine/src/project-engine.ts:2281` (dispatch — confirm workspace tasks route correctly post-U0) +- `packages/core/src/store.ts` (`mergeTask` `~:11150` — the 3rd merge path: `checkout`/`squash`/`commit` `:11256` + worktree removal, `runGitCommand` pins `cwd:rootDir` `~:10989`) +- `packages/engine/src/executor.ts:1742` (`finalizeAlreadyInReviewTask` — gate workspace tasks away from `store.mergeTask`) +- `packages/engine/src/self-healing.ts:5830` (no-`enqueueMerge`-queue fallback — same gate) +- `packages/engine/src/workspace-completion.ts` (import the predicate) +- `packages/engine/src/__tests__/merger-workspace.test.ts` (new — real two-repo fixture) + +**Approach — the `landOneRepo` seam (the core blocker).** `runAiMerge` is a single terminal pipeline: a successful merge falls into `finalizeMerged` (`:1194/1285`) → `finalizeTask` → `store.moveTask(taskId,'done')` (`:1364/1384`). Extract a `landOneRepo(repoAbsPath, entry)` step (clean-room + mergeAndReview + landSquash + `store.updateTask({workspaceWorktrees})` setting `merged=true`/`mergeTargetBranch` **atomically**) that **lands the local integration ref but does NOT finalize the task**; drive the per-repo loop + final `moveToDone` from a workspace-aware caller gated on `isWorkspaceTaskLanded`. Specifics the seam must handle: +- `finalizeMerged` inseparably removes the **singular `task.worktree`** (`:1345`) and deletes the task branch before `moveTask`. Split it so `landOneRepo` removes the **per-entry `workspaceWorktrees[repo]`** worktree/branch itself — do **not** leave per-repo worktree cleanup to `store.mergeTask` (a naive extraction would leave every sub-repo worktree un-removed, since `runAiMerge` removes worktrees inside `finalizeMerged`, not via `store.mergeTask`). +- `finalizeMerged`/`landSquash` take `projectRootDir` as clean-room parent + local-sync checkout — pass `repoAbsPath` per repo. +- The clean-room temp prefix `fusion-ai-merge--` (`:172`) is task-keyed — make naming + `pruneExistingAiMergeWorktrees` **repo-scoped** or the N clean-rooms collide. +- `runAiMerge`'s no-branch lost-work guard (reads singular `task.baseCommitSha`/`task.mergeDetails`, unused in workspace mode) re-targets to `workspaceWorktrees[repo]`. + +**`store.mergeTask` (the 3rd merge path):** in workspace mode, gate the two callers (`executor.ts:1742` `finalizeAlreadyInReviewTask`, `self-healing.ts:5830` no-queue fallback) so a workspace task does not reach `store.mergeTask`'s `git checkout`/`merge --squash` at the non-git root; route workspace finalization through the `landOneRepo` loop instead. If `store.mergeTask` must run for per-repo worktree cleanup, iterate `workspaceWorktrees` with cwd = each sub-repo. + +**Sequencing (KTD8 — land-as-you-go, LOCAL ref):** each repo's `landOneRepo` advances that repo's **local integration ref via CAS** (no remote push — KTD8); persist `merged` atomically before the next; re-entry skips entries already `merged===true` (the persisted flag is the signal — no live re-derivation, KTD4). Loop order is arbitrary/key-order for v1 (local-ref window is operator-resettable — KTD8). Per-repo file-scope check uses the **prefix-stripped** filtered scope (KTD5). **Empty per-repo case:** a repo whose merge yields `!squashSha` (no net change vs the rebuilt tip — the authority, see U5) sets `merged=true` and reclaims its worktree **via the same land/finalize split — NOT by calling `finalizeMerged({empty:true})` directly**, which would `moveTask('done')` the whole task. Aggregate a `MergeResult.perRepo` breakdown. **Operator escape hatch (unconditional):** `revert-landed-repo`/`force-complete` does a clean **local** reset and clears `merged`/`mergeTargetBranch` atomically (KTD4) with an audit event. **Remove the R7 guard** (now in U0) here once the loop is the gate; add a test confirming a workspace task reaches the merger after U6. + +**Execution note:** Start with a failing two-repo merge contract test (both land on their own mains; task done only after both; crash between repos resumes correctly). Characterize existing `runAiMerge` single-repo behavior first. + +**Test scenarios:** +- Two-repo task, both clean → each clean-room advances its own **local integration ref** (no remote push); done via `isWorkspaceTaskLanded`; `perRepo` has both. (happy path) +- Repo A lands (local ref), repo B conflicts → A `merged`, B not, task NOT done, `perRepo` names B; operator escape path exercised. (the data-safety case) +- Crash after repo A persists `merged`, before repo B → re-entry skips A (persisted flag), resumes B, never re-lands A. (crash re-entry) +- Operator revert-landed-repo on A → clean **local** reset; `merged`/`mergeTargetBranch` cleared atomically; `isWorkspaceTaskLanded` false; self-healing doesn't treat complete. (escape hatch / no drift) +- Repo A acquired, no edits → `!squashSha` (tip-relative) short-circuits to `merged=true` via the land/finalize split (NOT `finalizeMerged({empty:true})`, which would finalize the whole task), worktree reclaimed, not stranded. (orphan-prevention — joint with U5) +- Repo A acquired, no edits, **another task advanced A's integration tip** between acquire and merge → clean-room rebuilds on the new tip, still `!squashSha`/`merged=true`, does not re-land the other task's work. (tip-relative empty authority) +- `landOneRepo` for repo A removes the **per-entry** `workspaceWorktrees[A]` worktree (not the singular `task.worktree`) and does not finalize the task. (finalize/cleanup split) +- Workspace task routed to `store.mergeTask` (via `finalizeAlreadyInReviewTask` / self-healing no-queue fallback) is gated — does not `git checkout` the non-git root. (3rd-merge-path gating) +- File-scope violation in repo B (path outside `wolf-frontend/**`) → `FileScopeViolationError` for B only, state reset. (invariant) +- A path under `wolf-server/**` is NOT out-of-scope when merging `wolf-frontend` (per-repo filter + prefix strip). (false-positive fix) +- N sub-repos' clean-rooms do not collide (repo-scoped temp prefix). (collision fix) +- Workspace task reaches the merger after U6 (R7 guard removed). (dead-wiring prevention) +- Single-repo task → `runAiMerge` unchanged. (regression) + +**Verification:** `runAiMerge` lands each repo independently on its local integration ref via per-repo clean-rooms (no remote push), persists atomically, resumes after a crash, supports a clean local operator revert, gates `store.mergeTask` for workspace tasks, and finalizes only when all repos land; single-repo merges unaffected. + +--- + +### U7. Per-repo file-scope leases (compare-time) + +**Goal:** Skip cross-repo lease comparison at overlap-check time, without restructuring the lease map. (Same-sub-repo exclusivity for the disjoint-scope case is handled in U2 via the repo-path registry — KTD6.) + +**Requirements:** KTD5. + +**Dependencies:** U5 (imports the shared repo-prefix helper). + +**Files:** +- `packages/engine/src/scheduler.ts` (overlap checks `~:1546`/`:1612` — derive repo prefix and skip cross-repo; leave `activeScopes` shape unchanged `:1373-1450`) +- `packages/core/src/store.ts` (`parseFileScopeFromPrompt` — add a repo-prefix-aware accessor; keep the flat list working for non-workspace via `unscoped`) +- `packages/engine/src/__tests__/scheduler-workspace-leases.test.ts` (new) + +**Approach:** At overlap-check time, canonicalize each scope entry, derive its repo prefix via the U5 helper, and skip comparison when two entries belong to different repos. Non-workspace tasks use the `unscoped` sentinel and behave exactly as today. + +**Test scenarios:** +- Active task holds `wolf-frontend/**`; queued wants `wolf-server/**` → NOT blocked. (over-blocking fix) +- Active holds `wolf-server/src/**`; queued wants `wolf-server/src/**` → blocked. (true overlap preserved) +- A File Scope path whose first segment matches no configured repo → routes to `unscoped`, logged, not silently no-leased. (fallback) +- Non-workspace tasks → lease behavior identical to today. (regression) + +**Verification:** No false cross-repo blocking; same-repo overlap protection intact. (Disjoint-scope same-sub-repo serialization is verified in U2.) + +--- + +### U8. Workspace-aware self-healing reconcilers + +**Goal:** Make the worktree/branch reconcilers iterate `workspaceWorktrees`, run per-repo git (not rootDir), key candidates by `(repo, fusion/)`, and stop mis-reclaiming multi-repo tasks. + +**Requirements:** KTD2, KTD4. + +**Dependencies:** U5, U6. + +**Files:** +- `packages/engine/src/self-healing.ts` — `reconcileTaskWorktreeMetadata` `:3974`, `reclaimStaleActiveBranches` `:3291`, `reconcileInReviewBranchRebind` `:3786` (runs `for-each-ref`/`show-ref` against `rootDir`), `reclaimSelfOwnedBranchConflicts` `:2739`, `reclaimPrConflicts` `:2515`, `reconcileCompletedTask` `:3555` +- `packages/engine/src/__tests__/self-healing-workspace.test.ts` (new) +- `AGENTS.md` (Run Audit section — **enumerate the exact new `task:*-workspace-*` event names**; the FN-6230 auto-close gate matches on these strings) + +**Approach:** Branch each reconciler on `task.workspaceWorktrees`: verify/rebind/reclaim **each** entry, running git with cwd = the sub-repo and scoping candidate-matching + SHA-dedup to the correct sub-repo (so two repos that both have a `fusion/` branch and divergent `main` are never matched across repos). Use `isWorkspaceTaskLanded` for completion. The in-review rebind no longer treats a multi-repo task as ambiguous. Preserve the `autoMerge:false` / live-session backward-move guards per repo. Emit a persisted audit event per workspace reconcile/reclaim. + +**Execution note:** Characterize existing single-worktree reconciler behavior first; keep the scalar path for non-workspace tasks. + +**Test scenarios:** +- `reconcileTaskWorktreeMetadata` on a two-repo task with one stale entry → rebinds only the stale repo. (per-repo) +- Two sub-repos each with a `fusion/` branch + divergent `main` → candidate-matching never crosses repos. (the collision case) +- `reconcileInReviewBranchRebind` no longer skips a workspace task as ambiguous. (deliberate-skip fix) +- All-landed workspace task treated complete by `reconcileCompletedTask` (conjunction). (completion) +- One-repo-unlanded workspace task under `autoMerge:false`/live session → not moved backward. (guard preserved) +- Each reconcile/reclaim emits its persisted audit event. (observability) +- Non-workspace tasks → every reconciler unchanged. (regression) + +**Verification:** Reconcilers maintain multi-repo tasks per repo, never cross-match branches, and leave single-repo reconciliation unchanged. + +--- + +### U9. End-to-end workspace harness (narrow) + +**Goal:** One narrow end-to-end smoke test of a workspace task, on the real-fixture harness U1 introduced. + +**Requirements:** all (verification backbone). + +**Dependencies:** U1–U8. + +**Files:** +- `packages/engine/src/__tests__/workspace-e2e.test.ts` (new — real two-repo fixture, mock AI provider) + +**Approach:** Register a workspace, run a scripted-mock task that acquires both repos, edits + commits in each, calls `fn_task_done`, and asserts both branches merge to their own mains and the task lands via `isWorkspaceTaskLanded`. **FN-5048 discipline:** decompose most coverage into per-seam tests (U2/U3/U4/U6 each own theirs); this e2e is a *narrow smoke* — fixture → acquire×2 → merge → landed — with **fake timers, no real polling loops**, gated like `smoke:boot`. + +**Test scenarios:** +- Full e2e: two-repo workspace task runs, edits both, merges both, lands — no real polling. (happy path smoke) +- One-sub-repo workspace task completes (common case). (edge) + +**Verification:** A workspace task runs end-to-end without real polling; per-seam invariants are covered by their own units. + +--- + +### U10. Dashboard "doesn't look broken" floor for workspace tasks + +**Goal:** Ensure the existing task views render workspace tasks (no `task.worktree`, populated `workspaceWorktrees`) without breakage. **Not** a full registration UI (deferred). + +**Requirements:** KTD2. + +**Dependencies:** U1. + +**Files:** +- Each component that reads `task.worktree`/`task.branch` for display (grep under `packages/dashboard/app/` and name them during implementation — task detail view and any task-row/summary). +- `packages/dashboard/app/__tests__/` (new test asserting graceful render) +- `CONCEPTS.md` or `docs/dashboard-guide.md` (one-line non-atomic-merge-semantics note) + +**Approach:** Add a nil-guard so each affected component renders a static placeholder (e.g. "N repos acquired") or hides the worktree/branch field when `task.worktree` is absent and `workspaceWorktrees` is populated. **Scope ceiling:** "doesn't look broken" only — a placeholder or flat per-repo path list, NOT a new rich per-repo-status component (that is the deferred registration UI). + +**Non-atomic-semantics note (review):** add a one-line note to `CONCEPTS.md` (or `docs/dashboard-guide.md`) stating that workspace-task merges are **non-atomic**: each sub-repo lands on its own local integration ref independently, a partial-land window is possible mid-task, and it is local + operator-resettable (nothing reaches a shared remote from the merge). Sets the expectation at the point of use without expanding U10 into the deferred registration UI. + +**Test scenarios:** +- Task with `task.worktree` undefined + two `workspaceWorktrees` entries → renders a per-repo list, no crash. (happy path) +- Single-repo task → unchanged. (regression) + +**Verification:** Workspace tasks are observable (not broken) in the dashboard at every execution stage. + +--- + +## Scope Boundaries + +**In scope:** merger unification onto `runAiMerge` (U0); the full execution lifecycle for one-task-spanning-repos — session scoping, per-repo acquisition hardening, capture/review, the per-repo clean-room merge loop, the shared landed predicate, per-repo leases + same-repo exclusivity, self-healing reconcilers, a narrow e2e, and a dashboard breakage floor. + +### Deferred to Follow-Up Work +- Hard deletion of `aiMergeTask` (U0 is a soft deprecation; remove the body in a later pass once no references remain). +- Full dashboard UI for registering/visualizing workspace projects and rich per-repo task status (U10 is only the breakage floor). +- `fn init` ergonomics beyond auto-detect (interactive repo selection, exclusions). +- Concurrency limits / fairness across many sub-repos in one task. +- A `/ce-compound` "single-worktree invariant inventory → multi-repo equivalents" learnings doc once this lands (the invariant table is its seed). + +### Outside this product's identity +- Reusing the **branch-group** shared-branch machinery — workspace mode (N repos × 1 branch each) is a distinct axis from branch groups (N tasks × 1 shared branch); conflating them reintroduces the documented branch-group hazards. +- The `kb→fn` brand rename (tracked separately). + +--- + +## Decisions Made + +All four design questions from the planning session are resolved: + +- **D1 (merger unification, → U0).** `runAiMerge` becomes the sole merge path; `aiMergeTask` is soft-deprecated. Workspace mode targets one canonical path. *Rationale:* `aiMergeTask` is already the "legacy pipeline" and `"ai"` is the default, so the change is cheap and removes dual-path forks. +- **D2 (atomicity, → KTD8/U6).** Land-as-you-go on each repo's **local integration ref** (no remote push — `runAiMerge` doesn't push), with an **unconditional** operator revert/force-complete escape hatch (a clean local reset). *Rationale:* the merge advances a local ref, so a partial state is local and cheap to reset; two-phase would cost N held clean-rooms for a guarantee the local-ref model already makes cheap. **Workspace mode is local-ref-only** — remote push stays the separate existing per-repo mechanism, out of scope (D5). +- **D3 (coherence expectation, → KTD2).** Session-time coherence is accepted; a transient half-applied **local** integration state is operator-resolved. *Rationale:* because nothing is pushed to a shared remote by the merge, the window is local-only and the operator escape hatch fully restores it. +- **D5 (merge mechanism, → KTD8, round 3).** Workspace mode matches `runAiMerge`'s **local integration ref advance**; it does **not** add per-repo remote push. *Rationale:* parity with the existing canonical merge path; remote push is handled by the separate PR/pull mechanisms per repo. +- **D4 (scope, → whole plan).** Full N>1 end-to-end in one plan (thin-N=1-slice alternative considered and declined). + +Residual sub-design items are now specified work, not open questions: the per-repo clean-room rework + `landOneRepo` seam (U6), the repo-scoped temp-worktree naming (U6), and the AGENTS.md Run-Audit event enumeration (U8). + +--- + +## Risks & Dependencies + +- **R1 — Missed `cwd:rootDir` / per-repo site strands work (critical).** Post-unification the merge surface is one path (`runAiMerge`), but the `cwd:rootDir` sites in `store.mergeTask`, self-healing, and the clean-room parent dir remain. Mitigation: the invariant inventory is the enumeration checklist; `isWorkspaceTaskLanded` (U5) is the single completion chokepoint; every reconciler keeps an explicit non-workspace path; grep every scalar `task.worktree`/`task.branch`/`task.baseCommitSha` read **and every `cwd: rootDir`** before declaring done. +- **R2 — Partial merge = silent data loss.** Mitigation: U5/U6 make "done" strictly conjunctive; U6 persists `merged` atomically per repo and supports crash re-entry; the operator escape hatch + atomic flag-clear (KTD4/KTD8) handle the stranded case. Partial-failure + crash-re-entry tests are mandatory. +- **R3 — Base-commit inflation per repo.** Mitigation: KTD3 + U2 capture local-first against the **resolved** integration branch (the existing helper hardcodes `main` — must be extended); regression test commits without pushing and uses a non-`main` integration branch. +- **R4 — Merger unification touches all tasks (U0).** Routing every task through `runAiMerge` is a behavior change for any project still on `"deterministic"`. Mitigation: low blast radius (`"ai"` is already the default); soft deprecation keeps `aiMergeTask` callable; U0 tests the `"deterministic"`→`runAiMerge` migration path with a warning, not an error. +- **R5 — Stranded half-merge.** Mitigation: the operator revert/force-complete escape hatch is in U6 **unconditionally**; because landing is a local integration-ref advance (D5), revert is a **clean local reset** (not a compensate-forward remote revert) and clears the `merged` flag atomically (KTD4); test the forever-unmergeable-B scenario. +- **R6 — Refactor-vs-main churn / stale line anchors.** This rewrites `runAiMerge`/executor/self-healing while main keeps changing them; cited line numbers will drift. Mitigation: phase the work, keep the non-workspace path untouched, prefer symbol/function anchors over line numbers, follow `docs/solutions/best-practices/merge-conflict-extraction-vs-semantics-and-parallel-bootstrap.md`. +- **R7 — Pre-U6 workspace task strands.** Mitigation: **U0** (Phase 0, before U1 — moved earlier in review to cover the U0→U1 window) adds a merge-boundary guard across all merge entry points (`runAiMerge`, `store.mergeTask`, the CLI callers) holding workspace tasks until U6; **U6 removes it** (with a test) when the per-repo loop becomes the gate. +- **R8 — Same-sub-repo concurrency window.** Two concurrent workspace tasks can acquire the same sub-repo with disjoint in-repo scopes (file-scope leases don't catch them; the pool is a recycle cache, not a lock). Mitigation: the repo-path exclusivity registry is implemented in U2 (Phase A), at acquisition. +- **R9 — Auto-merge confirmation gate on partially-landed workspace tasks.** The fast-path auto-merge gate (`project-engine.ts:1934-1992`) and `getTaskHardMergeBlocker` read singular `mergeDetails`/`mergeConfirmed`; their behavior for a workspace task with some entries `merged` and some not is untraced. Mitigation: U6/U8 must route these gates through `isWorkspaceTaskLanded` (the conjunction chokepoint), not the scalar fields; trace before Phase C. +- **Dependency:** wire any new engine capability at all engine-construction sites (`daemon.ts`/`serve.ts`/`dashboard.ts`) per the branch-group dead-wiring learning. + +--- + +## Phased Delivery + +Single plan, five phases (each a reviewable PR-sized slice; the non-workspace path stays green throughout). The real-fixture test harness is built in Phase A (U1). All four design questions are decided, so nothing blocks Phase A. + +- **Phase 0 — Merger unification:** U0 (`runAiMerge` becomes the sole path). Lands first so all workspace work targets one merge function. +- **Phase A — Run + safety floor:** U1 (incl. harness rewrite + R7 merge guard), U2, U10. +- **Phase B — Capture & review:** U3, U4. +- **Phase C — Merge (per-repo clean-room):** U5, U6, U7. The hardest phase — the `runAiMerge` `landOneRepo` rework. +- **Phase D — Heal & e2e:** U8, U9. + +> Note: Phases A–B deliver no standalone *user-shippable* value — a workspace task that runs but cannot merge is not usable — so realized value is concentrated in Phase C/D. The R7 merge guard (now in **Phase 0 / U0**) keeps the interim safe (held, not stranded) from the moment the dispatch is unified. Per the D4 decision, the thin-N=1-slice alternative (which would front-load value) was declined in favor of the full build. Also: U1 (Phase A) gates root preflights off, but per-repo contamination/`verifyWorktreeInvariants` returns in U3 (Phase B) — do not run a workspace task for real until Phase B lands (or pull per-repo contamination forward into U2). + +--- + +## Alternatives Considered + +- **Parent task + per-repo child tasks (rejected, user-confirmed).** One coordinator fans out a child per sub-repo, each on the untouched single-worktree path. Lower blast radius and fewer dual-path forks, but loses single-agent cross-repo coherence and adds cross-task dependency orchestration, and reworks the PR's existing foundation. Rejected because cross-repo coherence during execution is the motivating use case. +- **Two-phase / dry-run-all-then-land merge (rejected, → KTD8).** Would narrow the incoherent window, but costs N held clean-rooms + a new validated-but-unlanded lifecycle state, since `runAiMerge` has no dry-run-without-landing primitive — and the local-ref-only model (D5) already makes a partial state cheap to reset, so the extra cost buys little. Land-as-you-go + escape hatch chosen instead. +- **Per-repo remote push during merge (rejected, → D5).** Would publish each repo to its shared remote as it lands, making the partial-land window visible to other developers and the escape hatch a compensate-forward revert (can't unwind what others pulled). Rejected: `runAiMerge` is local-ref-only today; workspace mode keeps parity and leaves remote push to the existing separate per-repo mechanisms. +- **Thin N=1 vertical slice first (considered, declined → D4).** Would front-load usable value and isolate the hard clean-room-per-repo redesign to a later increment, but the user chose full N>1 end-to-end. +- **Reuse branch-group shared-branch machinery (rejected).** Branch groups model N tasks sharing 1 branch; workspace mode is 1 task across N repos each with its own branch. Data shapes don't align; the branch-group hazards are documented and severe. + +--- + +## Sources & Research + +- PR #1710 (`feat/workspace-multi-repo`) foundation diff; codebase verification on `pr-1710` (incl. `project-engine.ts:2280-2282` dispatch, `merger-ai.ts` `runAiMerge`/`finalizeMerged`/`{empty:true}`, `store.mergeTask` call sites). +- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md` → KTD3. +- `docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md` → KTD4 (conjunction predicate; dead-wiring at all engine sites). +- `docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md` → R1 (merge-gate fan-out). +- `docs/solutions/logic-errors/branch-group-name-collision-strands-mission-triage.md` → KTD5/U2/U8 (idempotency across uniqueness dimensions; persisted audit on failure). +- `docs/architecture.md` reconciler inventory (FN-4962, FN-5083/FN-6695, FN-4954, FN-4948, FN-5279) → U8. +- `CONCEPTS.md` workspace definition; `AGENTS.md` File-Scope invariant, Surface Enumeration (FN-5893), slow-test (FN-5048), Run Audit (FN-6230 auto-close gate). +- Codebase maps + three ce-doc-review rounds (this session): surfaced the default-merge-path concern (resolved by U0), the `cwd:rootDir` surface, the `runAiMerge` terminal-finalize seam, the pool-isn't-a-lock correction, the `merged`-flag drift, the per-repo base-commit / file-scope-prefix corrections, and — round 3 — the **local-ref-not-push** correction (KTD8/D5), `store.mergeTask` being a **third merge path**, U0's two extra `aiMergeTask` callers, the empty-diff tip-relative authority, and the U0→U1 guard window. +- Planning-session decisions (D1–D5): merger unification onto `runAiMerge` (D1), land-as-you-go local-ref atomicity (D2), session-time local-state coherence (D3), full N>1 scope (D4), local-ref-only mechanism / no per-repo remote push (D5). diff --git a/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md b/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md new file mode 100644 index 0000000000..d16a26537d --- /dev/null +++ b/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md @@ -0,0 +1,191 @@ +--- +title: "refactor: Merger unification (U0) — make runAiMerge the sole merge path" +status: active +date: 2026-06-21 +type: refactor +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, U0 / Phase 0) +depth: standard +--- + +# refactor: Merger unification (U0) — make `runAiMerge` the sole merge path + +## Summary + +Phase 0 / U0 of the workspace-mode master plan. Make `runAiMerge` (the FN-5633 clean-room AI merge path, **already the default**) the **sole** merge path and soft-deprecate `aiMergeTask` (the "legacy `deterministic` pipeline") — deprecate the `merger.mode` setting by making its value inert (keep the type and field; see KTD2). This is a **standalone merge-consolidation refactor** with its own review/rollback story — it routes *every* task in *every* project through `runAiMerge`, not just workspace tasks, and is worth doing even if workspace mode were cancelled. It lands first so all downstream workspace work targets one canonical merge function with no dual-path forks. + +It also installs the **R7 workspace merge-boundary guard** at every merge entry point, so that once workspace tasks can be created (later phases) one reaching merge before the per-repo loop (U6 in the master plan) is held with a clear error rather than crashing against the non-git workspace root. + +**Scope:** dispatch collapse + the two direct CLI/dashboard callers + `@deprecated` markers + `merger.mode` setting retirement + the blast-radius audit + the R7 guard. **Out of scope:** hard deletion of `aiMergeTask` (soft-deprecate only — body retained), and any per-repo / multi-repo merge logic (master-plan U6). + +--- + +## Problem Frame + +Merge is dispatched at `packages/engine/src/project-engine.ts:2275-2282`: + +```ts +const mergerMode = normalizeMergerMode(settings.merger?.mode); // defaults to "ai" +return mergerMode === "ai" + ? runAiMerge(store, cwd, taskId, mergeOptionsWithSettings) + : aiMergeTask(store, cwd, taskId, mergerOptions); +``` + +`"ai"` is the default (`normalizeMergerMode` returns `"ai"` for anything not exactly `"deterministic"`), so `runAiMerge` is already what most tasks hit. But `aiMergeTask` (`packages/engine/src/merger.ts`, the "legacy pipeline") is still reachable two ways the engine dispatch doesn't cover: +- `packages/cli/src/commands/dashboard.ts:1302` `onMergeImpl` (the `--no-engine` UI-only merge) calls `aiMergeTask` directly at `:1330`. +- `packages/cli/src/commands/task.ts:847` `runTaskMerge` (the `fn task merge` CLI command) calls `aiMergeTask` directly at `:854`. + +So collapsing only the engine dispatch leaves two live `aiMergeTask` callers. U0 unifies all three onto `runAiMerge`, soft-deprecates `aiMergeTask`, and retires the now-meaningless `merger.mode` setting. + +Separately, the master plan's later phases add workspace tasks (`task.workspaceWorktrees` populated) whose merge must go through a per-repo loop (master U6). Until that exists, a workspace task reaching any merge path would run `runAiMerge`/`store.mergeTask`/the CLI callers against the **non-git workspace root** and crash. U0 installs a guard at every merge entry point that rejects populated-`workspaceWorktrees` tasks with a clear error naming U6 — covering the window from U0 through master-plan U6. + +--- + +## Key Technical Decisions + +> **ID namespace note:** the `KTD1–KTD4` and `U1–U4` identifiers below are **local to this U0 implementation plan**. They decompose master-plan **U0** (Phase 0) and are a **separate namespace** from the master plan's `KTD0–KTD8` / `U0–U10`. When the master plan says "U6 removes the R7 guard," that's master-plan U6 — unrelated to this plan's U-IDs. + +### KTD1 — Soft deprecation, not deletion +Mark `aiMergeTask` and any helpers that become unreferenced `@deprecated` with a pointer to `runAiMerge`; **retain the bodies** for a later deletion pass. Rationale: keeps the diff reviewable and reversible; deletion is a separate follow-up once no references remain. + +### KTD2 — Keep the `merger.mode` setting and type; ignore the `"deterministic"` value +`MergerMode` / `MergerSettings.mode` (`packages/core/src/types.ts:508-519`) is **published `@runfusion/fusion` surface**. **Keep the type and the field** (removing them would be a breaking change) — only make the *value* inert: the dispatch ignores it and always calls `runAiMerge`, and logs a **one-time** deprecation warning when a resolved `merger.mode === "deterministic"` is observed. A changeset is required (minor — behavior change + deprecation). Rationale: avoids a breaking type removal while making the setting inert. "Deprecate/retire" in this plan means *inert*, never *removed*. + +### KTD3 — R7 guard at every merge entry point, keyed on `task.workspaceWorktrees` +The guard is a single shared predicate (e.g. `assertNotWorkspaceTaskMerge(task)`) called at the top of each merge entry point — the engine dispatch, `store.mergeTask`, `onMergeImpl`, and `runTaskMerge` — that throws a clear, named error (`Workspace task cannot merge until per-repo merge support (master-plan U6) lands`) when `task.workspaceWorktrees` is non-empty. Rationale: one predicate, all doors; prevents the non-git-root crash in the U0→U6 window. **Master-plan U6 removes this guard** when the per-repo loop becomes the gate. + +### KTD4 — Audit, don't assert, the deterministic blast radius +Before claiming low blast radius, grep test fixtures, CI configs, and seeded/default project settings for `merger.mode` / `"deterministic"` and `testMode`/mock interactions, and cite the result in the PR. Expectation (user-confirmed for their projects): effectively unused. The audit confirms it rather than the plan asserting it. + +--- + +## Implementation Units + +> **Units `U1–U4` below are local to this plan** (they decompose master-plan U0); they are **not** the master plan's `U1–U10`. U4 (audit) may run in parallel with U1–U3. +> +> **Standing requirements:** `FNXC:Workspace ` dated comments at each non-obvious decision point (dispatch collapse, the R7 guard, the deprecation warning). A `.changeset/*.md` (`@runfusion/fusion: minor`). Respect the merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`) and FN-5048 (narrow seams, fake timers, no real polling / mock-the-world). **Base branch (decided):** branch off the **foundation** (`pr-1710` / `feat/workspace-multi-repo` head) — the R7 guard (U3) reads `task.workspaceWorktrees`, which the foundation adds and `main` lacks. Do **not** commit onto `pr-1710` directly; use a new branch and open a **stacked PR targeting `feat/workspace-multi-repo`** so the diff is only U0's changes. + +### U1. Collapse the engine dispatch and route the two direct callers to `runAiMerge` + +**Goal:** Every merge entry point calls `runAiMerge`; no production code path calls `aiMergeTask`. + +**Requirements:** KTD2. + +**Dependencies:** none. + +**Files:** +- `packages/engine/src/project-engine.ts` (`:2275-2282` — drop the `mergerMode` ternary; always `runAiMerge`; keep computing `mergeOptionsWithSettings`) +- `packages/cli/src/commands/dashboard.ts` (`:1302` `onMergeImpl`, the `aiMergeTask` call at `:1330` → `runAiMerge`; update the `:1294-1298` comment; import at `:44`) +- `packages/cli/src/commands/task.ts` (`:847` `runTaskMerge`, the `aiMergeTask` call at `:854` → `runAiMerge`; import at `:2`) +- `packages/engine/src/__tests__/` (dispatch test — assert all entry points route to `runAiMerge`) + +**Approach:** Replace the engine dispatch ternary with an unconditional `runAiMerge(store, cwd, taskId, mergeOptionsWithSettings)`. Update `onMergeImpl` and `runTaskMerge` to call `runAiMerge` with the equivalent option shape they pass today — feasibility confirmed parity: `aiMergeTask` and `runAiMerge` share the `MergerOptions` interface (`merger.ts:5998`), both CLI callers pass only `agentStore`/`onAgentText` (both in `MergerOptions`, both consumed by `runAiMerge`), and `runAiMerge`'s 5th `deps` param defaults to `{}`, so the 4-arg calls are safe. **U2 implements the `"deterministic"` deprecation warning** (at the dispatch point); U1 just stops branching on the mode. Do not change `runAiMerge`'s own behavior. + +**Patterns to follow:** the existing `runAiMerge(store, cwd, taskId, mergeOptionsWithSettings)` call already in the `"ai"` branch. + +**Test scenarios:** +- Engine dispatch with `settings.merger.mode` unset / `"ai"` / `"deterministic"` → all three call `runAiMerge` (spy/mock the two merge fns, assert only `runAiMerge` is invoked). (behavior unification across modes) +- `runTaskMerge` (the `fn task merge` command) invokes `runAiMerge`, not `aiMergeTask`. (CLI caller) +- `onMergeImpl` (UI-only `--no-engine`) invokes `runAiMerge`, not `aiMergeTask`. (dashboard caller) +- Existing single-repo `runAiMerge` behavior is unchanged (no regression in the `runAiMerge` unit tests). (regression) + +**Verification:** A grep for `aiMergeTask(` in non-test production code returns zero call sites; all merge entry points route to `runAiMerge`. + +--- + +### U2. Soft-deprecate `aiMergeTask` and retire the `merger.mode` setting + +**Goal:** Mark `aiMergeTask` `@deprecated` (body retained) and make `merger.mode` inert with a one-time deprecation warning, plus a changeset. + +**Requirements:** KTD1, KTD2. + +**Dependencies:** U1. + +**Files:** +- `packages/engine/src/merger.ts` (`aiMergeTask` + any helpers that U1 leaves unreferenced → `@deprecated` jsdoc pointing to `runAiMerge`; bodies retained) +- `packages/core/src/types.ts` (`:505-519` — `MergerMode`/`MergerSettings.mode` jsdoc marks `"deterministic"` deprecated; do not remove the type) +- `packages/engine/src/project-engine.ts` (one-time deprecation warning when a resolved `merger.mode === "deterministic"` is seen) +- `.changeset/.md` (`@runfusion/fusion: minor`) +- `packages/engine/src/__tests__/` (warning-emission test) + +**Approach:** Add `@deprecated` jsdoc to `aiMergeTask` and the helpers U1 orphaned (do not delete). **Confirm the live-helper set first:** `runAiMerge` (`merger-ai.ts:66`) imports `captureSingleCommitLandedMetadata` (defined in `merger.ts:6059`) from `merger.js` — that helper is **shared and must NOT be `@deprecated`**. Grep `merger-ai.ts`'s imports from `merger.js` to enumerate every helper `runAiMerge` still depends on, and exclude those from deprecation; only tag what is genuinely orphaned after U1. In `types.ts`, annotate `"deterministic"` as deprecated in the `MergerMode` jsdoc without changing the enum (avoids a breaking type change). Emit a single deprecation warning (guarded so it logs once per process, e.g. a module-level flag) when the dispatch resolves `"deterministic"`. Write the changeset describing the merge-path consolidation and the `merger.mode` deprecation. + +**Test scenarios:** +- A project resolving `merger.mode === "deterministic"` → routed to `runAiMerge` **and** a deprecation warning is logged exactly once per process (not an error, not repeated). This warning assertion lives in U2's test, not U1's dispatch test. (migration / warn-not-error) +- `merger.mode` unset → no warning. (no false positives) +- `aiMergeTask` retains its body and exports (callable, just unreferenced in production). (soft-delete invariant) + +**Verification:** `aiMergeTask` is `@deprecated` but present; `"deterministic"` logs one warning and routes to `runAiMerge`; a changeset exists. + +--- + +### U3. R7 workspace merge-boundary guard at every merge entry point + +**Goal:** A populated-`workspaceWorktrees` task is rejected from every merge path with a clear error naming master-plan U6, covering the window until per-repo merge support lands. + +**Requirements:** KTD3. + +**Dependencies:** U1. + +**Files:** +- `packages/engine/src/` (new shared predicate, e.g. `assertNotWorkspaceTaskMerge(task)` — throws a named error when `task.workspaceWorktrees` is non-empty) +- `packages/engine/src/project-engine.ts` (call it at the top of the merge dispatch) +- `packages/core/src/store.ts` (call it at the top of `mergeTask` `:11150` — the third merge path) +- `packages/cli/src/commands/dashboard.ts` (`onMergeImpl`), `packages/cli/src/commands/task.ts` (`runTaskMerge`) +- `packages/engine/src/__tests__/` (guard test across entry points) + +**Approach:** One shared predicate reused at all four entry points (dispatch, `store.mergeTask`, `onMergeImpl`, `runTaskMerge`). It throws `Workspace task cannot merge until per-repo merge support (master-plan U6) lands` when `task.workspaceWorktrees` has any entry. For non-workspace tasks it is a no-op, so single-repo behavior is unchanged. Add an `FNXC:Workspace` comment explaining the U0→U6 window the guard covers and that U6 removes it. + +**Test scenarios:** +- A task with two `workspaceWorktrees` entries → each of the four entry points throws the named error mentioning U6; no `git checkout` runs against the root. (guard at every door) +- A normal single-repo task (no `workspaceWorktrees`) → guard is a no-op; merge proceeds via `runAiMerge`. (no regression) +- The thrown error names U6 / "per-repo merge support" so it's actionable. (clear messaging) + +**Verification:** No workspace task can reach any merge path's git operations before master-plan U6; single-repo merges are unaffected. + +--- + +### U4. Deterministic-mode blast-radius audit + +**Goal:** Cite, not assert, that the `"deterministic"` path is effectively unused. + +**Requirements:** KTD4. + +**Dependencies:** none (can run in parallel with U1–U3). + +**Files:** +- (audit only — no source change) PR description / commit body records the result. + +**Approach:** Grep test fixtures, CI configs (`.github/workflows/`), and seeded/default project settings for `merger.mode`, `"deterministic"`, and `testMode`/mock-provider interactions that might assert `aiMergeTask`'s deterministic (non-AI) output. Enumerate any suite that depends on the deterministic path; if found, note whether U1 reroutes it cleanly (warn + `runAiMerge`) or needs a fixture update. Cite the result in the PR. + +**Test scenarios:** `Test expectation: none -- audit/investigation unit; output is the cited result in the PR, not a code change.` + +**Verification:** The PR states which (if any) fixtures/CI/projects referenced `"deterministic"`, confirming the low-blast-radius claim with evidence. + +--- + +## Scope Boundaries + +**In scope:** dispatch collapse, the two CLI/dashboard callers, `@deprecated` markers, `merger.mode` retirement + changeset, the R7 guard at all merge entry points, and the blast-radius audit. + +### Deferred to Follow-Up Work +- **Hard deletion of `aiMergeTask`** and its orphaned helpers (separate pass once no references remain). +- All per-repo / multi-repo merge logic — the `runAiMerge` `landOneRepo` clean-room rework, `store.mergeTask` per-repo gating beyond the R7 guard, etc. (master-plan U6). +- Removing the `MergerMode` type / `merger.mode` setting entirely (breaking change; revisit after the deprecation has shipped). + +--- + +## Risks & Dependencies + +- **R1 — A missed `aiMergeTask` caller leaves a live legacy path.** Mitigation: U1's verification greps for zero non-test `aiMergeTask(` call sites; the dispatch test asserts all entry points route to `runAiMerge`. +- **R2 — Deterministic-mode consumers silently switch to AI merge.** Mitigation: U4 audits before claiming low blast radius; U2 warns (not errors) on `"deterministic"`. +- **R3 — Option-shape mismatch between `aiMergeTask` and `runAiMerge` at the CLI callers.** Mitigation: U1 confirms `runAiMerge`'s signature/options match what `onMergeImpl`/`runTaskMerge` pass today before rerouting; covered by the CLI caller tests. +- **R4 — Published-surface change.** `merger.mode` is `@runfusion/fusion` surface. Mitigation: keep the type (KTD2), changeset required (U2). +- **Dependency:** none external; lands before master-plan Phase A. + +--- + +## Sources & Research + +- Master plan `docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md` (U0 / Phase 0, KTD0, R4, R7). +- Codebase verification (this session): dispatch `project-engine.ts:2275-2282`; direct callers `dashboard.ts:1302/1330`, `task.ts:847/854`; `MergerMode`/`normalizeMergerMode`/`MergerSettings` `types.ts:508-519`; `store.mergeTask` `store.ts:11150`. +- `AGENTS.md`: changeset policy (published `@runfusion/fusion`), merge-gate commands, FN-5048 slow-test rules, FN-5633 (AI merge default). diff --git a/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md b/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md new file mode 100644 index 0000000000..f8b4220cb6 --- /dev/null +++ b/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md @@ -0,0 +1,176 @@ +--- +title: "feat: Workspace mode Phase A — session scoping, per-repo acquisition, dashboard floor" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase A / U1·U2·U10) +depth: deep +--- + +# feat: Workspace mode Phase A — session scoping, per-repo acquisition, dashboard floor + +> **ID namespace:** the `U1·U2·U3` below are **local to this Phase-A plan**. They decompose master-plan **U1, U2, U10** (a separate namespace). "Master-plan U6/U8" references point at the master plan, not these IDs. + +## Summary + +Phase A of the workspace-mode master plan: make a workspace task **run** (acquire → browse → edit per sub-repo), short of capture/review/merge (Phases B–D). Three units: (U1) executor session scoping so the session roots at the non-git workspace root and edits happen only in per-repo worktrees; (U2) per-repo acquisition hardening (identity guard, per-repo base SHA against the resolved integration branch, same-sub-repo exclusivity); (U3 = master U10) a dashboard "doesn't look broken" floor. + +Builds on the **foundation** (PR #1710 — `task.workspaceWorktrees`, `fn_acquire_repo_worktree`, `acquireWorkspaceRepoWorktree`) + **U0** (PR #1711 — `runAiMerge` sole merge path, R7 guard). Settled design: **D2/D3/D5 — land-as-you-go on each repo's LOCAL integration ref** (no remote push), session-time coherence accepted. The R7 merge-boundary guard already exists at the merge chokepoint (U0); U1 must not route around it. + +**Scope out:** capture/contamination/review (master U3/U4 = Phase B), the per-repo merge loop (master U6 = Phase C), self-healing reconcilers (master U8 = Phase D). + +**Stacking:** this branch is off the U0 branch, so the PR diff includes foundation + U0 + Phase A and **must not merge until #1710/#1711 land**. + +--- + +## Problem Frame + +In workspace mode `rootDir` is a **non-git** parent. On the current base the executor still, for every task: acquires one root worktree at `executor.ts:~7430` (`acquireTaskWorktree({rootDir})`), runs preflights (`resolveContaminationBaseRef`, `captureBaseCommitSha`, identity-guard install, `verifyWorktreeInvariants`) against that path, binds the agent session cwd to it, and tracks `activeWorktrees: Map`. Against a non-git root, the root acquisition and every git preflight fail. The foundation gave the agent `fn_acquire_repo_worktree` (per-repo worktrees on demand) but nothing in the executor lifecycle skips the root path or hardens per-repo acquisition. Phase A closes that gap for the **run** stage. + +--- + +## Key Technical Decisions + +### KTD1 — Skip root acquisition + all rootDir preflights; session cwd = workspace root (master KTD1) +When `this.workspaceConfig` is present: skip `acquireTaskWorktree({rootDir})` and gate each intervening preflight so none runs git against the non-git root; set session cwd = `this.rootDir` (browse-only); do not set `task.worktree`; `scopePromptToWorktree` is a no-op. The non-workspace path stays byte-for-byte unchanged (branch on `workspaceConfig`). + +### KTD2 — `activeWorktrees` becomes `taskId → Set` (master KTD1) — VERIFIED consumer list +A workspace task holds N sub-repo worktrees; liveness/owner checks must see all of them. Convert the map and update **every** consumer to membership semantics. The complete, code-verified consumer set (feasibility-checked — the earlier draft mislabeled these): +- **Membership / owner checks:** `findActiveWorktreeOwner` (`:14491`), `hasActiveWorktreeBinding` (`:14518`), the FN-6736 phantom-binding reclaim (`~:2055`). +- **`listWorktreeHolders` (`:14480`)** — emits one `{taskId, worktreePath}` per entry; consumed by the **FN-6782 leaked-slot reaper** (`self-healing.ts:~8310`) and `in-process-runtime.ts:~791`. A workspace task must **flat-map its Set into N holder rows**, or `maxWorktrees`-slot accounting under-counts and leaks/mis-reaps. Verify the reaper math against multi-row holders. +- **Single-path getters — define the Set-collapse contract (KTD-decision):** `getWorktreePath(taskId): string|undefined` (`:15424`), the `verifyWorktreeInvariants` resolution `?? this.activeWorktrees.get(task.id)` (`:10461`), and the conflict-set iteration (`~:14444`, `worktreePath === conflictPath`). **Contract:** for a workspace task these single-path consumers operate per-sub-repo (the caller already has the repo/path in context) — `getWorktreePath` returns `undefined` for a multi-worktree workspace task (callers must use the per-repo `workspaceWorktrees` entry), and `verifyWorktreeInvariants` is iterated per worktree in Phase B (master U3), so its singular resolution is gated off in workspace mode here. +- **Unregister resolvers (`:1586`/`:1603`/`:1618`)** — `deleteActiveSession`/`StepExecutor`/`WorkflowStepSession` each read one path for `activeSessionRegistry.unregisterPath`; with a Set they must unregister **every** path (loop), not one. Plus cleanup at `~:14922`. + +Non-workspace tasks hold a one-element set — behavior unchanged. **Grep all `activeWorktrees.` sites before declaring done** (FN-5893); the list above is the verification spine, not a license to skip the grep. + +### KTD3 — Per-repo base SHA against the *resolved* integration branch, local-first (master KTD3) +`resolveCapturedBaseCommitSha` (`base-commit-capture.ts:26-55`) **hardcodes `main`** and takes `(worktreePath, logger?)`. Extend it to accept the integration branch as an **optional trailing param defaulting to the current `main` literal**, so the existing single-repo caller (`executor.ts:~12075`) and the 4 `base-commit-capture.real-git.test.ts` cases stay green without change. At each sub-repo acquisition capture `baseCommitSha` measured **local-first** (`merge-base HEAD || origin/`), per `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`. + +> **Integration-branch resolution gotcha (feasibility-verified):** `resolveIntegrationBranch(rootDir, settings)` (`integration-branch.ts:74`) checks `resolveFromSettings(settings)` **FIRST** and returns a populated `settings.integrationBranch` before ever consulting the repo's `origin/HEAD`. So `resolveIntegrationBranch(repoAbsPath, settings)` would return the **shared** override for every sub-repo — the exact thing KTD3 forbids. **Call it with the shared override stripped:** `resolveIntegrationBranch(repoAbsPath, { ...settings, integrationBranch: undefined })`, so each sub-repo falls through to its own `origin/HEAD`. Store as `workspaceWorktrees[repo].baseCommitSha`. + +### KTD4 — Same-sub-repo exclusivity via `activeSessionRegistry` path-keying, not the pool (master KTD6) +`WorktreePool` is a recycle cache (gated on `recycleWorktrees`), **not** a cross-task lock. Serialize two concurrent workspace tasks contending for the same sub-repo via a repo-path exclusivity registry built on `activeSessionRegistry` path-keying (which `runAiMerge` already uses), registered **at acquisition** (U2). Disjoint-scope contention on the same sub-repo is otherwise unprotected (file-scope leases don't catch it). + +### KTD5 — Dashboard floor only (master U10) +Nil-guard components that render `task.worktree`/`task.branch` so a workspace task (no `task.worktree`, populated `workspaceWorktrees`) shows a placeholder or flat per-repo list, never a crash/empty. Ceiling: "doesn't look broken" — no rich per-repo-status component (deferred registration UI). Plus a one-line non-atomic-merge-semantics note in `CONCEPTS.md`/`docs/dashboard-guide.md`. + +--- + +## Implementation Units + +> **Standing requirements (every unit):** `FNXC:Workspace ` comments at non-obvious decision points; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (narrow seams, real git only where an invariant requires it, fake timers over polling, no mock-the-world); FN-5893 surface enumeration (update every enumerated consumer, don't half-convert); merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`). Branch off the U0 branch — do not commit to `main` or the U0 branch. + +### U1. Executor session scoping — skip root acquisition + preflights, browse-only root, activeWorktrees Set + +**Goal:** In workspace mode the executor skips root acquisition and every rootDir git preflight, runs the session rooted at the workspace dir, and tracks per-task worktree *sets*. + +**Requirements:** KTD1, KTD2. + +**Dependencies:** none (foundation + U0 present on the base). + +**Files:** +- `packages/engine/src/executor.ts` (acquisition `~:7430`; preflights `:7525` base capture, `:7536` contamination, identity-guard install, `verifyWorktreeInvariants`; session create `~:8443-8494`; retry session `~:8935`; `activeWorktrees` `:7667` + consumers `findActiveWorktreeOwner`/`hasActiveWorktreeBinding`/`getActiveWorktreeHolders`/FN-6736 reclaim `~:2055`/getters `~:1585`/`:14491`/`:14518`; `scopePromptToWorktree`) +- `packages/engine/src/__tests__/executor-workspace.test.ts` (**rewrite** — replace the `vi.mock`-the-subject tests with a **real two-repo git fixture harness** reusable by U2 and later phases) + +**Approach:** Gate the root acquisition + each preflight behind `!this.workspaceConfig`. In workspace mode set session cwd = `this.rootDir`, leave `task.worktree` unset, no-op `scopePromptToWorktree`. Convert `activeWorktrees` to `taskId → Set`; update each enumerated consumer to membership semantics (a non-workspace task = a one-element set). Mirror the existing `this.workspaceConfig === undefined` lazy-load guard at `executor.ts:7413-7418`. + +**Execution note:** Build the real two-repo fixture harness first (create temp git repos, branch, commit); the foundation's self-mocking test proves nothing. The harness is shared infrastructure for the rest of the phases. + +**Test scenarios:** +- Workspace config present → root `acquireTaskWorktree` NOT called; no preflight runs git against rootDir; session `cwd === rootDir`. (happy path) +- Non-workspace task → acquisition + every preflight called exactly as before; `cwd === worktreePath`. (regression — the singular path is untouched) +- Each enumerated `activeWorktrees` consumer returns correct results when a task holds two sub-repo paths (membership, not equality). (integration) +- Retry session in workspace mode uses `cwd === rootDir`. (edge) +- Workspace task that acquires zero sub-repos reaches `fn_task_done` without throwing on missing `task.worktree`. (edge/empty) + +**Verification:** A workspace task starts a session at the workspace root with no root worktree and no rootDir git preflight; `activeWorktrees` reflects all acquired sub-repo paths; a single-repo task is unchanged. + +--- + +### U2. Per-repo acquisition hardening — identity guard, per-repo base SHA, same-repo exclusivity + +**Goal:** Each sub-repo worktree gets identity hooks, a correct per-repo base SHA (local-first, resolved integration branch), and same-sub-repo concurrency protection — all at acquisition. + +**Requirements:** KTD3, KTD4. + +**Dependencies:** U1 (shares the fixture harness). + +**Files:** +- `packages/engine/src/worktree-acquisition.ts` (`acquireWorkspaceRepoWorktree` `~:598-650`) +- `packages/engine/src/base-commit-capture.ts` (**extend `resolveCapturedBaseCommitSha` to accept the integration branch** — it hardcodes `main`) +- `packages/engine/src/worktree-hooks.ts` (`installTaskWorktreeIdentityGuard`) +- `activeSessionRegistry` path-keying (repo-path exclusivity registry — KTD4; NOT `worktree-pool.ts`) +- `packages/core/src/types.ts` (extend the `Task.workspaceWorktrees` entry with `baseCommitSha?`) +- `packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts` (new — real two-repo git fixture) + +**Approach:** After `acquireTaskWorktree` returns for a sub-repo: (1) install the identity guard via `installTaskWorktreeIdentityGuard`, passing the **same settings args the executor passes** at `executor.ts:14035-14040` (`commitMsgHookEnabled`, `taskPrefix`, `taskAttributionTrailerName`) for single-repo parity — note `acquireWorkspaceRepoWorktree` calls `acquireTaskWorktree` *without* a `createWorktree` override, so the default backend installs **no** guard today (this work is genuinely missing); (2) resolve the integration branch via `resolveIntegrationBranch(repoAbsPath, { ...settings, integrationBranch: undefined })` (strip the shared override — KTD3 gotcha) and capture `baseCommitSha` via the extended `resolveCapturedBaseCommitSha(worktreePath, logger?, integrationBranch?)`; (3) persist `baseCommitSha` into `workspaceWorktrees[repo]`; (4) register same-sub-repo exclusivity in the `activeSessionRegistry` path-keyed registry — choose a **distinct registry kind/ownerKey** for the acquisition-time exclusivity entry so it does not collide with the executor's later session registration on the same sub-repo path (the registry exposes `registerPath`/`lookupByPath`/`isPathActive`/`pathsForTask`). Idempotent across `(taskId, repo)` (re-acquire returns the existing entry, no re-install/re-capture). + +**Execution note:** Real two-repo fixture; commit-without-pushing to exercise the local-ahead-of-origin invariant. + +**Test scenarios:** +- Acquiring repo A captures `baseSha_A` = the local integration tip even when `origin/` is behind. Covers the inflation invariant. (happy path + regression) +- A sub-repo whose integration branch is **not** `main` captures against that branch and does not inherit a shared `settings.integrationBranch`. (KTD3 correction) +- Identity-guard hook present; a commit on a non-`fusion/` branch is rejected. (integration) +- Two concurrent workspace tasks acquiring the same sub-repo (even with disjoint in-repo scopes) are serialized by the exclusivity registry. (concurrency — KTD4) +- Re-acquiring repo A returns the existing entry without re-capture/re-install. (idempotency) +- Acquisition failure persists an audit event and surfaces an error (no swallowed stall). (error path) + +**Verification:** Each sub-repo worktree has identity hooks, a correct per-repo base SHA (local-first, right branch), and same-sub-repo concurrency protection registered at acquisition. + +--- + +### U3. Dashboard "doesn't look broken" floor (master U10) + +**Goal:** Existing task views render a workspace task (no `task.worktree`, populated `workspaceWorktrees`) without breakage. + +**Requirements:** KTD5. + +**Dependencies:** none (independent of U1/U2; reads the data shape the foundation already added). + +**Files:** +- Each `packages/dashboard/app/` component that reads `task.worktree`/`task.branch` for display (grep and enumerate during implementation — task detail view + any task-row/summary) +- `CONCEPTS.md` or `docs/dashboard-guide.md` (one-line non-atomic-merge-semantics note) +- `packages/dashboard/app/__tests__/` (new — graceful render test) + +**Approach:** Add a nil-guard so each affected component renders a static placeholder (e.g. "N repos acquired") or a flat per-repo path list when `task.worktree` is absent and `workspaceWorktrees` is populated. **Ceiling:** placeholder/flat list only — a new rich per-repo-status component crosses into the deferred registration UI. Add the one-line semantics note (workspace-task merges are non-atomic: repos land independently on local integration refs; partial-land is local + operator-resettable). + +**Test scenarios:** +- Task with `task.worktree` undefined + two `workspaceWorktrees` entries → renders a per-repo list/placeholder, no crash/empty. (happy path) +- Single-repo task → unchanged. (regression) + +**Verification:** Workspace tasks are observable (not broken) in the dashboard. + +--- + +## Scope Boundaries + +**In scope:** the **run** stage — session scoping (U1), per-repo acquisition hardening (U2), dashboard breakage floor (U3). + +### Deferred to Follow-Up Work (later master-plan phases) +- Per-repo modified-files capture, contamination, `verifyWorktreeInvariants` iteration (master U3 = Phase B). +- Per-repo review + `fn_task_done` completion verification (master U4 = Phase B). +- The shared landed predicate, per-repo `runAiMerge` clean-room loop, leases (master U5/U6/U7 = Phase C). +- Self-healing reconcilers, e2e harness (master U8/U9 = Phase D). +- Rich dashboard per-repo status / workspace registration UI. + +> **Contamination-window caveat (carried from the master plan):** U1 gates the root preflights off, but per-repo contamination/`verifyWorktreeInvariants` does not return until master U3 (Phase B). Do not run a workspace task for real until Phase B lands — Phase A delivers acquisition + browse, not a verified end-to-end run. + +--- + +## Risks & Dependencies + +- **R1 — Half-converted `activeWorktrees` consumers (FN-5893).** Missing one consumer silently breaks liveness/owner checks for multi-repo tasks. Mitigation: KTD2 enumerates every consumer; grep all `activeWorktrees.get(`/`.has(`/`===`-on-path sites before declaring done. +- **R2 — A preflight left un-gated runs git against the non-git root → crash.** Mitigation: U1 explicitly enumerates and gates each preflight between the workspace guard and session create; test asserts no rootDir git in workspace mode. +- **R3 — Base-commit inflation per repo.** Mitigation: KTD3 extends the hardcoded-`main` helper and captures local-first against the resolved branch; regression test commits-without-pushing + uses a non-`main` integration branch. +- **R4 — Same-sub-repo concurrency unprotected.** Mitigation: KTD4 registers exclusivity at acquisition (U2), not via the recycle pool. +- **R5 — Non-workspace regression.** The whole point of branching on `workspaceConfig` is parity for single-repo tasks. Mitigation: every unit carries a non-workspace "unchanged" regression test; the gate's existing engine-core suite must stay green. +- **Stacking dependency:** builds on foundation #1710 + U0 #1711; the PR diff includes both and must not merge until they land. + +--- + +## Sources & Research + +- Master plan `docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md` (U1/U2/U10, KTD1/KTD3/KTD6 — KTD7 is Phase B, invariant inventory, D2/D3/D5). +- Codebase anchors (verified this session): `executor.ts` acquisition/preflight/session/`activeWorktrees`; `worktree-acquisition.ts` `acquireWorkspaceRepoWorktree`; `base-commit-capture.ts` hardcoded-`main`; `resolveIntegrationBranch`; `activeSessionRegistry` path-keying; foundation `task.workspaceWorktrees`. +- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md` → KTD3 (local-first base capture). +- `AGENTS.md`: FN-5048 slow-test rules, FN-5893 surface enumeration, changeset policy, merge gate. diff --git a/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md b/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md new file mode 100644 index 0000000000..15dd907348 --- /dev/null +++ b/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md @@ -0,0 +1,145 @@ +--- +title: "feat: Workspace mode Phase B — per-repo capture, contamination, review, completion verify" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase B / U3·U4) +depth: deep +--- + +# feat: Workspace mode Phase B — per-repo capture, contamination, review, completion verify + +> **ID namespace:** local `U1·U2` decompose master-plan **U3, U4**. +> **Anchors below are feasibility-verified against the Phase-B base** (not the master plan's approximate numbers). + +## Summary + +Phase B makes the executor's capture / contamination / verify / review / completion paths iterate `task.workspaceWorktrees` per sub-repo, using each repo's own `baseCommitSha` (Phase A, U2). It does **not** simply "un-gate stubs" — the feasibility pass found capture/contamination/scope-leak are not gated at all today; they **silently degrade to empty** against the non-git root (git failures swallowed). Phase B adds the missing workspace branches and reuses the existing `captureModifiedFiles` machinery (whose `resolveDiffBaseRef` merge-base fallback + `filterFilesToOwnTaskCommits` contamination audit are exactly what's needed) per repo. + +Builds on Phase A (PR #1713). **Scope out:** the merge loop (master U6 = Phase C), self-healing (master U8 = Phase D). + +**Stacking:** off the Phase-A branch; PR diff includes the stack; must not merge until it lands. + +--- + +## Problem Frame + +Phase A rooted workspace sessions at the non-git workspace root and acquired per-repo worktrees, but the executor's change-capture, contamination, worktree-invariant, review, and completion-verify paths still operate on a single `task.worktree`. Against the non-git root they either are explicitly stubbed (one site) or silently produce empty results (the rest). Phase B routes each of these through every acquired sub-repo worktree, `cwd` = the sub-repo, diffing against that repo's `workspaceWorktrees[repo].baseCommitSha`, with repo-prefixed file lists so review/dashboard/later-merge keep repo context. + +--- + +## Key Technical Decisions + +### KTD1 — Per-repo change capture by **reusing `captureModifiedFiles`**, not a raw diff (master KTD7) +**Verified reality:** capture is **not** workspace-gated. The post-session call `captureModifiedFiles(worktreePath, …, "post-session")` (executor.ts **:7898**) runs ungated with `worktreePath` = the browse-only non-git root and returns `[]` only because `resolveDiffBaseRef`/`resolveContaminationBaseRef` swallow the git failure. So U1 **adds** a workspace branch at :7898 (and the sibling branch-attribution audit at **:7914**), it does not replace one. + +Per repo, call the **existing** `captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source)` — NOT a hand-built `git diff ..HEAD`. Reasons (all verified): (a) `repo.baseCommitSha` may be **undefined** (Phase A made base capture non-fatal); `resolveDiffBaseRef` (:~12184) handles that via a merge-base fallback. (b) the real **contamination** signal is the `filterFilesToOwnTaskCommits` raw-vs-attributed divergence audit **inside** `captureModifiedFiles` (:~12225-12246) — reusing it restores contamination for free. Prefix each repo's returned files with the repo path and aggregate into `task.modifiedFiles`. + +> **`assertCleanBranchAtBase` is a no-op** (branch-conflicts.ts: `void`s all params — "informational only"). Do **not** add a per-repo iteration of it; it would restore zero protection. Contamination comes from per-repo `captureModifiedFiles`. + +### KTD2 — `verifyWorktreeInvariants` iterates per acquired worktree, preserving its result union (master KTD7) +The **one** workspace stub in this region is `verifyWorktreeInvariants` returning `{ok:true}` at executor.ts **:10508** (def **:10500**). Un-stub it: iterate every `workspaceWorktrees` entry, asserting each HEAD is on `fusion/` and toplevel matches the recorded `worktreePath`. **Preserve the exact discriminated union** `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` (consumed at **:10889**; the `reason` enum drives the requeue/handoff branches at :10894-10936) — add a `repo` field to the failure shape; return the **first** failing repo. + +### KTD3 — Per-repo review by looping the **existing single-cwd `reviewStep`** N times (master KTD7) +**Decision (user-confirmed): accept the N× reviewer cost.** The reviewer is an **agent** spawned with `cwd` = worktree and told (in prompt text, reviewer.ts:~760) to run `git diff` itself — it does not read a diff passed in code. So per-repo review = spawning **one reviewer agent per sub-repo**. Architecture: the **callers loop** and call the existing single-cwd `reviewStep` (reviewer.ts **:122**) once per acquired worktree (cwd = repo, scope = prefix-derived subset); aggregate repo-tagged verdicts into the task's single review record as a **conjunction** (reviewed only if every repo passes). `reviewStep` itself stays single-cwd. + +**Both review call sites iterate (user-confirmed FN-5893 coverage):** +- `createReviewStepTool` → `reviewStep` (executor.ts **:11148**, the in-session `fn_review_step` path). +- the **step-inversion seam** `reviewStep(worktreePath=active.worktreePath || detail.worktree || this.rootDir, …)` at executor.ts **:5668** (foreach/step-inversion path). + +### KTD4 — `fn_task_done` completion verification iterates per repo, including the scope-leak guard (master KTD7) +`fn_task_done` (`createTaskDoneTool` executor.ts **:10832**) must, in workspace mode: (a) call the per-repo `verifyWorktreeInvariants` (KTD2) for every acquired worktree; (b) iterate the **scope-leak guard** `evaluateTaskDoneScopeLeak` (executor.ts **:10711**, invoked at **:11009**) per repo — it currently runs `captureUncommittedModifiedFiles(worktreePath)` + `captureModifiedFiles(worktreePath, task.baseCommitSha, …)` against the singular root and silently passes; per-repo iteration (cwd = sub-repo, `repo.baseCommitSha`) restores the uncommitted-in-scope block. Block completion on any dirty/misbound repo or uncommitted in-scope change, naming the repo. + +> **Repo-prefix derivation helper** (shared, master U5 will reuse): canonicalize → match first path segment to a configured repo → `unscoped` fallback. New `packages/engine/src/workspace-paths.ts`. Keep it minimal — no lease logic (Phase C / master U7). + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace ` comments; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (reuse the Phase-A `_workspace-fixture.ts` harness; real git only where the invariant requires it; fake timers; no mock-the-world); FN-5893 surface enumeration; the merge gate. Branch off Phase A (already checked out: `gsxdsm/workspace-phase-b`). + +### U1. Per-repo capture, contamination, and worktree-invariant verification (master U3) + +**Goal:** Change-capture, contamination, and `verifyWorktreeInvariants` cover every acquired sub-repo worktree with repo context and correct cwd. + +**Requirements:** KTD1, KTD2. + +**Dependencies:** none beyond Phase A. + +**Files:** +- `packages/engine/src/executor.ts` — **add** a workspace branch at the post-session capture **:7898** (+ attribution audit **:7914**) that loops `workspaceWorktrees` calling `captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, …)` per repo, repo-prefixing results; **un-stub** `verifyWorktreeInvariants` **:10508** to iterate per worktree preserving the `{ok|reason|observed|expected}` union (+ `repo`). +- `packages/engine/src/__tests__/executor-workspace-capture.test.ts` (new — real two-repo fixture via `_workspace-fixture.ts`) + +**Approach:** Per KTD1/KTD2. Reuse `captureModifiedFiles` (do not hand-build `git diff`); do not iterate the no-op `assertCleanBranchAtBase`. Singular non-workspace path unchanged. + +**Execution note:** Reuse `_workspace-fixture.ts`; commit edits onto each sub-repo's `fusion/` branch to exercise real diffs + the divergence audit. + +**Test scenarios:** +- Edits in repo A and B → `task.modifiedFiles` carries repo-prefixed paths from both, each diffed against its own `baseCommitSha`. (happy path) +- A repo with `baseCommitSha` undefined → capture still works via the merge-base fallback (no `git diff undefined..HEAD`). (edge — Phase A non-fatal base) +- A foreign commit in a sub-repo's range → the `filterFilesToOwnTaskCommits` divergence/contamination audit fires for that repo. (contamination) +- A worktree HEAD drifted off `fusion/` → `verifyWorktreeInvariants` returns `{ok:false, reason:'wrong_branch', repo, observed, expected}` (not `{ok:true}`); the `reason` enum is preserved for the :10889 consumer. (error path) +- Single-repo (non-workspace) task → capture/verify byte-for-byte identical. (regression) + +**Verification:** Capture + contamination audit + invariant verify run per acquired worktree with repo context; the result union is intact; single-repo unchanged. + +--- + +### U2. Per-repo review (both call sites) + `fn_task_done` completion + scope-leak verification (master U4) + +**Goal:** Review every acquired sub-repo (both review entry points) and block completion until every sub-repo passes review, invariant, and scope-leak checks. + +**Requirements:** KTD3, KTD4, KTD2. + +**Dependencies:** U1 (per-repo verify + capture). + +**Files:** +- `packages/engine/src/executor.ts` — `createReviewStepTool` **:11148** and the step-inversion seam **:5668** loop `reviewStep` per acquired worktree; `createTaskDoneTool` **:10832** calls per-repo verify (U1) + iterates `evaluateTaskDoneScopeLeak` **:10711** per repo. +- `packages/engine/src/reviewer.ts` — `reviewStep` (**:122**) stays single-cwd; callers loop. Aggregate repo-tagged verdicts (conjunction) into the task review record; reviewer findings carry the repo tag. +- `packages/engine/src/workspace-paths.ts` (new — the repo-prefix-derivation helper; master U5 reuses) +- `packages/engine/src/__tests__/reviewer-workspace.test.ts`, `packages/engine/src/__tests__/executor-workspace-taskdone.test.ts` (new) + +**Approach:** Per KTD3/KTD4. Both review sites loop the existing single-cwd `reviewStep` once per sub-repo (N reviewer agents — accepted cost) and aggregate as a conjunction. `fn_task_done` per-repo verify + per-repo scope-leak. + +**Test scenarios:** +- Two-repo task → two reviewer passes (one per repo cwd); review record reflects both; reviewed only when both pass. (conjunction) +- A reviewer finding in repo B is repo-tagged. (integration) +- Step-inversion review seam (:5668) for a workspace task reviews each sub-repo, not the non-git root. (FN-5893 second surface) +- `fn_task_done` with an uncommitted in-scope change in repo A → completion blocked, naming repo A (the scope-leak guard fires per-repo). (error path) +- `fn_task_done` with a worktree off `fusion/` → blocked via per-repo verify. (error path) +- The prefix helper: `wolf-server/src/**` → repo `wolf-server`; non-matching first segment → `unscoped`. (helper) +- Single-repo task → one review pass + singular scope-leak/verify, unchanged. (regression) + +**Verification:** A workspace task is reviewed/complete only when every sub-repo passes review + invariant + scope-leak; both review entry points iterate; single-repo unchanged. + +--- + +## Scope Boundaries + +**In scope:** per-repo capture/contamination/verify (U1); per-repo review at both call sites + `fn_task_done` verify + scope-leak (U2); the repo-prefix helper. + +### Deferred to Follow-Up Work (later phases) +- The per-repo merge loop, the landed predicate, the file-scope leases (master U5/U6/U7 = Phase C). +- Self-healing reconcilers, e2e (master U8/U9 = Phase D). +- Per-repo worktree teardown (carried Phase-A residual). +- Store-level **atomic** per-repo `workspaceWorktrees` merge — Phase A added a re-read mitigation; the fully-atomic merge is still open and **becomes reachable in Phase B** (multi-repo acquisition first exercised here). Track for Phase C. + +--- + +## Risks & Dependencies + +- **R1 — "Add a branch" vs "replace a stub" confusion.** Capture/contamination/scope-leak silently degrade (not gated); an implementer expecting a stub to replace won't find one. Mitigation: KTD1/KTD4 + U1/U2 cite the exact add sites (:7898/:7914, :10711) and the one real stub (:10508). +- **R2 — Hand-built `git diff` breaks on undefined base.** Mitigation: KTD1 mandates reusing `captureModifiedFiles`; test covers the undefined-base repo. +- **R3 — `verifyWorktreeInvariants` union shape.** The `reason` enum is load-bearing at :10889. Mitigation: KTD2 preserves the union; test asserts the `reason`. +- **R4 — No-op contamination function.** Mitigation: KTD1 explicitly forbids iterating `assertCleanBranchAtBase`; contamination rides on per-repo `captureModifiedFiles`. +- **R5 — N× reviewer cost.** Accepted (user decision). Mitigation: note in the PR; cost scales with repo count (typically 2-3). +- **Stacking dependency:** off Phase A (#1713); diff includes the stack. + +--- + +## Sources & Research + +- Master plan (U3/U4, KTD7, contamination-window caveat). +- Phase B feasibility pre-check (verified anchors: capture not gated/:7898 add-site, `assertCleanBranchAtBase` no-op, undefined-base via `resolveDiffBaseRef`, verify union :10508/:10889, review agent N× cost + the :5668 second surface, scope-leak :10711, anchor corrections). +- Phase A (#1713): per-repo `baseCommitSha`, `activeWorktrees` Set, `_workspace-fixture.ts`. +- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`. diff --git a/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md b/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md new file mode 100644 index 0000000000..c2bd15ef6a --- /dev/null +++ b/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md @@ -0,0 +1,151 @@ +--- +title: "feat: Workspace mode Phase C — per-repo merge loop (land-as-you-go on local integration refs)" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase C / U5·U6·U7) +depth: deep +--- + +# feat: Workspace mode Phase C — per-repo merge loop (land-as-you-go on local integration refs) + +> **ID namespace:** local `U0·U1·U2·U3` decompose master-plan **U5, U6, U7** (+ a Phase-B-deferred extraction). +> **Anchors are feasibility-pending** — a pre-check runs before implementation (as in Phases A/B). Treat `~:` numbers as approximate until verified. + +## Summary + +Phase C replaces U0's **R7 guard** — which currently makes every workspace-task merge *throw* `WorkspaceTaskMergeError` — with the real **per-repo merge loop**: for each acquired sub-repo, land that repo's `fusion/` branch onto **that repo's LOCAL integration ref** via a repo-scoped clean-room (the `runAiMerge` mechanism, applied per repo), with no remote push. This is **land-as-you-go** (settled **D2/D5**): repos land independently; a partial land (A lands, B fails) leaves A landed locally and is operator-resettable; an unconditional operator escape hatch always exists. + +After Phase C a workspace task can fully run → capture → review → **merge**. **Scope out:** self-healing reconcilers + e2e harness (master U8/U9 = Phase D). + +**Stacking:** off Phase B (#1714); PR diff includes the stack; must not merge until it lands. + +--- + +## Problem Frame + +`runAiMerge` (merger-ai.ts) lands **one** `task.worktree`'s `fusion/` branch into a single clean-room temp worktree and advances **one** local integration ref via `update-ref` CAS (no push). U0 added the **R7 chokepoint guard** `assertNotWorkspaceTaskMerge(task)` so a `workspaceWorktrees`-bearing task fails fast rather than silently mis-merging the single root. Phase C turns that fail-fast into a real loop: iterate the acquired sub-repos, run the clean-room land per repo against that repo's own local integration ref, track which repos have landed (idempotent retry), hold a per-repo file-scope lease during each land, and aggregate a per-repo `MergeResult`. The single-repo `runAiMerge` path is untouched. + +--- + +## Key Technical Decisions + +> **OPEN FORKS — to be confirmed by the feasibility pre-check + user before implementation.** Marked `‹FORK›`. The settled semantics (D2/D5) bound them, but the code shape is to verify. + +### KTD0 — Extract `workspace-executor.ts` FIRST (Phase-B-deferred maintainability P1) +Before adding the merge loop, move the workspace branches Phase A/B inlined into `executor.ts` (`captureWorkspaceModifiedFiles`, `reviewWorkspacePerRepo`, the per-repo `verifyWorktreeInvariants` block) into `packages/engine/src/workspace-executor.ts` as module-level functions receiving executor state as args; the `if (this.workspaceConfig)` call sites delegate. Pure move + delegate, no behavior change — its own commit, gate-green, before any Phase-C behavior. This keeps the 16k-line file from absorbing the merge loop too. + +### KTD1 — Extract `landOneRepo` from `runAiMerge`, then loop it (master U6; D2/D5) — FORK-A RESOLVED +**Verified:** `runAiMerge`'s land sequence (mkdtemp clean room → `git worktree add --detach` → `installWorktreeDependencies` → `mergeAndReview` → `landSquash` → the concurrent-advance CAS retry loop → `activeSessionRegistry` register/unregister) is an **un-factored inline closure** at `merger-ai.ts:1064-1216`, bound to one `projectRootDir`/`integrationBranch`/`branch`; `mergeAndReview`/`finalizeMerged` are module-private. The CAS seam `advanceIntegrationBranchRef` already takes `rootDir`/`integrationBranch` explicitly. **No remote push anywhere** — D2/D5 "no push" confirmed. + +So U1 **extracts** an exported `landOneRepo(store, repoRootDir, branch, integrationBranch, options)` from that closure (returns a per-repo `LandResult`), leaving `runAiMerge` as the byte-for-byte single-repo caller. `landWorkspaceTask(task)` loops the acquired sub-repos calling `landOneRepo` per repo, aggregating a repo-tagged result. **`landOneRepo` stays in `merger-ai.ts`** (the private helpers live there); only the thin `landWorkspaceTask` orchestrator may sit in a new `workspace-merger.ts`. + +**Per-repo integration branch (P1 the plan missed):** `workspaceWorktrees[repo]` does NOT store the integration branch (acquisition computes it then discards). `landOneRepo` must **re-resolve per repo** with the same override-stripping acquisition uses — `resolveIntegrationBranch(repoRoot, { ...settings, integrationBranch: undefined, baseBranch: undefined })` — so each sub-repo lands on its own `origin/HEAD`, not a shared branch. + +**Per-sub-repo prune rooting (correctness):** `pruneExistingAiMergeWorktrees`/`cleanupStaleTempMergeWorktrees` sweep by the `fusion-ai-merge--` prefix; N per-repo clean rooms share the taskId. Root each sweep at the **sub-repo** (`resolveAiMergeRoot(subRepoRoot)`) so one repo's prune cannot race another repo's live clean room for the same task. + +### KTD2 — Door table: route the engine + CLI/dashboard doors, keep the rest throwing (master U6) — RESOLVED +Six guard sites. Per-door (FN-5893): +1. **`project-engine.ts:~2300` engine dispatch** → route `workspaceWorktrees`-bearing tasks to `landWorkspaceTask`. +2. **`runAiMerge:~979` chokepoint guard** → STAYS as defense-in-depth for direct single-repo callers (workspace tasks enter via `landWorkspaceTask`, not here). +3. **`store.mergeTask:~11159`** (core, cannot import `@fusion/engine`) → STAYS throwing. +4. **CLI `dashboard.ts:~1312` + `task.ts:~861`** → **route workspace tasks through the engine merge (`landWorkspaceTask`)** instead of `store.mergeTask`, so user-triggered `fn task merge` / the dashboard merge button work on workspace tasks **(user decision: manual merge works in Phase C)**. +5. **`aiMergeTask` (merger.ts:~7666, deprecated)** → STAYS throwing. + +### KTD3 — `landedSha`-only per repo; `landWorkspaceTask` finalizes once; auto-retry then park (master U5) — FORK-B RESOLVED +**Verified:** `finalizeMerged`/`finalizeTask` are **task-global** — they write one task-level `mergeDetails` and move the WHOLE task to `done` (`merger-ai.ts:1298-1401`). So `landOneRepo` must advance the ref + record `workspaceWorktrees[repo].landedSha` **only** (no task move). `landWorkspaceTask` calls `finalizeTask`/move-done **exactly once** after every acquired repo's landed predicate is true. + +**Landed predicate:** a repo is landed iff `entry.branch` tip is an ancestor of (or equals) its local integration ref tip (or the recorded `landedSha` is present); `landWorkspaceTask` **skips landed repos** (idempotent). + +**Partial-land (user decision: auto-retry then park):** repo B fails after A landed → task goes to a non-done state with A's `landedSha` persisted; the failure **consumes a `mergeRetry`** and the engine **auto-retries `landWorkspaceTask`** (skipping landed A, re-attempting B) up to the existing `MAX`, then **operator-parks** (D5 escape hatch as terminal). No new partial-landed status type — `landedSha` on the entry is the only state added (`types.ts:~2256`). + +### KTD4 — Per-repo land lease via `activeSessionRegistry` new kind (master U7) — FORK-C RESOLVED +**Verified:** there is NO separate engine file-scope lease — `activeSessionRegistry` (path-keyed, `kind` enum) is the only mechanism (`runAiMerge` already registers the clean room under `kind:"ai-merge"`). Add a new `ActiveSessionKind` `"workspace-repo-land"` keyed on the **sub-repo absolute path**; register before `landOneRepo`, unregister in `finally`. **The lease is for serialization / clean-room-collision avoidance, not ref correctness** — `advanceIntegrationBranchRef`'s CAS already makes interleaved `update-ref` safe (concurrent-advance → rebuild). Set test expectations accordingly. + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace ` comments; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (real two-repo git fixture via `_workspace-fixture.ts`; assert local-ref advancement with NO push; fake timers; no mock-the-world); FN-5893 surface enumeration; the merge gate. Branch off Phase B (`gsxdsm/workspace-phase-c`). + +### U0. Extract `workspace-executor.ts` (no behavior change) +**Goal:** Move Phase A/B workspace helpers out of `executor.ts` into `workspace-executor.ts`; call sites delegate. Pure refactor. +**Requirements:** KTD0. +**Dependencies:** none. +**Files:** `packages/engine/src/executor.ts`, `packages/engine/src/workspace-executor.ts` (new), existing workspace tests (imports may shift). +**Approach:** Move `captureWorkspaceModifiedFiles`, `reviewWorkspacePerRepo`, the per-repo `verifyWorktreeInvariants` body; pass `store`/`captureModifiedFiles`/etc. as args. No logic change. +**Test scenarios:** the existing Phase A/B workspace suites pass unchanged (the move is correct iff they stay green). `Test expectation: behavior-preserving — existing suites are the oracle.` +**Verification:** All Phase A/B workspace tests + `test:gate` green; `executor.ts` shrinks; no behavior diff. + +### U1. Extract `landOneRepo`, loop it in `landWorkspaceTask`, route the doors (master U6) +**Goal:** Land each acquired sub-repo's branch onto its own local integration ref (land-as-you-go, no push), via an extracted `landOneRepo`; route the engine + CLI/dashboard doors. +**Requirements:** KTD1, KTD2. +**Dependencies:** U0. +**Files:** `packages/engine/src/merger-ai.ts` (extract `landOneRepo` from the `:1064-1216` closure; add `landWorkspaceTask`), `packages/engine/src/project-engine.ts` (`~:2300` dispatch → `landWorkspaceTask`), `packages/cli/src/commands/dashboard.ts` (`~:1312`) + `packages/cli/src/commands/task.ts` (`~:861`) (route workspace tasks to the engine merge), optional `packages/engine/src/workspace-merger.ts` (thin orchestrator), `packages/engine/src/__tests__/workspace-merger.test.ts` (new). +**Approach:** Per KTD1/KTD2. **(a)** Extract `landOneRepo(store, repoRootDir, branch, integrationBranch, options)` from the inline closure — `runAiMerge` becomes its single-repo caller, byte-for-byte. **(b)** `landWorkspaceTask` loops the acquired sub-repos: re-resolve each repo's integration branch (override-stripped), root the prune at the sub-repo, call `landOneRepo`, aggregate repo-tagged results. **(c)** Route the engine dispatch + both CLI doors to `landWorkspaceTask` for `workspaceWorktrees`-bearing tasks; `store.mergeTask`/`aiMergeTask`/the `runAiMerge` chokepoint keep throwing (defense-in-depth). +**Execution note:** Real two-repo fixture; commit on each `fusion/`; assert each repo's **local** integration ref advanced and **no remote ref/push** occurred; assert per-sub-repo prune rooting. +**Test scenarios:** +- Two acquired repos, both clean → both local integration refs advance against each repo's own resolved branch; no push/remote ref; result tags both. (happy) +- Repos with different integration branches → each lands on its own (override-stripping works; not a shared branch). (per-repo resolution) +- A conflict in repo B → repo A lands (its `landedSha` recorded); B's result reports the conflict; the task is NOT moved done. (partial — D2/D5) +- The single-repo (non-workspace) `runAiMerge` path → byte-for-byte unchanged (it calls the extracted `landOneRepo`). (regression) +- `store.mergeTask`/`aiMergeTask` with a workspace task → still throws `WorkspaceTaskMergeError`. (defense-in-depth) +- A workspace task via the CLI/dashboard merge door → routes to `landWorkspaceTask` (does not throw). (user-facing door) +**Verification:** Workspace merges land per repo on local refs (no push) via `landOneRepo`; single-repo unchanged; user doors route; non-routed doors stay guarded. + +### U2. Per-repo landed predicate + idempotent retry (master U5) +**Goal:** Track landed repos; retry skips them. +**Requirements:** KTD3. +**Dependencies:** U1. +**Files:** `packages/core/src/types.ts` (`workspaceWorktrees[repo].landedSha?`), the loop in U1, `packages/engine/src/__tests__/workspace-merger-idempotency.test.ts` (new). +**Approach:** Per KTD3. `landOneRepo` records `workspaceWorktrees[repo].landedSha` only (no task move); `landWorkspaceTask` calls `finalizeTask`/move-done exactly once after every acquired repo's landed predicate holds. Landed predicate = ancestor check (or `landedSha` present); skip landed repos. Partial-land → non-done state with `landedSha` persisted; the failure **consumes a `mergeRetry`** and is **auto-retried up to `MAX`, then operator-parked** (user decision). +**Test scenarios:** +- Re-running `landWorkspaceTask` after repo A landed + repo B failed → A is skipped (not re-landed), B is retried; A's ref does not move twice. (idempotency — partial land) +- Landed predicate true when branch tip is an ancestor of the integration tip. (predicate) +- `finalizeTask` runs exactly once, only after ALL repos landed (not per-repo). (completion — no premature done) +- Partial-land failure consumes one `mergeRetry`; after `MAX` retries the task is operator-parked, not silently failed. (retry/park) +**Verification:** Partial lands are idempotent on retry; the task moves done exactly once; auto-retry then park works; no double-land. + +### U3. Per-repo file-scope lease during land (master U7) +**Goal:** Serialize concurrent same-sub-repo lands. +**Requirements:** KTD4. +**Dependencies:** U1. +**Files:** the lease seam (FORK-C), the loop in U1, `packages/engine/src/__tests__/workspace-merger-lease.test.ts` (new). +**Approach:** Per KTD4. Acquire a per-repo integration-ref lease before each `landOneRepo`, release in `finally`. +**Test scenarios:** +- Two workspace tasks landing the same sub-repo concurrently → serialized (one waits/fails-fast, no interleaved `update-ref`). (concurrency) +- Disjoint sub-repos → land in parallel without contention. (no false serialization) +- Lease released on land failure (no stuck lock). (cleanup) +**Verification:** Same-sub-repo lands serialize; the lease never leaks. + +--- + +## Scope Boundaries + +**In scope:** the extraction (U0), the per-repo merge loop + R7-throw replacement (U1), landed predicate + idempotent retry (U2), per-repo lease (U3). + +### Deferred to Follow-Up Work (Phase D / master U8·U9) +- Self-healing reconcilers for partial-landed / stuck workspace merges. +- The e2e workspace harness. +- Per-repo worktree teardown (carried residual). +- Remote push of integration refs (explicitly out — D2/D5 are local-ref only). +- Store-level atomic per-repo `workspaceWorktrees` merge (carried residual). + +--- + +## Risks & Dependencies + +- **R1 — R7 throw replacement must not weaken the single-repo guard.** Mitigation: KTD2 dispatches only when `workspaceWorktrees` non-empty; untaught doors keep the throw; regression + defense-in-depth tests. +- **R2 — Partial-land leaves inconsistent local state.** Accepted (D2/D5: local + operator-resettable). Mitigation: KTD3 idempotent retry + persisted `landedSha`; the local-ref-only design means no remote pollution. +- **R3 — Clean-room helper reuse across the loop.** `runAiMerge`'s temp-worktree/CAS seams must be callable per repo without cross-repo state bleed. Mitigation: feasibility pre-check verifies the seams; U1 asserts no cross-repo bleed. +- **R4 — Lease vs acquisition-exclusivity confusion.** The Phase-A/U2 acquisition lock and the Phase-C land lease are different scopes. Mitigation: KTD4 distinct kind; test both. +- **R5 — `executor.ts` extraction regression (U0).** Mitigation: behavior-preserving; existing suites are the oracle; gate-green before U1. +- **Stacking dependency:** off Phase B (#1714); diff includes the stack. + +--- + +## Sources & Research + +- Master plan (U5/U6/U7, KTD2/KTD4/KTD7, D2/D5, R7). +- This session: `runAiMerge` advances the LOCAL integration ref via `update-ref` CAS (~merger-ai.ts:817/847), no push; the R7 chokepoint guard `assertNotWorkspaceTaskMerge` (~:979) + the door guards; `store.mergeTask` (third path); `SelfHealingManager.cleanupStaleTempMergeWorktrees` prefix sweep. +- Phase A/B (#1713/#1714): per-repo `baseCommitSha`, `activeWorktrees` Set, `workspace-paths.ts`, `_workspace-fixture.ts`, the workspace helpers U0 extracts. diff --git a/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md b/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md new file mode 100644 index 0000000000..ceeaf86a6f --- /dev/null +++ b/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md @@ -0,0 +1,136 @@ +--- +title: "feat: Workspace mode Phase D — self-healing reconcilers + e2e harness" +status: active +date: 2026-06-22 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase D / U8·U9) +depth: deep +--- + +# feat: Workspace mode Phase D — self-healing reconcilers + e2e harness + +> **ID namespace:** local `U1·U2` decompose master-plan **U8, U9**. +> **Anchors feasibility-VERIFIED.** The pre-check found a P0 (an existing reconciler wrongly finalizes a partial-landed workspace task) and resolved all three forks — folded in below. + +## Summary + +Phase D closes the workspace-mode lifecycle. **The headline is not new reconcilers — it's making the EXISTING self-healing layer workspace-aware**, because Phase C's `status:"merging"` and the singular `task.worktree===null` shape make the current reconcilers either wrongly finalize or silently skip workspace tasks. Plus new reconcilers for partial-land recovery, phantom land-lease reclaim, and per-repo worktree cleanup, and an e2e harness proving the full lifecycle with no remote push. Final phase. + +Builds on Phase C (#1717): `landWorkspaceTask`, `isRepoLanded` (exported), `workspaceWorktrees[repo].landedSha`, the `workspace-repo-land` lease, `WorkspacePartialLandError`, the canonical `isWorkspaceTask`. + +**Stacking:** off Phase C; PR diff includes the whole stack; must not merge until it lands. + +--- + +## Problem Frame + +Phase C made workspace merges land-as-you-go, but the engine's self-healing reconcilers reason about a singular `task.worktree` + a single landed commit. Two are actively wrong/blind for workspace tasks, and three new states have no recovery: + +- **(P0) `recoverInterruptedMergingTasks` (self-healing.ts:6670) + `recoverStaleMergingStatus` (:2446)** act on any `ACTIVE_MERGE_STATUSES` task; `landWorkspaceTask` sets `"merging"` (merger-ai.ts:1525). If the holder dies after repo A lands, these call the **singular** `findLandedTaskCommit` (:1620, git over the non-git workspace `rootDir`) and on a one-repo hit **finalize the whole task to done + emit `task:merged`** — marking a partial-landed workspace task fully merged. +- **(P1) `recoverMergeableReviewTasks` (:5758)** filters on `Boolean(t.worktree)` (:5778) → a mergeable workspace task whose merge enqueue was dropped is **silently skipped forever**. +- New states with no recovery: a **partial-landed** stuck task, a **phantom `workspace-repo-land` lease** held by a dead task, and **orphaned per-repo worktrees**. +- **Triple-proof** (`evaluateBackwardMoveTripleProof` :820) classifies liveness via `task.worktree`/`canonicalFusionBranchName` — not workspace-aware (liveness lives across N sub-repo worktrees). + +--- + +## Key Technical Decisions + +### KTD1 — Make the EXISTING merging-status + mergeable-review reconcilers workspace-aware (P0/P1; master U8; FN-5893) +For an `isWorkspaceTask(task)` candidate: +- `recoverInterruptedMergingTasks` / `recoverStaleMergingStatus` must **NOT** use `findLandedTaskCommit`/single-commit finalize. Instead clear the transient `"merging"` status and decide via the **per-repo** `isRepoLanded` predicate: all repos landed → finalize once (the `finalizeWorkspaceTask` path); partial/none → re-enqueue (KTD3). Never finalize a workspace task on one repo's commit. +- `recoverMergeableReviewTasks` must admit `isWorkspaceTask` candidates (relax the `Boolean(t.worktree)` gate to `Boolean(t.worktree) || isWorkspaceTask(t)`), so a zero-landed mergeable workspace task is re-enqueued, not skipped. + +### KTD2 — New partial-land reconciler + workspace-aware liveness; re-enqueue via `enqueueMerge` (master U8; FORK-A resolved) +A new reconciler finds workspace tasks in a non-done state with a stale binding and re-enqueues the merge via **`this.options.enqueueMerge?.(task.id)`** (`SelfHealingOptions.enqueueMerge` :308, wired in-process-runtime.ts:795 → `internalEnqueueMerge` → routes workspace tasks to `landWorkspaceTask`) — **NOT a direct `landWorkspaceTask` call**. `landWorkspaceTask` is idempotent (`isRepoLanded` skips landed repos). Reuse `allowsAutoMergeProcessing` (task-merge.ts:62 — the canonical FN-5147 `autoMerge:false` guard) + user-pause + a **workspace-aware liveness predicate** (any sub-repo worktree active via `activeSessionRegistry.pathsForTask(task.id)` + `isPathActive`, since triple-proof isn't workspace-aware). Emits `task:reconcile-workspace-partial-land` (+ `-no-action`). +**FORK-A (unrecoverable):** a repo is unrecoverable iff its `fusion/` branch is gone **AND** `landedSha` is unset (nothing landed, nothing to land) → park `status:"failed"`. Branch gone but `landedSha` set → already landed (`isRepoLanded` ancestor check) → skip. Otherwise retryable. + +### KTD3 — Phantom `workspace-repo-land` lease reclaim via a new registry enumeration seam (master U8) +`ActiveSessionRegistry` exposes only `lookupByPath`/`isPathActive`/`pathsForTask` — no enumeration by kind, and a dead task is gone from the in-progress lists (so FN-6736's iterate-tasks approach can't surface a leaked lease). **Add an enumeration seam** `entriesByKind(kind)` → `{path, taskId, kind, registeredAt}[]` (`registeredAt` already tracked, active-session-registry.ts:31). The reconciler enumerates `workspace-repo-land` entries, and for each whose owner is terminal/dead AND `registeredAt` older than a floor (reuse the FN-6736 `graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER` analog, :966), clears it + emits `task:reclaim-phantom-workspace-land-lease`. + +### KTD4 — Per-repo worktree cleanup from the STORED paths, no directory walk (master U8; FORK-B resolved) +**FORK-B premise was wrong** — per-repo worktrees are not anonymous: `workspaceWorktrees[repo].worktreePath` is persisted (types.ts:2276). For a done/dead workspace task, read each recorded `worktreePath` and `git worktree remove --force` it, guarded by `activeSessionRegistry.isPathActive(path)` (mirroring self-healing.ts:9955). **No temp-root readdir/walk** (AGENTS.md) — bounded by construction. Emits `task:reconcile-orphaned-workspace-worktree`. + +### KTD5 — e2e harness placement: engine-default (`describeIfGit`), not the gate (master U9; FORK-C resolved) +The merge gate (`engine-core`) is an explicit allow-list excluding real-git tests — a real two-repo fixture e2e cannot run there. Model the **merge + recovery** e2e on `workspace-merger.test.ts` (unmarked, `describeIfGit`, engine-default lane): drive `landWorkspaceTask` directly + invoke the U1/KTD2 reconciler method directly with fake timers; assert local-ref advancement, **no push**, and partial-land recovery. Reuse the existing `executor-workspace-capture.test.ts` / `reviewer-workspace.test.ts` direct-call tests for the capture/review legs. Reserve a single `.slow.test.ts` (engine-slow lane) only if a full ProjectEngine acquire→capture→review→merge loop must be proven. + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace `; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (real two-repo fixture; fake timers; no mock-the-world; **no unbounded temp walk**); FN-5893 (the EXISTING reconcilers are in scope, not just new ones); the merge gate. Branch off Phase C (`gsxdsm/workspace-phase-d`). + +### U1. Workspace-aware self-healing (master U8) + +**Goal:** Make the existing reconcilers workspace-safe (P0/P1) and add partial-land recovery, phantom-lease reclaim, and per-repo worktree cleanup — none moving a human-gated/live task backward. + +**Requirements:** KTD1, KTD2, KTD3, KTD4. + +**Dependencies:** Phase C. + +**Files:** +- `packages/engine/src/self-healing.ts` — workspace-aware branches in `recoverInterruptedMergingTasks` (:6670), `recoverStaleMergingStatus` (:2446), `recoverMergeableReviewTasks` (:5758); the new partial-land reconciler (re-enqueue via `enqueueMerge`); the phantom-lease reclaim (via the new registry seam); the per-repo worktree cleanup; the workspace-aware liveness predicate. +- `packages/engine/src/active-session-registry.ts` — new `entriesByKind(kind)` enumeration seam. +- `packages/engine/src/run-audit.ts` — add the four literals to the `DatabaseMutationType` union (`task:reconcile-workspace-partial-land`, `-no-action`, `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`). +- `AGENTS.md` — add the new run-audit events to the Run Audit list. +- `packages/engine/src/__tests__/self-healing-workspace.test.ts` (new — real two-repo fixture). + +**Approach:** Per KTD1-KTD4. Reuse `allowsAutoMergeProcessing` + the workspace-aware liveness predicate as the "safe to move backward" gate; re-enqueue via `enqueueMerge`; mirror FN-6736 for the lease floor; cleanup from stored paths. + +**Test scenarios:** +- A partial-landed (repo A `landedSha`, repo B not) task stuck `"merging"` with no live holder → `recoverInterruptedMergingTasks` does **NOT** finalize it done; the partial-land reconciler re-enqueues; a later land completes it (skipping A). (P0 regression + recovery) +- A zero-landed mergeable workspace task whose merge was dropped → `recoverMergeableReviewTasks` re-enqueues it (not skipped by the `worktree` gate). (P1) +- `autoMerge:false` / user-paused / a live sub-repo worktree (via `pathsForTask`+`isPathActive`) → `-no-action` (not moved backward). (FN-5147 guards) +- A `workspace-repo-land` lease owned by a terminal/dead task, older than the floor → reclaimed; owned by a live merging task → untouched. (phantom reclaim) +- A done workspace task's recorded per-repo worktrees → removed (guarded by `isPathActive`); a live task's → untouched; **no temp-root walk**. (cleanup) +- A repo with branch gone + `landedSha` unset → parked failed; branch gone + `landedSha` set → skipped as landed. (FORK-A) +- Single-repo (non-workspace) tasks → all reconcilers behave identically. (regression) + +**Verification:** No reconciler wrongly finalizes/skips/moves-backward a workspace task; partial/phantom/orphan states recover; single-repo unchanged; no unbounded walk. + +### U2. End-to-end merge + recovery harness (master U9) + +**Goal:** Prove a real two-repo workspace task lands both repos on local refs with no push, and that partial-land recovers via U1. + +**Requirements:** KTD5. + +**Dependencies:** U1. + +**Files:** `packages/engine/src/__tests__/workspace-e2e.test.ts` (new — engine-default lane, `describeIfGit`, real two-repo fixture, fake timers). + +**Approach:** Per KTD5. Drive `landWorkspaceTask` on a real two-repo fixture; assert both local integration refs advanced, **no `refs/remotes` change / no push**, `landedSha` per repo, finalize-once. Partial-land: force repo B conflict → assert A landed + task not done, then invoke the U1 partial-land reconciler (fake timers) → assert recovery. Reference the existing `executor-workspace-capture` / `reviewer-workspace` tests for the capture/review legs (don't re-drive the full engine loop unless a `.slow` test is added). + +**Test scenarios:** +- Two repos land → both local refs advanced, **no push**, both `landedSha`, task done once. (e2e happy + no-push invariant) +- Partial-land → A landed, task not done → U1 reconciler → recovery completes. (e2e recovery) + +**Verification:** Real workspace task lands end-to-end with no remote push; partial-land self-heals. + +--- + +## Scope Boundaries + +**In scope:** workspace-aware existing reconcilers + the three new reconcilers (U1), the merge+recovery e2e (U2). + +### Deferred to Follow-Up Work +- Extracting `workspace-merger.ts`; per-sub-repo cwd reachability verification; store-level atomic per-repo merge (Phase-C residuals). +- A full ProjectEngine acquire→capture→review→merge `.slow` loop test (only if needed). +- Rich dashboard per-repo merge-status UI. Remote push of integration refs (out — D2/D5). + +--- + +## Risks & Dependencies + +- **R1 (P0-class) — wrongly finalizing/skipping/moving-backward a workspace task.** The whole point of U1. Mitigation: KTD1 fixes the two wrong/blind reconcilers; every reconciler reuses `allowsAutoMergeProcessing` + the workspace-aware liveness predicate + triple-proof analog; tests assert the `-no-action` + no-wrong-finalize paths. +- **R2 — unbounded temp walk.** Mitigation: KTD4 uses stored paths only; test asserts no walk. +- **R3 — e2e lane.** Mitigation: KTD5 places it in engine-default (`describeIfGit`), not the gate. +- **R4 — reconciler idempotency / double-act.** Mitigation: `isRepoLanded` + `enqueueMerge` idempotency. +- **Stacking:** off Phase C (#1717). + +--- + +## Sources & Research + +- Master plan (U8/U9, FN-5147/FN-6736). +- Phase-D feasibility pre-check (verified anchors: the P0 `recoverInterruptedMergingTasks`/`findLandedTaskCommit` finalize, `recoverMergeableReviewTasks` `Boolean(t.worktree)` gate :5778, `enqueueMerge` :308, no registry `entriesByKind`, stored `worktreePath`, engine-core gate allow-list, `allowsAutoMergeProcessing` :62, triple-proof :820, FN-6736 floor :966). +- Phase C (#1717): `isRepoLanded`, `landedSha`, the lease, `landWorkspaceTask`, `isWorkspaceTask`. +- `self-healing.ts`, `active-session-registry.ts`, `run-audit.ts`, `_workspace-fixture.ts`, `workspace-merger.test.ts` (the lane model). diff --git a/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md b/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md new file mode 100644 index 0000000000..4221daeea7 --- /dev/null +++ b/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md @@ -0,0 +1,102 @@ +--- +title: "Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped" +date: 2026-06-21 +category: docs/solutions/logic-errors +module: engine (workflow store + graph executor) +problem_type: logic_error +component: service_object +symptoms: + - "Enabling a built-in optional-group (browser-verification) on a coding/stepwise task did nothing — the group's steps never ran." + - "The default-on seed path and direct graph-executor unit tests passed, masking the bug; only user-driven enable (create-with-enable or update/toggle) failed." + - "No error surfaced — the enabled group was silently bypassed." +root_cause: logic_error +resolution_type: code_fix +severity: high +related_components: + - workflow-store + - graph-executor + - optional-group +tags: + - optional-group + - enabledworkflowsteps + - per-task-override + - id-collision + - workflow-store + - silent-bypass +--- + +# Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped + +## Problem + +A graph-native `optional-group` workflow node is enabled per task via the `enabledWorkflowSteps` array, keyed by the group's **node id**. The graph executor runs the group only when `task.enabledWorkflowSteps.includes(node.id)`. But the store's `resolveEnabledWorkflowSteps` ran every id through the **legacy step-template materializer** (`getBuiltInWorkflowTemplate` → `ensureWorkflowStepForTemplate`). The built-in `browser-verification` group deliberately reused the template id `"browser-verification"` as its node id (for back-compat), so that id matched a `WORKFLOW_STEP_TEMPLATES` entry and was **remapped to a materialized `WorkflowStep` row id** (≠ the node id). The executor's membership check then never matched, and the enabled group was silently bypassed — the headline use case (turn the optional step on) did nothing, with no error. + +## Symptoms + +- Enabling `browser-verification` on a coding/stepwise task ran nothing pre-merge. +- Direct graph-executor tests (which pass a raw `enabledWorkflowSteps: ["browser-verification"]`) and the default-on **seed** path passed — masking the defect. +- Only the **user-driven** enable paths failed: create-with-explicit-enable and `updateTask({ enabledWorkflowSteps })` (the per-task toggle in the UI). + +## What Didn't Work + +- **Trusting the existing tests.** The unit tests used group ids like `og-on`/`og-off` that do **not** collide with any `WORKFLOW_STEP_TEMPLATES` id, so `getBuiltInWorkflowTemplate` returned undefined and the id passed through untouched — the tests were green precisely because they avoided the colliding id. The bug only fires when the group id equals a built-in template id. +- **Assuming the executor test covered it.** The two-task divergence test enabled the group by writing `enabledWorkflowSteps` straight onto the task, bypassing the store's resolver — so it never exercised the remap. The defect lived entirely in the create/update **resolution** path, one layer above the executor. + +## Solution + +Pass a workflow's optional-group node ids through `resolveEnabledWorkflowSteps` **untouched** — they are executor toggle keys, not legacy step-template ids to be materialized. + +```ts +// NEW: enumerate every optional-group node id (regardless of defaultOn). +export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] { + return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId); // templateId === group node id +} + +// store.ts — the resolver gains an optional pass-through set: +private async resolveEnabledWorkflowSteps( + stepIds?: string[], + optionalGroupIds?: Set, +): Promise { + // ... + // Optional-group toggle ids pass through raw — never materialized as legacy step rows. + const template = optionalGroupIds?.has(stepId) + ? undefined + : this.getBuiltInWorkflowTemplate(stepId); + const resolvedId = template ? (await this.ensureWorkflowStepForTemplate(stepId)).id : stepId; + // ... +} + +// helper resolving the task's workflow IR → its optional-group id set: +private async optionalGroupIdSet(workflowId?: string | null): Promise> { + const wfId = workflowId ?? (await this.getDefaultWorkflowId()); + if (!wfId) return new Set(); + const def = await this.getWorkflowDefinition(wfId); + if (!def || def.kind === "fragment") return new Set(); + return new Set(resolveAllOptionalGroupIds(def.ir)); +} +``` + +Both user-enable call sites supply the set: create (`optionalGroupIdSet(input.workflowId)`) and update (`optionalGroupIdSet(getTaskWorkflowSelection(task.id)?.workflowId)`). + +**Regression test** — must use a **colliding** id (`browser-verification`), since non-colliding ids never reproduce it: create-with-enable and update/toggle both assert the raw group node id survives in `enabledWorkflowSteps`. + +## Why This Works + +The bug is a **per-task override that is read correctly at the action site but rewritten en route**. The override (`enabledWorkflowSteps`) was consulted exactly where the action runs (the graph executor), but the value was mutated in the **resolution path** before it got there, because two id namespaces overlap: graph-native optional-group **node ids** and legacy **`WorkflowStep` template ids**. The materializer is meaningful only for the retired declaration/`workflow-step`-seam execution model; for a graph-native group it is pure harm. Marking group ids as pass-through keeps the key **identity-stable** from definition through every consumer, so the executor's `includes(node.id)` check matches. + +(Verified the related slim-projection trap does **not** apply: the executor reads `enabledWorkflowSteps` off the `TaskDetail` snapshot it is handed, not a column-narrowed SELECT, so the array is fully hydrated.) + +## Prevention + +- **When introducing a new identity/key that shares a namespace with an existing one, grep every reader AND every *transformer* of that key.** A silent remap in a resolver is as fatal as a missing read — the override "survives" but as the wrong value. Demand each consumer is either re-keyed or argued identity-stable. +- **Regression tests for namespace collisions must use a *colliding* value.** A test with a deliberately distinct id proves nothing about the collision; pick the id that actually overlaps the legacy namespace (here, a built-in template id reused as a node id). +- **Test the path the user actually takes, not just the layer under test.** The executor-level test bypassed the store resolver where the bug lived; a create/update round-trip through the store would have caught it. Prefer at least one end-to-end seam test per per-task facet. +- **A facet that "works on seed/default but not on toggle" is the tell.** Asymmetry between the seed path (writes raw ids) and the user-enable path (runs the resolver) localizes the defect to the resolver. + +## Related Issues + +This is the **id-namespace-collision variant** of the per-task/per-entity override blast-radius class. Same disease (override invisible to the user, no error), different organ (key rewritten in resolution vs. not consulted at a trigger gate): + +- [Per-task auto-merge override ignored by trigger-layer gates](../logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md) — sibling: override dead from the user's perspective; theirs is a missed trigger gate, ours is a resolution-path key remap. Its "consult the override everywhere between definition and action" rule covers this case too. +- [Per-entity execution-principal override: the full blast-radius checklist](../architecture-patterns/per-entity-execution-principal-override-blast-radius.md) — the generalizing checklist; closest prior art is its "validate composite node ids against the graph, never round-trip them" example. This bug is a new bullet for that checklist. +- [Workflow-native execution through runtime primitives](../architecture-patterns/workflow-native-runtime-primitives.md) — context: the legacy-`WorkflowStep`-row vs. graph-node two-control-planes tension this collision exploits. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 9d844de3ec..cce2877d43 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -17,6 +17,7 @@ import { resolveGlobalDir, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS, isWorkflowColumnsEnabled, + isWorkspaceTask, resolveColumnFlags, BUILTIN_CODING_WORKFLOW_IR, mergeBuiltInZaiProviderModels, @@ -41,7 +42,8 @@ import { type RuntimeLogger, } from "@fusion/dashboard"; import { - aiMergeTask, + runAiMerge, + landWorkspaceTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, @@ -1295,11 +1297,49 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // wrapper function while the underlying implementation is swapped when the // engine starts in engine mode. // - // In UI-only mode: calls aiMergeTask directly (no engine, no semaphore). + // In UI-only mode: calls runAiMerge directly (no engine, no semaphore). // In engine mode: replaced by engine.onMerge() after ProjectEngine starts // (semaphore-gated via the engine's InProcessRuntime). // + // FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified all merge + // entry points onto runAiMerge (the FN-5633 clean-room AI merge path); + // aiMergeTask is soft-deprecated. + // const onMergeImpl = async (taskId: string) => { + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2): + // Dashboard merge button (UI-only mode). A workspace-mode task routes through + // the ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its + // own LOCAL integration ref, no push) instead of throwing — manual merge works in + // Phase C (user decision). U0's R7 throw is replaced here by routing; the engine + // chokepoint + store.mergeTask/aiMergeTask keep throwing as defense-in-depth. + const mergeTask = await store.getTask(taskId).catch(() => null); + // FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask` + // (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check. + const isWorkspaceMerge = !!mergeTask && isWorkspaceTask(mergeTask); + if (isWorkspaceMerge) { + const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, { + agentStore, + }); + const latest = await store.getTask(taskId).catch(() => mergeTask!); + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B3): + // landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2), + // so the merge door must report merged=true when the workspace fully landed — mirroring + // the engine dispatch's MergeResult. The first landed sub-repo's landedSha is the recorded + // commitSha (same convention finalizeWorkspaceTask uses). On a partial land, merged stays + // false and the partial-land error surfaces on the task log. + const landedSha = workspaceResult.repos.find((r) => r.status === "landed")?.landedSha; + return { + task: latest ?? mergeTask!, + branch: getTaskBranchName(taskId), + merged: workspaceResult.allLanded, + mergeConfirmed: workspaceResult.allLanded || undefined, + commitSha: workspaceResult.allLanded ? landedSha : undefined, + worktreeRemoved: false, + branchDeleted: false, + error: workspaceResult.allLanded ? undefined : "partial workspace land — see task log", + }; + } + const settings = await store.getSettings(); if (getMergeStrategy(settings) === "pull-request") { const githubClient = new GitHubClient(); @@ -1327,7 +1367,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ); try { - return await aiMergeTask(store, cwd, taskId, { + return await runAiMerge(store, cwd, taskId, { agentStore, onAgentText: (delta) => streamedMergeLog.push(delta), }); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index b70c94603b..3fca040855 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,5 +1,5 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; -import { aiMergeTask } from "@fusion/engine"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { runAiMerge, landWorkspaceTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning"; @@ -851,7 +851,40 @@ export async function runTaskMerge(id: string, projectName?: string) { console.log(`\n Merging ${id} with AI...\n`); try { - const result = await aiMergeTask(store, projectPath, id, { + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2): + // User-triggered `fn task merge`. A workspace-mode task routes through the + // ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own + // LOCAL integration ref, no push) instead of throwing — manual merge works in + // Phase C (user decision). U0's R7 throw is replaced here by routing; the + // engine chokepoint + store.mergeTask/aiMergeTask keep throwing. + const mergeTaskRecord = await store.getTask(id).catch(() => null); + // FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask` + // (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check. + const isWorkspaceMerge = !!mergeTaskRecord && isWorkspaceTask(mergeTaskRecord); + if (isWorkspaceMerge) { + const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, { + onAgentText: (delta) => process.stdout.write(delta), + }); + console.log(); + for (const repo of workspaceResult.repos) { + const label = + repo.status === "landed" ? `landed ${repo.landedSha?.slice(0, 8) ?? ""} → ${repo.integrationBranch}` + : repo.status === "empty" ? "no net changes" + : `failed: ${repo.error ?? "unknown"}`; + console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`); + } + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B3): + // landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2), + // so report it as merged rather than "remains in review until U2". A partial land leaves + // the task in review (landed repos stay landed locally) and exits non-zero. + console.log( + `\n ${workspaceResult.allLanded ? "✓ All sub-repos landed — task finalized to done" : "✗ Partial land — see failures above (task remains in review; landed repos stay landed locally)"}\n`, + ); + if (!workspaceResult.allLanded) process.exit(1); + return; + } + + const result = await runAiMerge(store, projectPath, id, { onAgentText: (delta) => process.stdout.write(delta), }); diff --git a/packages/core/src/__tests__/assert-not-workspace-task-merge.test.ts b/packages/core/src/__tests__/assert-not-workspace-task-merge.test.ts new file mode 100644 index 0000000000..676fdd7671 --- /dev/null +++ b/packages/core/src/__tests__/assert-not-workspace-task-merge.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { assertNotWorkspaceTaskMerge } from "../types.js"; + +// FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0). +// This shared predicate is called at all four merge entry points (engine +// dispatch, store.mergeTask, CLI onMergeImpl, CLI runTaskMerge). Workspace-mode +// tasks (populated workspaceWorktrees) must be held until per-repo merge support +// lands (master-plan U6); single-repo tasks are a no-op. +describe("assertNotWorkspaceTaskMerge (R7 workspace merge-boundary guard)", () => { + it("is a no-op for a single-repo task (no workspaceWorktrees)", () => { + expect(() => assertNotWorkspaceTaskMerge({ id: "FN-1" })).not.toThrow(); + }); + + it("is a no-op when workspaceWorktrees is an empty record", () => { + expect(() => + assertNotWorkspaceTaskMerge({ id: "FN-1", workspaceWorktrees: {} }), + ).not.toThrow(); + }); + + it("throws a U6-named error for a populated workspace task", () => { + expect(() => + assertNotWorkspaceTaskMerge({ + id: "FN-WS", + workspaceWorktrees: { + "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" }, + "repo-b": { worktreePath: "/tmp/b", branch: "fusion/fn-ws-b" }, + }, + }), + ).toThrow( + "Workspace task FN-WS cannot merge until per-repo merge support (master-plan U6) lands", + ); + }); + + it("throws even with a single workspace worktree entry", () => { + expect(() => + assertNotWorkspaceTaskMerge({ + id: "FN-WS1", + workspaceWorktrees: { "repo-a": { worktreePath: "/tmp/a", branch: "b" } }, + }), + ).toThrow(/master-plan U6/); + }); +}); diff --git a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts index 985260d595..7d7f31bfdf 100644 --- a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts @@ -38,11 +38,33 @@ describe("builtin coding workflow ir", () => { const seams = BUILTIN_CODING_WORKFLOW_IR.nodes .map((node) => String(node.config?.seam ?? "")) .filter((seam) => seam.length > 0); - expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review"])); + expect(seams).toEqual(expect.arrayContaining(["execute", "review"])); + // U6: the `workflow-step` seam was replaced by the browser-verification + // optional-group; no node declares the legacy seam anymore. + expect(seams).not.toContain("workflow-step"); expect(seams).not.toContain("merge"); expect(seams).not.toContain("triage"); }); + it("expresses pre-merge browser-verification as a default-off optional-group (U6)", () => { + const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n])); + expect(byId.get("workflow-step")).toBeUndefined(); + const group = byId.get("browser-verification"); + expect(group?.kind).toBe("optional-group"); + expect(group?.config?.name).toBe("Browser Verification"); + expect(group?.config?.defaultOn).toBe(false); + // execute → browser-verification → review on the success path; failure → end. + expect(BUILTIN_CODING_WORKFLOW_IR.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ from: "execute", to: "browser-verification", condition: "success" }), + expect.objectContaining({ from: "browser-verification", to: "review", condition: "success" }), + expect.objectContaining({ from: "browser-verification", to: "end", condition: "failure" }), + ]), + ); + // The legacy optionalSteps declaration is gone (the group replaces it). + expect("optionalSteps" in BUILTIN_CODING_WORKFLOW_IR).toBe(false); + }); + it("defines the six legacy columns in legacy order (KTD-1)", () => { expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2"); if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); @@ -73,16 +95,17 @@ describe("builtin coding workflow ir", () => { it("places seam nodes in their columns", () => { const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n])); expect(byId.get("execute")?.column).toBe("in-progress"); - expect(byId.get("workflow-step")?.column).toBe("in-progress"); + // U6: browser-verification optional-group replaces the workflow-step seam. + expect(byId.get("browser-verification")?.column).toBe("in-progress"); expect(byId.get("review")?.column).toBe("in-review"); expect(byId.get("merge-gate")?.column).toBe("in-review"); expect(byId.get("merge-attempt")?.column).toBe("in-review"); }); - it("assigns descriptive names to execute/workflow-step/review/merge seam nodes", () => { + it("assigns descriptive names to execute/review seam nodes and the browser-verification group", () => { const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n])); expect(byId.get("execute")?.config?.name).toBe("Execute"); - expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps"); + expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification"); expect(byId.get("review")?.config?.name).toBe("Review"); }); @@ -94,9 +117,8 @@ describe("builtin coding workflow ir", () => { expect(config.maxRetries).toBeLessThanOrEqual(10); const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n])); - expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps"); + expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification"); expect(byId.get("review")?.config?.name).toBe("Review"); - expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined(); expect(byId.get("review")?.config?.maxRetries).toBeUndefined(); expect(byId.get("merge-attempt")?.config?.maxReworkCycles).toBe(3); }); diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index 7dbedd6888..491314877f 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -152,7 +152,11 @@ describe("built-in workflows", () => { const byId = new Map(ir.nodes.map((node) => [node.id, node])); expect(byId.get("execute")?.column).toBe("in-progress"); - expect(byId.get("workflow-step")?.column).toBe("in-progress"); + // U6: the legacy `workflow-step` seam is replaced by the pre-merge + // `browser-verification` optional-group, placed in the implementation column. + expect(byId.get("workflow-step")).toBeUndefined(); + expect(byId.get("browser-verification")?.kind).toBe("optional-group"); + expect(byId.get("browser-verification")?.column).toBe("in-progress"); expect(byId.get("review")?.column).toBe("in-review"); // Merge is the native primitive region (FN-6035), placed in in-review. expect(byId.get("merge")).toBeUndefined(); @@ -312,9 +316,12 @@ describe("built-in workflows", () => { expect(executeConfig?.maxRetries).toBeLessThanOrEqual(10); const byId = new Map(candidate.nodes.map((node) => [node.id, node])); - expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps"); + // U6: pre-merge browser-verification is an optional-group (default OFF), + // not the legacy `workflow-step` seam. + expect(byId.get("workflow-step")).toBeUndefined(); + expect(byId.get("browser-verification")?.kind).toBe("optional-group"); + expect(byId.get("browser-verification")?.config?.name).toBe("Browser Verification"); expect(byId.get("review")?.config?.name).toBe("Review"); - expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined(); expect(byId.get("review")?.config?.maxRetries).toBeUndefined(); // The merge lifecycle is no longer a single `merge` seam node (FN-6035): it // is expressed as the merge-gate/merge-attempt/branch-group primitive region. @@ -600,17 +607,22 @@ describe("built-in workflows", () => { description: "implicit builtin default", }); + // U6: builtin:coding now carries the `browser-verification` optional-group + // (an interpreter-deferred construct), so its DEFAULT-workflow materialization + // falls back to no legacy WorkflowStep rows and records no selection row — + // identical to the stepwise built-in below. The group is defaultOn:false, so + // enabledWorkflowSteps stays empty. await store.setDefaultWorkflowId("builtin:coding"); const codingTask = await store.createTask({ description: "default builtin coding" }); expect((await store.getTask(codingTask.id)).enabledWorkflowSteps ?? []).toEqual([]); - expect(store.getTaskWorkflowSelection(codingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); + expect(store.getTaskWorkflowSelection(codingTask.id)).toBeUndefined(); const reservedCodingTask = await store.createTaskWithReservedId( { description: "reserved default builtin coding" }, { taskId: "reserved-default-builtin-coding" }, ); expect((await store.getTask(reservedCodingTask.id)).enabledWorkflowSteps ?? []).toEqual([]); - expect(store.getTaskWorkflowSelection(reservedCodingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); + expect(store.getTaskWorkflowSelection(reservedCodingTask.id)).toBeUndefined(); await store.setDefaultWorkflowId("builtin:stepwise-coding"); const stepwiseTask = await store.createTask({ description: "default builtin stepwise" }); diff --git a/packages/core/src/__tests__/workflow-compiler.test.ts b/packages/core/src/__tests__/workflow-compiler.test.ts index 21086ebfcb..361db9cd16 100644 --- a/packages/core/src/__tests__/workflow-compiler.test.ts +++ b/packages/core/src/__tests__/workflow-compiler.test.ts @@ -4,7 +4,6 @@ import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js"; import { compileWorkflowToSteps, - MERGE_REGION_NODE_KINDS, validateLinearity, WorkflowCompileError, WORKFLOW_INTERPRETER_DEFERRED_SUFFIX, @@ -127,33 +126,25 @@ describe("compileWorkflowToSteps (U2)", () => { expect(() => compileWorkflowToSteps(ir)).toThrow(/interpreter \(deferred\)/i); }); - it("validates builtin workflow linearity while preserving stepwise interpreter deferral", () => { - expect(validateLinearity(BUILTIN_CODING_WORKFLOW_IR)).toBeNull(); + it("defers both builtin coding and stepwise to the interpreter (U6: coding now carries an optional-group)", () => { + // U6: builtin:coding gained the `browser-verification` optional-group on its + // pre-merge path — a branching, single-pass container the linear WorkflowStep + // runner cannot lower. Like stepwise, coding is now interpreter-deferred. + const codingErr = validateLinearity(BUILTIN_CODING_WORKFLOW_IR); + expect(codingErr).toBeInstanceOf(WorkflowCompileError); + expect(codingErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); const stepwiseErr = validateLinearity(BUILTIN_STEPWISE_CODING_WORKFLOW_IR); expect(stepwiseErr).toBeInstanceOf(WorkflowCompileError); expect(stepwiseErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); }); - it("compiles the builtin coding workflow without merge-region steps", () => { - const steps = compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR); - const mergeRegionNodeIds = BUILTIN_CODING_WORKFLOW_IR.nodes - .filter((node) => MERGE_REGION_NODE_KINDS.has(node.kind)) - .map((node) => node.id); - - expect(steps.map((step) => step.name)).toEqual([]); - expect(mergeRegionNodeIds).toEqual( - expect.arrayContaining([ - "merge-gate", - "merge-retry", - "merge-manual-hold", - "branch-group-member-integration", - "branch-group-promotion", - "merge-attempt", - "recovery-router", - ]), - ); - expect(steps.some((step) => mergeRegionNodeIds.includes(step.name))).toBe(false); + it("defers compiling the builtin coding workflow to the interpreter (U6)", () => { + // The browser-verification optional-group makes the graph non-linear, so + // compileWorkflowToSteps throws the interpreter-deferred error rather than + // producing a (previously empty) linear pre-merge step list. + expect(() => compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR)).toThrow(WorkflowCompileError); + expect(() => compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR)).toThrow(/interpreter \(deferred\)/i); }); it("compiles a workflow whose post-review merge region branches into primitives (FN-6035)", () => { diff --git a/packages/core/src/__tests__/workflow-ir-optional-group.test.ts b/packages/core/src/__tests__/workflow-ir-optional-group.test.ts new file mode 100644 index 0000000000..88806e3383 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-optional-group.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js"; +import type { WorkflowIrEdge, WorkflowIrNode, WorkflowIrV2 } from "../workflow-ir-types.js"; + +/* +FNXC:WorkflowOptionalGroup 2026-06-21-11:00: +U1 validation contract for the `optional-group` container node — the single-pass, +toggle-gated subgraph that replaces the declaration-based optional-steps model. +Mirrors the loop validation suite minus loop-specific exit config. +*/ + +const columns: WorkflowIrV2["columns"] = [{ id: "work", name: "Work", traits: [] }]; + +function groupTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } { + return { + nodes: [ + { id: "verify", kind: "prompt", config: { prompt: "verify in browser" } }, + { id: "report", kind: "prompt", config: { prompt: "report" } }, + ], + edges: [{ from: "verify", to: "report" }], + }; +} + +function groupIr(config: Record = {}): WorkflowIrV2 { + return { + version: "v2", + name: "optional-group-test", + columns, + nodes: [ + { id: "start", kind: "start" }, + { + id: "browser-verification", + kind: "optional-group", + config: { + name: "Browser Verification", + defaultOn: false, + template: groupTemplate(), + ...config, + }, + }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "browser-verification" }, + { from: "browser-verification", to: "end" }, + ], + }; +} + +describe("optional-group validation", () => { + it("parses and round-trips a valid optional-group node", () => { + const parsed = parseWorkflowIr(groupIr()) as WorkflowIrV2; + const group = parsed.nodes.find((n) => n.id === "browser-verification"); + + expect(group?.kind).toBe("optional-group"); + expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed); + }); + + it("does not require defaultOn (defaults to off via the resolver)", () => { + expect(() => parseWorkflowIr(groupIr({ defaultOn: undefined }))).not.toThrow(); + }); + + it("rejects a non-boolean defaultOn", () => { + expect(() => parseWorkflowIr(groupIr({ defaultOn: "yes" as unknown as boolean }))).toThrow( + /defaultOn must be a boolean/, + ); + }); + + it("rejects an empty template", () => { + expect(() => parseWorkflowIr(groupIr({ template: { nodes: [], edges: [] } }))).toThrow(/non-empty/); + }); + + it("rejects a missing template", () => { + expect(() => parseWorkflowIr(groupIr({ template: undefined }))).toThrow( + /must declare a template/, + ); + }); + + it("rejects duplicate template node ids", () => { + const template = groupTemplate(); + template.nodes.push({ id: "verify", kind: "script" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/duplicate node ids/); + }); + + it("rejects template edges that leave the template", () => { + const template = groupTemplate(); + template.edges.push({ from: "report", to: "end" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/references a node outside/); + }); + + it("rejects rework edges inside the template (single-pass guarantee)", () => { + const template = groupTemplate(); + template.edges.push({ from: "report", to: "verify", kind: "rework" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain rework edges/); + }); + + it("rejects failure-condition edges inside the template (single-pass bails before routing them)", () => { + const template = groupTemplate(); + // A parallel failure edge that the single-pass walk would silently never take. + template.edges.push({ from: "verify", to: "report", condition: "failure" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain failure-condition edges/); + }); + + it("rejects nested loop/foreach/optional-group regions", () => { + const template = groupTemplate(); + template.nodes.push({ + id: "nested", + kind: "optional-group", + config: { template: groupTemplate() }, + }); + template.edges.push({ from: "report", to: "nested" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow( + /nested loop\/foreach\/optional-group/, + ); + }); + + it("rejects more than one entry node", () => { + const template: ReturnType = { + nodes: [ + { id: "a", kind: "prompt", config: { prompt: "a" } }, + { id: "b", kind: "prompt", config: { prompt: "b" } }, + { id: "join", kind: "prompt", config: { prompt: "join" } }, + ], + // a and b both have no incoming edge → two entries. + edges: [ + { from: "a", to: "join" }, + { from: "b", to: "join" }, + ], + }; + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/exactly one entry node/); + }); + + it("rejects a template node id colliding with a top-level node id", () => { + const template = groupTemplate(); + template.nodes[0] = { id: "start", kind: "prompt", config: { prompt: "collide" } }; + template.edges = [{ from: "start", to: "report" }]; + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/collides with a top-level node id/); + }); + + it("leaves graphs without optional-group nodes byte-identical", () => { + const ir: WorkflowIrV2 = { + version: "v2", + name: "plain", + columns, + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + }; + const parsed = parseWorkflowIr(ir); + expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed); + }); +}); diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index 291e681e31..6285828f7f 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -155,7 +155,12 @@ describe("parseWorkflowIr — v2 columns & placement", () => { }); }); -describe("parseWorkflowIr — optionalSteps", () => { +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: +// The legacy `optionalSteps` declaration field is retired. A legacy persisted +// `optionalSteps` key on an old v2 row is now TOLERATED — no longer validated or +// required — so old rows still parse as v2 (optional steps are graph-native +// `optional-group` nodes now). +describe("parseWorkflowIr — legacy optionalSteps tolerated", () => { const columns = DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })); const base = (): WorkflowIrV2 => v2( columns, @@ -166,27 +171,25 @@ describe("parseWorkflowIr — optionalSteps", () => { [{ from: "start", to: "end" }], ); - it("parses and serializes optionalSteps deterministically", () => { - const ir: WorkflowIrV2 = { + it("parses a legacy v2 row carrying an optionalSteps key without throwing", () => { + const ir = { ...base(), + // Legacy declaration shapes — including ones the old validator rejected — + // are now ignored, not validated. optionalSteps: [ { templateId: "browser-verification" }, - { templateId: "plugin:example:step", defaultOn: true }, + { defaultOn: "yes" }, + "nope", ], - }; + } as unknown as WorkflowIr; + expect(() => parseWorkflowIr(ir)).not.toThrow(); const parsed = parseWorkflowIr(ir); - expect(parsed).toEqual(ir); + expect(parsed.version).toBe("v2"); + // The key passes through untouched (round-trips through serialize/parse). expect(JSON.parse(serializeWorkflowIr(parsed))).toEqual(ir); }); - it("rejects malformed optionalSteps", () => { - expect(() => parseWorkflowIr({ ...base(), optionalSteps: "nope" } as unknown as WorkflowIr)).toThrow(WorkflowIrError); - expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{}] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/); - expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "" }] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/); - expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "browser-verification", defaultOn: "yes" }] } as unknown as WorkflowIr)).toThrow(/defaultOn must be a boolean/); - }); - it("upgrades v1 graphs without optionalSteps", () => { const parsed = parseWorkflowIr({ version: "v1", @@ -196,7 +199,7 @@ describe("parseWorkflowIr — optionalSteps", () => { }); expect(parsed.version).toBe("v2"); if (parsed.version !== "v2") throw new Error("expected v2"); - expect(parsed.optionalSteps).toBeUndefined(); + expect((parsed as { optionalSteps?: unknown }).optionalSteps).toBeUndefined(); }); }); diff --git a/packages/core/src/__tests__/workflow-optional-steps.test.ts b/packages/core/src/__tests__/workflow-optional-steps.test.ts index d3ea6ba91e..5f101815d0 100644 --- a/packages/core/src/__tests__/workflow-optional-steps.test.ts +++ b/packages/core/src/__tests__/workflow-optional-steps.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js"; -import { resolveWorkflowOptionalSteps } from "../workflow-optional-steps.js"; -import type { WorkflowIr, WorkflowIrV2 } from "../workflow-ir-types.js"; +import { + resolveDefaultOnOptionalGroupIds, + resolveWorkflowOptionalSteps, +} from "../workflow-optional-steps.js"; +import type { + WorkflowIr, + WorkflowIrNode, + WorkflowIrV2, + WorkflowOptionalGroupConfig, +} from "../workflow-ir-types.js"; const v1: WorkflowIr = { version: "v1", @@ -14,105 +22,125 @@ const v1: WorkflowIr = { edges: [{ from: "start", to: "end" }], }; -function v2(optionalSteps?: WorkflowIrV2["optionalSteps"]): WorkflowIrV2 { +/** Build an optional-group node with a trivial single-prompt template. */ +function optionalGroupNode( + id: string, + config: Partial, +): WorkflowIrNode { + return { + id, + kind: "optional-group", + column: "todo", + config: { + ...config, + template: config.template ?? { + nodes: [{ id: `${id}-inner`, kind: "prompt" }], + edges: [], + }, + } satisfies WorkflowOptionalGroupConfig, + }; +} + +function v2(extraNodes: WorkflowIrNode[] = []): WorkflowIrV2 { return { version: "v2", name: "optional", columns: [{ id: "todo", name: "Todo", traits: [] }], nodes: [ { id: "start", kind: "start", column: "todo" }, + ...extraNodes, { id: "end", kind: "end", column: "todo" }, ], edges: [{ from: "start", to: "end" }], - optionalSteps, }; } -describe("resolveWorkflowOptionalSteps", () => { - it("resolves the builtin coding browser verification optional step", () => { - expect(resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)).toEqual([ +describe("resolveWorkflowOptionalSteps (optional-group nodes)", () => { + it("resolves two optional-group nodes with names + defaultOn from node config", () => { + const ir = v2([ + optionalGroupNode("og-browser", { name: "Browser Verification", defaultOn: false }), + optionalGroupNode("og-security", { name: "Security Audit", defaultOn: true }), + ]); + + expect(resolveWorkflowOptionalSteps(ir)).toEqual([ { - templateId: "browser-verification", + templateId: "og-browser", name: "Browser Verification", - description: "Verify web application functionality using browser automation", - icon: "globe", + description: "", phase: "pre-merge", defaultOn: false, }, + { + templateId: "og-security", + name: "Security Audit", + description: "", + phase: "pre-merge", + defaultOn: true, + }, ]); }); - it("resolves the builtin stepwise-coding browser verification optional step", () => { - expect(resolveWorkflowOptionalSteps(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)).toEqual([ - { - templateId: "browser-verification", - name: "Browser Verification", - description: "Verify web application functionality using browser automation", - icon: "globe", - phase: "pre-merge", - defaultOn: false, - }, - ]); + it("falls back to the node id when the group config omits a name", () => { + const ir = v2([optionalGroupNode("og-unnamed", { defaultOn: true })]); + const [resolved] = resolveWorkflowOptionalSteps(ir); + expect(resolved.templateId).toBe("og-unnamed"); + expect(resolved.name).toBe("og-unnamed"); + expect(resolved.defaultOn).toBe(true); }); - it("places a single workflow-step seam node between steps and review in stepwise", () => { - const ir = BUILTIN_STEPWISE_CODING_WORKFLOW_IR; - if (ir.version !== "v2") throw new Error("expected v2"); - const seamNodes = ir.nodes.filter( - (n) => n.kind === "prompt" && n.config?.seam === "workflow-step", - ); - expect(seamNodes).toHaveLength(1); - // success path: steps -> workflow-step -> review - expect(ir.edges).toEqual( - expect.arrayContaining([ - expect.objectContaining({ from: "steps", to: "workflow-step", condition: "success" }), - expect.objectContaining({ from: "workflow-step", to: "review", condition: "success" }), - ]), - ); - }); - - it("skips unknown template ids", () => { - expect( - resolveWorkflowOptionalSteps(v2([ - { templateId: "missing" }, - { templateId: "browser-verification" }, - ])), - ).toHaveLength(1); - }); - - it("returns an empty array for v1 and v2 workflows without optional steps", () => { + it("returns an empty array for v1 and v2 workflows without optional-group nodes", () => { expect(resolveWorkflowOptionalSteps(v1)).toEqual([]); expect(resolveWorkflowOptionalSteps(v2())).toEqual([]); }); - it("preserves declaration order and resolves plugin templates", () => { - const result = resolveWorkflowOptionalSteps( - v2([ - { templateId: "plugin:demo:first", defaultOn: true }, - { templateId: "browser-verification" }, - ]), - [ - { - id: "plugin:demo:first", - name: "Plugin First", - description: "Plugin optional verification", - prompt: "Run plugin verification", - category: "Quality", - icon: "plug", - phase: "post-merge", - }, - ], - ); - - expect(result.map((step) => step.templateId)).toEqual([ - "plugin:demo:first", - "browser-verification", + it("ignores a malformed (config-less) optional-group node without crashing", () => { + // A stale/partial optional-group node must not throw; it resolves to a + // defaultOn:false entry keyed by its id rather than breaking workflow loading. + const ir = v2([{ id: "og-bare", kind: "optional-group", column: "todo" }]); + expect(resolveWorkflowOptionalSteps(ir)).toEqual([ + { + templateId: "og-bare", + name: "og-bare", + description: "", + phase: "pre-merge", + defaultOn: false, + }, ]); - expect(result[0]).toMatchObject({ - name: "Plugin First", - icon: "plug", - phase: "post-merge", - defaultOn: true, - }); + }); + + it("resolves the built-in coding/stepwise browser-verification optional-group (U6)", () => { + // U6 migrated both built-ins: `browser-verification` is now an optional-group + // node (default OFF), so the resolver advertises exactly one toggle entry per + // built-in, keyed by the group node id `browser-verification`. + const expected = [ + { + templateId: "browser-verification", + name: "Browser Verification", + description: "", + phase: "pre-merge" as const, + defaultOn: false, + }, + ]; + expect(resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)).toEqual(expected); + expect(resolveWorkflowOptionalSteps(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)).toEqual(expected); + }); +}); + +describe("resolveDefaultOnOptionalGroupIds (task-creation seeding)", () => { + it("returns exactly the defaultOn:true group ids", () => { + const ir = v2([ + optionalGroupNode("og-off", { defaultOn: false }), + optionalGroupNode("og-on-a", { defaultOn: true }), + optionalGroupNode("og-on-b", { defaultOn: true }), + ]); + expect(resolveDefaultOnOptionalGroupIds(ir)).toEqual(["og-on-a", "og-on-b"]); + }); + + it("seeds an empty set when no optional-group has defaultOn (or none exist)", () => { + expect(resolveDefaultOnOptionalGroupIds(v2())).toEqual([]); + expect( + resolveDefaultOnOptionalGroupIds(v2([optionalGroupNode("og-off", { defaultOn: false })])), + ).toEqual([]); + expect(resolveDefaultOnOptionalGroupIds(v1)).toEqual([]); }); }); diff --git a/packages/core/src/__tests__/workflow-selection-store.test.ts b/packages/core/src/__tests__/workflow-selection-store.test.ts index 53d19e029f..898875d8da 100644 --- a/packages/core/src/__tests__/workflow-selection-store.test.ts +++ b/packages/core/src/__tests__/workflow-selection-store.test.ts @@ -176,6 +176,130 @@ describe("TaskStore workflow selection (U3)", () => { expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id); }); + // FNXC:WorkflowOptionalGroup 2026-06-21-14:30: a new task seeds + // `enabledWorkflowSteps` with exactly the defaultOn:true optional-group ids of + // its selected workflow (U3, R3), alongside the compiled workflow step ids. + describe("optional-group defaultOn seeding (U3/R3)", () => { + /** v2 workflow whose success path threads through two optional-group nodes. */ + function optionalGroupIr(): WorkflowIr { + const groupTemplate = (id: string) => ({ + nodes: [{ id: `${id}-inner`, kind: "prompt" as const, config: { prompt: "x" } }], + edges: [], + }); + return { + version: "v2", + name: "og-wf", + columns: [{ id: "todo", name: "Todo", traits: [] }], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { + id: "og-on", + kind: "optional-group", + column: "todo", + config: { name: "On Group", defaultOn: true, template: groupTemplate("og-on") }, + }, + { + id: "og-off", + kind: "optional-group", + column: "todo", + config: { name: "Off Group", defaultOn: false, template: groupTemplate("og-off") }, + }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [ + { from: "start", to: "og-on", condition: "success" }, + { from: "og-on", to: "og-off", condition: "success" }, + { from: "og-off", to: "end", condition: "success" }, + ], + }; + } + + it("seeds the defaultOn:true group id at creation from the default workflow", async () => { + const wf = await store.createWorkflowDefinition({ name: "OG Default", ir: optionalGroupIr() }); + await store.setDefaultWorkflowId(wf.id); + + const task = await store.createTask({ description: "seeded" }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toContain("og-on"); + expect(detail.enabledWorkflowSteps).not.toContain("og-off"); + }); + + it("seeds an empty set when the workflow has no optional groups", async () => { + const wf = await store.createWorkflowDefinition({ name: "No OG", ir: linearIr() }); + await store.setDefaultWorkflowId(wf.id); + + const task = await store.createTask({ description: "no groups" }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps ?? []).not.toContain("og-on"); + }); + + it("a stale optional-group id in enabledWorkflowSteps does not crash resolution", async () => { + // Group since removed from the workflow: the toggle resolver ignores the + // stale id rather than throwing, keeping create/edit surfaces alive. + const task = await store.createTask({ + description: "stale", + enabledWorkflowSteps: ["og-removed"], + }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toContain("og-removed"); + }); + + // FNXC:WorkflowOptionalGroup 2026-06-21-16:30: code-review P1 regression. A + // built-in optional-group id deliberately equals a WORKFLOW_STEP_TEMPLATES id + // (the browser-verification migration). Enabling it on a task must keep the + // RAW group node id in enabledWorkflowSteps — not a materialized WorkflowStep + // row id — or the executor's `enabledWorkflowSteps.includes(node.id)` check + // silently bypasses the group. (The og-on/og-off ids above don't collide, so + // only a colliding id exercises the remap bug.) + function collidingGroupIr(): WorkflowIr { + return { + version: "v2", + name: "bv-wf", + columns: [{ id: "todo", name: "Todo", traits: [] }], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { + id: "browser-verification", + kind: "optional-group", + column: "todo", + config: { + name: "Browser Verification", + defaultOn: false, + template: { nodes: [{ id: "bv-inner", kind: "prompt", config: { prompt: "verify" } }], edges: [] }, + }, + }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [ + { from: "start", to: "browser-verification", condition: "success" }, + { from: "browser-verification", to: "end", condition: "success" }, + ], + }; + } + + it("keeps a built-in-colliding optional-group id unremapped on create-with-enable", async () => { + const wf = await store.createWorkflowDefinition({ name: "BV", ir: collidingGroupIr() }); + await store.setDefaultWorkflowId(wf.id); + + const task = await store.createTask({ + description: "enable bv", + enabledWorkflowSteps: ["browser-verification"], + }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toContain("browser-verification"); + }); + + it("keeps a built-in-colliding optional-group id unremapped on update/toggle", async () => { + const wf = await store.createWorkflowDefinition({ name: "BV", ir: collidingGroupIr() }); + await store.setDefaultWorkflowId(wf.id); + + const task = await store.createTask({ description: "toggle bv" }); + await store.updateTask(task.id, { enabledWorkflowSteps: ["browser-verification"] }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toContain("browser-verification"); + }); + }); + it("explicit enabledWorkflowSteps overrides the project default", async () => { const wf = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() }); await store.setDefaultWorkflowId(wf.id); diff --git a/packages/core/src/builtin-browser-verification-group.ts b/packages/core/src/builtin-browser-verification-group.ts new file mode 100644 index 0000000000..72cab875e2 --- /dev/null +++ b/packages/core/src/builtin-browser-verification-group.ts @@ -0,0 +1,79 @@ +import type { WorkflowIrNode } from "./workflow-ir-types.js"; +import { WORKFLOW_STEP_TEMPLATES } from "./types.js"; + +/* +FNXC:WorkflowOptionalGroup 2026-06-21-15:10: +Both the built-in coding and stepwise-coding workflows express the optional +`browser-verification` step as an `optional-group` container node on the pre-merge +path (default OFF), REPLACING the legacy `optionalSteps: [{ templateId: +"browser-verification" }]` declaration + the hidden `workflow-step` seam node (U6). +Enabled (task's `enabledWorkflowSteps` includes the group id) → the browser- +verification step runs ONCE pre-merge between implementation and review. Disabled → +the group passes through (byte-inert), exactly preserving the prior runtime behavior +where the step only ran when toggled on. + +The group node id `browser-verification` is the STABLE per-task enable key (KTD-2): +keeping it identical to the prior `optionalSteps` templateId preserves any persisted +`enabledWorkflowSteps` entry. The inner template node carries a DISTINCT id +(`browser-verification-step`) because a template node id may not collide with the +group/top-level node id (U1 validation). + +The inner node mirrors the dashboard's `stepTemplateToNode` projection of the +canonical `browser-verification` WORKFLOW_STEP_TEMPLATE: a `prompt` node carrying the +template's prompt, `toolMode` (coding), and `gateMode` (advisory default). Sourcing +prompt/toolMode from the catalog keeps the built-in byte-identical to the template a +human would insert from the palette (KTD-5). +*/ + +function resolveBrowserVerificationTemplate() { + const tpl = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === "browser-verification"); + if (!tpl) { + throw new Error("browser-verification WORKFLOW_STEP_TEMPLATE is missing"); + } + return tpl; +} + +const BROWSER_VERIFICATION_TEMPLATE = resolveBrowserVerificationTemplate(); + +/** Stable per-task enable key + group node id (preserved from the prior templateId). */ +export const BROWSER_VERIFICATION_GROUP_ID = "browser-verification"; + +/** Inner template node id — distinct from the group id (template-node-id collision rule, U1). */ +export const BROWSER_VERIFICATION_STEP_NODE_ID = "browser-verification-step"; + +/** + * Build the `browser-verification` optional-group node placed on a workflow's + * pre-merge path. `column` matches where the legacy `workflow-step` seam sat + * (in-progress) so the editor renders the group in the implementation column. + * + * Mirrors `stepTemplateToNode(browser-verification)`: a single `prompt` node whose + * config carries the catalog prompt + `toolMode: "coding"` + `gateMode: "advisory"`. + */ +export function browserVerificationOptionalGroupNode(column: string): WorkflowIrNode { + const tpl = BROWSER_VERIFICATION_TEMPLATE; + return { + id: BROWSER_VERIFICATION_GROUP_ID, + kind: "optional-group", + column, + config: { + name: tpl.name, + defaultOn: false, + template: { + nodes: [ + { + id: BROWSER_VERIFICATION_STEP_NODE_ID, + kind: "prompt", + config: { + name: tpl.name, + description: tpl.description, + prompt: tpl.prompt ?? "", + toolMode: tpl.toolMode === "coding" ? "coding" : "readonly", + gateMode: tpl.gateMode ?? "advisory", + }, + }, + ], + edges: [], + }, + }, + }; +} diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index f663d6b7fd..b506ffec31 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -2,6 +2,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; import { builtinPromptConfig } from "./builtin-workflow-prompts.js"; +import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js"; /** * The built-in default workflow as a v2 IR. Its six columns have ids that are @@ -20,9 +21,16 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js"; * * The lifecycle seam nodes are placed in their columns. Planning is explicit so * the built-in workflow owns the specification phase rather than relying on - * triage code that runs outside the graph; workflow-step keeps the legacy - * pre-merge quality gate between implementation and review; execute/review/ - * merge keep the same observable pipeline and failure routing. + * triage code that runs outside the graph; execute/review/merge keep the same + * observable pipeline and failure routing. + * + * FNXC:WorkflowOptionalGroup 2026-06-21-15:10: + * The pre-merge optional `browser-verification` step is now an `optional-group` + * container node (default OFF) sitting on the success path between execute and + * review — REPLACING the legacy `workflow-step` seam node + the execution-inert + * `optionalSteps: [{ templateId: "browser-verification" }]` declaration (U6). A + * task whose `enabledWorkflowSteps` includes the group id runs browser + * verification pre-merge exactly as before; a task with it off bypasses it. */ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { version: "v2", @@ -65,12 +73,8 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { column: "in-progress", config: { ...builtinPromptConfig("execute", "Execute"), maxRetries: 2 }, }, - { - id: "workflow-step", - kind: "prompt", - column: "in-progress", - config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps"), - }, + // Pre-merge optional browser-verification (optional-group, default OFF). + browserVerificationOptionalGroupNode("in-progress"), { id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") }, { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, { id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } }, @@ -94,10 +98,10 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { edges: [ { from: "start", to: "planning" }, { from: "planning", to: "execute", condition: "success" }, - { from: "execute", to: "workflow-step", condition: "success" }, - { from: "workflow-step", to: "review", condition: "success" }, - { from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" }, - { from: "workflow-step", to: "end", condition: "outcome:deferred-paused" }, + // execute → browser-verification (optional-group) → review. When the group is + // disabled it passes through with outcome=success and routes straight to review. + { from: "execute", to: "browser-verification", condition: "success" }, + { from: "browser-verification", to: "review", condition: "success" }, { from: "review", to: "merge-gate", condition: "success" }, { from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" }, { from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" }, @@ -113,14 +117,13 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { { from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" }, { from: "planning", to: "end", condition: "failure" }, { from: "execute", to: "end", condition: "failure" }, - { from: "workflow-step", to: "end", condition: "failure" }, + { from: "browser-verification", to: "end", condition: "failure" }, { from: "review", to: "end", condition: "failure" }, { from: "merge-attempt", to: "end", condition: "failure" }, ], // Workflow-settings (U1, R4): declare the full moved-key catalog with defaults // byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3. settings: BUILTIN_WORKFLOW_SETTINGS, - optionalSteps: [{ templateId: "browser-verification" }], }; export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR); diff --git a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts index 348125fd5a..39f14f740e 100644 --- a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts +++ b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts @@ -2,6 +2,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; import { builtinPromptConfig } from "./builtin-workflow-prompts.js"; +import { browserVerificationOptionalGroupNode } from "./builtin-browser-verification-group.js"; /** * The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step @@ -123,16 +124,16 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { }, // KTD-5: rework exhaustion escalates to a manual hold (a human releases it). { id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } }, - // FNXC:WorkflowOptionalSteps 2026-06-21-00:00: - // The stepwise workflow must actually run a task's enabled optional steps (e.g. - // browser verification), so it needs the same pre-merge workflow-step seam the - // coding workflow has — declaring the optional step without this node would be a - // dead toggle. Pre-merge workflow-step seam (parity with builtin-coding-workflow-ir): - // the ONLY node that makes the graph invoke `runWorkflowSteps`, so a per-task - // `enabledWorkflowSteps` (e.g. the optional browser-verification step declared - // below) actually executes. Runs ONCE after the foreach completes, between - // implementation and review — not per step-instance. - { id: "workflow-step", kind: "prompt", column: "in-progress", config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps") }, + // FNXC:WorkflowOptionalGroup 2026-06-21-15:10: + // Pre-merge optional browser-verification as an `optional-group` container + // (default OFF), parity with builtin-coding-workflow-ir (U6). It REPLACES the + // prior `workflow-step` seam + `optionalSteps` declaration. R-3 run-once + // guarantee: the group sits on the post-foreach success path (steps → here → + // review), so when enabled the browser-verification step runs EXACTLY ONCE + // after every step-instance completes — never per step-instance — and when + // disabled the group passes through inert. Both the normal foreach-success path + // and the rework-exhausted manual-release path flow through this node. + browserVerificationOptionalGroupNode("in-progress"), { id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") }, { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, { id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } }, @@ -163,17 +164,16 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { { from: "parse", to: "steps", condition: "outcome:no-steps" }, { from: "parse", to: "end", condition: "failure" }, { from: "parse", to: "end", condition: "outcome:parse-error" }, - // Implementation complete → pre-merge workflow-step seam → review. Both the - // normal foreach-success path and the rework-exhausted manual-release path flow - // through the seam so enabled workflow steps run regardless of route. - { from: "steps", to: "workflow-step", condition: "success" }, - // KTD-5: bounded rework exhaustion → manual hold; release re-enters the seam. + // Implementation complete → pre-merge browser-verification optional-group → + // review. Both the normal foreach-success path and the rework-exhausted + // manual-release path flow through the group so an enabled task runs the step + // ONCE after the foreach (R-3), and a disabled task passes through to review. + { from: "steps", to: "browser-verification", condition: "success" }, + // KTD-5: bounded rework exhaustion → manual hold; release re-enters the group. { from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" }, - { from: "rework-hold", to: "workflow-step", condition: "success" }, - { from: "workflow-step", to: "review", condition: "success" }, - { from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" }, - { from: "workflow-step", to: "end", condition: "outcome:deferred-paused" }, - { from: "workflow-step", to: "end", condition: "failure" }, + { from: "rework-hold", to: "browser-verification", condition: "success" }, + { from: "browser-verification", to: "review", condition: "success" }, + { from: "browser-verification", to: "end", condition: "failure" }, { from: "steps", to: "end", condition: "failure" }, { from: "review", to: "merge-gate", condition: "success" }, { from: "review", to: "end", condition: "failure" }, @@ -193,9 +193,6 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { ], // Workflow-settings (U1, R4): same moved-key catalog as the default builtin. settings: BUILTIN_WORKFLOW_SETTINGS, - // Optional browser-verification step, parity with builtin-coding-workflow-ir. - // Default OFF; runnable because the workflow-step seam node above is present. - optionalSteps: [{ templateId: "browser-verification" }], }; export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr( diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index 2199bec314..3bef28e70a 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -181,11 +181,15 @@ export async function loadWorkspaceConfig(rootDir: string): Promise typeof r === "string") ) { const rawRepos = (parsed as { repos: unknown[] }).repos; const repos = rawRepos.filter((entry): entry is string => isInRootRelativePath(entry, pathMod)); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5784c8bbf..309ffdf8d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, 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, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, 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, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, 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, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, 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, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, 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, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; -export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; +export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, sanitizeBranchSegment, @@ -86,13 +86,13 @@ export type { WorkflowForeachConfig, WorkflowLoopConfig, WorkflowLoopExitCondition, + WorkflowOptionalGroupConfig, WorkflowIrArtifact, WorkflowFieldDefinition, WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender, // Workflow-settings (U1): typed setting declaration IR types. - WorkflowOptionalStep, WorkflowSettingDefinition, WorkflowSettingType, WorkflowSettingOption, @@ -119,7 +119,10 @@ export type { } from "./column-agent-resolver.js"; export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; export { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js"; -export { resolveWorkflowOptionalSteps } from "./workflow-optional-steps.js"; +export { + resolveWorkflowOptionalSteps, + resolveDefaultOnOptionalGroupIds, +} from "./workflow-optional-steps.js"; export type { ResolvedWorkflowOptionalStep } from "./workflow-optional-steps.js"; export { applyPromptOverridesToIr, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 010ca856fe..350bd90b63 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { existsSync, watch, type Dirent, type FSWatcher } from "node:fs"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, CommitAssociationDiffBackfillReport, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision, PluginActivation, PluginActivationInput } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; -import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; +import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey, assertNotWorkspaceTaskMerge } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { MOVED_SETTINGS_KEYS, @@ -86,6 +86,7 @@ import type { WorkflowNodeLayout, } from "./workflow-definition-types.js"; import { compileWorkflowToSteps, isInterpreterDeferredWorkflowCompileError } from "./workflow-compiler.js"; +import { resolveDefaultOnOptionalGroupIds, resolveAllOptionalGroupIds } from "./workflow-optional-steps.js"; import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, @@ -4286,7 +4287,26 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }); } - private async resolveEnabledWorkflowSteps(stepIds?: string[]): Promise { + /* + FNXC:WorkflowOptionalGroup 2026-06-21-16:30: + `optionalGroupIds` are the optional-group node ids of the task's workflow. They are executor toggle keys (matched by node id in `enabledWorkflowSteps`), NOT legacy `WorkflowStep` template ids. A built-in group id can deliberately collide with a `WORKFLOW_STEP_TEMPLATES` id (e.g. "browser-verification"); without this pass-through the colliding id is materialized into a step row whose id differs from the group node id, so the executor's `enabledWorkflowSteps.includes(node.id)` check fails and an enabled group is silently bypassed (P1 from code review). Editor-authored group ids never collide (they come from `newNodeId()`), so they already passed through; this guards the built-in collision. + */ + /** Optional-group node ids for a workflow (its `enabledWorkflowSteps` toggle + * keys). Falls back to the project default workflow when `workflowId` is + * nullish; empty for missing/fragment workflows. Used to keep group ids out of + * the legacy step-template materialization in {@link resolveEnabledWorkflowSteps}. */ + private async optionalGroupIdSet(workflowId?: string | null): Promise> { + const wfId = workflowId ?? (await this.getDefaultWorkflowId()); + if (!wfId) return new Set(); + const def = await this.getWorkflowDefinition(wfId); + if (!def || def.kind === "fragment") return new Set(); + return new Set(resolveAllOptionalGroupIds(def.ir)); + } + + private async resolveEnabledWorkflowSteps( + stepIds?: string[], + optionalGroupIds?: Set, + ): Promise { if (!stepIds?.length) return undefined; const resolved: string[] = []; @@ -4304,7 +4324,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} continue; } - const template = this.getBuiltInWorkflowTemplate(stepId); + // Optional-group toggle ids pass through raw — never materialized as legacy step rows. + const template = optionalGroupIds?.has(stepId) + ? undefined + : this.getBuiltInWorkflowTemplate(stepId); const resolvedId = template ? (await this.ensureWorkflowStepForTemplate(stepId)).id : stepId; @@ -4444,7 +4467,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} // Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length - ? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps) + ? await this.resolveEnabledWorkflowSteps( + input.enabledWorkflowSteps, + await this.optionalGroupIdSet(input.workflowId), + ) : undefined; // When a project default workflow is configured, new tasks inherit it @@ -4639,7 +4665,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const title = input.title?.trim() || undefined; let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length - ? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps) + ? await this.resolveEnabledWorkflowSteps( + input.enabledWorkflowSteps, + await this.optionalGroupIdSet(input.workflowId), + ) : undefined; let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined; @@ -8712,7 +8741,14 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} task.nextRecoveryAt = updates.nextRecoveryAt; } if (updates.enabledWorkflowSteps !== undefined) { - task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps(updates.enabledWorkflowSteps); + // Pass the task's own workflow optional-group ids through untouched so a + // toggled built-in group id (e.g. "browser-verification") is not remapped + // to a materialized step row the executor never matches (code-review P1). + const taskWorkflowId = this.getTaskWorkflowSelection(task.id)?.workflowId; + task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps( + updates.enabledWorkflowSteps, + await this.optionalGroupIdSet(taskWorkflowId), + ); } if (updates.noCommitsExpected === null) { task.noCommitsExpected = undefined; @@ -11251,6 +11287,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); + // FNXC:Workspace 2026-06-21-19:05: + // R7 merge-boundary guard (master-plan U0). Reject workspace-mode tasks + // BEFORE any git checkout/squash — they need the per-repo merge loop that + // lands in master-plan U6, which removes this guard. See the predicate's + // FNXC:Workspace note in @fusion/core types. + assertNotWorkspaceTaskMerge(task); const branch = task.branch || `fusion/${id.toLowerCase()}`; // Branch is derived from the task id (already validated at create time), // but assert as defense-in-depth against future id-format changes. @@ -15946,11 +15988,17 @@ ${stepsSection}`; if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return undefined; throw err; } + // FNXC:WorkflowOptionalGroup 2026-06-21-14:20: seed `enabledWorkflowSteps` + // with the ids of `optional-group` nodes whose `defaultOn` is true, mirroring + // the prior `optionalStep.defaultOn ?? false` precedence (U3, R3). These group + // ids are NOT WorkflowStep rows — they are toggle keys the executor reads at + // the optional-group seam — so they ride alongside the compiled step ids. + const defaultGroupIds = resolveDefaultOnOptionalGroupIds(def.ir); if (isBuiltinWorkflowId(workflowId) && inputs.length === 0) { - return { workflowId, stepIds: [] }; + return { workflowId, stepIds: defaultGroupIds }; } const stepIds = await this.materializeWorkflowSteps(workflowId, inputs); - return { workflowId, stepIds }; + return { workflowId, stepIds: [...stepIds, ...defaultGroupIds] }; } /** Resolve an EXPLICITLY requested workflow id (U6/R3/KTD-4) into materialized @@ -15971,11 +16019,15 @@ ${stepsSection}`; try { inputs = compileWorkflowToSteps(def.ir); } catch (err) { - if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return { workflowId, stepIds: [] }; + if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) + return { workflowId, stepIds: resolveDefaultOnOptionalGroupIds(def.ir) }; throw err; } + // FNXC:WorkflowOptionalGroup 2026-06-21-14:20: same defaultOn-group seeding as + // the default-workflow path, for an explicitly requested create-time workflow. + const defaultGroupIds = resolveDefaultOnOptionalGroupIds(def.ir); const stepIds = await this.materializeWorkflowSteps(workflowId, inputs); - return { workflowId, stepIds }; + return { workflowId, stepIds: [...stepIds, ...defaultGroupIds] }; } /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0cd5971485..bcdeda2481 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -512,8 +512,15 @@ export const MERGER_MODES = ["ai", "deterministic"] as const; * an AI agent merges the task branch and an AI reviewer audits it (with * corrective retries) before a fast-forward landing. Bypasses the legacy * scaffolding entirely. - * - "deterministic": the legacy `aiMergeTask` pipeline (prerebase / - * conflict-strategy ladder / post-merge audit / transient self-heal). + * - "deterministic": **DEPRECATED (master-plan U0, 2026-06-21) and INERT.** Once + * routed to the legacy `aiMergeTask` pipeline; now ignored — every merge uses + * the unified "ai" path (`runAiMerge`). The value is retained (not removed) to + * avoid a breaking `@runfusion/fusion` type change, and the engine logs a + * one-time deprecation warning when it observes a resolved "deterministic". + * + * FNXC:MergerUnification 2026-06-21-19:05: `merger.mode` is published surface, so + * the type and the `MergerSettings.mode` field stay; only the "deterministic" + * VALUE is deprecated/inert. Removing the type is a separate breaking change. */ export type MergerMode = (typeof MERGER_MODES)[number]; @@ -525,7 +532,12 @@ export function normalizeMergerMode(value: unknown): MergerMode { /** Settings for the AI merge path (FN-5633). */ export interface MergerSettings { - /** Which merge path to use. Default: "ai". */ + /** + * Which merge path to use. Default: "ai". + * @deprecated master-plan U0 (2026-06-21): the value is inert — every merge now + * uses the unified AI merge path (`runAiMerge`). Field retained as published + * surface; "deterministic" only triggers a one-time deprecation warning. + */ mode?: MergerMode; /** How many AI corrective rounds before landing the best result (advisory) or * hard-failing (blocking). Default: 3. The reviewer uses the project's @@ -1843,6 +1855,17 @@ export interface MergeDetails { * `task.mergeRetries`, which counts in-cycle aiMergeTask retries. */ transientRecoveryCount?: number; + /** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Workspace-mode aggregate landed map: sub-repo relative path → the squash sha + * that landed on that repo's local integration ref. Set ONLY by + * `landWorkspaceTask`'s finalize-once after EVERY acquired repo's landed + * predicate holds; the task-level `commitSha` points at one representative + * landed sha (the first sorted landed repo) so the existing `task:merged` + * consumer (which reads `mergeDetails.commitSha`) is satisfied. Empty/absent + * for single-repo tasks. + */ + workspaceLandedShas?: Record; } /** Represents an agent's checkout lease on a task. */ @@ -2244,8 +2267,23 @@ export interface Task { /** * Workspace mode only. Keyed by repo path relative to workspace rootDir. * Each entry records the on-disk worktree path and git branch for one sub-repo. + * + * FNXC:Workspace 2026-06-21-20:10: + * `baseCommitSha` is the per-repo fork-point captured at acquisition (U2/KTD3) + * against that sub-repo's RESOLVED integration branch, local-first. It is the + * per-repo analogue of the single-repo base-commit capture and prevents + * cross-repo files-changed inflation when local integration is ahead of origin. + * + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * `landedSha` is the per-repo "this repo's branch has landed on its local + * integration ref" marker, set by `landWorkspaceTask` after a sub-repo's squash + * advances that repo's ref. It is the ONLY partial-land state added (no new + * status type): a re-run's landed predicate skips a repo whose `landedSha` is + * present AND whose recorded value is an ancestor of (or equals) the repo's + * integration tip, so an interrupted multi-repo land retries only the un-landed + * repos and never re-advances an already-landed ref (idempotent retry). */ - workspaceWorktrees?: Record; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** @@ -2598,6 +2636,64 @@ export interface Task { updatedAt: string; } +/* +FNXC:Workspace 2026-06-21-19:05: +R7 workspace merge-boundary guard (master-plan U0). Workspace-mode tasks populate +`task.workspaceWorktrees` (one git worktree per sub-repo); their merge must run a +per-repo loop that does NOT exist yet — it lands in master-plan U6. Until then, a +workspace task reaching ANY merge entry point (engine dispatch, store.mergeTask, +the CLI `onMergeImpl` / `runTaskMerge` callers) would run git operations against +the NON-GIT workspace root and crash. This single shared predicate is called at the +top of every merge door, BEFORE any git work, so the task is held with a clear, +actionable error instead. It lives in @fusion/core so all four call sites — including +store.mergeTask, which cannot import from @fusion/engine — share ONE implementation. +The guard throws a NAMED `WorkspaceTaskMergeError` so callers (e.g. the engine merge +dispatch catch) can distinguish this permanent config error from a transient merge +failure and avoid burning mergeRetries. Master-plan U6 REMOVES this guard when the +per-repo merge loop becomes the gate. +*/ + +/** + * Error thrown by {@link assertNotWorkspaceTaskMerge} when a workspace-mode task + * reaches a merge path. Named so callers can branch on it (e.g. park without + * burning mergeRetries) rather than treating it as a transient merge failure. + */ +export class WorkspaceTaskMergeError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkspaceTaskMergeError"; + } +} + +/** + * Throws {@link WorkspaceTaskMergeError} when `task.workspaceWorktrees` has at least + * one entry (a workspace-mode task). No-op for single-repo tasks. See the + * FNXC:Workspace note above. + * @param task the task about to enter a merge path + */ +export function assertNotWorkspaceTaskMerge(task: Pick): void { + if (isWorkspaceTask(task)) { + throw new WorkspaceTaskMergeError( + `Workspace task ${task.id} cannot merge until per-repo merge support (master-plan U6) lands`, + ); + } +} + +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B5/B7-dep — canonical workspace predicate): +A workspace-mode task is identified by having at least one `workspaceWorktrees` entry +(one git worktree per sub-repo). This single predicate replaces the inlined +`!!task.workspaceWorktrees && Object.keys(task.workspaceWorktrees).length > 0` that was +copy-pasted across the engine merge dispatch and the merge-confirmed reachability fast-path +(B2). It lives in @fusion/core so the engine, store, and CLI doors share ONE definition. +The dashboard keeps its own local `isWorkspaceTask` (WorkspaceWorktreesSummary, UI-only) — +this core export is for engine/CLI use. +*/ +export function isWorkspaceTask(task: Pick): boolean { + const worktrees = task.workspaceWorktrees; + return !!worktrees && Object.keys(worktrees).length > 0; +} + export type RetrySummary = { stuckKill: number; recovery: number; diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index f2d5c90e1d..2bc146afaa 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -23,6 +23,7 @@ export type WorkflowIrNodeKind = | "join" | "foreach" | "loop" + | "optional-group" | "step-review" | "parse-steps" | "code" @@ -164,6 +165,26 @@ export interface WorkflowLoopConfig { }; } +/* +FNXC:WorkflowOptionalGroup 2026-06-21-11:00: +An `optional-group` node is a container (mirroring `foreach`/`loop`) whose `template` subgraph the executor runs ONCE when the group is enabled for the task and passes through (skips) when disabled. +Enable state reuses the per-task `enabledWorkflowSteps` facet keyed by the group node id, seeded from `defaultOn` at task creation — this replaces the execution-inert declaration-based optional-steps model (`WorkflowOptionalStep`/`optionalSteps`). +Single pass only: no iteration, no rework budget. Rework edges are forbidden inside the template so the single-pass guarantee is unambiguous (validated in `validateOptionalGroup`). +*/ +/** Config for an `optional-group` container node. `defaultOn` seeds the per-task + * enable set at creation; the `template` is the subgraph run once when enabled. + * Unlike `foreach`/`loop`, there is no iteration or rework — a single pass. */ +export interface WorkflowOptionalGroupConfig { + /** Workflow-author default for whether new tasks enable this group. */ + defaultOn?: boolean; + /** Display name for the group (editor + per-task toggle surfaces). */ + name?: string; + template: { + nodes: WorkflowIrNode[]; + edges: WorkflowIrEdge[]; + }; +} + /** Step-inversion (KTD-12): a workflow-declared task document. Artifacts ride the * existing task-documents machinery; `step-source` artifacts feed `parse-steps`. */ export interface WorkflowIrArtifact { @@ -311,13 +332,10 @@ export interface WorkflowIrV1 { edges: WorkflowIrEdge[]; } -/** Workflow-declared optional step backed by a workflow-step template. - * Execution-inert: consumed by create/edit UI to seed per-task - * `enabledWorkflowSteps`, never by the graph executor. Absent on legacy graphs. */ -export interface WorkflowOptionalStep { - templateId: string; - defaultOn?: boolean; -} +/* +FNXC:WorkflowOptionalGroup 2026-06-21-18:00: +Retired the legacy declaration-based optional-steps model. The `WorkflowOptionalStep` interface and the `WorkflowIrV2.optionalSteps` field are removed — optional steps are now graph-native `optional-group` NODES (see `WorkflowOptionalGroupConfig` above), resolved by `resolveWorkflowOptionalSteps`. A legacy persisted `optionalSteps` key on an old v2 row is TOLERATED at parse (ignored, not validated) so old rows still load as v2. +*/ /** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement. * Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13) @@ -333,9 +351,6 @@ export interface WorkflowIrV2 { /** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on * legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */ settings?: WorkflowSettingDefinition[]; - /** Optional workflow-step templates tasks may independently enable/disable via - * `enabledWorkflowSteps`. Execution-inert; the graph executor ignores this facet. */ - optionalSteps?: WorkflowOptionalStep[]; } /** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 0a052a87a9..75d96e8968 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -9,11 +9,11 @@ import type { WorkflowHoldRelease, WorkflowForeachConfig, WorkflowLoopConfig, + WorkflowOptionalGroupConfig, WorkflowFieldDefinition, WorkflowFieldType, WorkflowSettingDefinition, WorkflowSettingType, - WorkflowOptionalStep, } from "./workflow-ir-types.js"; import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js"; @@ -602,6 +602,120 @@ function validateLoop( } } +/* +FNXC:WorkflowOptionalGroup 2026-06-21-11:00: +Validate an `optional-group` container template, mirroring `validateLoop` minus the loop's exit/iteration config. +The template runs once when enabled, so rework edges (and any cycles) are forbidden inside, single entry/exit is required, and nested foreach/loop groups are rejected — keeping the single-pass guarantee unambiguous. +`defaultOn` must be boolean when present; `name` must be a string when present. +*/ +function validateOptionalGroup( + node: WorkflowIrNode, + topLevelNodeIds: Set, + columnIds: Set, +): void { + const cfg = node.config as Partial | undefined; + const template = cfg?.template; + if ( + !cfg || + !template || + !Array.isArray(template.nodes) || + !Array.isArray(template.edges) + ) { + throw new WorkflowIrError( + `optional-group node '${node.id}' must declare a template with nodes and edges arrays`, + ); + } + if (template.nodes.length === 0) { + throw new WorkflowIrError(`optional-group node '${node.id}' template must be non-empty`); + } + if (cfg.defaultOn !== undefined && typeof cfg.defaultOn !== "boolean") { + throw new WorkflowIrError(`optional-group node '${node.id}' defaultOn must be a boolean`); + } + if (cfg.name !== undefined && typeof cfg.name !== "string") { + throw new WorkflowIrError(`optional-group node '${node.id}' name must be a string`); + } + + const templateNodes = template.nodes; + const templateIds = new Set(templateNodes.map((n) => n.id)); + if (templateIds.size !== templateNodes.length) { + throw new WorkflowIrError(`optional-group node '${node.id}' template has duplicate node ids`); + } + for (const inner of templateNodes) { + if (inner.kind === "loop" || inner.kind === "foreach" || inner.kind === "optional-group") { + throw new WorkflowIrError( + `optional-group node '${node.id}' template may not contain nested loop/foreach/optional-group ('${inner.id}')`, + ); + } + if (isStepExecuteNode(inner)) { + throw new WorkflowIrError( + `step-execute seam node '${inner.id}' is only legal inside a foreach template`, + ); + } + if (inner.column !== undefined && !columnIds.has(inner.column)) { + throw new WorkflowIrError( + `Workflow node '${inner.id}' references undefined column '${inner.column}'`, + ); + } + } + for (const edge of template.edges) { + const fromInside = templateIds.has(edge.from); + const toInside = templateIds.has(edge.to); + if (!fromInside || !toInside) { + throw new WorkflowIrError( + `optional-group node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`, + ); + } + if (isReworkEdge(edge)) { + throw new WorkflowIrError(`optional-group node '${node.id}' template may not contain rework edges`); + } + // FNXC:WorkflowOptionalGroup 2026-06-22-09:00: the single-pass walk + // (runOptionalGroup) surfaces a template-node failure as the GROUP's outcome + // and bails before evaluating that node's edges — so a `failure`-condition + // edge inside the template would silently never execute. Reject it as a typed + // authoring error; failure routing belongs on the group's OUTER edges. + // (Code review: Greptile P2.) + if (edge.condition === "failure") { + throw new WorkflowIrError( + `optional-group node '${node.id}' template may not contain failure-condition edges — ` + + `a template-node failure surfaces as the group's outcome and routes the group's outer failure edge`, + ); + } + } + + const incoming = new Map(); + const outgoingCount = new Map(); + for (const edge of template.edges) { + incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1); + outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1); + } + const entries = templateNodes.filter((n) => (incoming.get(n.id) ?? 0) === 0); + const exits = templateNodes.filter((n) => (outgoingCount.get(n.id) ?? 0) === 0); + if (entries.length !== 1) { + throw new WorkflowIrError( + `optional-group node '${node.id}' template must have exactly one entry node (found ${entries.length})`, + ); + } + if (exits.length !== 1) { + throw new WorkflowIrError( + `optional-group node '${node.id}' template must have exactly one exit node (found ${exits.length})`, + ); + } + + const templateById = new Map(templateNodes.map((n) => [n.id, n])); + const templateOutgoing = buildOutgoing(template.edges); + validateNoIllegalCycles(templateNodes, templateOutgoing); + validateParallelism(templateNodes, templateOutgoing, templateById); + validateStepReviewRouting(templateNodes, templateOutgoing, templateById, false); + + for (const id of templateIds) { + if (topLevelNodeIds.has(id)) { + throw new WorkflowIrError( + `optional-group node '${node.id}' template node id '${id}' collides with a top-level node id`, + ); + } + } +} + /** step-execute seam nodes are legal ONLY inside a foreach template (KTD-4): * reject any at the top level. (Inside-split-branch rejection is handled by * SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */ @@ -1071,29 +1185,6 @@ function validateSettings(settings: WorkflowSettingDefinition[] | undefined): vo } } -function validateOptionalSteps(optionalSteps: WorkflowOptionalStep[] | undefined): void { - if (optionalSteps === undefined) return; - if (!Array.isArray(optionalSteps)) { - throw new WorkflowIrError("Workflow IR optionalSteps must be an array"); - } - for (const optionalStep of optionalSteps) { - if (!optionalStep || typeof optionalStep !== "object" || Array.isArray(optionalStep)) { - throw new WorkflowIrError("Workflow optional step must be an object"); - } - if (typeof optionalStep.templateId !== "string" || optionalStep.templateId === "") { - throw new WorkflowIrError("Workflow optional step must have a non-empty templateId"); - } - if ( - optionalStep.defaultOn !== undefined && - typeof optionalStep.defaultOn !== "boolean" - ) { - throw new WorkflowIrError( - `Workflow optional step '${optionalStep.templateId}' defaultOn must be a boolean`, - ); - } - } -} - function validateColumns(ir: WorkflowIrV2): void { if (!Array.isArray(ir.columns)) { throw new WorkflowIrError("Workflow IR v2 columns must be an array"); @@ -1265,6 +1356,7 @@ function validateV2(ir: WorkflowIrV2): void { for (const node of ir.nodes) { if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds); if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds); + if (node.kind === "optional-group") validateOptionalGroup(node, topLevelIds, columnIds); } validateStepReviewRouting(ir.nodes, outgoing, nodesById, false); validateParseStepsNodes(ir); @@ -1272,7 +1364,11 @@ function validateV2(ir: WorkflowIrV2): void { validateNotifyNodes(ir.nodes); validateFields(ir.fields); validateSettings(ir.settings); - validateOptionalSteps(ir.optionalSteps); + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + // The legacy `optionalSteps` declaration field is retired (optional steps are + // now graph-native `optional-group` nodes). A legacy persisted `optionalSteps` + // key on an old v2 row is TOLERATED — no longer validated/required — so old + // rows still parse as v2. // Rework edges are legal intra-template (foreach, KTD-5) and — since U6 // generalized the bounded-rework mechanism to the top-level walk — for a @@ -1381,12 +1477,20 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { } // Step-inversion declarations (artifacts/fields), workflow settings (U1), and - // optional workflow-step declarations are v2-only features. + // any legacy persisted optional-step declarations are v2-only features. + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00 (updated 2026-06-22-09:00): + // `optionalSteps` is no longer a typed IR field (retired declaration model), but + // a legacy v2 row may still carry the key. Read it via an untyped cast so such a + // row is still treated as v2 (kept on v2, never silently downgraded). The mere + // PRESENCE of the key — including an empty `[]` — is the v2 signal: an author + // who wrote the key intended v2, and downgrading an `optionalSteps: []` row to + // v1 would still mutate its persisted shape. (Code review: CodeRabbit.) + const legacyOptionalSteps = (ir as { optionalSteps?: unknown }).optionalSteps; if ( (ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0) || (ir.settings && ir.settings.length > 0) || - (ir.optionalSteps && ir.optionalSteps.length > 0) + legacyOptionalSteps !== undefined ) { return ir; } diff --git a/packages/core/src/workflow-optional-steps.ts b/packages/core/src/workflow-optional-steps.ts index 6b1c714ee2..f3fea182cd 100644 --- a/packages/core/src/workflow-optional-steps.ts +++ b/packages/core/src/workflow-optional-steps.ts @@ -1,5 +1,9 @@ -import type { WorkflowIr } from "./workflow-ir-types.js"; -import { WORKFLOW_STEP_TEMPLATES, type WorkflowStepTemplate } from "./types.js"; +import type { + WorkflowIr, + WorkflowIrNode, + WorkflowOptionalGroupConfig, +} from "./workflow-ir-types.js"; +import type { WorkflowStepTemplate } from "./types.js"; export interface ResolvedWorkflowOptionalStep { templateId: string; @@ -10,37 +14,74 @@ export interface ResolvedWorkflowOptionalStep { defaultOn: boolean; } +/* +FNXC:WorkflowOptionalGroup 2026-06-21-14:05: +Re-pointed the per-task optional-step toggle SOURCE from the execution-inert `ir.optionalSteps` declaration to v2 `optional-group` NODES (one resolved entry per group). The legacy `WorkflowOptionalStep` type + `optionalSteps` IR field are now REMOVED (FNXC:WorkflowOptionalGroup 2026-06-21-18:00); a legacy persisted `optionalSteps` key on an old v2 row is tolerated/ignored at parse. +KEYING: the resolved entry is keyed by the group node `id`. The output field is still named `templateId` (not renamed) so the four consuming UI surfaces — inline quick-create card, New Task modal/TaskForm, task-detail Workflow tab, and the optional-steps dropdown — keep reading the same shape unchanged; they now toggle group ids into `enabledWorkflowSteps` instead of template ids. Renaming/recreating a group resets per-task state, identical to the prior `templateId` keying. +Display metadata: `name` comes from `config.name` (falling back to the node id), `defaultOn` from `config.defaultOn ?? false`. The group node carries no description/icon/phase, so `description` is "" and `phase` defaults to "pre-merge" — keeping every field the consumers read populated and non-blank. +*/ + +function isOptionalGroupNode( + node: WorkflowIrNode, +): node is WorkflowIrNode & { config: WorkflowOptionalGroupConfig } { + return node.kind === "optional-group"; +} + /** - * Resolve workflow-declared optional step template ids into display metadata. + * Resolve a workflow's `optional-group` nodes into per-task toggle display + * metadata. Each enabled group's node id is what a task stores in + * `enabledWorkflowSteps`; this resolver advertises which groups a task may + * toggle plus their seed default. * - * The declaration is intentionally execution-inert: it only advertises which - * template-backed workflow steps a task may toggle into `enabledWorkflowSteps`. - * Unknown template ids are skipped so stale/custom declarations never render - * blank UI rows or break workflow loading. + * Source: v2 `ir.nodes` where `kind === "optional-group"` (NOT the legacy + * `ir.optionalSteps` declaration). Non-v2 graphs and graphs without any + * optional-group node resolve to `[]`. A group with a missing or partial config + * still resolves to a usable entry — `name` falls back to the node id and + * `defaultOn` to false — rather than being dropped, so a stale/partial node never + * silently disappears from the toggle UI or breaks workflow loading. + * + * `pluginTemplates` is accepted for signature compatibility with the prior + * template-backed resolver; group nodes are self-describing, so it is currently + * unused. */ export function resolveWorkflowOptionalSteps( ir: WorkflowIr, - pluginTemplates: WorkflowStepTemplate[] = [], + _pluginTemplates: WorkflowStepTemplate[] = [], ): ResolvedWorkflowOptionalStep[] { - if (ir.version !== "v2" || !ir.optionalSteps?.length) return []; - - const templates = new Map(); - for (const template of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) { - templates.set(template.id, template); - } + if (ir.version !== "v2" || !Array.isArray(ir.nodes)) return []; const resolved: ResolvedWorkflowOptionalStep[] = []; - for (const optionalStep of ir.optionalSteps) { - const template = templates.get(optionalStep.templateId); - if (!template) continue; + for (const node of ir.nodes) { + if (!isOptionalGroupNode(node)) continue; + const config = (node.config ?? {}) as Partial; resolved.push({ - templateId: optionalStep.templateId, - name: template.name, - description: template.description, - icon: template.icon, - phase: template.phase ?? "pre-merge", - defaultOn: optionalStep.defaultOn ?? template.defaultOn ?? false, + // Keyed by the group node id (documented above); field name preserved. + templateId: node.id, + name: typeof config.name === "string" && config.name.trim() ? config.name : node.id, + description: "", + phase: "pre-merge", + defaultOn: config.defaultOn === true, }); } return resolved; } + +/** + * Ids of `optional-group` nodes whose effective `defaultOn` is true. Used to + * seed a new task's `enabledWorkflowSteps` at creation, mirroring the prior + * `optionalStep.defaultOn ?? false` precedence (U3, R3). Defensive: non-v2 + * graphs and graphs without optional groups yield `[]`. + */ +export function resolveDefaultOnOptionalGroupIds(ir: WorkflowIr): string[] { + return resolveWorkflowOptionalSteps(ir) + .filter((step) => step.defaultOn) + .map((step) => step.templateId); +} + +/* +FNXC:WorkflowOptionalGroup 2026-06-21-16:30: +Every optional-group node id in a workflow, regardless of `defaultOn`. These ids are executor toggle keys (the per-task `enabledWorkflowSteps` set), NOT legacy `WorkflowStep` template ids. A built-in group id can deliberately equal a `WORKFLOW_STEP_TEMPLATES` id (e.g. "browser-verification"), so the store must pass these through `resolveEnabledWorkflowSteps` untouched instead of materializing them into a step row whose id the executor would never match. +*/ +export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] { + return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId); +} diff --git a/packages/dashboard/app/components/CustomModelDropdown.css b/packages/dashboard/app/components/CustomModelDropdown.css index e943c7f93d..933c7d29f6 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.css +++ b/packages/dashboard/app/components/CustomModelDropdown.css @@ -67,8 +67,8 @@ border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); - /* Must sit above floating dashboard panels. */ - z-index: 1200; + /* Must sit above floating dashboard panels and the shared floating-window stack (10100+). */ + z-index: 11000; max-height: 320px; display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/DevServerView.css b/packages/dashboard/app/components/DevServerView.css index 5bb722fc38..437f83087e 100644 --- a/packages/dashboard/app/components/DevServerView.css +++ b/packages/dashboard/app/components/DevServerView.css @@ -582,6 +582,65 @@ exactly when the surrounding chrome is gone. margin: 0; } +.devserver-preview-modal-launcher { + align-items: stretch; +} + +.devserver-preview-modal-launcher__copy { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; +} + +.devserver-preview-modal-launcher__copy .devserver-preview-url-badge { + max-width: none; +} + +.devserver-preview-modal-launcher__description { + margin: 0; + color: var(--text-muted); + line-height: 1.5; +} + +.devserver-preview-modal-overlay { + align-items: center; + padding: var(--space-xl); +} + +.devserver-preview-modal { + width: min(calc(var(--space-2xl) * 28), calc(100vw - var(--space-xl) * 2)); + max-height: calc(100vh - var(--space-xl) * 2); +} + +.devserver-preview-modal__titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + border-bottom: 1px solid var(--border); +} + +.devserver-preview-modal__titlebar h2 { + margin: 0; + font-size: 1rem; +} + +.devserver-preview-modal__body { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.devserver-preview-modal__body .devserver-preview-container { + flex: 1; + min-height: min(60vh, calc(var(--space-2xl) * 14)); + max-height: none; +} + /* Legacy selector compatibility for static CSS tests */ .dev-server-preview-fallback { border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent); @@ -655,7 +714,8 @@ exactly when the surrounding chrome is gone. max-width: none; } - .devserver-preview-header { + .devserver-preview-header, + .devserver-preview-modal-launcher__copy { flex-wrap: wrap; } @@ -703,6 +763,20 @@ exactly when the surrounding chrome is gone. max-height: calc(var(--space-2xl) * 3); } + .devserver-preview-modal-overlay { + align-items: stretch; + padding: var(--space-md); + } + + .devserver-preview-modal { + width: 100%; + max-height: calc(100vh - var(--space-md) * 2); + } + + .devserver-preview-modal__body .devserver-preview-container { + min-height: calc(var(--space-2xl) * 7); + } + .dev-server-config { max-height: min(48vh, calc(var(--space-2xl) * 13)); } @@ -852,7 +926,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu flex-direction: column; } - .devserver-preview-panel { + .devserver-preview-panel, + .devserver-preview-modal-launcher { grid-column: auto; grid-row: auto; } @@ -862,10 +937,25 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu max-width: none; } - .devserver-preview-header { + .devserver-preview-header, + .devserver-preview-modal-launcher__copy { flex-wrap: wrap; } + .devserver-preview-modal-overlay { + align-items: stretch; + padding: var(--space-md); + } + + .devserver-preview-modal { + width: min(calc(var(--space-2xl) * 20), calc(100vw - var(--space-md) * 2)); + max-height: calc(100vh - var(--space-md) * 2); + } + + .devserver-preview-modal__body .devserver-preview-container { + min-height: calc(var(--space-2xl) * 7); + } + .devserver-preview-url-badge { order: 2; flex: 1 1 100%; @@ -915,8 +1005,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu } .dev-server-logs, - .devserver-preview-container, - .devserver-preview-iframe { + .devserver-preview-panel .devserver-preview-container, + .devserver-preview-panel .devserver-preview-iframe { min-height: calc(var(--space-2xl) * 4 + var(--space-md)); max-height: none; } diff --git a/packages/dashboard/app/components/DevServerView.tsx b/packages/dashboard/app/components/DevServerView.tsx index e88bf63bf8..b3b88febad 100644 --- a/packages/dashboard/app/components/DevServerView.tsx +++ b/packages/dashboard/app/components/DevServerView.tsx @@ -1,13 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react"; +import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square, X } from "lucide-react"; import type { Task, TaskDetail } from "@fusion/core"; import "./DevServerView.css"; import type { DetectedDevServerCommand } from "../api"; import { useDevServer } from "../hooks/useDevServer"; import { useDevServerLogs } from "../hooks/useDevServerLogs"; import { usePreviewEmbed } from "../hooks/usePreviewEmbed"; +import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { ToastType } from "../hooks/useToast"; import { DevServerLogViewer } from "./DevServerLogViewer"; import { PreviewIframe } from "./PreviewIframe"; @@ -37,6 +39,85 @@ function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting }; } + +const NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD = 480; + +function isTrueMobileViewport(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + + return window.matchMedia("(max-width: 768px)").matches; +} + +function getDirectRightDockBodyHost(element: HTMLElement): HTMLElement | null { + if (element.closest(".right-dock-expand-modal__body")) { + return null; + } + + const parent = element.parentElement; + if (!parent?.classList.contains("right-dock__body")) { + return null; + } + + return parent; +} + +function readHostInlineSize(host: HTMLElement): number { + if (host.clientWidth > 0) { + return host.clientWidth; + } + + const rect = host.getBoundingClientRect(); + return rect.width; +} + +function shouldUseNarrowRightDockPreviewMode(root: HTMLElement | null): boolean { + if (!root || isTrueMobileViewport()) { + return false; + } + + const host = getDirectRightDockBodyHost(root); + if (!host) { + return false; + } + + return readHostInlineSize(host) <= NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD; +} + +function useNarrowRightDockPreviewMode(rootRef: RefObject): boolean { + const [isNarrowRightDockPreviewMode, setIsNarrowRightDockPreviewMode] = useState(false); + + useEffect(() => { + const root = rootRef.current; + if (!root) { + setIsNarrowRightDockPreviewMode(false); + return; + } + + const host = getDirectRightDockBodyHost(root); + const updateMode = () => setIsNarrowRightDockPreviewMode(shouldUseNarrowRightDockPreviewMode(root)); + + updateMode(); + + if (!host || typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateMode); + return () => window.removeEventListener("resize", updateMode); + } + + const observer = new ResizeObserver(updateMode); + observer.observe(host); + window.addEventListener("resize", updateMode); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateMode); + }; + }, [rootRef]); + + return isNarrowRightDockPreviewMode; +} + let devServerViewWasPreviouslyInactive = false; function normalizeError(error: unknown): string { @@ -142,6 +223,14 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps const effectivePreviewUrl = previewUrl; const selectedSource = session?.config?.cwd ?? null; + const rootRef = useRef(null); + const isNarrowRightDockPreviewMode = useNarrowRightDockPreviewMode(rootRef); + + /* + FNXC:DevServer 2026-06-23-00:00: + The Dev Server preview must escape into a modal when the direct right-dock host is very narrow so preview chrome does not crowd logs and configuration in the same dock column. + The 480px threshold catches the dock's compact range before preview chrome becomes unusable while preserving full-page, true mobile viewport, and expanded pop-out inline previews. + */ const [showCandidates, setShowCandidates] = useState(true); const [commandInput, setCommandInput] = useState(""); const [previewInput, setPreviewInput] = useState(""); @@ -170,6 +259,9 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps }, [executingTasks, selectedTaskId]); const [previewMode, setPreviewMode] = useState("embedded"); + const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false); + const previewModalLauncherRef = useRef(null); + const previewModalRef = useRef(null); const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null; const { @@ -271,6 +363,60 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps setPreviewInput(effectivePreviewUrl ?? ""); }, [effectivePreviewUrl]); + const closePreviewModal = useCallback(() => { + setIsPreviewModalOpen(false); + window.requestAnimationFrame(() => previewModalLauncherRef.current?.focus()); + }, []); + const previewModalOverlayDismissProps = useOverlayDismiss(closePreviewModal); + + useEffect(() => { + if (!isPreviewModalOpen) { + return; + } + + previewModalRef.current?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePreviewModal(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const focusableElements = Array.from( + previewModalRef.current?.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? [], + ).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true"); + + const firstElement = focusableElements[0]; + const lastElement = focusableElements.at(-1); + if (!firstElement || !lastElement) { + return; + } + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [closePreviewModal, isPreviewModalOpen]); + + useEffect(() => { + if (!isNarrowRightDockPreviewMode && isPreviewModalOpen) { + setIsPreviewModalOpen(false); + } + }, [isNarrowRightDockPreviewMode, isPreviewModalOpen]); + const handleOpenInNewTab = useCallback(() => { if (!effectivePreviewUrl) { return; @@ -399,8 +545,136 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps const stopDisabled = status === "stopped" || actionInFlight !== null; const restartDisabled = status === "stopped" || status === "starting" || actionInFlight !== null; + const renderPreviewContent = () => ( + <> +
+
+ + {t("devserver.preview", "Preview")} +
+ + {isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")} + {effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")} + +
+ + + +
+
+ +
+ {!effectivePreviewUrl && !isRunning && ( +

{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}

+ )} + + {!effectivePreviewUrl && isRunning && ( +

{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}

+ )} + + {effectivePreviewUrl && previewMode === "external" && ( +
+

{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}

+ +
+ )} + + {effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && ( +
+ {embedStatus === "error" + ?
+ )} + + {effectivePreviewUrl && previewMode === "embedded" && !showFallback && ( + + )} +
+ + ); + return ( -
+
{/* FNXC:DevServer 2026-06-22-01:00: Migrated to the shared ViewHeader for cross-view consistency. The status badge sits next to the title inside the actions slot (wrapped in .dev-server-header-title so the existing mobile flex-wrap rule still applies), and the Start/Stop/Restart controls follow in .dev-server-header-actions. ViewHeader supplies the standard view padding; the view body must not repeat the top padding. @@ -641,126 +915,75 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
-
-
-
- - {t("devserver.preview", "Preview")} + {isNarrowRightDockPreviewMode ? ( +
+
+
+ + {t("devserver.preview", "Preview")} +
+ + {effectivePreviewUrl ? effectivePreviewUrl : t("devserver.notAvailable", "Not available")} +
- + {effectivePreviewUrl + ? t("devserver.previewModalLauncherDescription", "Open the live preview in a modal so logs and configuration stay usable in this narrow dock.") + : t("devserver.previewModalLauncherUnavailable", "Start the dev server or set a preview URL to open the preview modal.")} +

+ - - -
-
+ {t("devserver.openPreview", "Open preview")} + +
+ ) : ( +
+ {renderPreviewContent()} +
+ )} -
- {!effectivePreviewUrl && !isRunning && ( -

{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}

- )} - - {!effectivePreviewUrl && isRunning && ( -

{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}

- )} - - {effectivePreviewUrl && previewMode === "external" && ( -
-

{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}

+ {isNarrowRightDockPreviewMode && isPreviewModalOpen && ( +
+
+
+

{t("devserver.preview", "Preview")}

- )} - - {effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && ( -
- {embedStatus === "error" - ?
- + )}
); } diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index b5d7af8c75..8ab9bd73e1 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -696,13 +696,16 @@ The embedded title reads like other embedded-view titles (Planning modal-header- } } +/* +FNXC:SettingsMobile 2026-06-23-09:02: +Settings section headings should preserve hierarchy through spacing and type only. Avoid per-heading divider borders so mobile and desktop shared Settings sections keep the lighter scrollbar-focused chrome contract. +*/ .settings-section-heading { font-size: 14px; font-weight: 600; padding: var(--space-lg) 0 var(--space-md); margin: 0 0 var(--space-md); color: var(--text); - border-bottom: 1px solid var(--border); } /* First heading inside the section drops top padding to remove a redundant diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index ee7f96253a..28d6d01ed3 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1851,17 +1851,22 @@ export function SettingsModal({ return next; }); try { + /* + FNXC:Notifications 2026-06-23-08:49: + Settings notification tests must send the current unsaved ntfy form values for every ntfy test affordance. Users validate the exact topic/server/token they just typed before saving, so message/room test requests carry the same request-scoped config as the general ntfy test. + */ + const currentNtfyConfig = { + ntfyEnabled: form.ntfyEnabled, + ntfyTopic: form.ntfyTopic, + ...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}), + ...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}), + }; const config = providerId === "ntfy" - ? { - ntfyEnabled: form.ntfyEnabled, - ntfyTopic: form.ntfyTopic, - ...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}), - ...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}), - } + ? currentNtfyConfig : providerId === "ntfy-message" - ? { messageEventType: "message:agent-to-user" } + ? { ...currentNtfyConfig, messageEventType: "message:agent-to-user" } : providerId === "ntfy-room" - ? { messageEventType: "message:room" } + ? { ...currentNtfyConfig, messageEventType: "message:room" } : { webhookUrl: form.webhookUrl, webhookFormat: form.webhookFormat || "generic", diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 3e5898ec52..6400b3b02e 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -35,6 +35,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ". import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout"; import { useRetryWarning } from "../context/RetryWarningContext"; import { useColumnLabel } from "../i18n/labels"; +import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary"; /** Per-branch progress snapshot (U13). Surfaced as an optional additive field * on the task payload for the parallel-window badge (U9). */ @@ -625,6 +626,17 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.blockedBy === nextTask.blockedBy && previousTask.overlapBlockedBy === nextTask.overlapBlockedBy && previousTask.worktree === nextTask.worktree && + // FNXC:Workspace 2026-06-21-22:30: re-render the card when a workspace task acquires/ + // releases sub-repo worktrees so the "N repos acquired" placeholder stays current (U3). + // F7 — compare the sorted key SETS, not just the count: a same-count repo swap (one + // repo released, a different one acquired) keeps the count but must still re-render, + // otherwise the placeholder shows a stale repo set. + // FNXC:Workspace 2026-06-22-09:00: compare full VALUES, not only the key set. A + // pool-reclaim re-acquire keeps the same repo key but produces a different + // worktreePath/branch; a key-set-only check would leave the card showing stale path + // text. Whole-map JSON compare covers keys and values at negligible cost for small N. + JSON.stringify(previousTask.workspaceWorktrees ?? null) === + JSON.stringify(nextTask.workspaceWorktrees ?? null) && previousTask.branch === nextTask.branch && previousTask.baseBranch === nextTask.baseBranch && previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks && @@ -2186,6 +2198,10 @@ function TaskCardComponent({
); })()} + {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular task.branch, + so the branch-metadata row below renders nothing. Surface the acquired sub-repos + as a compact "N repos acquired" placeholder so the card isn't blank (U3/KTD5). */} + {isWorkspaceTask(task) && } {hasBranchMetadata && (
{branchMetadata.branch && ( diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index d851e339d6..1954edac2d 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -2327,3 +2327,39 @@ Narrow mobile task detail surfaces from both Board and List must allow horizonta color: var(--color-error); font-size: 0.75rem; } + +/* +FNXC:Workspace 2026-06-21-00:00: +Flat read-only per-sub-repo worktree list for a workspace task (U3/KTD5 dashboard floor). +Read-only list/placeholder only — not the deferred rich per-repo-status component. +*/ +.workspace-worktrees-summary { + margin: var(--space-sm) 0 0; +} +.workspace-worktrees-placeholder { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary, inherit); + margin-bottom: var(--space-xs); +} +.workspace-worktrees-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} +.workspace-worktrees-item { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-sm); + font-size: 0.75rem; + font-family: var(--font-mono, monospace); +} +.workspace-worktrees-repo { + font-weight: 600; +} +.workspace-worktrees-branch { + color: var(--color-text-secondary, inherit); +} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 2aafea8b95..4b6c5b929e 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -40,6 +40,7 @@ import { TaskChatTab } from "./TaskChatTab"; import { TaskReviewTab } from "./TaskReviewTab"; import { MergeDetails } from "./MergeDetails"; import { TaskChangesTab } from "./TaskChangesTab"; +import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary"; import { TaskForm, type PendingImage } from "./TaskForm"; import { useNodes } from "../hooks/useNodes"; import { WorkflowResultsTab } from "./WorkflowResultsTab"; @@ -3144,6 +3145,14 @@ export function TaskDetailContent({ {task.branchContext?.groupId && ( )} + {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular + task.worktree/task.branch; surface their acquired per-sub-repo worktrees + as a flat read-only list so the detail view isn't blank (U3/KTD5). */} + {/* FNXC:Workspace 2026-06-22-09:00: gate/render off the hydrated + 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) && } )} {task.status === "failed" && task.error && ( diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index a25ebb0685..50ce4480c7 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -1171,6 +1171,80 @@ Built-in workflow prompts need visible override state and a reset action without color: var(--ws-warning); } +/* ── Per-node Help (FNXC:WorkflowEditor 2026-06-21-10:00) ─────────── + * Collapsible
teaching what the selected node does, how to + * configure it, and its inputs/outputs/edges. Sits under the heading, + * collapsed by default so it never pushes config fields below the fold. */ +.wf-inspector-help { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.wf-inspector-help-summary { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + font-size: 0.78rem; + color: var(--text); + cursor: pointer; + list-style: none; + user-select: none; +} + +.wf-inspector-help-summary::-webkit-details-marker { + display: none; +} + +.wf-inspector-help-summary:hover { + background: var(--bg-tertiary); + border-radius: var(--radius-sm); +} + +/* Engine-managed badge for graph-only policy nodes (read-only lifecycle). */ +.wf-inspector-help-badge { + margin-left: auto; + padding: 1px var(--space-xs); + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-dim); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.wf-inspector-help-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0 var(--space-sm) var(--space-sm); + font-size: 0.76rem; + color: var(--text-muted); +} + +.wf-inspector-help-summary-text { + margin: 0; + color: var(--text); +} + +.wf-inspector-help-dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px var(--space-sm); + margin: 0; +} + +.wf-inspector-help-dl dt { + font-weight: 600; + color: var(--text-dim); +} + +.wf-inspector-help-dl dd { + margin: 0; + color: var(--text-muted); +} + .wf-field--checkbox { flex-direction: row; align-items: center; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 4fd00531b4..81cf94c863 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -16,8 +16,8 @@ import { } from "@xyflow/react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react"; -import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react"; +import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowIrNodeKind } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, @@ -56,6 +56,7 @@ import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext"; import { bareSkillName, type NodeSummaryCatalogs } from "./nodes/node-summary"; +import { nodeHelpForData } from "./nodes/node-help"; import { irToFlow, flowToIr, @@ -63,11 +64,11 @@ import { emptyWorkflowLayout, copyIrWithFreshIds, insertFragment, + optionalGroupFragmentIr, fragmentSeamConflicts, columnsOf, fieldsOf, settingsOf, - optionalStepsOf, columnsToBandNodes, reconcileNodeColumns, strictColumnForY, @@ -91,7 +92,6 @@ import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api"; import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel"; import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel"; -import { WorkflowOptionalStepsPanel } from "./WorkflowOptionalStepsPanel"; import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView"; @@ -103,7 +103,9 @@ import { } from "./workflow-mobile-graph"; type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; -type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "optional-steps" | "columns" | "actions"; +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: dropped the "optional-steps" mobile +// panel — the declaration authoring surface is retired (optional-group nodes now). +type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions"; function builtinSeamPrompt(config: Record | undefined): string { const seam = typeof config?.seam === "string" ? config.seam : ""; @@ -190,7 +192,6 @@ function serializeGraph( columns: WorkflowIrColumn[], fields: WorkflowFieldDefinition[], settings: WorkflowSettingDefinition[], - optionalSteps: WorkflowOptionalStep[], ): string { const { ir, layout } = flowToIr( name, @@ -199,7 +200,6 @@ function serializeGraph( columns.length ? columns : undefined, fields.length ? fields : undefined, settings.length ? settings : undefined, - optionalSteps.length ? optionalSteps : undefined, ); return JSON.stringify({ name, description, ir, layout }); } @@ -269,6 +269,8 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof // Step-inversion (KTD-3/4/12/15). { kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } }, { kind: "loop", label: "Loop", icon: Repeat, presetConfig: { maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } } }, + // FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group container holds a template subgraph run once when the task enables it (per-task `enabledWorkflowSteps`, seeded from `defaultOn`) and skipped otherwise. + { kind: "optional-group", label: "Optional group", icon: ToggleRight, presetConfig: { defaultOn: false } }, { kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } }, { kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } }, { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, @@ -320,6 +322,7 @@ const USER_NODE_KINDS: ReadonlySet = new Set([]); - const [optionalSteps, setOptionalSteps] = useState([]); + /* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + The legacy optional-step DECLARATION authoring state/panel is removed. Optional + steps are graph-native `optional-group` nodes authored through the canvas; the + editor no longer carries a separate `optionalSteps` declaration array. */ // FNXC:WorkflowEditor 2026-06-21-20:06: // Built-in workflow graph structure remains read-only, but prompt/gate node prompts need a separate per-project override state so editing prompts does not mark structural graph edits dirty or use the read-only workflow PATCH authority. const [promptOverrides, setPromptOverrides] = useState(null); @@ -834,7 +840,6 @@ function InnerEditor({ const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed"; const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed"; const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed"; - const optionalStepsCollapsedStorageKey = "fusion:wf-sidebar-optional-steps-collapsed"; const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { try { return localStorage.getItem(sidebarCollapsedStorageKey) === "1"; @@ -863,13 +868,6 @@ function InnerEditor({ return false; } }); - const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState(() => { - try { - return localStorage.getItem(optionalStepsCollapsedStorageKey) === "1"; - } catch { - return false; - } - }); useEffect(() => { try { localStorage.setItem(sidebarCollapsedStorageKey, sidebarCollapsed ? "1" : "0"); @@ -898,13 +896,6 @@ function InnerEditor({ // localStorage unavailable (private mode / SSR): non-fatal. } }, [settingsCollapsed]); - useEffect(() => { - try { - localStorage.setItem(optionalStepsCollapsedStorageKey, optionalStepsCollapsed ? "1" : "0"); - } catch { - // localStorage unavailable (private mode / SSR): non-fatal. - } - }, [optionalStepsCollapsed]); // React Flow instance for programmatic viewport control (auto-layout on load). const { setViewport } = useReactFlow(); // Wrapper around so keyboard deletion can return focus to the @@ -1092,10 +1083,10 @@ function InnerEditor({ if (isBuiltin) return false; if (!activeWorkflow || loadedSnapshotRef.current === null) return false; return ( - serializeGraph(name, description, nodes, edges, columns, fields, settings, optionalSteps) !== + serializeGraph(name, description, nodes, edges, columns, fields, settings) !== loadedSnapshotRef.current ); - }, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps]); + }, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]); const loadWorkflows = useCallback(async () => { setLoading(true); @@ -1243,7 +1234,6 @@ function InnerEditor({ setColumns([]); setFields([]); setSettings([]); - setOptionalSteps([]); setPromptOverrides(null); setPromptOverrideSavingNodeId(null); setName(""); @@ -1255,7 +1245,6 @@ function InnerEditor({ const loadedColumns = columnsOf(activeWorkflow); const loadedFields = fieldsOf(activeWorkflow); const loadedSettings = settingsOf(activeWorkflow); - const loadedOptionalSteps = optionalStepsOf(activeWorkflow); // Auto-layout on load: compute tidy positions and apply them before the // first render so nodes are visible in the top-left viewport. const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns); @@ -1265,7 +1254,6 @@ function InnerEditor({ setColumns(loadedColumns); setFields(loadedFields); setSettings(loadedSettings); - setOptionalSteps(loadedOptionalSteps); setName(activeWorkflow.name); setDescription(activeWorkflow.description ?? ""); setEditingName(false); @@ -1280,7 +1268,6 @@ function InnerEditor({ loadedColumns, loadedFields, loadedSettings, - loadedOptionalSteps, ); setSelectedNodeId(null); setSelectedEdgeId(null); @@ -1455,16 +1442,19 @@ function InnerEditor({ const baseConfig = kind === "gate" ? { gateMode: "gate" } : {}; const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig; - if (kind === "foreach" || kind === "loop") { + if (kind === "foreach" || kind === "loop" || kind === "optional-group") { // Template groups render as React Flow group nodes. Foreach seeds the - // required step-execute seam; loop seeds a regular prompt so authors can - // wire the repeated body immediately. The group node must precede its - // child for React Flow's parent extent to apply. + // required step-execute seam; loop + optional-group seed a regular prompt + // so authors can wire the body immediately. The group node must precede + // its child for React Flow's parent extent to apply. + // FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is authored exactly like a foreach/loop region — drop nodes inside; the subgraph runs once when the task enables the group. const childId = foreachChildFlowId(id, newNodeId()); const childLabel = kind === "foreach" ? t("workflowNodes.stepExecuteLabel", "Step execute") - : t("workflowNodes.loopStepLabel", "Loop step"); + : kind === "optional-group" + ? t("workflowNodes.optionalGroupStepLabel", "Optional step") + : t("workflowNodes.loopStepLabel", "Loop step"); const childConfig = kind === "foreach" ? { seam: "step-execute" } : { prompt: "" }; setNodes((ns) => [ ...ns, @@ -1521,6 +1511,34 @@ function InnerEditor({ [isBuiltin, addNode], ); + /* + FNXC:WorkflowOptionalGroup 2026-06-21-14:32: + "Insert as optional group" (U5/R5): drop an add-on already wrapped in an `optional-group` container in + one action, seeding the group's `defaultOn` from the template's `defaultOn`. Reuses `stepTemplateToNode` + (KTD-5 — the catalog stays flat) to project the add-on to a prompt/script node, then `optionalGroupFragmentIr` + to wrap it and the EXISTING `insertFragment` path to remap ids + expand the group's template child — so two + inserts of the same add-on never collide. The group name carries the template name so the per-task toggle + surfaces label it. + */ + const handleInsertStepTemplateAsOptionalGroup = useCallback( + (tpl: WorkflowStepTemplate) => { + if (isBuiltin) return; + const { kind, config } = stepTemplateToNode(tpl); + const fragmentIr = optionalGroupFragmentIr( + { kind: kind as WorkflowIrNodeKind, config }, + { name: tpl.name, defaultOn: tpl.defaultOn ?? false }, + ); + const result = insertFragment(nodes, edges, fragmentIr, { + x: 240, + y: 200 + (nodes.length % 4) * 40, + }); + setNodes(result.nodes); + setEdges(result.edges); + setSelectedNodeId(result.insertedNodeIds[0] ?? null); + }, + [isBuiltin, nodes, edges, setNodes, setEdges], + ); + // U9/R8: insert a fragment definition's body into the active graph. Pre-validates // seam duplication via fragmentSeamConflicts; on conflict, surfaces a persistent // inline error inside the Templates section and does NOT insert. Otherwise @@ -1619,11 +1637,12 @@ function InnerEditor({ setEdges(flow.edges); setColumns(columnsOf({ ...targetWorkflow, ir: result.ir })); setFields(fieldsOf({ ...targetWorkflow, ir: result.ir })); - // Hydrate settings + optionalSteps on the fragment/generate path too — it - // previously dropped both, which silently lost the declarations on the next - // save (the round-trip data loss U2 fixes for the primary load path). + // Hydrate settings on the fragment/generate path too — it previously dropped + // them, which silently lost the declarations on the next save (the round-trip + // data loss U2 fixes for the primary load path). Optional steps need no + // separate hydration: they are graph-native `optional-group` nodes carried by + // the node/edge mapping above (FNXC:WorkflowOptionalGroup 2026-06-21-18:00). setSettings(settingsOf({ ...targetWorkflow, ir: result.ir })); - setOptionalSteps(optionalStepsOf({ ...targetWorkflow, ir: result.ir })); setSelectedNodeId(null); setSelectedEdgeId(null); setValidationError(null); @@ -1996,7 +2015,6 @@ function InnerEditor({ columns.length ? columns : undefined, fields.length ? fields : undefined, settings.length ? settings : undefined, - optionalSteps.length ? optionalSteps : undefined, ); // Include name/description in the PATCH only when they changed from the // loaded workflow (KTD-10 inline rename/description persist here). @@ -2014,7 +2032,6 @@ function InnerEditor({ columns, fields, settings, - optionalSteps, ); setName(updated.name); setDescription(updated.description ?? ""); @@ -2084,7 +2101,7 @@ function InnerEditor({ } finally { setSaving(false); } - }, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps, unplaced, blockingViolationCount, projectId, addToast, t]); + }, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]); // Stamp the shared error-state badge onto offending nodes: unplaced step // nodes and any node the server flagged (seam-in-branch). One component @@ -2101,11 +2118,14 @@ function InnerEditor({ let errorBadge: string | undefined; if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column"); if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message; - const isTemplateGroup = n.data.kind === "foreach" || n.data.kind === "loop"; + const isTemplateGroup = + n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group"; const emptyHint = n.data.kind === "loop" ? t("workflowNodes.loopEmptyHint", "Drag loop steps here") - : t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here"); + : n.data.kind === "optional-group" + ? t("workflowNodes.optionalGroupEmptyHint", "Drag optional steps here") + : t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here"); const templateEmpty = isTemplateGroup ? (childCount.get(n.id) ?? 0) === 0 : undefined; if ( errorBadge === n.data.errorBadge && @@ -2129,6 +2149,8 @@ function InnerEditor({ * The structural start node needs an inspector because its entry column is editable and persisted in the workflow IR. Keep end structural-only until it has a meaningful editable property. */ const selectedNodeHasInspector = selectedNode !== null && selectedNode.data.kind !== "end"; + // FNXC:WorkflowEditor 2026-06-21-10:00: Help content for the inspector, keyed by the node's effective kind (preserved IR kind when a graph-only policy node collapsed onto a generic merge/gate/hold shape). + const selectedNodeHelp = selectedNode !== null ? nodeHelpForData(selectedNode.data) : null; const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null; const mobileNodeDetailStage = isMobileMode && selectedNodeHasInspector && !inspectorCollapsed; const mobileEdgeDetailStage = isMobileMode && selectedEdge !== null; @@ -2726,26 +2748,9 @@ function InnerEditor({ )} -
- - {!optionalStepsCollapsed && ( - p.template)} - /> - )} -
+ {/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: The optional-step + DECLARATION authoring sidebar section is removed. Optional steps + are authored as graph-native `optional-group` nodes on the canvas. */}
)} @@ -2881,7 +2886,6 @@ function InnerEditor({ ["add", t("workflowNodes.mobileAdd", "Add")], ["settings", t("workflowSettings.title", "Settings")], ["fields", t("workflowFields.title", "Fields")], - ["optional-steps", t("workflowOptionalSteps.title", "Optional steps")], ["columns", t("workflowColumns.title", "Columns")], ["actions", t("workflowNodes.mobileActions", "Actions")], ] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => ( @@ -2992,19 +2996,37 @@ function InnerEditor({ {templateGroups.stepEntries.length > 0 && (

{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}

+ {/* FNXC:WorkflowOptionalGroup 2026-06-21-14:38: mobile mirrors the desktop two-variant insert (node / optional group). */} {templateGroups.stepEntries.map((s) => ( - +
+ + +
))}
)} @@ -3060,16 +3082,6 @@ function InnerEditor({
)} - {mobilePanel === "optional-steps" && ( -
- p.template)} - /> -
- )} {mobilePanel === "columns" && (
@@ -3396,22 +3408,48 @@ function InnerEditor({ {t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}
+ {/* + FNXC:WorkflowOptionalGroup 2026-06-21-14:36: + Each built-in add-on surfaces TWO insert variants: the row inserts as a single node + (today's behavior), and a small secondary "as optional group" affordance wraps it in + an `optional-group` container (U5/R5). Both keep the established `wf-tpl-step-*` testid + convention (the wrap variant suffixes `-optional-group`). + */} {templateGroups.stepEntries.map((s) => ( - +
+ + +
))}
@@ -3587,7 +3625,8 @@ function InnerEditor({ !(compactLayoutEnabled && !isMobileMode) && (