Merge branch 'main' into feature/workflow-branch-group
This commit is contained in:
5
.changeset/fn-6939-dev-server-narrow-preview-modal.md
Normal file
5
.changeset/fn-6939-dev-server-narrow-preview-modal.md
Normal file
@@ -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.
|
||||
5
.changeset/fn-6953-ntfy-test-unsaved-config.md
Normal file
5
.changeset/fn-6953-ntfy-test-unsaved-config.md
Normal file
@@ -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.
|
||||
5
.changeset/merger-unification-runaimerge-sole-path.md
Normal file
5
.changeset/merger-unification-runaimerge-sole-path.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:DevServerDocs 2026-06-23-00:00: The narrow right-sidebar Dev Server host must describe the preview modal launcher so users do not expect the preview iframe to remain inline when the dock is too constrained for logs and preview together. -->
|
||||
|
||||
For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.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-<taskId>-`), 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 <target>` / `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/<id>` 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 <localIntegration> || origin/<integration>`). `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<taskId, scope[]>` (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<taskId, scope[]>` | 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/<id>` branch | per sub-repo, keyed `(repo, fusion/<id>)` | 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:
|
||||
|
||||
```
|
||||
Task.workspaceWorktrees: Record<repoRelPath, {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
baseCommitSha?: string; // NEW (KTD3) — per-repo, vs resolved integration branch
|
||||
merged?: boolean; // NEW (KTD4) — set on land, cleared atomically on revert
|
||||
mergeTargetBranch?: string; // NEW (KTD4) — the repo's integration branch the squash landed on
|
||||
}>
|
||||
```
|
||||
|
||||
`@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 <yyyy-MM-dd-hh:mm>` 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/<integration>` 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<path>` 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/<integration>` 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/<id>` 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 <baseSha>..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/<id>` → 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 === <that repo's resolved integration branch>`. 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-<taskId>-` (`: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/<id>)`, 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/<id>` 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/<id>` 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).
|
||||
191
docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md
Normal file
191
docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md
Normal file
@@ -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 <id> 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 <yyyy-MM-dd-hh:mm>` 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/<name>.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 <id> 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).
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CentralCore,
|
||||
AgentStore,
|
||||
PluginLoader,
|
||||
assertNotWorkspaceTaskMerge,
|
||||
getTaskMergeBlocker,
|
||||
getEnabledPiExtensionPaths,
|
||||
isEphemeralAgent,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
type RuntimeLogger,
|
||||
} from "@fusion/dashboard";
|
||||
import {
|
||||
aiMergeTask,
|
||||
runAiMerge,
|
||||
MissionAutopilot,
|
||||
MissionExecutionLoop,
|
||||
HeartbeatMonitor,
|
||||
@@ -1295,11 +1296,21 @@ 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-19:05: R7 merge-boundary guard (master-plan U0).
|
||||
// Reject workspace-mode tasks before any merge work; per-repo merge lands in
|
||||
// master-plan U6, which removes this guard.
|
||||
const mergeTask = await store.getTask(taskId).catch(() => null);
|
||||
if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask);
|
||||
|
||||
const settings = await store.getSettings();
|
||||
if (getMergeStrategy(settings) === "pull-request") {
|
||||
const githubClient = new GitHubClient();
|
||||
@@ -1327,7 +1338,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),
|
||||
});
|
||||
|
||||
@@ -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, assertNotWorkspaceTaskMerge, 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 { runAiMerge } 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,16 @@ 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-19:05: R7 merge-boundary guard (master-plan U0).
|
||||
// Reject workspace-mode tasks before any merge work; per-repo merge lands in
|
||||
// master-plan U6, which removes this guard.
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: unified onto runAiMerge (U0).
|
||||
// The guard lives INSIDE this try so its throw renders via the formatted
|
||||
// ` ✗ ...` output below instead of the generic top-level bin.ts handler.
|
||||
const mergeTaskRecord = await store.getTask(id).catch(() => null);
|
||||
if (mergeTaskRecord) assertNotWorkspaceTaskMerge(mergeTaskRecord);
|
||||
|
||||
const result = await runAiMerge(store, projectPath, id, {
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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, WorkspaceTaskMergeError } from "./types.js";
|
||||
export {
|
||||
resolveEntryPointBranchAssignment,
|
||||
sanitizeBranchSegment,
|
||||
|
||||
@@ -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,
|
||||
@@ -11287,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.
|
||||
|
||||
@@ -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
|
||||
@@ -2598,6 +2610,50 @@ 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<Task, "id" | "workspaceWorktrees">): void {
|
||||
const worktrees = task.workspaceWorktrees;
|
||||
if (worktrees && Object.keys(worktrees).length > 0) {
|
||||
throw new WorkspaceTaskMergeError(
|
||||
`Workspace task ${task.id} cannot merge until per-repo merge support (master-plan U6) lands`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type RetrySummary = {
|
||||
stuckKill: number;
|
||||
recovery: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>): 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<HTMLDivElement>(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<PreviewMode>("embedded");
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
const previewModalLauncherRef = useRef<HTMLButtonElement>(null);
|
||||
const previewModalRef = useRef<HTMLDivElement>(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<HTMLElement>(
|
||||
'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 = () => (
|
||||
<>
|
||||
<div className="devserver-preview-header">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
>
|
||||
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
|
||||
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
|
||||
</span>
|
||||
<div className="devserver-preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
|
||||
data-testid="devserver-preview-mode-toggle"
|
||||
>
|
||||
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.openInNewTab", "Open in new tab")}
|
||||
onClick={handleOpenInNewTab}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-open-tab"
|
||||
>
|
||||
<ExternalLink />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.refreshPreview", "Refresh preview")}
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-refresh"
|
||||
>
|
||||
<RefreshCw />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
|
||||
{!effectivePreviewUrl && !isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
|
||||
)}
|
||||
|
||||
{!effectivePreviewUrl && isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "external" && (
|
||||
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
|
||||
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm touch-target"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-external-open-tab"
|
||||
>
|
||||
{t("devserver.openInNewTab", "Open in new tab")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
|
||||
<div
|
||||
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
|
||||
data-testid="devserver-preview-fallback"
|
||||
role="alert"
|
||||
>
|
||||
{embedStatus === "error"
|
||||
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
|
||||
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">
|
||||
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
|
||||
</p>
|
||||
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">
|
||||
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
|
||||
</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-fallback-open-tab"
|
||||
>
|
||||
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleRetryEmbeddedPreview}
|
||||
data-testid="devserver-preview-fallback-retry"
|
||||
>
|
||||
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
|
||||
<PreviewIframe
|
||||
url={effectivePreviewUrl}
|
||||
embedStatus={embedStatus}
|
||||
onEmbedStatusChange={setEmbedStatus}
|
||||
iframeRef={iframeRef}
|
||||
blockReason={blockReason}
|
||||
onRetry={handleRetryEmbeddedPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dev-server-view" data-testid="dev-server-view">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="dev-server-view"
|
||||
data-testid="dev-server-view"
|
||||
data-narrow-right-dock-preview={isNarrowRightDockPreviewMode ? "true" : "false"}
|
||||
>
|
||||
{/*
|
||||
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
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
||||
<div className="devserver-preview-header">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
{isNarrowRightDockPreviewMode ? (
|
||||
<section
|
||||
className="dev-server-panel devserver-preview-modal-launcher"
|
||||
data-testid="devserver-preview-modal-launcher"
|
||||
aria-label={t("devserver.previewLabel", "Dev server preview")}
|
||||
>
|
||||
<div className="devserver-preview-modal-launcher__copy">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
>
|
||||
{effectivePreviewUrl ? effectivePreviewUrl : t("devserver.notAvailable", "Not available")}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
<p className="devserver-preview-modal-launcher__description">
|
||||
{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.")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
ref={previewModalLauncherRef}
|
||||
onClick={() => setIsPreviewModalOpen(true)}
|
||||
data-testid="devserver-preview-modal-open"
|
||||
>
|
||||
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
|
||||
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
|
||||
</span>
|
||||
<div className="devserver-preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
|
||||
data-testid="devserver-preview-mode-toggle"
|
||||
>
|
||||
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.openInNewTab", "Open in new tab")}
|
||||
onClick={handleOpenInNewTab}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-open-tab"
|
||||
>
|
||||
<ExternalLink />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title={t("devserver.refreshPreview", "Refresh preview")}
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-refresh"
|
||||
>
|
||||
<RefreshCw />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{t("devserver.openPreview", "Open preview")}
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
||||
{renderPreviewContent()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
|
||||
{!effectivePreviewUrl && !isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
|
||||
)}
|
||||
|
||||
{!effectivePreviewUrl && isRunning && (
|
||||
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "external" && (
|
||||
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
|
||||
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
|
||||
{isNarrowRightDockPreviewMode && isPreviewModalOpen && (
|
||||
<div className="modal-overlay open devserver-preview-modal-overlay" {...previewModalOverlayDismissProps}>
|
||||
<div
|
||||
className="modal devserver-preview-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="devserver-preview-modal-title"
|
||||
tabIndex={-1}
|
||||
ref={previewModalRef}
|
||||
data-testid="devserver-preview-modal"
|
||||
>
|
||||
<div className="devserver-preview-modal__titlebar">
|
||||
<h2 id="devserver-preview-modal-title">{t("devserver.preview", "Preview")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm touch-target"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-external-open-tab"
|
||||
className="btn btn-sm btn-icon"
|
||||
onClick={closePreviewModal}
|
||||
aria-label={t("devserver.closePreviewModal", "Close preview modal")}
|
||||
data-testid="devserver-preview-modal-close"
|
||||
>
|
||||
{t("devserver.openInNewTab", "Open in new tab")}
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
|
||||
<div
|
||||
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
|
||||
data-testid="devserver-preview-fallback"
|
||||
role="alert"
|
||||
>
|
||||
{embedStatus === "error"
|
||||
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
|
||||
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">
|
||||
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
|
||||
</p>
|
||||
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">
|
||||
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
|
||||
</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-fallback-open-tab"
|
||||
>
|
||||
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleRetryEmbeddedPreview}
|
||||
data-testid="devserver-preview-fallback-retry"
|
||||
>
|
||||
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="devserver-preview-modal__body">
|
||||
{renderPreviewContent()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
|
||||
<PreviewIframe
|
||||
url={effectivePreviewUrl}
|
||||
embedStatus={embedStatus}
|
||||
onEmbedStatusChange={setEmbedStatus}
|
||||
iframeRef={iframeRef}
|
||||
blockReason={blockReason}
|
||||
onRetry={handleRetryEmbeddedPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -51,12 +51,27 @@ describe("DevServerView mobile CSS/structure", () => {
|
||||
const mobileBlockMatch = css.match(/@media[^{]*\(max-width: 768px\)[^{]*\{([\s\S]*?)\n\}/g) ?? [];
|
||||
const mobileCss = mobileBlockMatch.join("\n");
|
||||
|
||||
const headerRuleCount = (mobileCss.match(/\.devserver-preview-header\s*\{/g) ?? []).length;
|
||||
const headerRuleCount = (mobileCss.match(/\.devserver-preview-header,\s*\.devserver-preview-modal-launcher__copy\s*\{/g) ?? []).length;
|
||||
expect(headerRuleCount).toBe(1);
|
||||
expect(mobileCss).toMatch(/\.devserver-preview-url-badge\s*\{[\s\S]*max-width:\s*100%/);
|
||||
expect(mobileCss).toMatch(/\.dev-server-header-title\s*\{[\s\S]*flex-wrap:\s*wrap/);
|
||||
});
|
||||
|
||||
it("defines narrow right-dock launcher and modal rules without duplicating mobile media rules", () => {
|
||||
const css = loadAllAppCss();
|
||||
const containerStart = css.indexOf("@container right-dock-body (max-width: 768px)");
|
||||
expect(containerStart).toBeGreaterThan(-1);
|
||||
const containerCss = css.slice(containerStart);
|
||||
|
||||
expect(containerCss).toMatch(/\.devserver-preview-panel,\s*\.devserver-preview-modal-launcher\s*\{[\s\S]*grid-column:\s*auto/);
|
||||
expect(containerCss).toMatch(/\.devserver-preview-modal\s*\{[\s\S]*width:\s*min\(calc\(var\(--space-2xl\) \* 20\), calc\(100vw - var\(--space-md\) \* 2\)\)/);
|
||||
expect(containerCss).toMatch(/\.devserver-preview-panel \.devserver-preview-container/);
|
||||
expect(containerCss).not.toMatch(/\.dev-server-logs,\s*\.devserver-preview-container,\s*\.devserver-preview-iframe/);
|
||||
|
||||
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)/);
|
||||
expect(css).toMatch(/@container right-dock-body \(max-width: 768px\)/);
|
||||
});
|
||||
|
||||
it("renders preview header elements and keeps URL badge outside preview actions", () => {
|
||||
mockUseDevServer.mockReturnValue(createDevServerHookState());
|
||||
mockUseDevServerLogs.mockReturnValue({
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("lucide-react", () => ({
|
||||
Search: () => <span data-testid="icon-search" />,
|
||||
ShieldAlert: () => <span data-testid="icon-shield-alert" />,
|
||||
Square: () => <span data-testid="icon-square" />,
|
||||
X: () => <span data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
function createState(overrides: Partial<DevServerState> = {}): DevServerState {
|
||||
@@ -201,6 +202,153 @@ describe("DevServerView preview panel", () => {
|
||||
|
||||
afterEach(() => {
|
||||
window.open = originalWindowOpen;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderInRightDock(width: number) {
|
||||
const host = document.createElement("div");
|
||||
host.className = "right-dock__body";
|
||||
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
|
||||
document.body.appendChild(host);
|
||||
|
||||
return render(<DevServerView addToast={addToast} projectId="project-a" />, { container: host });
|
||||
}
|
||||
|
||||
it("activates narrow right-dock preview mode only below the dock threshold", async () => {
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
|
||||
const narrow = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
narrow.unmount();
|
||||
document.body.innerHTML = "";
|
||||
|
||||
renderInRightDock(640);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
expect(screen.queryByTestId("devserver-preview-modal-launcher")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces the narrow right-dock inline preview with an accessible modal launcher", async () => {
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState({
|
||||
entries: [{ id: "log-1", timestamp: "2026-06-23T00:00:00.000Z", stream: "stdout", text: "ready" }],
|
||||
total: 1,
|
||||
}));
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
|
||||
renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("dev-server-logs-panel")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("devserver-preview-panel")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Dev server preview")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-modal-launcher")).toHaveTextContent("http://localhost:3000");
|
||||
expect(screen.getByTestId("devserver-preview-url-badge")).toHaveTextContent("http://localhost:3000");
|
||||
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-modal-open"));
|
||||
|
||||
const modal = await screen.findByTestId("devserver-preview-modal");
|
||||
expect(modal).toHaveAttribute("role", "dialog");
|
||||
expect(modal).toHaveAttribute("aria-modal", "true");
|
||||
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-open-tab")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("devserver-preview-refresh")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("devserver-preview-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps preview modes and fallback actions inside the narrow dock modal", async () => {
|
||||
const retry = vi.fn();
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
|
||||
const { rerender } = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-modal-open"));
|
||||
|
||||
previewEmbedState = createPreviewEmbedState({
|
||||
embedStatus: "blocked",
|
||||
isBlocked: true,
|
||||
embedContext: "The server may block iframe embedding...",
|
||||
retry,
|
||||
});
|
||||
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("devserver-preview-fallback")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Preview blocked")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-fallback-retry"));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
|
||||
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
|
||||
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-mode-toggle"));
|
||||
|
||||
expect(screen.getByTestId("devserver-preview-external-only")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("devserver-preview-external-open-tab"));
|
||||
expect(window.open).toHaveBeenCalledWith("http://localhost:3000", "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
it("keeps inline preview mode for true mobile viewport and expanded right-dock hosts", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(max-width: 768px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
mockUseDevServer.mockReturnValue(
|
||||
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
|
||||
);
|
||||
|
||||
const mobile = renderInRightDock(420);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
|
||||
mobile.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
const expandedHost = document.createElement("div");
|
||||
expandedHost.className = "right-dock-expand-modal__body";
|
||||
Object.defineProperty(expandedHost, "clientWidth", { configurable: true, value: 420 });
|
||||
document.body.appendChild(expandedHost);
|
||||
|
||||
render(<DevServerView addToast={addToast} projectId="project-a" />, { container: expandedHost });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows start-empty state when server is not configured", () => {
|
||||
|
||||
@@ -4895,6 +4895,54 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends unsaved ntfy form config before saving", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined });
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
|
||||
await user.click(screen.getByLabelText("Enable"));
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic");
|
||||
await user.click(screen.getByText("Advanced"));
|
||||
await user.type(screen.getByLabelText("Custom ntfy server URL (optional)"), "https://ntfy.override.example//");
|
||||
await user.type(screen.getByLabelText("Access token (optional)"), "override-token");
|
||||
await user.click(screen.getByRole("button", { name: /Test notification/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
expect.objectContaining({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps ntfy test disabled until the current form has a valid topic", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined });
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
|
||||
await user.click(screen.getByLabelText("Enable"));
|
||||
const testButton = screen.getByRole("button", { name: /Test notification/ });
|
||||
expect(testButton).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "bad topic!");
|
||||
expect(testButton).toBeDisabled();
|
||||
expect(mockTestNotification).not.toHaveBeenCalled();
|
||||
|
||||
await user.clear(screen.getByLabelText("ntfy Topic"));
|
||||
await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic");
|
||||
expect(testButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("clears a saved ntfy access token via global null-as-delete semantics", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
@@ -4929,7 +4977,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
{ messageEventType: "message:agent-to-user" },
|
||||
expect.objectContaining({
|
||||
messageEventType: "message:agent-to-user",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -4953,7 +5005,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
{ messageEventType: "message:room" },
|
||||
expect.objectContaining({
|
||||
messageEventType: "message:room",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1772,6 +1772,37 @@ describe("POST /settings/test-ntfy", () => {
|
||||
expect(url).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("uses unsaved request ntfy config when saved settings are disabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(url).toBe("https://ntfy.override.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
});
|
||||
|
||||
it("falls back to saved ntfyBaseUrl when request override is blank", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
@@ -1973,69 +2004,82 @@ describe("POST /settings/test-notification", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("ntfy provider dispatches a message-event pipeline test when messageEventType is provided", async () => {
|
||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
||||
it("ntfy provider sends a message-event test with unsaved config when messageEventType is provided", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", messageEventType: "message:agent-to-user" }),
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
messageEventType: "message:agent-to-user",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-message-topic",
|
||||
ntfyBaseUrl: "https://ntfy.message.example//",
|
||||
ntfyAccessToken: "message-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
"message:agent-to-user",
|
||||
expect.objectContaining({
|
||||
event: "message:agent-to-user",
|
||||
metadata: expect.objectContaining({
|
||||
fromId: "system",
|
||||
toId: "user",
|
||||
preview: "Fusion test message notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.message.example/fresh-message-topic");
|
||||
expect(options.headers).toMatchObject({
|
||||
Title: "New message from Fusion",
|
||||
Priority: "high",
|
||||
Authorization: "Bearer message-token",
|
||||
});
|
||||
expect(options.body).toBe("Fusion → you: Fusion test message notification");
|
||||
});
|
||||
|
||||
it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => {
|
||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
||||
it("ntfy provider sends a room message-event test with unsaved config when messageEventType is message:room", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }),
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
messageEventType: "message:room",
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-room-topic",
|
||||
ntfyBaseUrl: "https://ntfy.room.example//",
|
||||
ntfyAccessToken: "room-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
"message:room",
|
||||
expect.objectContaining({
|
||||
event: "message:room",
|
||||
metadata: expect.objectContaining({
|
||||
roomId: "test-room",
|
||||
roomName: "Test Room",
|
||||
senderName: "Fusion",
|
||||
preview: "Fusion test room notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.room.example/fresh-room-topic");
|
||||
expect(options.headers).toMatchObject({
|
||||
Title: "#Test Room — Fusion",
|
||||
Priority: "default",
|
||||
Authorization: "Bearer room-token",
|
||||
});
|
||||
expect(options.body).toBe("Fusion in #Test Room: Fusion test room notification");
|
||||
});
|
||||
|
||||
it("ntfy provider uses config override for baseUrl", async () => {
|
||||
@@ -2057,6 +2101,69 @@ describe("POST /settings/test-notification", () => {
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("ntfy provider sends with unsaved config when saved settings are disabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: "https://ntfy.override.example//",
|
||||
ntfyAccessToken: "override-token",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
});
|
||||
|
||||
it("ntfy provider ignores blank request baseUrl and token overrides", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "saved-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({
|
||||
providerId: "ntfy",
|
||||
config: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "fresh-topic",
|
||||
ntfyBaseUrl: " ",
|
||||
ntfyAccessToken: " ",
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.saved.example/fresh-topic");
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
});
|
||||
|
||||
it("ntfy provider sends Authorization header from saved or override token", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
import {
|
||||
buildSessionSkillContextSync,
|
||||
createFnAgent as engineCreateFnAgent,
|
||||
getActiveNotificationService,
|
||||
probeWorktrunk,
|
||||
resolveWorktrunkBinary,
|
||||
} from "@fusion/engine";
|
||||
@@ -2044,84 +2043,160 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
* Returns the user's global pi extension settings from ~/.pi/agent/settings.json.
|
||||
* Includes packages, extension paths, skill paths, prompt template paths, and theme paths.
|
||||
*/
|
||||
router.post("/settings/test-ntfy", async (req, res) => {
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest("ntfy server URL cannot be empty");
|
||||
}
|
||||
const normalizeHttpUrl = (value: string, fieldName: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest(`${fieldName} cannot be empty`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`ntfy server URL from ${source} must be a valid URL`);
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`${fieldName} must be a valid URL`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest("ntfy server URL must use http:// or https://");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest(`${fieldName} must use http:// or https://`);
|
||||
}
|
||||
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`);
|
||||
return normalized.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
const getOwnValue = (source: Record<string, unknown>, key: string): unknown => (
|
||||
Object.prototype.hasOwnProperty.call(source, key) ? source[key] : undefined
|
||||
);
|
||||
|
||||
const getRequestNtfyValue = (body: Record<string, unknown>, config: Record<string, unknown>, key: string): unknown => {
|
||||
const configValue = getOwnValue(config, key);
|
||||
return configValue !== undefined ? configValue : getOwnValue(body, key);
|
||||
};
|
||||
|
||||
type NtfyTestMessageEventType = "message:agent-to-user" | "message:agent-to-agent" | "message:room";
|
||||
|
||||
function resolveEffectiveNtfyTestConfig(
|
||||
settings: Record<string, unknown>,
|
||||
body: Record<string, unknown>,
|
||||
config: Record<string, unknown> = {},
|
||||
): { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string } {
|
||||
/*
|
||||
FNXC:Notifications 2026-06-23-08:34:
|
||||
Test sends must honor unsaved Settings form state because users enable ntfy, enter a topic/server/token, and test before saving. Resolve request-scoped values ahead of persisted settings without persisting or logging tokens.
|
||||
|
||||
FNXC:Notifications 2026-06-23-10:21:
|
||||
Every ntfy test affordance, including message and room tests, must publish with the request-scoped topic/server/token instead of the active notification service's persisted provider state.
|
||||
*/
|
||||
const enabledOverride = getRequestNtfyValue(body, config, "ntfyEnabled");
|
||||
if (enabledOverride !== undefined && enabledOverride !== null && typeof enabledOverride !== "boolean") {
|
||||
throw badRequest("ntfy enabled must be a boolean");
|
||||
}
|
||||
const ntfyEnabled = typeof enabledOverride === "boolean" ? enabledOverride : settings.ntfyEnabled === true;
|
||||
if (!ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
const topicOverride = getRequestNtfyValue(body, config, "ntfyTopic");
|
||||
if (topicOverride !== undefined && topicOverride !== null && typeof topicOverride !== "string") {
|
||||
throw badRequest("ntfy topic must be a string");
|
||||
}
|
||||
const topic = typeof topicOverride === "string" ? topicOverride : settings.ntfyTopic;
|
||||
if (typeof topic !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const baseUrlOverride = getRequestNtfyValue(body, config, "ntfyBaseUrl");
|
||||
if (baseUrlOverride !== undefined && baseUrlOverride !== null && typeof baseUrlOverride !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
const requestBaseUrl = typeof baseUrlOverride === "string" && baseUrlOverride.trim()
|
||||
? normalizeNtfyBaseUrl(baseUrlOverride, "request")
|
||||
: undefined;
|
||||
const storedBaseUrl = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
|
||||
const tokenOverride = getRequestNtfyValue(body, config, "ntfyAccessToken");
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
topic,
|
||||
ntfyBaseUrl: requestBaseUrl ?? storedBaseUrl ?? "https://ntfy.sh",
|
||||
ntfyAccessToken: requestToken ?? storedToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendNtfyTestNotification(
|
||||
options: { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string; messageEventType?: NtfyTestMessageEventType },
|
||||
): Promise<void> {
|
||||
const contentByEvent: Record<NtfyTestMessageEventType | "default", { title: string; message: string; priority: "default" | "high" }> = {
|
||||
default: {
|
||||
title: "Fusion test notification",
|
||||
message: "Fusion test notification — your notifications are working!",
|
||||
priority: "default",
|
||||
},
|
||||
"message:agent-to-user": {
|
||||
title: "New message from Fusion",
|
||||
message: "Fusion → you: Fusion test message notification",
|
||||
priority: "high",
|
||||
},
|
||||
"message:agent-to-agent": {
|
||||
title: "Fusion → recipient",
|
||||
message: "Fusion messaged recipient: Fusion test message notification",
|
||||
priority: "default",
|
||||
},
|
||||
"message:room": {
|
||||
title: "#Test Room — Fusion",
|
||||
message: "Fusion in #Test Room: Fusion test room notification",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
const content = contentByEvent[options.messageEventType ?? "default"];
|
||||
const headers: Record<string, string> = {
|
||||
Title: content.title,
|
||||
Priority: content.priority,
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
if (options.ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${options.ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${options.ntfyBaseUrl}/${options.topic}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: content.message,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/settings/test-ntfy", async (req, res) => {
|
||||
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const configValue = body.config;
|
||||
if (configValue !== undefined && (typeof configValue !== "object" || configValue === null || Array.isArray(configValue))) {
|
||||
throw badRequest("config must be an object when provided");
|
||||
}
|
||||
const config = (configValue ?? {}) as Record<string, unknown>;
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
// Validate ntfy is enabled
|
||||
if (!settings.ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
// Validate topic exists and matches required format
|
||||
const topic = settings.ntfyTopic;
|
||||
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const overrideValue = req.body?.ntfyBaseUrl;
|
||||
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
|
||||
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
|
||||
? normalizeNtfyBaseUrl(overrideValue, "request")
|
||||
: undefined;
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const tokenOverride = req.body?.ntfyAccessToken;
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
const ntfyAccessToken = requestToken ?? storedToken;
|
||||
if (ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
|
||||
await sendNtfyTestNotification(configForTest);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
@@ -2133,31 +2208,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
});
|
||||
|
||||
router.post("/settings/test-notification", async (req, res) => {
|
||||
const normalizeHttpUrl = (value: string, fieldName: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest(`${fieldName} cannot be empty`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`${fieldName} must be a valid URL`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest(`${fieldName} must use http:// or https://`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`);
|
||||
return normalized.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const providerId = body.providerId;
|
||||
@@ -2176,113 +2226,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
if (providerId === "ntfy") {
|
||||
const requestedMessageEventType = config.messageEventType ?? body.messageEventType;
|
||||
if (requestedMessageEventType !== undefined) {
|
||||
if (
|
||||
requestedMessageEventType !== "message:agent-to-user"
|
||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||
&& requestedMessageEventType !== "message:room"
|
||||
) {
|
||||
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
|
||||
}
|
||||
|
||||
const notificationService = getActiveNotificationService();
|
||||
if (!notificationService) {
|
||||
throw new ApiError(502, "Notification service is not active");
|
||||
}
|
||||
|
||||
try {
|
||||
const messageId = `test-${crypto.randomUUID()}`;
|
||||
if (requestedMessageEventType === "message:room") {
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId,
|
||||
roomId: "test-room",
|
||||
roomName: "Test Room",
|
||||
senderAgentId: "system",
|
||||
senderName: "Fusion",
|
||||
preview: "Fusion test room notification",
|
||||
type: "room-assistant",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId,
|
||||
fromId: "system",
|
||||
fromType: "agent",
|
||||
toId: "user",
|
||||
toType: "user",
|
||||
type: messageType,
|
||||
preview: "Fusion test message notification",
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json({ success: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ApiError(502, `Failed to dispatch message notification: ${message}`);
|
||||
}
|
||||
if (
|
||||
requestedMessageEventType !== undefined
|
||||
&& requestedMessageEventType !== "message:agent-to-user"
|
||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||
&& requestedMessageEventType !== "message:room"
|
||||
) {
|
||||
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
|
||||
}
|
||||
if (!settings.ntfyEnabled) {
|
||||
throw badRequest("ntfy notifications are not enabled");
|
||||
}
|
||||
|
||||
const topic = settings.ntfyTopic;
|
||||
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
const overrideValue = config.ntfyBaseUrl ?? body.ntfyBaseUrl;
|
||||
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
|
||||
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
|
||||
? normalizeNtfyBaseUrl(overrideValue, "request")
|
||||
: undefined;
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const tokenOverride = config.ntfyAccessToken ?? body.ntfyAccessToken;
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
const ntfyAccessToken = requestToken ?? storedToken;
|
||||
if (ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
|
||||
await sendNtfyTestNotification({
|
||||
...configForTest,
|
||||
messageEventType: requestedMessageEventType as NtfyTestMessageEventType | undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,16 +14,24 @@ const testState = vi.hoisted(() => {
|
||||
|
||||
return {
|
||||
currentStore: null as MockTaskStore | null,
|
||||
aiMergeTask: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
VerificationError: MockVerificationError,
|
||||
};
|
||||
});
|
||||
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||||
// dispatch onto runAiMerge (merger-ai.js). These error-recovery tests use the
|
||||
// merge fn as a mockable seam; they now mock/assert runAiMerge. VerificationError
|
||||
// still comes from merger.js (shared, not deprecated).
|
||||
vi.mock("../merger.js", () => ({
|
||||
aiMergeTask: testState.aiMergeTask,
|
||||
sweepStaleAutostashes: vi.fn(async () => undefined),
|
||||
VerificationError: testState.VerificationError,
|
||||
}));
|
||||
|
||||
vi.mock("../merger-ai.js", () => ({
|
||||
runAiMerge: testState.runAiMerge,
|
||||
}));
|
||||
|
||||
vi.mock("../runtimes/in-process-runtime.js", () => ({
|
||||
InProcessRuntime: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
@@ -42,7 +50,8 @@ vi.mock("../runtimes/in-process-runtime.js", () => ({
|
||||
|
||||
import { ProjectEngine } from "../project-engine.js";
|
||||
import { runtimeLog } from "../logger.js";
|
||||
import { aiMergeTask, VerificationError } from "../merger.js";
|
||||
import { VerificationError } from "../merger.js";
|
||||
import { runAiMerge } from "../merger-ai.js";
|
||||
|
||||
type MockTask = {
|
||||
id: string;
|
||||
@@ -118,9 +127,9 @@ function makeStore({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
// These tests mock + assert aiMergeTask (the legacy merge path); pin the
|
||||
// legacy merger so onMerge routes there rather than the AI merge path.
|
||||
merger: { mode: "deterministic" },
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||||
// these tests mock/assert runAiMerge directly. No `merger.mode` pin needed —
|
||||
// the dispatch ignores the value.
|
||||
...settings,
|
||||
})),
|
||||
listTasks: vi.fn(async () => listedTasks ?? taskSequence.filter((task): task is MockTask => Boolean(task))),
|
||||
@@ -193,7 +202,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(aiMergeTask).mockReset();
|
||||
vi.mocked(runAiMerge).mockReset();
|
||||
testState.currentStore = null;
|
||||
|
||||
errorSpy = vi.spyOn(runtimeLog, "error").mockImplementation(() => undefined);
|
||||
@@ -347,7 +356,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ mergeRetries: 2 }), makeTask({ mergeRetries: 3, branch: "fusion/fn-2084" })],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -379,7 +388,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
throw new Error("db write failed");
|
||||
}),
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("Conflict while merging"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("Conflict while merging"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await expect(runMergeCycle(engine)).resolves.toBeUndefined();
|
||||
@@ -396,7 +405,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -429,7 +438,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -462,7 +471,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -495,7 +504,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -525,7 +534,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -558,7 +567,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -586,7 +595,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("merge conflict detected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -600,7 +609,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const store = makeStore();
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("This operation was aborted"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("This operation was aborted"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||
@@ -626,7 +635,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ mergeTransientRetryCount: 3 }), makeTask({ mergeTransientRetryCount: 3 })],
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("socket hang up"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("socket hang up"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -647,7 +656,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const store = makeStore();
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("remote branch missing"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("remote branch missing"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await runMergeCycle(engine);
|
||||
@@ -800,7 +809,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
throw new Error("sqlite locked");
|
||||
}),
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("remote push rejected"));
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(new Error("remote push rejected"));
|
||||
|
||||
const engine = createEngine(store);
|
||||
await expect(runMergeCycle(engine)).resolves.toBeUndefined();
|
||||
@@ -874,7 +883,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("treats post-finalize verification failures as a no-op diagnostic", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed: assertion mismatch in workspace");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
tasks: [
|
||||
@@ -930,7 +939,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("moves task back to in-progress with merge-remediation status on verification errors", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore();
|
||||
const engine = createEngine(store);
|
||||
@@ -968,7 +977,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
recovered: false,
|
||||
},
|
||||
});
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ verificationFailureCount: 2, status: "in-review" })],
|
||||
@@ -987,7 +996,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("increments verificationFailureCount across consecutive verification bounces", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ verificationFailureCount: 1, status: "merging-fix" })],
|
||||
@@ -1008,7 +1017,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("caps verification-failure bounces and creates a follow-up task", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
// Task already bounced 2 times — this attempt would push it to 3 (the cap)
|
||||
const store = makeStore({
|
||||
@@ -1045,7 +1054,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("skips duplicate verification follow-up creation when active recovery task exists", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
tasks: [makeTask({ verificationFailureCount: 2, title: "do the thing" })],
|
||||
@@ -1078,7 +1087,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
it("logs when verification-error recovery fails", async () => {
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||
vi.mocked(runAiMerge).mockRejectedValueOnce(verificationError);
|
||||
|
||||
const store = makeStore({
|
||||
updateTask: vi.fn(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { ProjectEngine } from "../project-engine.js";
|
||||
import { ProjectEngine, __resetDeterministicMergerModeDeprecationWarned } from "../project-engine.js";
|
||||
import { runtimeLog } from "../logger.js";
|
||||
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
|
||||
import { NtfyNotifier } from "../notifier.js";
|
||||
@@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
runtimeStart: vi.fn(async () => undefined),
|
||||
runtimeStop: vi.fn(async () => undefined),
|
||||
runtimeResumeAfterUnpause: vi.fn(async () => undefined),
|
||||
aiMergeTask: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
currentStore: null as Record<string, unknown> | null,
|
||||
notifierStart: vi.fn(async () => undefined),
|
||||
@@ -61,8 +61,16 @@ vi.mock("../cron-runner.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||||
// dispatch onto runAiMerge (merger-ai.js). project-engine no longer imports
|
||||
// aiMergeTask; the merge seam these tests mock/assert is now runAiMerge.
|
||||
vi.mock("../merger.js", () => ({
|
||||
aiMergeTask: mocks.aiMergeTask,
|
||||
sweepStaleAutostashes: vi.fn(async () => undefined),
|
||||
VerificationError: class VerificationError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("../merger-ai.js", () => ({
|
||||
runAiMerge: mocks.runAiMerge,
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
@@ -253,8 +261,10 @@ const baseSettings: Record<string, unknown> = {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
// onMerge tests mock + assert aiMergeTask (legacy path); pin legacy mode.
|
||||
merger: { mode: "deterministic" },
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||||
// the onMerge tests mock/assert runAiMerge. The old `merger.mode` pin is gone
|
||||
// (the dispatch ignores it) — a dedicated test below covers the inert-mode +
|
||||
// one-time deprecation-warning behavior.
|
||||
taskStuckTimeoutMs: undefined,
|
||||
memoryAutoSummarizeEnabled: false,
|
||||
memoryAutoSummarizeThresholdChars: 50_000,
|
||||
@@ -411,7 +421,8 @@ describe("ProjectEngine PR monitoring wiring", () => {
|
||||
await engine.start();
|
||||
|
||||
expect(mocks.runtimeConfigurePrMonitoring).toHaveBeenCalled();
|
||||
const configArg = mocks.runtimeConfigurePrMonitoring.mock.calls.at(-1)?.[0] as {
|
||||
const calls = mocks.runtimeConfigurePrMonitoring.mock.calls;
|
||||
const configArg = calls[calls.length - 1]?.[0] as {
|
||||
onClosedPrFeedback?: (taskId: string, prInfo: Record<string, unknown>, comments: unknown[]) => Promise<void> | void;
|
||||
};
|
||||
expect(typeof configArg.onClosedPrFeedback).toBe("function");
|
||||
@@ -437,7 +448,7 @@ describe("ProjectEngine auto-summarize wiring", () => {
|
||||
vi.clearAllMocks();
|
||||
const mockStore = createMockStore(baseSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
mocks.aiMergeTask.mockResolvedValue({
|
||||
mocks.runAiMerge.mockResolvedValue({
|
||||
task: { id: "FN-001", column: "done" },
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
@@ -1130,7 +1141,7 @@ describe("ProjectEngine shutdown merge handling", () => {
|
||||
};
|
||||
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
mocks.runAiMerge.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const options = args[3] as { signal?: AbortSignal } | undefined;
|
||||
capturedSignal = options?.signal;
|
||||
await new Promise<never>((_, reject) => {
|
||||
@@ -1146,7 +1157,7 @@ describe("ProjectEngine shutdown merge handling", () => {
|
||||
engine.enqueueMerge("FN-queued");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(false);
|
||||
@@ -1164,10 +1175,10 @@ describe("ProjectEngine shutdown merge handling", () => {
|
||||
});
|
||||
expect(privateEngine.mergeAbortController).toBeNull();
|
||||
|
||||
const mergeCallsBeforeRequeue = mocks.aiMergeTask.mock.calls.length;
|
||||
const mergeCallsBeforeRequeue = mocks.runAiMerge.mock.calls.length;
|
||||
engine.enqueueMerge("FN-after-stop");
|
||||
expect(privateEngine.mergeQueue).toHaveLength(0);
|
||||
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(mergeCallsBeforeRequeue);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledTimes(mergeCallsBeforeRequeue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1185,15 +1196,15 @@ describe("ProjectEngine manual merge plumbing", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
});
|
||||
|
||||
it("passes manual=true to aiMergeTask for onMerge requests", async () => {
|
||||
mocks.aiMergeTask.mockResolvedValue({ merged: true, task: { id: "FN-5438" } } as any);
|
||||
it("passes manual=true to runAiMerge for onMerge requests", async () => {
|
||||
mocks.runAiMerge.mockResolvedValue({ merged: true, task: { id: "FN-5438" } } as any);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
|
||||
await engine.onMerge("FN-5438");
|
||||
|
||||
expect(mocks.aiMergeTask).toHaveBeenCalledWith(
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
"FN-5438",
|
||||
@@ -1204,6 +1215,149 @@ describe("ProjectEngine manual merge plumbing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 made runAiMerge the
|
||||
// sole merge path. These tests pin the unified dispatch: every merger.mode value
|
||||
// routes to runAiMerge, "deterministic" warns exactly once (never errors), and
|
||||
// the R7 workspace guard rejects populated-workspaceWorktrees tasks at the engine
|
||||
// merge entry point before any merge runs.
|
||||
describe("ProjectEngine U0 merge unification dispatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: the deterministic-mode deprecation
|
||||
// warning is gated by a per-project module-level ledger. Reset it before each
|
||||
// test so the once-per-project-per-process assertion is deterministic regardless
|
||||
// of which sibling test populated the ledger first (createEngine always uses the
|
||||
// same project root, so without this a prior deterministic merge would suppress
|
||||
// the warning here and the "fires once" test would see zero emissions).
|
||||
__resetDeterministicMergerModeDeprecationWarned();
|
||||
});
|
||||
|
||||
async function runOnMergeWithMode(mode: string | undefined) {
|
||||
const settings = { ...baseSettings, autoMerge: true } as Record<string, unknown>;
|
||||
if (mode === undefined) {
|
||||
delete settings.merger;
|
||||
} else {
|
||||
settings.merger = { mode };
|
||||
}
|
||||
const mockStore = createMockStore(settings);
|
||||
mockStore.store.getTask.mockResolvedValue({
|
||||
id: "FN-U0",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
status: "queued",
|
||||
} as any);
|
||||
mocks.currentStore = mockStore.store;
|
||||
mocks.runAiMerge.mockResolvedValue({ merged: true, task: { id: "FN-U0" } } as any);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await engine.onMerge("FN-U0");
|
||||
await engine.stop();
|
||||
}
|
||||
|
||||
it.each([
|
||||
["unset", undefined],
|
||||
["ai", "ai"],
|
||||
["deterministic", "deterministic"],
|
||||
])("routes merger.mode=%s to runAiMerge (never aiMergeTask)", async (_label, mode) => {
|
||||
await runOnMergeWithMode(mode as string | undefined);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
"FN-U0",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the merger.mode "deterministic" deprecation warning exactly once per project per process (warn, not error)', async () => {
|
||||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined);
|
||||
const deprecationWarnings = () =>
|
||||
warnSpy.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("merger.mode") && String(call[0]).includes("deprecated"),
|
||||
);
|
||||
try {
|
||||
// First deterministic merge: the warning must fire EXACTLY once.
|
||||
await runOnMergeWithMode("deterministic");
|
||||
expect(deprecationWarnings()).toHaveLength(1);
|
||||
|
||||
// A SECOND deterministic merge in the same process (same project root) must
|
||||
// NOT warn again — the per-project ledger suppresses the repeat. Total stays 1.
|
||||
await runOnMergeWithMode("deterministic");
|
||||
expect(deprecationWarnings()).toHaveLength(1);
|
||||
|
||||
// The warning is a warn (never an error), and the merge still proceeds via
|
||||
// runAiMerge despite the deprecated value.
|
||||
expect(deprecationWarnings()).toHaveLength(1);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("R7 guard: rejects a workspace-mode task at the engine merge entry point before any merge", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mockStore.store.getTask.mockResolvedValue({
|
||||
id: "FN-WS",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
status: "queued",
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" },
|
||||
"repo-b": { worktreePath: "/tmp/b", branch: "fusion/fn-ws-b" },
|
||||
},
|
||||
} as any);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await expect(engine.onMerge("FN-WS")).rejects.toThrow(
|
||||
/Workspace task FN-WS cannot merge until per-repo merge support \(master-plan U6\) lands/,
|
||||
);
|
||||
expect(mocks.runAiMerge).not.toHaveBeenCalled();
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
// Regression: the auto-merge park for a WorkspaceTaskMergeError must set status:"failed",
|
||||
// not status:null. status:null + mergeRetries:0 passes every eligibility gate, so the
|
||||
// cooldown sweep re-enqueues the task every tick → tight re-throw/re-park loop. status:"failed"
|
||||
// makes canMergeTask short-circuit; manual retry still works (it bypasses canMergeTask).
|
||||
it("R7 auto-merge park: workspace task is parked status:'failed' so it is not re-enqueued", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mockStore.store.getTask.mockResolvedValue({
|
||||
id: "FN-WS-AUTO",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
status: "queued",
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" },
|
||||
},
|
||||
} as any);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
// Auto-merge path (no manual resolver): the R7 door guard throws before runAiMerge,
|
||||
// and the dispatch catch parks the task.
|
||||
engine.enqueueMerge("FN-WS-AUTO");
|
||||
await vi.waitFor(() => {
|
||||
expect(mockStore.store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-WS-AUTO",
|
||||
expect.objectContaining({ status: "failed", mergeRetries: 0 }),
|
||||
);
|
||||
});
|
||||
expect(mocks.runAiMerge).not.toHaveBeenCalled();
|
||||
// Guard against regression to the re-enqueue loop (status:null park):
|
||||
expect(mockStore.store.updateTask).not.toHaveBeenCalledWith(
|
||||
"FN-WS-AUTO",
|
||||
expect.objectContaining({ status: null }),
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine merge queue priority ordering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -1244,7 +1398,7 @@ describe("ProjectEngine merge queue priority ordering", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const mergeOrder: string[] = [];
|
||||
mocks.aiMergeTask.mockImplementation(async (...args: unknown[]) => {
|
||||
mocks.runAiMerge.mockImplementation(async (...args: unknown[]) => {
|
||||
mergeOrder.push(args[2] as string);
|
||||
return { merged: true } as never;
|
||||
});
|
||||
@@ -1317,7 +1471,7 @@ describe("ProjectEngine merge queue priority ordering", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const mergeOrder: string[] = [];
|
||||
mocks.aiMergeTask.mockImplementation(async (...args: unknown[]) => {
|
||||
mocks.runAiMerge.mockImplementation(async (...args: unknown[]) => {
|
||||
mergeOrder.push(args[2] as string);
|
||||
return { merged: true } as never;
|
||||
});
|
||||
@@ -1887,7 +2041,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge skipping FN-paused — task is paused"));
|
||||
});
|
||||
expect(mocks.aiMergeTask).not.toHaveBeenCalled();
|
||||
expect(mocks.runAiMerge).not.toHaveBeenCalled();
|
||||
|
||||
logSpy.mockRestore();
|
||||
await engine.stop();
|
||||
@@ -1906,7 +2060,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
const disposeSession = vi.fn();
|
||||
mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
mocks.runAiMerge.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const options = args[3] as { signal?: AbortSignal; onSession?: (session: { dispose: () => void }) => void };
|
||||
capturedSignal = options.signal;
|
||||
options.onSession?.({ dispose: disposeSession });
|
||||
@@ -1936,7 +2090,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
engine.enqueueMerge("FN-active");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runAiMerge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const taskUpdatedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:updated")?.[1] as
|
||||
|
||||
@@ -3,15 +3,18 @@ import { EventEmitter } from "node:events";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
aiMergeTask: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
currentStore: null as (TaskStore & EventEmitter) | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../merger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger.js")>();
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||||
// dispatch onto runAiMerge (merger-ai.js). This test uses the merge fn as a
|
||||
// mockable seam to inject a verification failure; it now mocks runAiMerge.
|
||||
vi.mock("../../merger-ai.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger-ai.js")>();
|
||||
return {
|
||||
...actual,
|
||||
aiMergeTask: testState.aiMergeTask,
|
||||
runAiMerge: testState.runAiMerge,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,7 +66,8 @@ function createStore(task: Task, sequence: Task[]) {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
merger: { mode: "deterministic" },
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||||
// no `merger.mode` pin needed (dispatch ignores it).
|
||||
} as Settings)),
|
||||
listTasks: vi.fn(async () => [task]),
|
||||
getTask: vi.fn(async () => {
|
||||
@@ -109,7 +113,7 @@ async function runMergeCycle(engine: ProjectEngine, taskId: string): Promise<voi
|
||||
describe("post-finalize verification noop status-write guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.aiMergeTask.mockReset();
|
||||
testState.runAiMerge.mockReset();
|
||||
testState.currentStore = null;
|
||||
});
|
||||
|
||||
@@ -119,7 +123,7 @@ describe("post-finalize verification noop status-write guard", () => {
|
||||
])("keeps done task unchanged on $name write path", async ({ failureCount, blockedStatus }) => {
|
||||
const verificationError = new Error("Deterministic test verification failed: no-op race");
|
||||
verificationError.name = "VerificationError";
|
||||
testState.aiMergeTask.mockRejectedValueOnce(verificationError);
|
||||
testState.runAiMerge.mockRejectedValueOnce(verificationError);
|
||||
|
||||
const inReviewTask = makeTask({ verificationFailureCount: failureCount });
|
||||
const doneTask = makeTask({
|
||||
@@ -128,7 +132,11 @@ describe("post-finalize verification noop status-write guard", () => {
|
||||
mergeDetails: { mergeConfirmed: true, commitSha: "abcdef1234567890" },
|
||||
});
|
||||
|
||||
const { store, logs, audits } = createStore(inReviewTask, [inReviewTask, inReviewTask, inReviewTask, doneTask]);
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: the U0 R7 guard adds one
|
||||
// store.getTask read at the merge dispatch before runAiMerge, so the read
|
||||
// sequence gains one leading in-review entry; the post-failure recovery still
|
||||
// resolves the same done-task tail (the "already-done task" no-op path).
|
||||
const { store, logs, audits } = createStore(inReviewTask, [inReviewTask, inReviewTask, inReviewTask, inReviewTask, doneTask]);
|
||||
testState.currentStore = store;
|
||||
|
||||
const engine = new ProjectEngine(
|
||||
|
||||
@@ -9,15 +9,19 @@ import { commitOrAmendMergeWithFixes } from "../../merger.js";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
aiMergeTask: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
currentStore: null as (TaskStore & EventEmitter) | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../merger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger.js")>();
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||||
// dispatch onto runAiMerge (merger-ai.js). This test injects a verification
|
||||
// failure through the merge seam, so it now mocks runAiMerge. merger.js stays
|
||||
// real (importOriginal) for commitOrAmendMergeWithFixes used below.
|
||||
vi.mock("../../merger-ai.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../merger-ai.js")>();
|
||||
return {
|
||||
...actual,
|
||||
aiMergeTask: testState.aiMergeTask,
|
||||
runAiMerge: testState.runAiMerge,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -57,7 +61,8 @@ function createStore(task: Task, taskSequence?: Task[]) {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
pollIntervalMs: 15_000,
|
||||
merger: { mode: "deterministic" },
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||||
// no `merger.mode` pin needed (dispatch ignores it).
|
||||
} as Settings)),
|
||||
listTasks: vi.fn(async () => [task]),
|
||||
getTask: vi.fn(async () => {
|
||||
@@ -106,7 +111,7 @@ async function runMergeCycle(engine: ProjectEngine, taskId: string): Promise<voi
|
||||
describe("post-finalize verification failure reliability interactions (real git)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.aiMergeTask.mockReset();
|
||||
testState.runAiMerge.mockReset();
|
||||
testState.currentStore = null;
|
||||
});
|
||||
|
||||
@@ -174,7 +179,7 @@ describe("post-finalize verification failure reliability interactions (real git)
|
||||
|
||||
const verificationError = new Error("Deterministic test verification failed");
|
||||
verificationError.name = "VerificationError";
|
||||
testState.aiMergeTask.mockRejectedValueOnce(verificationError);
|
||||
testState.runAiMerge.mockRejectedValueOnce(verificationError);
|
||||
|
||||
const engine = new ProjectEngine(
|
||||
{
|
||||
|
||||
@@ -143,6 +143,7 @@ import type { PluginRunner } from "./plugin-runner.js";
|
||||
import { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { StepSessionExecutor } from "./step-session-executor.js";
|
||||
import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js";
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: the foundation branch imported `acquireWorkspaceRepoWorktree` here but never used it in executor.ts (the agent tool wraps it via agent-tools.ts), which fails lint on the inherited base. Removed until master-plan U1 re-adds it together with its per-repo acquisition usage.
|
||||
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
||||
import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js";
|
||||
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
|
||||
|
||||
@@ -176,8 +176,13 @@ export {
|
||||
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
|
||||
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
|
||||
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
||||
// FNXC:MergerUnification 2026-06-22-00:00: @deprecated must sit on aiMergeTask's own
|
||||
// export so IDE/type-aware tooling flags only aiMergeTask, not the helpers it shares with
|
||||
// runAiMerge (those are NOT deprecated). A single @deprecated on the multi-member block
|
||||
// would mark every symbol below as deprecated.
|
||||
/** @deprecated Use runAiMerge — aiMergeTask is the soft-deprecated legacy path. */
|
||||
export { aiMergeTask } from "./merger.js";
|
||||
export {
|
||||
aiMergeTask,
|
||||
listAutostashOrphans,
|
||||
applyAutostashBySha,
|
||||
dropAutostashBySha,
|
||||
@@ -195,6 +200,9 @@ export {
|
||||
getConflictedFiles,
|
||||
type AutostashHandle,
|
||||
} from "./merger.js";
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: runAiMerge is the sole merge path
|
||||
// (master-plan U0); exported for the CLI callers (fn task merge + UI-only merge).
|
||||
export { runAiMerge } from "./merger-ai.js";
|
||||
export {
|
||||
resolveMergePolicy,
|
||||
type ResolvedMergePolicy,
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
*
|
||||
* This is "AI mode" — a self-contained merge implementation that deliberately
|
||||
* does NOT share the legacy `aiMergeTask` pipeline (prerebase / conflict-strategy
|
||||
* ladder / transient self-heal), which is buggy and error-prone. The engine
|
||||
* dispatches here when `merger.mode === "ai"` (the default).
|
||||
* ladder / transient self-heal), which is buggy and error-prone.
|
||||
*
|
||||
* FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 made this the SOLE
|
||||
* merge path. Every merge entry point (engine dispatch, `fn task merge`, the
|
||||
* UI-only dashboard merge) routes here; `merger.mode` is inert (a "deterministic"
|
||||
* value only logs a one-time deprecation warning). The legacy `aiMergeTask`
|
||||
* pipeline is soft-deprecated.
|
||||
*
|
||||
* Shape:
|
||||
* 1. Clean room — create a throwaway detached worktree at the integration
|
||||
@@ -37,6 +42,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import {
|
||||
assertNotWorkspaceTaskMerge,
|
||||
buildTaskLineageTrailer,
|
||||
evaluateNoCommitsNoOpFinalize,
|
||||
getPrimaryPrInfo,
|
||||
@@ -977,6 +983,13 @@ export async function runAiMerge(
|
||||
deps: AgentDeps = {},
|
||||
): Promise<MergeResult> {
|
||||
const task = await store.getTask(taskId);
|
||||
// FNXC:MergerUnification 2026-06-21-19:05:
|
||||
// Chokepoint R7 guard. runAiMerge is the SOLE merge path (master-plan U0), so it
|
||||
// self-enforces the workspace merge-boundary here — immediately after the task read
|
||||
// and BEFORE any git work — even if a door's pre-read was skipped/swallowed or a
|
||||
// direct importer calls runAiMerge without the door-level guard. Throws the named
|
||||
// WorkspaceTaskMergeError; the door guards remain as fast-fail defense-in-depth.
|
||||
assertNotWorkspaceTaskMerge(task);
|
||||
const branch = resolveTaskWorkingBranch(task);
|
||||
|
||||
if (task.column === "done" || task.column === "archived") {
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
import { isBranchAuthoritativeForTask } from "./branch-conflicts.js";
|
||||
import { hostname } from "node:os";
|
||||
import {
|
||||
assertNotWorkspaceTaskMerge,
|
||||
buildTaskLineageTrailer,
|
||||
evaluateNoCommitsNoOpFinalize,
|
||||
getTaskMergeBlocker,
|
||||
@@ -7642,6 +7643,17 @@ export async function syncGroupPrOnLanding(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Soft-deprecated by master-plan U0 (2026-06-21). `runAiMerge`
|
||||
* (`merger-ai.ts`, the FN-5633 clean-room AI merge path) is now the SOLE merge
|
||||
* path; no production code calls `aiMergeTask`. The body is RETAINED for a later
|
||||
* deletion pass and direct unit tests, but new callers must use `runAiMerge`.
|
||||
* The `merger.mode === "deterministic"` setting that once routed here is inert.
|
||||
*
|
||||
* FNXC:MergerUnification 2026-06-21-19:05: legacy deterministic merge pipeline,
|
||||
* superseded by runAiMerge. Helpers it shares with runAiMerge (e.g.
|
||||
* captureSingleCommitLandedMetadata) are NOT deprecated.
|
||||
*/
|
||||
export async function aiMergeTask(
|
||||
store: TaskStore,
|
||||
rootDir: string,
|
||||
@@ -7652,6 +7664,11 @@ export async function aiMergeTask(
|
||||
|
||||
// 1. Validate task state
|
||||
const task = await store.getTask(taskId);
|
||||
// FNXC:MergerUnification 2026-06-21-19:05: defense-in-depth R7 guard on the
|
||||
// deprecated path — even though no production code calls aiMergeTask, its body is
|
||||
// reachable via direct unit tests/importers, so enforce the workspace merge-boundary
|
||||
// here too (throws the named WorkspaceTaskMergeError) before any git work.
|
||||
assertNotWorkspaceTaskMerge(task);
|
||||
if (task.column === "done" || task.column === "archived") {
|
||||
const message = `merger: skipping squash for ${taskId} — task already finalized (column=${task.column})`;
|
||||
mergerLog.log(message);
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
ResearchSynthesisRequest,
|
||||
ResearchSynthesisResult,
|
||||
} from "@fusion/core";
|
||||
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
|
||||
import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
|
||||
@@ -30,7 +30,7 @@ import { GridlockDetector } from "./gridlock-detector.js";
|
||||
import { createFusionAuthStorage, getFusionOAuthAlertStatePath } from "./auth-storage.js";
|
||||
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
|
||||
import type { RoutineRunner } from "./routine-runner.js";
|
||||
import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js";
|
||||
import { sweepStaleAutostashes, VerificationError } from "./merger.js";
|
||||
import { runAiMerge } from "./merger-ai.js";
|
||||
import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn, type SyncGroupPrFn } from "./group-merge-coordinator.js";
|
||||
import { PRIORITY_MERGE } from "./concurrency.js";
|
||||
@@ -81,6 +81,25 @@ const execFileAsync = promisify(execFile);
|
||||
*/
|
||||
const MERGE_HANDOFF_GRACE_MS = 300;
|
||||
|
||||
/*
|
||||
FNXC:MergerUnification 2026-06-21-19:05:
|
||||
Master-plan U0 made `runAiMerge` the SOLE merge path; `merger.mode` is now inert
|
||||
(the type/field are retained as published surface — see types.ts MergerMode). When a
|
||||
project still resolves `merger.mode === "deterministic"` we WARN (never error) once
|
||||
per project per process and proceed via `runAiMerge` anyway. The warning is keyed by
|
||||
project root so EACH project with the stale setting warns once — a single module-level
|
||||
boolean would suppress the warning for all other projects after the first emission.
|
||||
*/
|
||||
const deterministicMergerModeDeprecationWarnedProjects = new Set<string>();
|
||||
|
||||
/**
|
||||
* Test-only: clears the per-project deprecation-warning ledger so a test can assert
|
||||
* the warning fires exactly once per project per process. Not used by production code.
|
||||
*/
|
||||
export function __resetDeterministicMergerModeDeprecationWarned(): void {
|
||||
deterministicMergerModeDeprecationWarnedProjects.clear();
|
||||
}
|
||||
|
||||
interface RemoteLifecycleEvaluation {
|
||||
provider: TunnelProvider;
|
||||
config?: TunnelProviderConfig;
|
||||
@@ -1929,7 +1948,7 @@ export class ProjectEngine {
|
||||
|
||||
// FN-5627 auto-recovery: clear the poisoned mergeDetails,
|
||||
// increment the merge retry counter, and re-enqueue. The next
|
||||
// dequeue runs a fresh `aiMergeTask` against the task branch —
|
||||
// dequeue runs a fresh `runAiMerge` against the task branch —
|
||||
// because the merger's TOCTOU is now fixed, the redo either
|
||||
// lands cleanly or fails with a real merger error that surfaces
|
||||
// through normal lifecycle. We don't need an executor to be
|
||||
@@ -1983,7 +2002,7 @@ export class ProjectEngine {
|
||||
// Re-enqueue this task for the next cycle. We continue past
|
||||
// the current iteration because `task` is a stale snapshot;
|
||||
// the re-enqueued tick reads fresh state with mergeConfirmed=false
|
||||
// and falls through to the normal `aiMergeTask` path.
|
||||
// and falls through to the normal `runAiMerge` path.
|
||||
this.internalEnqueueMerge(taskId);
|
||||
continue;
|
||||
}
|
||||
@@ -2283,18 +2302,41 @@ export class ProjectEngine {
|
||||
this.activeMergeSession = session;
|
||||
},
|
||||
};
|
||||
// FN-5633: "ai" mode (default) uses the standalone AI merge path
|
||||
// (clean-room worktree + AI merge + AI reviewer); "deterministic"
|
||||
// keeps the legacy aiMergeTask pipeline.
|
||||
// FNXC:Workspace 2026-06-21-19:40:
|
||||
// R7 merge-boundary guard (master-plan U0). Reject workspace-mode
|
||||
// tasks BEFORE any git work — they need the per-repo merge loop that
|
||||
// lands in master-plan U6 (which removes this guard). Load the task
|
||||
// here so the dispatch shares the one predicate in @fusion/core.
|
||||
// This door is a FAST-FAIL only: a getTask failure is swallowed to null
|
||||
// and the guard is skipped, but the unconditional chokepoint guard inside
|
||||
// runAiMerge (which re-reads the task) is the authoritative enforcement,
|
||||
// so a transient read failure here cannot let a workspace task reach git work.
|
||||
const mergeTask = await store.getTask(taskId).catch(() => null);
|
||||
if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask);
|
||||
|
||||
// FNXC:MergerUnification 2026-06-21-19:05:
|
||||
// Master-plan U0 collapsed the merge dispatch: `runAiMerge` (the
|
||||
// FN-5633 clean-room AI merge path) is the SOLE merge path. The
|
||||
// `merger.mode` setting is inert — we no longer branch on it. A
|
||||
// resolved "deterministic" value only triggers a once-per-project
|
||||
// deprecation warning (warn, never error) before proceeding via
|
||||
// `runAiMerge`; the warning is keyed by project root (cwd) so each
|
||||
// stale project warns once rather than just the first project seen.
|
||||
const settings = await store.getSettings().catch(() => ({}) as Settings);
|
||||
const mergerMode = normalizeMergerMode(settings.merger?.mode);
|
||||
if (
|
||||
normalizeMergerMode(settings.merger?.mode) === "deterministic"
|
||||
&& !deterministicMergerModeDeprecationWarnedProjects.has(cwd)
|
||||
) {
|
||||
deterministicMergerModeDeprecationWarnedProjects.add(cwd);
|
||||
runtimeLog.warn(
|
||||
'merger.mode "deterministic" is deprecated and inert: all merges now use the unified AI merge path (runAiMerge). Remove the setting; the legacy aiMergeTask pipeline is soft-deprecated.',
|
||||
);
|
||||
}
|
||||
const mergeOptionsWithSettings = {
|
||||
...mergerOptions,
|
||||
allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true,
|
||||
};
|
||||
return mergerMode === "ai"
|
||||
? runAiMerge(store, cwd, taskId, mergeOptionsWithSettings)
|
||||
: aiMergeTask(store, cwd, taskId, mergerOptions);
|
||||
return runAiMerge(store, cwd, taskId, mergeOptionsWithSettings);
|
||||
};
|
||||
|
||||
let result: MergeResult;
|
||||
@@ -2335,6 +2377,38 @@ export class ProjectEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
// FNXC:Workspace 2026-06-21-19:40:
|
||||
// R7 workspace merge-boundary park (master-plan U0). A WorkspaceTaskMergeError
|
||||
// is a PERMANENT config error (workspace task hit a merge door before the
|
||||
// per-repo merge loop exists — master-plan U6), NOT a transient merge failure.
|
||||
// Park with status:"failed" so the auto-merge cooldown sweep STOPS re-attempting:
|
||||
// `canMergeTask` short-circuits on status==="failed". (Parking with status:null +
|
||||
// mergeRetries:0 passes every eligibility gate, so the sweep re-enqueues every tick
|
||||
// → tight WorkspaceTaskMergeError re-throw/re-park loop.) Keep mergeRetries:0 (not
|
||||
// the cap) so a human's manual merge after the config is addressed is not blocked by
|
||||
// exhausted retries — and manual merge flows through the manual-resolver branch
|
||||
// (rejectMergeResolvers), which bypasses canMergeTask, so "failed" never blocks it.
|
||||
// Detect by err.name (matches the VerificationError/MergeAbortedError convention and
|
||||
// is robust across the @fusion/core→@fusion/engine package boundary).
|
||||
const isWorkspaceMergeError =
|
||||
err instanceof Error && err.name === "WorkspaceTaskMergeError";
|
||||
if (isWorkspaceMergeError) {
|
||||
runtimeLog.error(
|
||||
`${hasManualResolver ? "Manual" : "Auto"}-merge blocked for ${taskId}: workspace-mode tasks cannot merge until per-repo merge support (master-plan U6) lands; parking as failed (manual retry still works) without exhausting mergeRetries: ${errorMsg}`,
|
||||
);
|
||||
await store
|
||||
.logEntry(taskId, `Merge blocked: ${errorMsg}`, "WorkspaceTaskMergeError")
|
||||
.catch(() => undefined);
|
||||
if (hasManualResolver) {
|
||||
this.rejectMergeResolvers(taskId, err instanceof Error ? err : new Error(errorMsg));
|
||||
} else {
|
||||
await store
|
||||
.updateTask(taskId, { status: "failed", mergeRetries: 0, error: errorMsg })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
runtimeLog.error(`${hasManualResolver ? "Manual" : "Auto"}-merge failed for ${taskId}: ${errorMsg}`);
|
||||
|
||||
// Surface every merge failure on the task log so the dashboard shows
|
||||
|
||||
@@ -723,7 +723,7 @@ export async function acquireWorkspaceRepoWorktree(
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkspaceWorktree 2026-06-21-00:00:
|
||||
FNXC:WorkspaceWorktree 2026-06-21-19:05:
|
||||
Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree`
|
||||
is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites
|
||||
those singular fields on the task row after each acquisition. Passing the live task straight
|
||||
|
||||
Reference in New Issue
Block a user