diff --git a/AGENTS.md b/AGENTS.md index 8920f8c7b5..1532e2c194 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,7 +267,7 @@ Per-task opt-out exists: `task.scopeOverride = true` (log the reason). When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a human. Lifecycle-mutating self-healing must not move these tasks backward, pause/fail them, or re-enqueue them for execution. -Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignmentMode === "shared"`) still run the member→shared-branch local integration step while auto-merge is off. This exception is only for assembling `branch_groups.branchName`; shared-branch → default-branch promotion remains gated by group/global auto-merge. +Scoped exception (FN-5819/FN-8823): while project auto-merge is On, shared-branch-group members (`branchContext.assignmentMode === "shared"`) still run the member→shared-branch local integration step subject to the user-Off hold. Under project auto-merge Off, every member is held unless its task explicitly sets `autoMerge: true`; shared-branch → default-branch promotion remains separately gated by group/global auto-merge. ### Mock provider (test mode) diff --git a/docs/architecture.md b/docs/architecture.md index 934233e300..5b02533399 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2112,7 +2112,7 @@ The GitHub tracking state listener now attaches to every registered project stor ### Merge strategies - Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`) - `aiMergeTask()` in `merger.ts` performs merge flow -- FN-5782 wires branch-group routing into merge target resolution: tasks with `branchContext.assignmentMode === "shared"` and a resolvable `branch_groups` row merge onto `branch_groups.branchName` (`mergeTarget.source = "branch-group-integration"`) instead of the project default branch; ungrouped and `per-task-derived` tasks keep the existing direct-to-default path unchanged. Merge emits `merge:branch-group-routed` audit telemetry for routed members. FN-5846 extends the same contract to deterministic/self-healing finalize paths (`recoverAlreadyMergedReviewTasks`, interrupted/deadlock/misbound finalizers, and the `mergeConfirmed` fast path): a resolvable shared member is re-routed to the group branch before reachability checks, `mergeTargetSource`/`mergeTargetBranch` are stamped by the finalizer, `recordBranchGroupMemberLanded` is called, and a defensive audit event is emitted if a path would otherwise evaluate the member against the project default branch. FN-8811 narrows the member fast-path exception: only `autoMerge === false` with `autoMergeProvenance === "user"` enters the pre-integration manual hold. Mission policy (`"mission"`), legacy stamps, and inherited false values keep flowing for a live intermediate group; stale/default-branch groups use the normal standalone policy, and group→default promotion remains independently gated. FN-5788 adds a callable promotion-decision hook (`evaluateBranchGroupPromotion`) and `merge:branch-group-promotion-gated` telemetry; FN-5830 lands the completion gate + promotion machinery via `evaluateBranchGroupCompletion` and idempotent `promoteBranchGroup` (single shared→default merge/PR with finalized status and PR tracking persistence). +- FN-5782 wires branch-group routing into merge target resolution: tasks with `branchContext.assignmentMode === "shared"` and a resolvable `branch_groups` row merge onto `branch_groups.branchName` (`mergeTarget.source = "branch-group-integration"`) instead of the project default branch; ungrouped and `per-task-derived` tasks keep the existing direct-to-default path unchanged. Merge emits `merge:branch-group-routed` audit telemetry for routed members. FN-5846 extends the same contract to deterministic/self-healing finalize paths (`recoverAlreadyMergedReviewTasks`, interrupted/deadlock/misbound finalizers, and the `mergeConfirmed` fast path): a resolvable shared member is re-routed to the group branch before reachability checks, `mergeTargetSource`/`mergeTargetBranch` are stamped by the finalizer, `recordBranchGroupMemberLanded` is called, and a defensive audit event is emitted if a path would otherwise evaluate the member against the project default branch. With project auto-merge On, FN-8811 narrows the member fast-path exception: only `autoMerge === false` with `autoMergeProvenance === "user"` enters the pre-integration manual hold. FN-8823 adds that with project auto-merge Off, `hasSharedBranchMemberAutoMergeHold` holds every member unless the task explicitly sets `autoMerge: true`; stale/default-branch groups use the normal standalone policy, and group→default promotion remains independently gated. FN-5788 adds a callable promotion-decision hook (`evaluateBranchGroupPromotion`) and `merge:branch-group-promotion-gated` telemetry; FN-5830 lands the completion gate + promotion machinery via `evaluateBranchGroupCompletion` and idempotent `promoteBranchGroup` (single shared→default merge/PR with finalized status and PR tracking persistence). - FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-6278 adds a stable cwd preflight before root-derived git spawns: in `reuse-task-worktree` mode, an empty, missing, incomplete, or de-registered `task.worktree` is repaired/reacquired before the first spawn, while `cwd-integration-branch` remains a no-op project-root path. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`. - `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior) - Supports workflow-step execution after merge (post-merge phase) @@ -2292,7 +2292,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count ..` is > 0, and `git diff --quiet ..` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases. - **Verified no-op/duplicate executor completion (FN-6275/FN-7488)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`), the task already carries a no-commit contract, or the PROMPT declares a source-free gitignored task-artifact delivery. The source-free path is intentionally narrow: File Scope must be populated and limited to board/task artifacts such as `.fusion/tasks/...`, task documents/logs, or attachments; the prompt must forbid force-adding ignored `.fusion/` artifacts and fabricating empty commits or equivalently state that source-free/gitignored task artifacts are the only deliverables; and any tracked source/docs/config/test/changeset scope keeps the `no_commits` refusal active (even if `.fusion/` artifacts are also listed). These exemptions only relax the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Prompt-derived source-free completions log `prompt-derived source-free task-artifact contract` for operator audit. Ordinary zero-commit implementation completions without one of these contracts are still refused. - **In-review branch-binding self-heal (FN-5083/FN-6695)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Unsafe metadata repair is also skipped with `task:auto-rebind-skipped`: `userPaused` preserves authoritative user intent, and `checkedOutBy` preserves live agent checkout ownership. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`. -- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. That includes merge-active unusable-worktree recovery: automation emits `task:reconcile-missing-worktree-merge-active-no-action` with `reason:"auto-merge-off"` instead of moving the row, while explicit operator retry remains supported because it is a manual reset request. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819/FN-8811 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off only when the group is open and its normalized branch differs from the resolved project default branch. An operator-authored task `autoMerge:false` paired with `autoMergeProvenance:"user"` is the sole opt-in exception: it parks at the manual hold before member integration, while mission policy, inherited, and legacy false values continue flowing. This is a soft pre-integration only and does not permit shared-branch → default-branch promotion; a default-branch collision follows the normal manual-release path. FN-7182 applies the same human-gated treatment to an open `PrInfo.manual` PR created or linked from the dashboard **Create PR** action: automatic merge queues and self-healing stand down until the PR is closed/merged or handled manually, while pipeline-created PRs without `manual` remain auto-merge eligible. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. +- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. That includes merge-active unusable-worktree recovery: automation emits `task:reconcile-missing-worktree-merge-active-no-action` with `reason:"auto-merge-off"` instead of moving the row, while explicit operator retry remains supported because it is a manual reset request. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819/FN-8811/FN-8823 exception: while project auto-merge is On, shared-group members (`branchContext.assignmentMode === "shared"`) are allowed through the member→`branch_groups.branchName` integration step only when the group is open and its normalized branch differs from the resolved project default branch; an operator-authored task `autoMerge:false` paired with `autoMergeProvenance:"user"` parks at the manual hold. Under project auto-merge Off, `hasSharedBranchMemberAutoMergeHold` instead holds every member unless its task explicitly sets `autoMerge:true`, before group liveness can admit it. This is a soft pre-integration only and does not permit shared-branch → default-branch promotion; a default-branch collision follows the normal manual-release path. FN-7182 applies the same human-gated treatment to an open `PrInfo.manual` PR created or linked from the dashboard **Create PR** action: automatic merge queues and self-healing stand down until the PR is closed/merged or handled manually, while pipeline-created PRs without `manual` remain auto-merge eligible. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. - **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-integration-branch` (`cwd-main` remains a deprecated alias normalized to that mode). - **Orphaned execution sweep is observation-only (FN-5337)**: `recoverOrphanedExecutions` only annotates stale in-progress candidates with `task:orphan-detected-no-action` and `[orphan-detected] ... no action (operator-decides)` logs. It must never move `in-progress`/`in-review` backward to `todo` or mutate lease/worktree metadata. Proof-based backward recovery remains exclusively in `recoverInProgressLimbo` (FN-5219), `RestartRecoveryCoordinator`, `recoverMissingWorktreeReviewFailures`, and explicit executor/merger failure paths. Reintroducing lifecycle mutation here requires hard git/session proof gating plus CEO+CTO+PM sign-off. - **Self-owned reclaim resume-limbo escalation (FN-5704)**: `reclaimSelfOwnedBranchConflicts` tracks `resumeLimboCount`, `resumeLimboTipSha`, and `resumeLimboStepSignature` for in-progress reclaim/unpause loops. If reclaim finds no progress (same tip, same step-status signature, and no active-session signal) for `MAX_NO_PROGRESS_RESUME_ATTEMPTS` consecutive sweeps, self-healing escalates by moving the task to `todo` with `preserveWorktree: true`, `preserveProgress: true`, and `preserveResumeState: true` instead of endlessly re-arming resume. Escalation emits `task:resume-limbo-escalated` run-audit metadata (`frozenTipSha`, `idleMs`, `resumeAttemptCount`, `currentStep`) and resets the limbo counter. diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index a27fa325cc..9e0a48071d 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -2747,7 +2747,13 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { await engine.stop(); }); - it("startup merge sweep enqueues shared-group members when autoMerge is false", async () => { + /* + FNXC:SharedBranchMemberHold 2026-08-09-09:09: + FN-8823 applies the project-Off consent rule to startup merge recovery as well + as direct admission. An explicit per-task On remains eligible, but group + liveness cannot re-admit a non-opted-in shared member. + */ + it("startup merge sweep holds non-opted-in shared-group members when autoMerge is false", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: false }); mockStore.store.getBranchGroup.mockReturnValue({ id: "BG-5819", status: "open", branchName: "fusion/groups/bg-5819" }); const inReviewTasks = [ @@ -2759,6 +2765,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { status: null, branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" }, }, + { id: "FN-opted-in", column: "in-review", paused: false, mergeRetries: 0, status: null, autoMerge: true, branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" } }, { id: "FN-plain", column: "in-review", paused: false, mergeRetries: 0, status: null }, ]; // Critical stale-status cleanup reads first; deferred startup then evaluates eligibility. @@ -2769,9 +2776,10 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); - await vi.waitFor(() => expect(enqueueSpy).toHaveBeenCalledWith("FN-shared")); + await vi.waitFor(() => expect(enqueueSpy).toHaveBeenCalledWith("FN-opted-in")); - expect(enqueueSpy).toHaveBeenCalledWith("FN-shared"); + expect(enqueueSpy).toHaveBeenCalledWith("FN-opted-in"); + expect(enqueueSpy).not.toHaveBeenCalledWith("FN-shared"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain"); await engine.stop(); @@ -3689,23 +3697,30 @@ describe("allowInReviewMergeProcessing per-task autoMerge override", () => { await expect(gate({ autoMerge: false }, { autoMerge: true })).resolves.toBe(true); }); - it("still exempts live shared-branch-group member integration on an intermediate branch when the global setting is off", async () => { - await expect(gate( - { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }, - { autoMerge: false, integrationBranch: "main" }, - { status: "open", branchName: "mission/M-3324" }, - )).resolves.toBe(true); + /* + FNXC:SharedBranchMemberHold 2026-08-09-09:09: + FN-8823 supersedes the FN-5819 live-member exemption when project auto-merge + is Off. Every non-opted-in member is held before liveness is considered; an + explicit task-level On is the sole consent path through this requester. + */ + it("holds live shared-branch-group member integration on an intermediate branch when the global setting is off", async () => { + const shared = { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }; + const settings = { autoMerge: false, integrationBranch: "main" }; + const group = { status: "open" as const, branchName: "mission/M-3324" }; + + await expect(gate(shared, settings, group)).resolves.toBe(false); + await expect(gate({ ...shared, autoMerge: true }, settings, group)).resolves.toBe(true); }); - it("holds only an operator-authored false override before live member integration", async () => { + it("holds every non-opted-in provenance before live member integration when global auto-merge is off", async () => { const shared = { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }; const settings = { autoMerge: false, integrationBranch: "main" }; const group = { status: "open" as const, branchName: "mission/M-3324" }; await expect(gate({ ...shared, autoMerge: false, autoMergeProvenance: "user" }, settings, group)).resolves.toBe(false); - await expect(gate({ ...shared, autoMerge: false, autoMergeProvenance: "mission" }, settings, group)).resolves.toBe(true); - await expect(gate({ ...shared, autoMerge: false, autoMergeProvenance: "legacy-stamp" }, settings, group)).resolves.toBe(true); - await expect(gate({ ...shared, autoMerge: false }, settings, group)).resolves.toBe(true); + await expect(gate({ ...shared, autoMerge: false, autoMergeProvenance: "mission" }, settings, group)).resolves.toBe(false); + await expect(gate({ ...shared, autoMerge: false, autoMergeProvenance: "legacy-stamp" }, settings, group)).resolves.toBe(false); + await expect(gate({ ...shared, autoMerge: false }, settings, group)).resolves.toBe(false); }); it("keeps live shared-branch-group member integration on the default branch behind the manual gate", async () => { diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 76e1e34c55..6c752e0eb9 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -5113,12 +5113,12 @@ describe("SelfHealingManager", () => { }); /* - FNXC:SharedBranchMemberHold 2026-08-05-23:14: - A self-healing sweep is a production merge requester. It must preserve the - intentional mission member fast path under global Off, but never bypass an - operator-authored task Off hold while doing recovery admission. + FNXC:SharedBranchMemberHold 2026-08-09-09:09: + FN-8823 supersedes the mission-policy fast path under project Off. A + self-healing merge requester must treat project Off as withheld consent for + every non-opted-in shared member, preserving the FN-8811 user hold as a subset. */ - it("re-enqueues a mission-policy shared member under global auto-merge off but preserves a user hold", async () => { + it("holds every non-opted-in shared member under global auto-merge off, including mission policy", async () => { const enqueueMerge = vi.fn().mockReturnValue(true); const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", @@ -5165,10 +5165,47 @@ describe("SelfHealingManager", () => { }, ]); + expect(await managerWithRecovery.recoverMergeableReviewTasks()).toBe(0); + expect(enqueueMerge).not.toHaveBeenCalledWith("FN-8811-MISSION"); + expect(enqueueMerge).not.toHaveBeenCalledWith("FN-8811-USER"); + expect(store.mergeTask).not.toHaveBeenCalled(); + + managerWithRecovery.stop(); + }); + + it("recovers an explicitly opted-in shared member under global auto-merge off", async () => { + const enqueueMerge = vi.fn().mockReturnValue(true); + const managerWithRecovery = new SelfHealingManager(store, { + rootDir: "/tmp/test-project", + enqueueMerge, + }); + (store.getSettings as ReturnType).mockResolvedValue({ + autoMerge: false, + integrationBranch: "main", + globalPause: false, + enginePaused: false, + }); + (store.getBranchGroup as ReturnType).mockResolvedValue({ + status: "open", + branchName: "mission/M-8811", + }); + (store.listTasks as ReturnType).mockResolvedValue([{ + id: "FN-8811-OPTED-IN", + column: "in-review", + paused: false, + status: null, + error: null, + worktree: "/tmp/test-project/.worktrees/fn-8811-opted-in", + steps: [{ name: "Ship it", status: "done" }], + workflowStepResults: [{ id: "ws-opted-in", status: "passed", phase: "pre-merge" }], + autoMerge: true, + branchContext: { assignmentMode: "shared", groupId: "BG-8811", source: "mission" }, + log: [], + }]); + expect(await managerWithRecovery.recoverMergeableReviewTasks()).toBe(1); expect(enqueueMerge).toHaveBeenCalledTimes(1); - expect(enqueueMerge).toHaveBeenCalledWith("FN-8811-MISSION"); - expect(enqueueMerge).not.toHaveBeenCalledWith("FN-8811-USER"); + expect(enqueueMerge).toHaveBeenCalledWith("FN-8811-OPTED-IN"); managerWithRecovery.stop(); });