FN-8811: preserve explicit shared-member review holds

Keep shared branch-group integration moving unless an operator explicitly holds the task.

- Track auto-merge provenance and distinguish explicit user holds from inherited mission policy.
- Preserve manual holds across workflow recovery, merge coordination, API updates, and dashboard status.
- Add regression coverage, document the behavior, and quarantine the observed flaky test.

Files changed:
 .changeset/fn-8811-shared-member-review-hold.md    |   7 ++
 docs/architecture.md                               |   4 +-
 docs/dashboard-guide.md                            |   1 +
 .../mission-store.sync-auto-merge.test.ts          |   7 +-
 .../__tests__/postgres/mission-store.pg.test.ts    |   1 +
 .../__tests__/postgres/store-movement.pg.test.ts   |  20 ++++
 packages/core/src/__tests__/task-merge.test.ts     |  14 +++
 .../core/src/async-stores/async-mission-store.ts   |   6 +-
 packages/core/src/index.gate.ts                    |   1 +
 packages/core/src/index.ts                         |   1 +
 packages/core/src/merge/task-merge.ts              |  20 +++-
 packages/core/src/missions/mission-store.ts        |   6 +-
 packages/core/src/task-store/serialization.ts      |   2 +-
 packages/core/src/task-store/task-creation.ts      |   8 +-
 packages/core/src/types/task/task-core.ts          |  12 ++-
 .../components/__tests__/TaskDetailModal.test.tsx  |  63 ++++++++++++
 .../dashboard/src/__tests__/routes-tasks.test.ts   |  47 +++++++++
 .../src/routes/register-task-workflow-routes.ts    |  15 ++-
 ...cutor-live-branch-group-auto-merge-hold.test.ts |  87 +++++++++++++++++
 .../src/__tests__/group-merge-coordinator.test.ts  |  99 ++++++++++++++++++-
 .../engine/src/__tests__/project-engine.test.ts    |  57 ++++++++++-
 .../self-healing-paused-abort-recovery.test.ts     |  52 +++++++++-
 packages/engine/src/__tests__/self-healing.test.ts | 106 +++++++++++++++++++++
 .../workflow-graph-executor-handlers.test.ts       |  23 +++++
 packages/engine/src/executor.ts                    |  37 ++++++-
 packages/engine/src/project-engine.ts              |  25 +++--
 packages/engine/src/self-healing.ts                |  71 ++++++++++++--
 .../src/workflow-node-runners/merge-runner.ts      |  24 ++++-
 .../src/workflows/workflow-graph-executor.ts       |   4 +
 .../src/workflows/workflow-graph-task-runner.ts    |   6 ++
 .../engine/src/workflows/workflow-node-handlers.ts |   5 +-
 packages/engine/vitest.config.ts                   |  11 ++-
 scripts/lib/test-quarantine.json                   |   5 +
 33 files changed, 789 insertions(+), 58 deletions(-)

Fusion-Task-Id: FN-8811

Fusion-Task-Lineage: 5c1609bf-3132-4988-a254-fedec6c0e33d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-05 17:39:15 -07:00
parent 1e4c7faf68
commit 4f4aef7173
33 changed files with 789 additions and 58 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Honor an operator's review hold before a mission task joins its shared branch.
category: fix
dev: Mission policy auto-merge values retain distinct provenance from operator task overrides.

View File

@@ -2107,7 +2107,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-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. 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-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)
@@ -2286,7 +2286,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 <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` 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/<id>` 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/<id>` 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/<id>` 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 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. 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 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.
- **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.

View File

@@ -669,6 +669,7 @@ Rules:
- `auto-new` creates a branch after task creation using `fusion/{task-id}-{short-name}` (for example `fusion/fn-5671-branch-strategy-dropdown`).
- `Merge target / base branch` stays optional for all modes and uses the same branch-dropdown + `Custom…` fallback behavior as Planning Mode.
- In **More options → Model Configuration**, **Auto-merge** is a per-task override with three states: **Default** (follow project setting), **Enabled**, or **Disabled**.
- A live mission/shared-branch task normally integrates into its intermediate `mission/<id>` branch even when project or mission policy disables auto-merge. If an operator explicitly selects **Disabled** on that task, Fusion holds it in Review before member integration; release the existing manual merge hold to integrate it into the shared branch. This does not change the separate shared-branch → default-branch promotion policy. In Task Detail edit mode, **Merge target / base branch** uses the existing branch control; clear it to return to the project default.
- In **More options → Model Configuration**, task and agent model pickers expose **Thinking Level** inside the same model dropdown panel instead of as a separate adjacent selector. Task pickers offer **Default (project setting)** plus **Off**, **Minimal**, **Low**, **Medium**, **High**, and **Very High**; agent creation, Agent Onboarding review, and Agent Detail built-in-model settings are concrete-only and start/fall back to **Off**.
- Shared model dropdowns keep the active provider header visible while scrolling. Use the provider chevron to collapse a provider's model rows; this dashboard-local preference persists across sessions, while filtering temporarily shows matching rows from collapsed providers.
- In **More options → Model Configuration**, **Planner oversight** is a per-task override of the workflow-native `plannerOversightLevel` setting (FN-7508): **Inherit from workflow** (default) plus **Off**, **Observe**, **Steer**, and **Autonomous recovery**. This selector appears in both the New Task dialog and the Task Detail edit form (same shared control). Selecting **Inherit from workflow** clears the per-task override (sent as `null` on edit, omitted on create) so the task falls back to the effective `plannerOversightLevel` configured on its workflow — set project/global defaults for this in the **Workflow Editor → Values** tab, not in Project Settings; it is workflow-native, not a project setting.

View File

@@ -2,7 +2,7 @@
FNXC:MissionAutoMerge 2026-07-18-12:00:
The legacy synchronous MissionStore remains a supported fallback even though PostgreSQL
is the production backend. Keep its create-only triage contract aligned with AsyncMissionStore:
only an explicit false mission override is forwarded to TaskStore.createTask.
only an explicit false mission override is forwarded to TaskStore.createTask with mission policy provenance.
*/
import { readFile } from "node:fs/promises";
@@ -15,9 +15,10 @@ describe("MissionStore synchronous triage auto-merge contract", () => {
const triageFeature = source.slice(source.indexOf("async triageFeature("), source.indexOf("async triageSlice("));
expect(triageFeature).toContain('if (guard.action === "duplicate" && guard.existing)');
expect(triageFeature).toContain("...(mission?.autoMerge === false ? { autoMerge: false } : {}),");
const missionPolicyCreate = '...(mission?.autoMerge === false ? { autoMerge: false, autoMergeProvenance: "mission" as const } : {}),';
expect(triageFeature).toContain(missionPolicyCreate);
expect(triageFeature.indexOf('if (guard.action === "duplicate" && guard.existing)'))
.toBeLessThan(triageFeature.indexOf("...(mission?.autoMerge === false ? { autoMerge: false } : {}),"));
.toBeLessThan(triageFeature.indexOf(missionPolicyCreate));
expect(triageFeature).toContain('usesProjectDefaultStrategy ? `mission/${missionId}`');
expect(triageFeature).toContain('ensureBranchGroupForSource("mission", missionId');
});

View File

@@ -190,6 +190,7 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
const triaged = tasks.filter((task) => ["Single", "Bulk"].includes(task.title));
expect(triaged).toHaveLength(2);
expect(triaged.map((task) => task.autoMerge)).toEqual([false, false]);
expect(triaged.map((task) => task.autoMergeProvenance)).toEqual(["mission", "mission"]);
// Single and bulk triage must join the one lazily-created mission group, not merely any group.
expect(new Set(triaged.map((task) => task.branchContext?.groupId))).toEqual(new Set([triaged[0]!.branchContext!.groupId]));
expect(triaged[0]!.branchContext?.groupId).toBeDefined();

View File

@@ -155,4 +155,24 @@ pgTest("TaskStore moveTask autoMerge provenance (PostgreSQL)", () => {
expect(allowsAutoMergeProcessing(inheritedMoved, { autoMerge: false })).toBe(false);
expect(allowsAutoMergeProcessing(inheritedMoved, { autoMerge: true })).toBe(true);
});
it("round-trips trusted mission policy without converting it to an operator override", async () => {
const store = h.store();
const created = await store.createTask({
title: "mission policy false",
description: "Mission-created shared member policy",
autoMerge: false,
autoMergeProvenance: "mission",
});
expect(created).toMatchObject({ autoMerge: false, autoMergeProvenance: "mission" });
expect(await store.getTask(created.id)).toMatchObject({ autoMerge: false, autoMergeProvenance: "mission" });
const userOverride = await store.updateTask(created.id, { autoMerge: false });
expect(userOverride).toMatchObject({ autoMerge: false, autoMergeProvenance: "user" });
const cleared = await store.updateTask(created.id, { autoMerge: null });
expect(cleared.autoMerge).toBeUndefined();
expect(cleared.autoMergeProvenance).toBeUndefined();
});
});

View File

@@ -15,6 +15,7 @@ import {
allowsAutoMergeProcessing,
isSharedBranchGroupMemberIntegration,
isLiveSharedBranchGroupMemberIntegration,
hasUserAutoMergeHold,
resolveEffectiveAutoMerge,
resolveEffectiveGroupAutoMerge,
resolveTaskMergeTarget,
@@ -78,6 +79,19 @@ describe("resolveEffectiveAutoMerge", () => {
});
});
describe("hasUserAutoMergeHold", () => {
it.each([
[{ autoMerge: false, autoMergeProvenance: "user" }, true],
[{ autoMerge: false, autoMergeProvenance: "mission" }, false],
[{ autoMerge: false, autoMergeProvenance: "legacy-stamp" }, false],
[{ autoMerge: false }, false],
[{ autoMerge: true, autoMergeProvenance: "user" }, false],
[{ autoMerge: undefined, autoMergeProvenance: "user" }, false],
] as const)("requires false with user provenance: %o", (task, expected) => {
expect(hasUserAutoMergeHold(task)).toBe(expected);
});
});
describe("allowsAutoMergeProcessing", () => {
it("lets explicit per-task true with user provenance through when the global setting is off", () => {
expect(allowsAutoMergeProcessing({ autoMerge: true, autoMergeProvenance: "user" }, { autoMerge: false })).toBe(true);

View File

@@ -2394,10 +2394,10 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
}
: {}),
/*
FNXC:MissionAutoMerge 2026-07-18-12:00:
An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override.
FNXC:MissionAutoMerge 2026-08-05-22:50:
An autoMerge:false mission stamps each newly triaged task as mission policy so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override; policy must not impersonate an operator manual-hold choice.
*/
...(mission?.autoMerge === false ? { autoMerge: false } : {}),
...(mission?.autoMerge === false ? { autoMerge: false, autoMergeProvenance: "mission" as const } : {}),
// FNXC:MissionTaskPrefix 2026-07-26-12:00: thread the mission's optional taskPrefix into TaskCreateInput so the distributed allocator mints ERR-N (etc.) instead of the project prefix.
...(mission?.taskPrefix ? { taskPrefix: mission.taskPrefix } : {}),
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),

View File

@@ -1014,6 +1014,7 @@ export {
getTaskCompletionBlocker,
isTaskReadyForMerge,
allowsAutoMergeProcessing,
hasUserAutoMergeHold,
isSharedBranchGroupMemberIntegration,
isLiveSharedBranchGroupMemberIntegration,
resolveEffectiveAutoMerge,

View File

@@ -1145,6 +1145,7 @@ export {
getLatestFailedPreMergeReviewStep,
isTaskReadyForMerge,
allowsAutoMergeProcessing,
hasUserAutoMergeHold,
isSharedBranchGroupMemberIntegration,
isLiveSharedBranchGroupMemberIntegration,
resolveEffectiveAutoMerge,

View File

@@ -49,6 +49,19 @@ export function resolveEffectiveAutoMerge(
return task.autoMerge ?? settings.autoMerge;
}
/**
* FNXC:SharedBranchMemberHold 2026-08-05-22:50:
* FN-8811 keeps live member→group integration fast by default, but an operator's
* explicit task-level Off choice is a durable request for the existing manual
* review hold. Mission policy, legacy stamps, and inherited values must never
* impersonate that consent boundary.
*/
export function hasUserAutoMergeHold(
task: Pick<Task, "autoMerge" | "autoMergeProvenance">,
): boolean {
return task.autoMerge === false && task.autoMergeProvenance === "user";
}
/**
* Gate for auto-merge *processing* (engine enqueue + self-healing sweeps).
* Additive relative to the global setting: when `settings.autoMerge` is on,
@@ -85,9 +98,10 @@ export function resolveEffectiveGroupAutoMerge(
/**
* Shared-branch-group members perform a soft pre-integration step:
* member branch → shared group branch. This path is exempt from the global
* `autoMerge:false` in-review terminal gate so member integration can proceed,
* but shared-branch → default-branch promotion remains separately gated.
* member branch → shared group branch. This path is exempt from inherited and
* mission-policy `autoMerge:false` terminal gates so member integration can
* proceed. `hasUserAutoMergeHold` is the narrow operator opt-in that overrides
* this exemption; shared-branch → default promotion remains separately gated.
*/
export function isSharedBranchGroupMemberIntegration(
task: Pick<Task, "branchContext">,

View File

@@ -4275,10 +4275,10 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
Apply the selected Missions header workflow atomically during TaskStore.createTask so newly triaged features land in the intended workflow lane. Duplicate-guard reuses skip this create path, preserving existing duplicate tasks without workflow mutation.
*/
/*
FNXC:MissionAutoMerge 2026-07-18-12:00:
An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override.
FNXC:MissionAutoMerge 2026-08-05-22:50:
An autoMerge:false mission stamps each newly triaged task as mission policy so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override; policy must not impersonate an operator manual-hold choice.
*/
...(mission?.autoMerge === false ? { autoMerge: false } : {}),
...(mission?.autoMerge === false ? { autoMerge: false, autoMergeProvenance: "mission" as const } : {}),
// FNXC:MissionTaskPrefix 2026-07-26-12:00: thread the mission's optional taskPrefix into TaskCreateInput for distributed id minting.
...(mission?.taskPrefix ? { taskPrefix: mission.taskPrefix } : {}),
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),

View File

@@ -85,7 +85,7 @@ export function rowToTask(row: TaskRow): Task {
executionStartBranch: row.executionStartBranch || undefined,
branch: row.branch || undefined,
autoMerge: row.autoMerge === null ? undefined : row.autoMerge === 1,
autoMergeProvenance: row.autoMergeProvenance === "user" || row.autoMergeProvenance === "legacy-stamp"
autoMergeProvenance: row.autoMergeProvenance === "user" || row.autoMergeProvenance === "mission" || row.autoMergeProvenance === "legacy-stamp"
? row.autoMergeProvenance
: undefined,
baseCommitSha: row.baseCommitSha || undefined,

View File

@@ -490,7 +490,9 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext),
branchContext: input.branchContext,
autoMerge: input.autoMerge,
autoMergeProvenance: input.autoMerge === undefined ? undefined : "user",
// FNXC:SharedBranchMemberHold 2026-08-05-22:50: trusted mission creation
// preserves policy provenance; operator/API create requests retain user provenance.
autoMergeProvenance: input.autoMerge === undefined ? undefined : input.autoMergeProvenance ?? "user",
// FNXC:CodingIdeasWorkflow 2026-07-05-19:45: land the task in its
// workflow's manual intake column (e.g. Coding (Ideas) → "ideas") when
// no explicit column is given (main FN-7591 parity).
@@ -967,7 +969,9 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat
sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext),
branchContext: input.branchContext,
autoMerge: input.autoMerge,
autoMergeProvenance: input.autoMerge === undefined ? undefined : "user",
// FNXC:SharedBranchMemberHold 2026-08-05-22:50: trusted mission creation
// preserves policy provenance; operator/API create requests retain user provenance.
autoMergeProvenance: input.autoMerge === undefined ? undefined : input.autoMergeProvenance ?? "user",
// FNXC:CodingIdeasWorkflow 2026-07-05-19:45: land the task in its
// workflow's manual intake column (e.g. Coding (Ideas) → "ideas") when
// no explicit column is given (main FN-7591 parity).

View File

@@ -709,10 +709,12 @@ export interface Task {
* `PrInfo.autoMergeStrategy`), which must not be conflated with this field. */
autoMerge?: boolean;
/** Provenance for `autoMerge`.
* `"user"` means a sticky explicit user-set override.
* `"user"` means a sticky explicit operator-authored task override.
* `"mission"` means mission policy authored the value; it must not be
* treated as an operator request to hold a shared-member integration.
* `"legacy-stamp"` means an ambiguous value written by the pre-FN-6245
* review-entry stamp and is operator-clearable. Absent means unknown/none. */
autoMergeProvenance?: "user" | "legacy-stamp";
autoMergeProvenance?: "user" | "mission" | "legacy-stamp";
/** Actual git working branch name used for this task's worktree. May differ from
* the conventional `fn/{task-id}` when conflict recovery generated a
* unique suffixed name (e.g., `fn/fn-042-2`). */
@@ -1351,6 +1353,12 @@ export interface TaskCreateInput {
taskPrefix?: string;
/** Optional per-task auto-merge override. Undefined means no task-level override. */
autoMerge?: boolean;
/**
* FNXC:SharedBranchMemberHold 2026-08-05-22:50: trusted system writer marker
* for a mission policy value. Dashboard/API request parsing deliberately does
* not expose this field to callers, preserving user provenance as consent.
*/
autoMergeProvenance?: "mission";
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
/** Linked GitLab tracking metadata for GitLab.com and self-managed GitLab items. */

View File

@@ -429,6 +429,69 @@ describe("TaskDetailModal planner Chat tab", () => {
});
});
/*
FNXC:TaskBaseBranchEditor 2026-08-05-23:22:
FN-8811 retains one accessible task-detail branch editor rather than adding a Review-tab
copy. The editor must initialize the task merge target, submit a project-scoped PATCH,
and preserve its prior task snapshot when that request fails.
*/
describe("TaskDetailModal base-branch editor", () => {
function renderEditableTask(baseBranch?: string, projectId = "project-8811") {
const onTaskUpdated = vi.fn();
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-8811", column: "todo" as any, baseBranch })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={noop}
projectId={projectId}
/>,
);
return { onTaskUpdated };
}
it("labels, initializes, saves, and clears the existing merge target field", async () => {
const user = userEvent.setup();
const { updateTask } = await import("../../api");
const updated = makeTask({ id: "FN-8811", column: "todo" as any, baseBranch: "mission/M-8811" });
vi.mocked(updateTask).mockReset();
vi.mocked(updateTask).mockResolvedValue(updated);
const { onTaskUpdated } = renderEditableTask("mission/M-8811");
await user.click(screen.getByRole("button", { name: "Edit task" }));
const baseBranch = screen.getByLabelText("Merge target / base branch");
expect(baseBranch).toHaveValue("mission/M-8811");
await user.clear(baseBranch);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(updateTask).toHaveBeenCalledWith("FN-8811", { baseBranch: null }, "project-8811"));
expect(onTaskUpdated).toHaveBeenCalledWith(updated);
});
it("keeps the task snapshot and editable value when a base-branch save fails", async () => {
const user = userEvent.setup();
const { updateTask } = await import("../../api");
vi.mocked(updateTask).mockReset();
vi.mocked(updateTask).mockRejectedValueOnce(new Error("network unavailable"));
const { onTaskUpdated } = renderEditableTask(undefined);
await user.click(screen.getByRole("button", { name: "Edit task" }));
const baseBranch = screen.getByLabelText("Merge target / base branch");
await user.type(baseBranch, "mission/M-8811");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(screen.getByText("Save failed")).toBeInTheDocument());
expect(baseBranch).toHaveValue("mission/M-8811");
expect(onTaskUpdated).not.toHaveBeenCalled();
});
});
describe("TaskDetailModal summarize title action", () => {
it("orders board detail header actions as edit, expand, then Back to board", () => {
const onBackToBoard = vi.fn();

View File

@@ -1387,6 +1387,20 @@ describe("POST /tasks", () => {
);
});
it("rejects client-supplied autoMerge provenance via POST", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", autoMerge: false, autoMergeProvenance: "mission" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("autoMergeProvenance is server-managed");
expect(store.createTask).not.toHaveBeenCalled();
});
it("returns 400 for invalid autoMerge value via POST", async () => {
const res = await REQUEST(
buildApp(),
@@ -1928,6 +1942,39 @@ describe("PATCH /tasks/:id branch fields", () => {
}));
});
it("rejects client-supplied autoMerge provenance while preserving server-owned task updates", async () => {
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-001",
JSON.stringify({ autoMerge: false, autoMergeProvenance: "mission" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("autoMergeProvenance is server-managed");
expect(store.updateTask).not.toHaveBeenCalled();
});
it("forwards a null autoMerge patch so TaskStore clears the user override and provenance", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
autoMerge: undefined,
autoMergeProvenance: undefined,
});
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-001",
JSON.stringify({ autoMerge: null }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ autoMerge: null }));
});
it("returns 400 for invalid branch payload types", async () => {
const res = await REQUEST(
buildApp(),

View File

@@ -1629,6 +1629,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
reviewLevel,
executionMode,
autoMerge,
autoMergeProvenance,
priority,
source,
branch,
@@ -1690,6 +1691,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (autoMerge !== undefined && typeof autoMerge !== "boolean") {
throw badRequest("autoMerge must be a boolean");
}
// FNXC:SharedBranchMemberHold 2026-08-05-22:50: only trusted TaskStore
// writers may mark mission policy; HTTP callers express operator intent
// solely through the autoMerge value.
if (autoMergeProvenance !== undefined) {
throw badRequest("autoMergeProvenance is server-managed");
}
// Validate priority if provided.
if (priority !== undefined && priority !== null && !isTaskPriority(priority)) {
@@ -5640,6 +5647,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (hasBodyField("autoMerge") && autoMerge !== undefined && autoMerge !== null && typeof autoMerge !== "boolean") {
throw new Error("autoMerge must be a boolean");
}
if (hasBodyField("autoMergeProvenance")) {
throw badRequest("autoMergeProvenance is server-managed");
}
let validatedSourceIssue: import("@fusion/core").TaskSourceIssue | null | undefined;
if (hasBodyField("sourceIssue")) {
@@ -5881,7 +5891,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (dependencies !== undefined) updates.dependencies = dependencies;
if (enabledWorkflowSteps !== undefined) updates.enabledWorkflowSteps = enabledWorkflowSteps;
if (hasBodyField("noCommitsExpected")) updates.noCommitsExpected = noCommitsExpected;
if (hasBodyField("autoMerge")) updates.autoMerge = autoMerge === null ? undefined : autoMerge;
// FNXC:SharedBranchMemberHold 2026-08-05-23:55: preserve null through
// the trusted TaskStore boundary so an operator can clear a prior user hold
// and return a shared member to inherited/mission policy.
if (hasBodyField("autoMerge")) updates.autoMerge = autoMerge;
if (hasBodyField("modelProvider")) updates.modelProvider = validatedModelProvider;
if (hasBodyField("modelId")) updates.modelId = validatedModelId;
if (hasBodyField("validatorModelProvider")) updates.validatorModelProvider = validatedValidatorModelProvider;

View File

@@ -55,6 +55,13 @@ const mergeAbortResult = {
context: { "node:merge:value": "aborted" },
};
const interruptedExecuteResult = {
interruptedAbortKind: "engine-pause",
interruptedNodeId: "execute",
visitedNodeIds: ["execute"],
context: {},
};
describe("executor shared-branch autoMerge:false liveness gates", () => {
it("does not route an engine-created dissolved-group member to auto-merge retry", async () => {
const { executor, store } = makeExecutor(null);
@@ -71,6 +78,30 @@ describe("executor shared-branch autoMerge:false liveness gates", () => {
expect(store.getBranchGroup).toHaveBeenCalledWith("BG-STALE");
});
it("holds a live shared-group member when an operator explicitly turns auto-merge off", async () => {
const { executor } = makeExecutor({ status: "open", branchName: "mission/M-1980" });
const task = makeInReviewTask({ autoMerge: false, autoMergeProvenance: "user" });
await expect((executor as any).isRetryableBenignMergePauseAbort(
task,
mergeAbortResult,
"merge-seam",
true,
)).resolves.toBe(false);
});
it.each(["mission", "legacy-stamp", undefined] as const)("keeps live shared-group policy or legacy false values flowing (%s)", async (autoMergeProvenance) => {
const { executor } = makeExecutor({ status: "open", branchName: "mission/M-1980" });
const task = makeInReviewTask({ autoMerge: false, autoMergeProvenance });
await expect((executor as any).isRetryableBenignMergePauseAbort(
task,
mergeAbortResult,
"merge-seam",
true,
)).resolves.toBe(true);
});
it("still routes live shared-group members through the local integration retry gate", async () => {
const { executor } = makeExecutor({ status: "open", branchName: "mission/M-1980" });
const task = makeInReviewTask();
@@ -83,6 +114,62 @@ describe("executor shared-branch autoMerge:false liveness gates", () => {
)).resolves.toBe(true);
});
it("re-enters an interrupted mission-policy member rather than stranding its local integration", async () => {
const { executor } = makeExecutor({ status: "open", branchName: "mission/M-1980" });
const task = makeInReviewTask({
autoMerge: false,
autoMergeProvenance: "mission",
status: null,
error: null,
});
await expect((executor as any).isReentrantPausedAbortedInFlightNode(
task,
interruptedExecuteResult,
"engine-abort",
true,
false,
)).resolves.toBe(true);
});
it("does not let live pre-merge remediation reopen an operator-held member", async () => {
const { executor, store } = makeExecutor({ status: "open", branchName: "mission/M-1980" });
const task = makeInReviewTask({ autoMerge: false, autoMergeProvenance: "user" });
store.getTask.mockResolvedValue(task);
const sendBack = vi.spyOn(executor as any, "sendTaskBackForFix");
await expect((executor as any).requestPreMergeOptionalStepFix(task.id, task, {
stepName: "Code Review",
feedback: "Please revise",
phase: "pre-merge",
status: "failed",
verdict: "REVISE",
nodeId: "code-review",
})).resolves.toBe(false);
expect(sendBack).not.toHaveBeenCalled();
});
it("does not let failed-step recovery reopen an operator-held member", async () => {
const { executor } = makeExecutor({ status: "open", branchName: "mission/M-1980" });
const task = makeInReviewTask({
autoMerge: false,
autoMergeProvenance: "user",
workflowStepResults: [{
workflowStepId: "code-review",
workflowStepName: "Code Review",
phase: "pre-merge",
status: "failed",
output: "Please revise",
}],
});
const sendBack = vi.spyOn(executor as any, "sendTaskBackForFix");
await expect(executor.recoverFailedPreMergeWorkflowStep(task)).resolves.toBe(false);
expect(sendBack).not.toHaveBeenCalled();
});
it("holds an open shared group that would integrate directly into main", async () => {
const { executor } = makeExecutor({ status: "open", branchName: "main" });
const task = makeInReviewTask();

View File

@@ -6,6 +6,16 @@ import { tmpdir } from "node:os";
import { describe, expect, it, afterEach, beforeEach, vi } from "vitest";
import { type TaskStore } from "@fusion/core";
const createResolvedAgentSessionMock = vi.hoisted(() => vi.fn());
vi.mock("../agents/agent-session-helpers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../agents/agent-session-helpers.js")>()),
createResolvedAgentSession: createResolvedAgentSessionMock,
}));
vi.mock("../pi.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../pi.js")>()),
promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise<void> }, prompt: string) => session.prompt(prompt)),
}));
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js";
import {
evaluateBranchGroupCompletion,
@@ -1252,6 +1262,7 @@ function createPostReviewTask(groupId: string): Record<string, any> {
function createPostReviewStore(task: Record<string, any>, branchGroup: Record<string, any> | null) {
return {
getTask: vi.fn(async () => task),
listTasks: vi.fn(async () => [task]),
getSettings: vi.fn(async () => ({
autoMerge: false,
merger: { maxReviewPasses: 0 },
@@ -1266,6 +1277,7 @@ function createPostReviewStore(task: Record<string, any>, branchGroup: Record<st
logEntry: vi.fn(async () => undefined),
appendAgentLog: vi.fn(async () => undefined),
recordRunAuditEvent: vi.fn(async () => undefined),
getActiveMergingTask: vi.fn(async () => null),
emit: vi.fn(),
} as any;
}
@@ -1273,7 +1285,8 @@ function createPostReviewStore(task: Record<string, any>, branchGroup: Record<st
function createInterpreterMergeEngine(repo: string, store: any): any {
const engine = Object.create(ProjectEngine.prototype) as any;
engine.config = { workingDirectory: repo };
engine.runtime = { getTaskStore: () => store };
engine.options = {};
engine.runtime = { getTaskStore: () => store, getPluginRunner: () => undefined };
return engine;
}
@@ -1368,6 +1381,90 @@ describe("resolveBranchGroupMergeRouting", () => {
});
}, 30_000);
/*
FNXC:SharedBranchMemberHold 2026-08-05-23:45:
FN-8811 requires an operator-authored task-level Off choice to stop before
member→group integration, while the explicit Merge & Close release must still
land exactly once on the mission branch. Exercise the production requester and
real merger together so a gate-only test cannot hide a release-target regression.
*/
it("holds a user-off member before release, then lands exactly once on its mission branch", async () => {
const repo = makeRepo();
const mainBefore = git(repo, "git rev-parse main");
git(repo, "git checkout -q -b fusion/fn-3324");
writeFileSync(join(repo, "user-hold-feature.txt"), "release only after operator confirmation\n");
git(repo, "git add user-hold-feature.txt && git commit -q -m feature");
git(repo, "git checkout -q main");
git(repo, "git branch mission/M-8811 main");
const task = {
...createPostReviewTask("BG-user-hold"),
autoMerge: false,
autoMergeProvenance: "user",
};
const store = createPostReviewStore(task, {
id: "BG-user-hold",
status: "open",
branchName: "mission/M-8811",
});
const engine = createInterpreterMergeEngine(repo, store);
const blockedMerge = vi.fn(async () => {
throw new Error("user hold must prevent automatic member integration");
});
engine.onMerge = blockedMerge;
const held = await engine.requestInterpreterMerge("FN-3324");
expect(held).toMatchObject({ merged: false, noOp: true });
expect(blockedMerge).not.toHaveBeenCalled();
expect(git(repo, "git rev-parse main")).toBe(mainBefore);
expect(() => git(repo, "git show mission/M-8811:user-hold-feature.txt")).toThrow();
let mergeAttempts = 0;
/*
* FNXC:SharedBranchMemberHold 2026-08-06-00:24:
* FN-8811 requires release to travel through the production queue, not a
* test-owned drain. The session fake supplies deterministic AI responses,
* while ProjectEngine's real drain invokes runAiMerge against Git.
*/
createResolvedAgentSessionMock.mockImplementation(async (options: any) => ({
session: {
async prompt() {
if (String(options.systemPrompt).includes("read-only")) {
options.onText?.("REVIEW_VERDICT: approve");
return;
}
mergeAttempts += 1;
git(options.cwd, "git merge --squash fusion/fn-3324");
git(options.cwd, "git add -A && git commit -q -m 'squash user-held member'");
},
dispose: vi.fn(),
getSessionStats: vi.fn(() => ({ tokens: { input: 1, output: 1 } })),
},
}));
engine.manualMergeResolvers = new Map();
engine.mergeActive = new Set();
engine.mergeQueue = [];
engine.capacityDeferredMergeTaskIds = new Set();
engine.capacityDeferredMerges = new Map();
engine.coordinatorAdmittedMergeTaskIds = new Set();
engine.started = true;
engine.shuttingDown = false;
const released = await ProjectEngine.prototype.onMerge.call(engine, "FN-3324");
createResolvedAgentSessionMock.mockReset();
expect(released.merged).toBe(true);
expect(mergeAttempts).toBe(1);
expect(git(repo, "git rev-parse main")).toBe(mainBefore);
expect(git(repo, "git show mission/M-8811:user-hold-feature.txt")).toBe("release only after operator confirmation");
expect(task.mergeDetails).toMatchObject({
mergeConfirmed: true,
mergeTargetBranch: "mission/M-8811",
mergeTargetSource: "branch-group-integration",
});
}, 30_000);
it("creates the group branch when missing", async () => {
const rootDir = makeRepo();
const branchGroup = {

View File

@@ -3680,7 +3680,7 @@ describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
await expect(gate({ autoMerge: false }, { autoMerge: false })).resolves.toBe(false);
});
it("keeps everything flowing when the global setting is on — explicit autoMerge:false is parked manual-required downstream", async () => {
it("keeps standalone values flowing when the global setting is on", async () => {
await expect(gate({}, { autoMerge: true })).resolves.toBe(true);
await expect(gate({ autoMerge: false }, { autoMerge: true })).resolves.toBe(true);
});
@@ -3693,6 +3693,17 @@ describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
)).resolves.toBe(true);
});
it("holds only an operator-authored false override before live member integration", 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);
});
it("keeps live shared-branch-group member integration on the default branch behind the manual gate", async () => {
await expect(gate(
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
@@ -3705,14 +3716,52 @@ describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
["missing", null],
["finalized", { status: "finalized" as const }],
["abandoned", { status: "abandoned" as const }],
])("blocks shared-branch-group member integration for %s groups when global autoMerge is off", async (_label, branchGroup) => {
["default-branch", { status: "open" as const, branchName: "main" }],
])("blocks false shared members for %s groups even when global autoMerge is on", async (_label, branchGroup) => {
await expect(gate(
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
{ autoMerge: false },
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"], autoMerge: false, autoMergeProvenance: "mission" },
{ autoMerge: true, integrationBranch: "main" },
branchGroup,
)).resolves.toBe(false);
});
it("keeps stale false members in the interpreter manual hold until the explicit release path merges once into the group", async () => {
const task = {
id: "FN-8811",
column: "in-review",
branch: "fusion/fn-8811",
autoMerge: false,
autoMergeProvenance: "mission",
branchContext: { assignmentMode: "shared", groupId: "BG-8811", source: "mission" },
} as Task;
const settings = { autoMerge: true, globalPause: false, enginePaused: false, integrationBranch: "main" } as Settings;
const store = {
getTask: vi.fn(async () => task),
getSettings: vi.fn(async () => settings),
getBranchGroup: vi.fn(async () => ({ status: "open", branchName: "main" })),
getTaskWorkflowSelection: () => undefined,
getTaskWorkflowSelectionAsync: async () => undefined,
} as unknown as TaskStore;
const onMerge = vi.fn(async () => ({ task, branch: task.branch ?? "", merged: true, mergeTargetBranch: "mission/M-8811" }));
const self: any = {
config: { workingDirectory: "/tmp/proj_test" },
runtime: { getTaskStore: () => store },
onMerge,
};
self.allowInReviewMergeProcessing = (candidate: Task, candidateSettings: Settings, candidateStore: TaskStore) =>
(ProjectEngine.prototype as any).allowInReviewMergeProcessing.call(self, candidate, candidateSettings, candidateStore);
const held = await (ProjectEngine.prototype as any).requestInterpreterMerge.call(self, task.id);
expect(held).toMatchObject({ merged: false, noOp: true });
expect(onMerge).not.toHaveBeenCalled();
// The operator's explicit release uses onMerge, not the auto-merge requester.
await self.onMerge(task.id, { manual: true });
expect(onMerge).toHaveBeenCalledTimes(1);
expect(onMerge).toHaveBeenCalledWith(task.id, { manual: true });
});
it.each([
["api", { sourceType: "api" }],
["user-created", { sourceType: undefined }],

View File

@@ -47,6 +47,7 @@ function createMockStore(tasks: Task[]): TaskStore & EventEmitter {
} as unknown as Settings),
listTasks: vi.fn().mockResolvedValue(tasks),
getTask: vi.fn(async (id: string) => byId.get(id) ?? null),
getBranchGroup: vi.fn().mockResolvedValue(null),
updateTask: vi.fn().mockResolvedValue({} as Task),
logEntry: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
@@ -240,6 +241,56 @@ describe("recoverPausedAbortFailures", () => {
);
});
it("preserves an operator-authored shared-member hold while mission policy false stays review progress", async () => {
const shared = { assignmentMode: "shared", groupId: "BG-8811", source: "mission" } as Task["branchContext"];
const store = createMockStore([
parkTask({ id: "FN-USER", column: "in-review", error: MANUAL_HOLD_PARK_ERROR, steps: DONE_STEPS, autoMerge: false, autoMergeProvenance: "user", branchContext: shared }),
parkTask({ id: "FN-MISSION", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: false, autoMergeProvenance: "mission", branchContext: shared }),
]);
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue({ status: "open", branchName: "mission/M-8811" });
const manager = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
getExecutingTaskIds: () => new Set<string>(),
});
expect(await manager.recoverPausedAbortFailures()).toBe(2);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
target: "FN-USER",
metadata: expect.objectContaining({ recoveryReason: "pause-abort-manual-merge-hold" }),
}));
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
target: "FN-MISSION",
metadata: expect.objectContaining({ recoveryReason: "pause-abort-review-progress" }),
}));
});
it.each([
["missing", null],
["finalized", { status: "finalized", branchName: "mission/M-8811" }],
["default-branch", { status: "open", branchName: "main" }],
])("restores a false mission member as a standalone manual hold when its group is %s", async (_label, group) => {
const store = createMockStore([parkTask({
id: "FN-STALE-GROUP",
column: "in-review",
error: MANUAL_HOLD_PARK_ERROR,
steps: DONE_STEPS,
autoMerge: false,
autoMergeProvenance: "mission",
branchContext: { assignmentMode: "shared", groupId: "BG-8811", source: "mission" } as Task["branchContext"],
})]);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: false, globalPause: false, enginePaused: false } as Settings);
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue(group);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project", getExecutingTaskIds: () => new Set<string>() });
expect(await manager.recoverPausedAbortFailures()).toBe(1);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
target: "FN-STALE-GROUP",
metadata: expect.objectContaining({ recoveryReason: "pause-abort-manual-merge-hold" }),
}));
});
it("skips paused, executing, incomplete in-review, and non-pause-abort failures", async () => {
const store = createMockStore([
parkTask({ id: "FN-A", paused: true }),
@@ -285,7 +336,6 @@ describe("recoverPausedAbortFailures", () => {
parkTask({ id: "FN-X", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: true }),
parkTask({ id: "FN-M", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: true, mergeDetails: { mergeConfirmed: true } as any }),
parkTask({ id: "FN-T", column: "in-review", error: `${IN_REVIEW_PARK_ERROR} merge-conflict`, steps: DONE_STEPS, autoMerge: true }),
parkTask({ id: "FN-A", column: "in-review", error: MANUAL_HOLD_PARK_ERROR, steps: DONE_STEPS, autoMerge: undefined, branchContext: { groupId: "BG-1", source: "mission", assignmentMode: "shared" } as any }),
];
const store = createMockStore(candidates);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({

View File

@@ -210,6 +210,7 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
enqueueMergeQueue: vi.fn().mockResolvedValue(undefined),
peekMergeQueue: vi.fn().mockReturnValue([]),
mergeTask: vi.fn().mockResolvedValue(undefined),
getBranchGroup: vi.fn().mockResolvedValue(null),
archiveTaskAndCleanup: vi.fn().mockResolvedValue({} as Task),
/*
FNXC:PgMigrationQuarantine 2026-07-16-08:00:
@@ -5111,6 +5112,111 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
/*
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.
*/
it("re-enqueues a mission-policy shared member under global auto-merge off but preserves a user hold", async () => {
const enqueueMerge = vi.fn().mockReturnValue(true);
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
enqueueMerge,
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoMerge: false,
integrationBranch: "main",
globalPause: false,
enginePaused: false,
});
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue({
status: "open",
branchName: "mission/M-8811",
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-8811-MISSION",
column: "in-review",
paused: false,
status: null,
error: null,
worktree: "/tmp/test-project/.worktrees/fn-8811-mission",
steps: [{ name: "Ship it", status: "done" }],
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
autoMerge: false,
autoMergeProvenance: "mission",
branchContext: { assignmentMode: "shared", groupId: "BG-8811", source: "mission" },
log: [],
},
{
id: "FN-8811-USER",
column: "in-review",
paused: false,
status: null,
error: null,
worktree: "/tmp/test-project/.worktrees/fn-8811-user",
steps: [{ name: "Ship it", status: "done" }],
workflowStepResults: [{ id: "ws-2", status: "passed", phase: "pre-merge" }],
autoMerge: false,
autoMergeProvenance: "user",
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");
managerWithRecovery.stop();
});
/*
FNXC:SharedBranchMemberHold 2026-08-05-23:22:
A default-branch group has no intermediate integration boundary. Recovery must
leave a false mission policy at the standalone manual hold even when global
auto-merge is On, rather than using the live-member exemption by group shape.
*/
it("does not recover a default-branch member with a false mission policy into a merge", async () => {
const enqueueMerge = vi.fn().mockReturnValue(true);
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
enqueueMerge,
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoMerge: true,
integrationBranch: "main",
globalPause: false,
enginePaused: false,
});
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue({
status: "open",
branchName: "main",
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([{
id: "FN-8811-DEFAULT",
column: "in-review",
paused: false,
status: null,
error: null,
worktree: "/tmp/test-project/.worktrees/fn-8811-default",
steps: [{ name: "Ship it", status: "done" }],
workflowStepResults: [{ id: "ws-default", status: "passed", phase: "pre-merge" }],
autoMerge: false,
autoMergeProvenance: "mission",
branchContext: { assignmentMode: "shared", groupId: "BG-8811", source: "mission" },
log: [],
}]);
await expect(managerWithRecovery.recoverMergeableReviewTasks()).resolves.toBe(0);
expect(enqueueMerge).not.toHaveBeenCalled();
expect(store.mergeTask).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("routes through enqueueMerge when wired so mergeStrategy is honored", async () => {
const enqueueMerge = vi.fn().mockReturnValue(true);
const managerWithRecovery = new SelfHealingManager(store, {

View File

@@ -3,6 +3,7 @@ import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core";
import type { TaskDetail, WorkflowIr } from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflows/workflow-graph-executor.js";
import { createMergeGateHandler } from "../workflow-node-runners/merge-runner.js";
const task = { id: "FN-5767" } as TaskDetail;
@@ -10,6 +11,28 @@ function settingsOn() {
return { experimentalFeatures: { workflowGraphExecutor: true } };
}
describe("merge gate shared-member provenance", () => {
const liveMember = vi.fn(async () => true);
const invokeGate = (taskOverride: Partial<TaskDetail>, isLiveSharedBranchMember = liveMember) =>
createMergeGateHandler({ isLiveSharedBranchMember })(
{ id: "merge-gate", kind: "merge-gate" } as never,
{ task: { ...task, ...taskOverride }, settings: { autoMerge: false } } as never,
);
it("routes only an operator-authored false override to the manual hold", async () => {
await expect(invokeGate({ autoMerge: false, autoMergeProvenance: "user" })).resolves.toMatchObject({ value: "auto-off" });
expect(liveMember).not.toHaveBeenCalled();
});
it.each(["mission", "legacy-stamp", undefined] as const)("keeps a live member flowing for non-user false provenance (%s)", async (autoMergeProvenance) => {
await expect(invokeGate({ autoMerge: false, autoMergeProvenance })).resolves.toMatchObject({ value: "auto-on" });
});
it("keeps false values on a non-live group in the normal manual policy path", async () => {
await expect(invokeGate({ autoMerge: false, autoMergeProvenance: "mission" }, async () => false)).resolves.toMatchObject({ value: "auto-off" });
});
});
describe("WorkflowGraphExecutor traversal", () => {
it("walks linear graph", async () => {
const ir: WorkflowIr = {

View File

@@ -16,7 +16,7 @@ import { DEFAULT_PROVIDER_INSTANCE_ID, type ProviderInstanceRef, type TaskStore,
import { getUnmetSchedulingDependencies } from "./scheduler.js";
import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js";
import { emitWorkflowLifecycleEvent } from "@fusion/core";
import { resolveTaskLifecycleColumns, resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, PLAN_REVIEW_GROUP_ID, upsertWorkflowStepResult, normalizeWorkflowReviewFindings, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel, parseExplicitDuplicateMarker, nonExecutableDuplicateRedirectReason } from "@fusion/core";
import { resolveTaskLifecycleColumns, resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, hasUserAutoMergeHold, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, PLAN_REVIEW_GROUP_ID, upsertWorkflowStepResult, normalizeWorkflowReviewFindings, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel, parseExplicitDuplicateMarker, nonExecutableDuplicateRedirectReason } from "@fusion/core";
import {
BLOCKED_THRASH_LIMIT,
buildExternalBlockMetadataPatch,
@@ -5611,6 +5611,14 @@ export class TaskExecutor {
if (info.status !== "advisory_failure" && info.status !== "failed") return false;
const liveTask = await this.store.getTask(taskId).catch(() => fallbackTask);
/*
* FNXC:SharedBranchMemberHold 2026-08-06-00:12:
* An operator-authored task Off is a durable manual checkpoint, not merely
* an auto-merge admission preference. Pre-merge remediation must not reopen
* implementation and thereby bypass that checkpoint before the operator
* releases or revises the held member.
*/
if (hasUserAutoMergeHold(liveTask)) return false;
const missingArtifactKeys = parseRequiredArtifactMissingValue(info.failureValue);
if (missingArtifactKeys) {
await this.recoverMissingRequiredArtifacts(liveTask, missingArtifactKeys, {
@@ -5930,6 +5938,13 @@ export class TaskExecutor {
*/
async recoverFailedPreMergeWorkflowStep(task: Task): Promise<boolean> {
try {
/*
* FNXC:SharedBranchMemberHold 2026-08-06-00:12:
* Startup/self-healing recovery is another pre-merge remediation requester.
* Do not let it send a user-held member back to execution: only an explicit
* operator release or revision may advance that manual checkpoint.
*/
if (hasUserAutoMergeHold(task)) return false;
/*
FNXC:WorkflowPostMerge 2026-06-26-14:00:
U7c: gate-ness is now sourced from the recorded `WorkflowStepResult.status`, NOT a
@@ -6673,6 +6688,8 @@ export class TaskExecutor {
getTask: (taskId: string) => this.store.getTask(taskId),
},
runId: resolvedRunId,
isLiveSharedBranchMember: (nodeTask) =>
this.isLiveSharedBranchGroupMember(nodeTask),
primitives: this.createAuthoritativeWorkflowPrimitives(settings),
seams: this.createAuthoritativeWorkflowSeams(settings),
prepareNodeExecution: (node, nodeTask, requirement) =>
@@ -10646,7 +10663,7 @@ export class TaskExecutor {
};
}
private async isLiveSharedBranchGroupMember(live: Pick<TaskDetail, "branchContext">): Promise<boolean> {
private async isLiveSharedBranchGroupMember(live: Pick<TaskDetail, "branchContext" | "autoMerge" | "autoMergeProvenance">): Promise<boolean> {
const groupId = live.branchContext?.groupId?.trim();
// FNXC:PostgresCutover 2026-07-10: getBranchGroup is async on the PG branch.
const branchGroup = groupId ? await this.store.getBranchGroup(groupId) : null;
@@ -10739,6 +10756,10 @@ export class TaskExecutor {
} catch {
return false;
}
// FNXC:SharedBranchMemberHold 2026-08-05-22:50: FN-8811 lets only an
// operator-authored Off choice fence the live member fast path; policy and
// legacy false values retain member→group flow.
if (hasUserAutoMergeHold(live)) return false;
const sharedBranchMember = await this.isLiveSharedBranchGroupMember(live);
if (!sharedBranchMember && !allowsAutoMergeProcessing(live, settings)) return false;
if (!sharedBranchMember && resolveEffectiveAutoMerge(live, settings) === false) return false;
@@ -10843,8 +10864,10 @@ export class TaskExecutor {
return false;
}
/* FNXC:AutoMergeHold 2026-07-09-17:07: FN-7749's benign manual-hold classifier must exclude only live shared-group integrations. FN-7750 stale shared-group members are standalone manual-hold rows and should not be stranded as pause-abort failures. */
if (await this.isLiveSharedBranchGroupMember(live)) return false;
return !allowsAutoMergeProcessing(live, settings) || resolveEffectiveAutoMerge(live, settings) === false;
if (await this.isLiveSharedBranchGroupMember(live) && !hasUserAutoMergeHold(live)) return false;
return hasUserAutoMergeHold(live)
|| !allowsAutoMergeProcessing(live, settings)
|| resolveEffectiveAutoMerge(live, settings) === false;
}
private async handleStaleInReviewPlanPauseAbortReplay(
@@ -11085,9 +11108,13 @@ export class TaskExecutor {
if (settings.globalPause === true) return false;
}
if (live.column === resumeLanes.review) {
if (live.autoMerge === false) return false;
if (!settings) return false;
const sharedBranchMember = await this.isLiveSharedBranchGroupMember(live);
// FNXC:SharedBranchMemberHold 2026-08-05-23:55: an interrupted live
// member with mission/legacy policy false must resume its local integration;
// only the user hold (and every stale/default-group false override) remains
// terminal at this recovery boundary.
if (hasUserAutoMergeHold(live) || (live.autoMerge === false && !sharedBranchMember)) return false;
if (!sharedBranchMember && !allowsAutoMergeProcessing(live, settings)) return false;
if (live.mergeDetails?.mergeConfirmed === true) return false;
}

View File

@@ -24,6 +24,7 @@ import {
resolveColumnFlags,
type TraitFlags,
allowsAutoMergeProcessing,
hasUserAutoMergeHold,
compareTasksByPriorityThenAgeAndId,
emitOverseerConfirmation,
emitOverseerEscalation,
@@ -2935,23 +2936,27 @@ export class ProjectEngine {
* pushed wins. listTasks returns createdAt ASC — without this sort an
* older low-priority task would start before a later urgent one.
*/
private async allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge">, settings: Pick<Settings, "autoMerge">, store: Partial<Pick<TaskStore, "getBranchGroup">> = this.runtime.getTaskStore()): Promise<boolean> {
if (allowsAutoMergeProcessing(task, settings)) {
return true;
}
private async allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge" | "autoMergeProvenance">, settings: Pick<Settings, "autoMerge">, store: Partial<Pick<TaskStore, "getBranchGroup">> = this.runtime.getTaskStore()): Promise<boolean> {
// FNXC:SharedBranchMemberHold 2026-08-05-23:35: resolve group liveness before
// general admission. Only a live intermediate group may bypass false policy;
// a user-authored Off always remains a manual hold.
if (hasUserAutoMergeHold(task)) return false;
const groupId = task.branchContext?.groupId?.trim();
const branchGroup = groupId ? await store.getBranchGroup?.(groupId) : null;
if (!branchGroup || branchGroup.status !== "open" || !branchGroup.branchName.trim()) {
return false;
const projectDefaultBranch = await resolveIntegrationBranch(this.config.workingDirectory, settings as Settings);
if (isLiveSharedBranchGroupMemberIntegration(task, branchGroup, projectDefaultBranch)) {
return true;
}
const projectDefaultBranch = await resolveIntegrationBranch(this.config.workingDirectory, settings as Settings);
/*
FNXC:AutoMergeHold 2026-07-09-16:53:
FN-7750 / Runfusion#1980: shared-branch member integration may bypass the global `autoMerge:false` hold only while its group row is still open. Stale, finalized, abandoned, or missing groups must flow through the standalone manual-hold gate so no task provenance can solo auto-merge to main.
FNXC:AutoMergeHold 2026-08-05-23:35:
A shared member with a missing, closed, or default-branch group is no longer
an intermediate integration. Its false task value must use the standalone
manual-release path even when project auto-merge is enabled.
*/
return isLiveSharedBranchGroupMemberIntegration(task, branchGroup, projectDefaultBranch);
if (task.autoMerge === false && groupId) return false;
return allowsAutoMergeProcessing(task, settings);
}
private async emitLegacyAutoMergeStampAdvisory(store: TaskStore): Promise<void> {

View File

@@ -30,7 +30,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, resolveReboundTargetForTask, resolveArchiveTargetForTask, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr,
import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, hasUserAutoMergeHold, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isLiveSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, resolveReboundTargetForTask, resolveArchiveTargetForTask, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr,
resolveNearDuplicateCanonicalFlags,
LEGACY_COLUMN_IDS_BY_ROLE,
TERMINAL_ROLES,
@@ -972,12 +972,25 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
};
}
private classifyPausedAbortWorkflowRecovery(
/*
FNXC:SharedBranchMemberHold 2026-08-05-23:35:
Pause-abort recovery must resolve the same live intermediate-group predicate as
merge admission. Shared metadata alone is stale after group closure or a
default-branch collision, so it cannot suppress that task's standalone hold.
*/
private async isLiveSharedMemberIntegration(task: Task, settings: Settings): Promise<boolean> {
const groupId = task.branchContext?.groupId?.trim();
const group = groupId ? await (this.store as Partial<Pick<TaskStore, "getBranchGroup">>).getBranchGroup?.(groupId) : null;
const defaultBranch = await resolveIntegrationBranch(this.options.rootDir, settings);
return isLiveSharedBranchGroupMemberIntegration(task, group, defaultBranch);
}
private async classifyPausedAbortWorkflowRecovery(
task: Task,
settings: Settings,
isExecuting: boolean,
columns: { review: ReadonlySet<string>; activeWork: ReadonlySet<string> },
): WorkflowRecoveryRoute {
): Promise<WorkflowRecoveryRoute> {
const isPausedAbortPark =
task.status === "failed" &&
typeof task.error === "string" &&
@@ -995,17 +1008,21 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|| errorText.includes("max retries");
const completedSteps = task.steps.length > 0
&& task.steps.every((step) => step.status === "done" || step.status === "skipped");
const sharedBranchMember = isSharedBranchGroupMemberIntegration(task);
const liveSharedBranchMember = await this.isLiveSharedMemberIntegration(task, settings);
const autoMergeProcessing = allowsAutoMergeProcessing(task, settings) || liveSharedBranchMember;
const hasReviewProgress =
columns.review.has(task.column)
&& allowsAutoMergeProcessing(task, settings)
&& autoMergeProcessing
&& task.mergeDetails?.mergeConfirmed !== true
&& !isTerminalMergePark
&& completedSteps;
const hasManualMergeHoldProgress =
columns.review.has(task.column)
&& (!allowsAutoMergeProcessing(task, settings) || resolveEffectiveAutoMerge(task, settings) === false)
&& !sharedBranchMember
&& (hasUserAutoMergeHold(task) || !allowsAutoMergeProcessing(task, settings) || resolveEffectiveAutoMerge(task, settings) === false)
// FNXC:SharedBranchMemberHold 2026-08-05-22:50: the operator's explicit
// Off choice is the one shared-member exception that must resume in place,
// rather than letting recovery re-enqueue member→group integration.
&& (!liveSharedBranchMember || hasUserAutoMergeHold(task))
&& task.mergeDetails?.mergeConfirmed !== true
&& !isTerminalMergePark
&& completedSteps;
@@ -8201,8 +8218,42 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
*/
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
/*
FNXC:SharedBranchMemberHold 2026-08-05-23:14:
Recovery is a merge requester, not merely cleanup. It must use the same
member→group admission rule as ProjectEngine: an open intermediate group
keeps mission-policy/inherited Off flowing, while an operator-authored Off
remains a durable manual hold. Filtering only with allowsAutoMergeProcessing
stranded mission members whenever the project switch was Off and, conversely,
would enqueue a user hold when the project switch was On.
*/
const canRecoverMergeableReviewTask = async (task: Task): Promise<boolean> => {
if (hasUserAutoMergeHold(task)) return false;
const groupId = task.branchContext?.groupId?.trim();
const branchGroup = groupId ? await this.store.getBranchGroup(groupId) : null;
const projectDefaultBranch = await resolveIntegrationBranch(this.options.rootDir, settings);
if (isLiveSharedBranchGroupMemberIntegration(task, branchGroup, projectDefaultBranch)) {
return true;
}
/*
FNXC:SharedBranchMemberHold 2026-08-05-23:22:
A stale or default-branch group is not an intermediate member integration.
Its false task value must retain the standalone manual-hold path even when
the project switch is On; recovery must not turn that durable hold into a
fresh merge request merely because it runs after the graph paused.
*/
return allowsAutoMergeProcessing(task, settings)
&& resolveEffectiveAutoMerge(task, settings) !== false;
};
const mergeAdmission = await Promise.all(tasks.map(async (task) => [
task.id,
await canRecoverMergeableReviewTask(task),
] as const));
const mergeAdmissionByTaskId = new Map(mergeAdmission);
const mergeable = tasks.filter((t) =>
allowsAutoMergeProcessing(t, settings) &&
mergeAdmissionByTaskId.get(t.id) === true &&
!t.paused &&
!executingIds.has(t.id) &&
t.status !== "failed" &&
@@ -12283,7 +12334,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
for (const t of tasks) {
if (!this.isPauseAbortParkCandidate(t)) continue;
const columns = await this.resolvePauseAbortColumnsFor(t.id, columnCache);
if (this.classifyPausedAbortWorkflowRecovery(t, settings, executingIds.has(t.id), columns).kind !== "no-action") {
if ((await this.classifyPausedAbortWorkflowRecovery(t, settings, executingIds.has(t.id), columns)).kind !== "no-action") {
parked.push(t);
}
}
@@ -12323,7 +12374,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
continue;
}
const freshColumns = await this.resolvePauseAbortColumnsFor(fresh.id, columnCache);
const route = this.classifyPausedAbortWorkflowRecovery(fresh, settings, latestExecutingIds.has(fresh.id), freshColumns);
const route = await this.classifyPausedAbortWorkflowRecovery(fresh, settings, latestExecutingIds.has(fresh.id), freshColumns);
if (route.kind === "no-action") {
continue;
}

View File

@@ -1,4 +1,4 @@
import type { Settings } from "@fusion/core";
import { hasUserAutoMergeHold, type Settings, type TaskDetail } from "@fusion/core";
import type { WorkflowNodeHandler } from "../workflows/workflow-graph-executor.js";
import type { WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "../execution/runtime-primitives.js";
@@ -38,10 +38,28 @@ export function createMergeAttemptHandler(deps: MergeAttemptRunnerDeps): Workflo
};
}
export function createMergeGateHandler(): WorkflowNodeHandler {
export interface MergeGateHandlerDeps {
/** Resolves whether the task currently has a live intermediate group target. */
isLiveSharedBranchMember?: (
task: Pick<TaskDetail, "branchContext" | "autoMerge" | "autoMergeProvenance">,
settings: Pick<Settings, "autoMerge">,
) => Promise<boolean>;
}
export function createMergeGateHandler(deps: MergeGateHandlerDeps = {}): WorkflowNodeHandler {
return async (_node, ctx) => {
const settingsAutoMerge = (ctx.settings as Partial<Settings> | undefined)?.autoMerge;
const autoMerge = ctx.task.autoMerge !== false && settingsAutoMerge !== false;
const settings = { autoMerge: settingsAutoMerge ?? true };
/*
FNXC:SharedBranchMemberHold 2026-08-05-22:50:
FN-8811 requires the graph's first merge decision to preserve the live
member→group fast path for inherited, mission, and legacy false values.
Only the operator-authored false pair takes the existing manual-hold edge;
the live resolver also keeps stale/default-branch groups on normal policy.
*/
const autoMerge = !hasUserAutoMergeHold(ctx.task)
&& ((await deps.isLiveSharedBranchMember?.(ctx.task, settings)) === true
|| (ctx.task.autoMerge !== false && settings.autoMerge !== false));
return {
outcome: "success",
value: autoMerge ? "auto-on" : "auto-off",

View File

@@ -21,6 +21,7 @@ import {
WORKFLOW_ID_CONTEXT_KEY,
WORKFLOW_RUN_ID_CONTEXT_KEY,
type CodeNodeRunner,
type DefaultNodeHandlerDeps,
type ForeachActiveContext,
type ParseStepsHandlerDeps,
type WorkflowNotifyDispatch,
@@ -172,6 +173,8 @@ export interface WorkflowGraphExecutorDeps {
/** PR-entity nodes (U3): deps for `pr-create`/`pr-respond`/`pr-merge` (injected
* GitHub callbacks + store accessor). Absent → the pr-* kinds fail cleanly. */
prNodes?: PrNodeDeps;
/** Resolves the live intermediate-group exemption for a merge-gate node. */
isLiveSharedBranchMember?: DefaultNodeHandlerDeps["isLiveSharedBranchMember"];
maxRetriesPerNode?: number;
/** Per-branch run-state persistence (U13). Optional — fully in-memory without it. */
branchPersistence?: WorkflowBranchPersistence;
@@ -459,6 +462,7 @@ export class WorkflowGraphExecutor {
runCode: deps.runCode,
notifyDispatch: deps.notifyDispatch,
prNodes: deps.prNodes,
isLiveSharedBranchMember: deps.isLiveSharedBranchMember,
}),
...(deps.runnerRegistry?.toHandlers() ?? {}),
...(deps.handlers ?? {}),

View File

@@ -86,6 +86,8 @@ export interface WorkflowGraphTaskRunnerDeps {
store: WorkflowGraphRunnerStore;
seams: WorkflowLegacySeams;
primitives?: WorkflowRuntimePrimitives;
/** Resolves the live intermediate-group exemption for graph merge gates. */
isLiveSharedBranchMember?: WorkflowGraphExecutorDeps["isLiveSharedBranchMember"];
runCustomNode: WorkflowCustomNodeRunner;
/** Workflow-node prerequisite fulfillment, invoked after graph-level classification. */
prepareNodeExecution?: (
@@ -387,6 +389,10 @@ export class WorkflowGraphTaskRunner {
runCode: this.deps.runCode,
notifyDispatch: this.deps.notifyDispatch,
prNodes: this.deps.prNodes,
// FNXC:SharedBranchMemberHold 2026-08-06-00:24: carry the executor's
// live group resolver into the graph merge gate; omitting it turns
// mission-policy members into manual holds before integration.
isLiveSharedBranchMember: this.deps.isLiveSharedBranchMember,
/*
FNXC:WorkflowResume 2026-06-29-08:49:
Production graph runs must fetch live task steps during foreach replay. The runner is the workflow boundary that has store access, so it supplies the fresh projection seam instead of making executor self-healing guess after a stale step node fails.

View File

@@ -24,6 +24,7 @@ import {
import {
createMergeAttemptHandler,
createMergeGateHandler,
type MergeGateHandlerDeps,
} from "../workflow-node-runners/merge-runner.js";
import { createExitGateHandler } from "../workflow-node-runners/exit-gate-runner.js";
@@ -647,6 +648,8 @@ export interface DefaultNodeHandlerDeps {
notifyDispatch?: WorkflowNotifyDispatch;
/** PR node deps (U3). When absent, the three pr-* kinds fail cleanly. */
prNodes?: PrNodeDeps;
/** Resolves the live shared-member integration exemption at the merge gate. */
isLiveSharedBranchMember?: MergeGateHandlerDeps["isLiveSharedBranchMember"];
}
export function createDefaultNodeHandlers(
@@ -723,7 +726,7 @@ export function createDefaultNodeHandlers(
"parse-steps": parseSteps,
code: createCodeNodeHandler(deps?.runCode),
notify: createNotifyHandler(deps?.notifyDispatch),
"merge-gate": createMergeGateHandler(),
"merge-gate": createMergeGateHandler({ isLiveSharedBranchMember: deps?.isLiveSharedBranchMember }),
"merge-attempt": createMergeAttemptHandler({
primitives: deps?.primitives,
seams,

View File

@@ -250,10 +250,12 @@ export default defineConfig({
"src/__tests__/merger-landed-files-capture.test.ts",
"src/__tests__/branch-attribution.test.ts",
/*
FNXC:EngineTests 2026-07-28-20:10:
Gate admission evidence (U9 safeguard baseline). This one file proves FIVE of the merge lane's safeguards: user pause on merge admission, autoMerge:false, capacity single-flight, the pre-enqueue merge-proof consult, and at-most-once enqueue. A U9 mutation audit found NONE of them defended by blocking CI — and two (user pause, single-flight) had no test at all until this change. Merge is where irreversible work happens and U9 is about to move it behind graph nodes, so these must fail the gate, not a non-blocking run hours after the merge. Deterministic: the store, runtime, merger, and notifier are all mocked; no real git, no network. Measured 5.02s standalone / 103 tests.
FNXC:EngineTests 2026-08-06-00:04:
FN-8811 quarantines project-engine.test.ts after its workspace-busy case
contradicted its 60s cap with a real 120s retry and left a timed-out git
subprocess. The paired ledger entry owns its 14-day deletion ratchet; keep
this gate allow-list free of the file until a root-cause fix rescues it.
*/
"src/__tests__/project-engine.test.ts",
/*
FNXC:EngineTests 2026-07-28-21:05 (#2520 review — greptile P1):
Capacity single-flight IS covered — by this purpose-built file, not by anything in project-engine.test.ts. It was outside blocking CI, which is the real gap. Removing `if (this.mergeRunning) return;` fails "refuses a second concurrent drain while one merge is in flight" here and nowhere else. Deterministic, 3.69s / 3 tests.
@@ -382,6 +384,9 @@ export default defineConfig({
Quarantined on sight per AGENTS.md; mirrored in scripts/lib/test-quarantine.json.
*/
// SQLite-path gate test evicted + quarantined (see engine-core comment + ledger).
// FNXC:EngineTests 2026-08-06-00:04: paired with the ledger's FN-8811
// workspace-busy quarantine; do not appease its timing assertion.
"src/__tests__/project-engine.test.ts",
"node_modules/**",
"dist/**",
// FNXC:PgMigrationQuarantine 2026-07-18-04:30: FN-8270 rescued the final seven VAL-REMOVAL-005 holdouts by awaiting PG audit reads and modeling async collaborators. Their paired ledger entries and excludes were removed only after targeted green runs.

View File

@@ -5,6 +5,11 @@
"file": "packages/dashboard/src/__tests__/register-model-routes-kimi-k3-supplemental.test.ts",
"reason": "Real Pi SDK ModelRuntime catalog construction timed out at the 15s test budget in 2/6 sampled loaded CI runs; failing run https://github.com/Runfusion/Fusion/actions/runs/30664201149 and issue https://github.com/Runfusion/Fusion/issues/3245. FN-8647 landed a reusable shared-construction seam without widening timeouts. FN-8669 must obtain an owner budget decision and re-measure CI before the 14-day deletion deadline; this entry and its paired exclusion must remain through 2026-08-15.",
"quarantinedAt": "2026-08-01"
},
{
"file": "packages/engine/src/__tests__/project-engine.test.ts",
"reason": "FN-8811 observed B4/B5 workspace busy contention asserting a stale 60s cap while runtime schedules 120s, then the subprocess guard timed out on git remote; reproduced in isolation on 2026-08-06. Do not widen timing or weaken the assertion; restore only with a root-cause fix before the deletion deadline.",
"quarantinedAt": "2026-08-06"
}
]
}