FN-5819: allow shared-group members to merge into group branch with auto-merge off
Allow shared branch-group members to continue member→group integration even when project auto-merge is disabled. - add core helper to detect shared branch-group member integrations and export it - update engine in-review enqueue/handoff/startup flows to permit shared-member integration while still honoring global/engine pause gates - keep self-healing from pulling shared-member in-review tasks backward during auto-merge-off maintenance - expand project-engine and reliability-interaction tests for shared-member autoMerge-off behavior and add FN-5819 backstop docs updates Files changed: AGENTS.md | 2 + docs/architecture.md | 3 +- packages/core/src/__tests__/task-merge.test.ts | 37 +++++++++ packages/core/src/index.ts | 1 + packages/core/src/task-merge.ts | 13 +++ packages/engine/src/__tests__/project-engine.test.ts | 64 ++++++++++++++ packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts | 48 +++++++++++ packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts | 97 ++++++++++++++++++++++ packages/engine/src/project-engine.ts | 35 ++++---- packages/engine/src/self-healing.ts | 3 +- 10 files changed, 284 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-5819 Fusion-Task-Lineage: b5eeced7-7c47-4573-9ab4-ca577bec65e1
This commit is contained in:
@@ -146,6 +146,8 @@ Per-task opt-out exists: `task.scopeOverride = true` (log the reason).
|
||||
|
||||
When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a human. Lifecycle-mutating self-healing must not move these tasks backward, pause/fail them, or re-enqueue them for execution.
|
||||
|
||||
Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignmentMode === "shared"`) still run the member→shared-branch local integration step while auto-merge is off. This exception is only for assembling `branch_groups.branchName`; shared-branch → default-branch promotion remains gated by group/global auto-merge.
|
||||
|
||||
### Mock provider (test mode)
|
||||
|
||||
`testMode?: boolean` is now available in both project and global settings. If project `testMode === true` (or the resolved default provider is `"mock"` at any tier), every AI lane is forced to `mock/scripted`, overriding per-task and per-lane model selections. The dashboard exposes this via the Settings Modal "Enable test mode" toggle and a persistent "Test mode — no real AI calls" banner.
|
||||
|
||||
@@ -1737,7 +1737,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
|
||||
- **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers and emits `scheduler:overlap-priority-inversion` once per (candidate, blocker, pass).
|
||||
- **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.
|
||||
- **In-review branch-binding self-heal (FN-5083)**: `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. 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`, `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. 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`, `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. 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; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. 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.
|
||||
@@ -1774,6 +1774,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
||||
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` locks the mission-validation trigger invariant so done mission-linked tasks still start validation when the mission loop was stopped, startup recovery replays done implementing features with unpassed assertions, and recovery remains idempotent for already-passed features.
|
||||
- FN-5782 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts` guards branch-group merge routing so `shared` members land on `branch_groups.branchName`, grouped multi-member merges converge on the same integration branch, ungrouped/`per-task-derived` tasks stay on direct default-branch merge flow, and routed merges emit `merge:branch-group-routed` audit metadata.
|
||||
- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards the promotion eligibility hook/audit seam so member landings emit `merge:branch-group-promotion-gated` with deterministic reason metadata (`eligible`, `group-automerge-disabled`, `settings-automerge-disabled`, `global-pause`, `engine-paused`) while group branches remain open and do not auto-promote to the default branch.
|
||||
- FN-5819 backstop: `packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts` guards the scoped autoMerge-off exception so shared members still integrate their per-task branches into the single group branch, do not land on main, and are not moved backward by self-healing maintenance.
|
||||
- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends coverage so zero-assertion auto-pass deterministically advances `loopState` to `passed`, sets `lastValidatorStatus="passed"`, emits `validation_auto_passed_no_assertions`, and does not re-fire on repeated recovery passes.
|
||||
- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions.
|
||||
- FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe invariants: legacy dependency satisfaction remains authoritative while parity diffs emit, and shadow dequeue selection never advances `manual-required` rows.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getTaskHardMergeBlocker,
|
||||
getTaskMergeBlocker,
|
||||
isTaskReadyForMerge,
|
||||
isSharedBranchGroupMemberIntegration,
|
||||
resolveEffectiveAutoMerge,
|
||||
resolveEffectiveGroupAutoMerge,
|
||||
resolveTaskMergeTarget,
|
||||
@@ -62,6 +63,42 @@ describe("resolveEffectiveGroupAutoMerge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSharedBranchGroupMemberIntegration", () => {
|
||||
it("returns true for shared members with a resolvable group id", () => {
|
||||
expect(isSharedBranchGroupMemberIntegration({
|
||||
branchContext: {
|
||||
assignmentMode: "shared",
|
||||
groupId: "BG-1",
|
||||
source: "planning",
|
||||
},
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for per-task-derived grouped members", () => {
|
||||
expect(isSharedBranchGroupMemberIntegration({
|
||||
branchContext: {
|
||||
assignmentMode: "per-task-derived",
|
||||
groupId: "BG-1",
|
||||
source: "planning",
|
||||
},
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no group id is present", () => {
|
||||
expect(isSharedBranchGroupMemberIntegration({
|
||||
branchContext: {
|
||||
assignmentMode: "shared",
|
||||
groupId: " ",
|
||||
source: "planning",
|
||||
},
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when branch context is absent", () => {
|
||||
expect(isSharedBranchGroupMemberIntegration({ branchContext: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTaskMergeTarget", () => {
|
||||
it("prefers task baseBranch when present", () => {
|
||||
expect(resolveTaskMergeTarget({ baseBranch: "release/1.2", branchContext: undefined })).toEqual({
|
||||
|
||||
@@ -318,6 +318,7 @@ export {
|
||||
getTaskHardMergeBlocker,
|
||||
getTaskCompletionBlocker,
|
||||
isTaskReadyForMerge,
|
||||
isSharedBranchGroupMemberIntegration,
|
||||
resolveEffectiveAutoMerge,
|
||||
resolveEffectiveGroupAutoMerge,
|
||||
resolveTaskMergeTarget,
|
||||
|
||||
@@ -55,6 +55,19 @@ export function resolveEffectiveGroupAutoMerge(
|
||||
return group.autoMerge ?? settings.autoMerge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function isSharedBranchGroupMemberIntegration(
|
||||
task: Pick<Task, "branchContext">,
|
||||
): boolean {
|
||||
return task.branchContext?.assignmentMode === "shared"
|
||||
&& Boolean(task.branchContext.groupId?.trim());
|
||||
}
|
||||
|
||||
export function resolveTaskMergeTarget(
|
||||
task: Pick<Task, "baseBranch" | "branchContext">,
|
||||
options: MergeTargetResolverOptions = {},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { ProjectEngine } from "../project-engine.js";
|
||||
import { runtimeLog } from "../logger.js";
|
||||
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
|
||||
@@ -1879,6 +1880,69 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it("startup merge sweep enqueues shared-group members when autoMerge is false", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: false });
|
||||
mockStore.store.listTasks.mockResolvedValueOnce([
|
||||
{
|
||||
id: "FN-shared",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
status: null,
|
||||
branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" },
|
||||
},
|
||||
{ id: "FN-plain", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
||||
]);
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
|
||||
|
||||
await engine.start();
|
||||
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-shared");
|
||||
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain");
|
||||
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it("task:moved handoff keeps shared members blocked by global or engine pause", async () => {
|
||||
vi.useFakeTimers();
|
||||
for (const settings of [
|
||||
{ ...baseSettings, autoMerge: false, globalPause: true, enginePaused: false },
|
||||
{ ...baseSettings, autoMerge: false, globalPause: false, enginePaused: true },
|
||||
]) {
|
||||
const mockStore = createMockStore(settings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
|
||||
|
||||
await engine.start();
|
||||
enqueueSpy.mockClear();
|
||||
const movedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as
|
||||
| ((event: { task: Task; to: string }) => void)
|
||||
| undefined;
|
||||
if (!movedHandler) throw new Error("task:moved handler was not registered");
|
||||
|
||||
movedHandler({
|
||||
task: {
|
||||
id: "FN-shared",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
steps: [],
|
||||
branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" },
|
||||
} as unknown as Task,
|
||||
to: "in-review",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-shared");
|
||||
await engine.stop();
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("global unpause sweep does not enqueue paused in-review tasks", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
@@ -74,6 +74,54 @@ describe("FN-5782 reliability interactions: branch group merge routing", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("records shared-member landing even when autoMerge is false", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5819-RI-AUTO-OFF",
|
||||
settings: { testMode: true, autoMerge: false } as any,
|
||||
});
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
await stageMergeBranch(store, rootDir, task.id, "fn5819AutoOff");
|
||||
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "planning",
|
||||
sourceId: "PS-FN5819",
|
||||
branchName: "fusion/groups/fn-5819-auto-off",
|
||||
});
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5819AutoOff.ts`)).toContain("fn5819AutoOff");
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5819AutoOff.ts")).toThrow();
|
||||
|
||||
const updatedGroup = store.getBranchGroup(group.id)!;
|
||||
expect(updatedGroup.status).toBe("open");
|
||||
expect(updatedGroup.worktreePath).toBe(join(`${rootDir}-worktrees`, task.id.toLowerCase()));
|
||||
|
||||
const events = store.getRunAuditEvents().filter((event) => event.target === task.id);
|
||||
expect(events).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
mutationType: "merge:branch-group-routed",
|
||||
metadata: expect.objectContaining({
|
||||
mergeTargetBranch: group.branchName,
|
||||
mergeTargetSource: "branch-group-integration",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mutationType: "merge:branch-group-promotion-gated",
|
||||
metadata: expect.objectContaining({
|
||||
groupId: group.id,
|
||||
effectiveEligible: false,
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it.skipIf(!hasGit)("lands two shared members of same group onto one integration branch", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-A", settings: { testMode: true } as any });
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type TaskStore } from "@fusion/core";
|
||||
import { aiMergeTask } from "../../merger.js";
|
||||
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
async function stageMergeBranch(store: TaskStore, rootDir: string, taskId: string, fileName: string): Promise<void> {
|
||||
const task = await store.getTask(taskId);
|
||||
const branch = `fusion/${taskId.toLowerCase()}`;
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, taskId.toLowerCase());
|
||||
|
||||
await store.updateTask(taskId, {
|
||||
baseBranch: "",
|
||||
branch,
|
||||
column: "in-review",
|
||||
worktree: worktreePath,
|
||||
steps: (task?.steps ?? []).map((step) => ({ ...step, status: "done" as const })),
|
||||
currentStep: (task?.steps ?? []).length ?? 0,
|
||||
} as any);
|
||||
|
||||
git(rootDir, `git checkout -b ${branch}`);
|
||||
await mkdir(join(rootDir, "packages/engine/src"), { recursive: true });
|
||||
git(rootDir, `sh -c 'printf ${JSON.stringify(`export const ${fileName} = true;\n`)} > ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}'`);
|
||||
git(rootDir, `git add ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}`);
|
||||
git(rootDir, `git commit -m ${JSON.stringify(`feat: add ${fileName}`)}`);
|
||||
git(rootDir, "git checkout main");
|
||||
}
|
||||
|
||||
describe("FN-5819 reliability interactions: shared group member integration", () => {
|
||||
it.skipIf(!hasGit)("keeps shared-member integration forward under autoMerge false", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5819-RI-A",
|
||||
settings: { testMode: true, autoMerge: false } as any,
|
||||
});
|
||||
|
||||
try {
|
||||
const { rootDir, store, task, manager } = fixture;
|
||||
const second = await store.createTask({
|
||||
id: "FN-5819-RI-B",
|
||||
title: "FN-5819-RI-B",
|
||||
description: "second member",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/fn-5819-ri-b",
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
const nongroup = await store.createTask({
|
||||
id: "FN-5819-RI-NONGROUP",
|
||||
title: "FN-5819-RI-NONGROUP",
|
||||
description: "non-group in-review",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/fn-5819-ri-nongroup",
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "planning",
|
||||
sourceId: "PS-FN5819",
|
||||
branchName: "fusion/groups/fn-5819-shared",
|
||||
});
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
await store.setTaskBranchGroup(second.id, group.id);
|
||||
|
||||
await stageMergeBranch(store, rootDir, task.id, "fn5819MemberA");
|
||||
await stageMergeBranch(store, rootDir, second.id, "fn5819MemberB");
|
||||
|
||||
const first = await aiMergeTask(store, rootDir, task.id);
|
||||
const secondResult = await aiMergeTask(store, rootDir, second.id);
|
||||
expect(first.merged).toBe(true);
|
||||
expect(secondResult.merged).toBe(true);
|
||||
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5819MemberA.ts`)).toContain("fn5819MemberA");
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5819MemberB.ts`)).toContain("fn5819MemberB");
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5819MemberA.ts")).toThrow();
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5819MemberB.ts")).toThrow();
|
||||
|
||||
const moveSpy = vi.spyOn(store, "moveTask");
|
||||
await (manager as any).runMaintenance();
|
||||
|
||||
expect(moveSpy.mock.calls.some(([id, column]) => id === task.id && column === "todo")).toBe(false);
|
||||
expect(moveSpy.mock.calls.some(([id, column]) => id === second.id && column === "todo")).toBe(false);
|
||||
expect(moveSpy.mock.calls.some(([id, column]) => id === task.id && column === "in-progress")).toBe(false);
|
||||
expect(moveSpy.mock.calls.some(([id, column]) => id === second.id && column === "in-progress")).toBe(false);
|
||||
|
||||
const refreshedNonGroup = await store.getTask(nongroup.id);
|
||||
expect(refreshedNonGroup.column).toBe("in-review");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ScheduledTask,
|
||||
AutomationRunResult,
|
||||
} from "@fusion/core";
|
||||
import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
|
||||
import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
|
||||
@@ -1375,9 +1375,13 @@ 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 enqueueEligibleInReviewTasks(tasks: readonly Task[]): number {
|
||||
private allowInReviewMergeProcessing(task: Pick<Task, "branchContext">, settings: Pick<Settings, "autoMerge">): boolean {
|
||||
return settings.autoMerge || isSharedBranchGroupMemberIntegration(task);
|
||||
}
|
||||
|
||||
private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge">): number {
|
||||
const eligible = sortTasksByPriorityThenAgeAndId(
|
||||
tasks.filter((t) => !t.paused && this.canMergeTask(t as any)) as Task[],
|
||||
tasks.filter((t) => !t.paused && this.canMergeTask(t as any) && this.allowInReviewMergeProcessing(t, settings)) as Task[],
|
||||
);
|
||||
for (const t of eligible) {
|
||||
this.internalEnqueueMerge(t.id);
|
||||
@@ -1487,15 +1491,14 @@ export class ProjectEngine {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!settings.autoMerge) {
|
||||
runtimeLog.log(`Auto-merge skipping ${taskId} — autoMerge disabled`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const task = await store.getTask(taskId);
|
||||
if (!task || task.column !== "in-review") {
|
||||
continue;
|
||||
}
|
||||
if (!this.allowInReviewMergeProcessing(task, settings)) {
|
||||
runtimeLog.log(`Auto-merge skipping ${taskId} — autoMerge disabled`);
|
||||
continue;
|
||||
}
|
||||
if (task.paused && !task.mergeDetails?.mergeConfirmed) {
|
||||
runtimeLog.log(`Auto-merge skipping ${taskId} — task is paused`);
|
||||
continue;
|
||||
@@ -2570,7 +2573,7 @@ export class ProjectEngine {
|
||||
runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: ${settings.globalPause ? "globalPause" : "enginePaused"} active`);
|
||||
return;
|
||||
}
|
||||
if (!settings.autoMerge) {
|
||||
if (!this.allowInReviewMergeProcessing(latestTask, settings)) {
|
||||
runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: autoMerge disabled`);
|
||||
return;
|
||||
}
|
||||
@@ -2687,7 +2690,7 @@ export class ProjectEngine {
|
||||
|
||||
try {
|
||||
const settings = await store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused || !settings.autoMerge) {
|
||||
if (settings.globalPause || settings.enginePaused || !this.allowInReviewMergeProcessing(task, settings)) {
|
||||
return;
|
||||
}
|
||||
if (this.options.getTaskMergeBlocker?.(task)) {
|
||||
@@ -2759,10 +2762,8 @@ export class ProjectEngine {
|
||||
}
|
||||
|
||||
const settings = await store.getSettings();
|
||||
if (!settings.autoMerge) return;
|
||||
|
||||
|
||||
const enqueued = this.enqueueEligibleInReviewTasks(tasks as Task[]);
|
||||
const enqueued = this.enqueueEligibleInReviewTasks(tasks as Task[], settings);
|
||||
if (enqueued > 0) {
|
||||
runtimeLog.log(`Auto-merge startup sweep: enqueueing ${enqueued} task(s)`);
|
||||
}
|
||||
@@ -2819,9 +2820,9 @@ export class ProjectEngine {
|
||||
|
||||
try {
|
||||
const settings = await store.getSettings();
|
||||
if (!settings.globalPause && !settings.enginePaused && settings.autoMerge) {
|
||||
if (!settings.globalPause && !settings.enginePaused) {
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
this.enqueueEligibleInReviewTasks(tasks as Task[]);
|
||||
this.enqueueEligibleInReviewTasks(tasks as Task[], settings);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
runtimeLog.warn(
|
||||
@@ -2876,13 +2877,13 @@ export class ProjectEngine {
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.globalPause || settings.enginePaused || !settings.autoMerge) {
|
||||
if (settings.globalPause || settings.enginePaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
this.enqueueEligibleInReviewTasks(tasks as Task[]);
|
||||
this.enqueueEligibleInReviewTasks(tasks as Task[], settings);
|
||||
} catch (err: unknown) {
|
||||
runtimeLog.warn(
|
||||
`${source}: failed to scan in-review tasks for auto-merge: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { promisify } from "node:util";
|
||||
import { setImmediate as setImmediateCb } from "node:timers";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger, schedulerLog } from "./logger.js";
|
||||
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
@@ -4314,6 +4314,7 @@ export class SelfHealingManager {
|
||||
const candidates = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
!t.paused &&
|
||||
!isSharedBranchGroupMemberIntegration(t) &&
|
||||
Boolean(t.worktree) &&
|
||||
t.mergeDetails?.mergeConfirmed !== true &&
|
||||
t.status !== "merging" &&
|
||||
|
||||
Reference in New Issue
Block a user