FN-5846: route shared-group member finalization to shared branch

Ensure shared branch-group members keep their routed shared-branch target through merge finalization and recovery.

- ignore a shared member's default-base branch when resolving branch-group merge routing
- reroute merge-confirmed fast paths and self-healing recovery flows to the branch-group integration branch
- stamp merge target metadata, record member landings, and add regression coverage plus architecture/changeset updates

Files changed:
 .changeset/fn-5846-shared-group-merge-routing.md   |   5 +
 docs/architecture.md                               |   4 +-
 packages/engine/src/__tests__/group-merge-coordinator.test.ts  |  26 +++
 packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts  |  73 +++++--
 packages/engine/src/group-merge-coordinator.ts     |  11 +-
 packages/engine/src/project-engine.ts              |  49 ++++-
 packages/engine/src/self-healing.ts                | 212 ++++++++++++++++++++-
 7 files changed, 357 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-5846

Fusion-Task-Lineage: c8fbf8b8-3cbe-4ee5-96d9-9040bd4f0df5
This commit is contained in:
gsxdsm
2026-06-01 20:19:37 -07:00
parent 0a418e6875
commit e561290174
7 changed files with 357 additions and 23 deletions

View File

@@ -295,6 +295,32 @@ describe("resolveBranchGroupMergeRouting", () => {
expect(routing).toBeNull();
});
it("routes shared members to the group branch even when task baseBranch points at default", async () => {
const branchGroup = {
id: "BG-1",
sourceType: "planning",
sourceId: "planning:x",
branchName: "fusion/groups/planning-x",
autoMerge: false,
prState: "none",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
};
const routing = await resolveBranchGroupMergeRouting({
task: {
baseBranch: "main",
branchContext: { groupId: "BG-1", source: "planning", assignmentMode: "shared" },
},
store: { getBranchGroup: () => branchGroup } as any,
projectDefaultBranch: "main",
});
expect(routing?.mergeTarget.branch).toBe(branchGroup.branchName);
expect(routing?.mergeTarget.source).toBe("branch-group-integration");
});
it("creates the group branch when missing", async () => {
const rootDir = makeRepo();
const branchGroup = {

View File

@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { type TaskStore } from "@fusion/core";
import { evaluateBranchGroupCompletion, promoteBranchGroup } from "../../group-merge-coordinator.js";
import { aiMergeTask } from "../../merger.js";
import { SelfHealingManager } from "../../self-healing.js";
import { acquireTaskWorktree } from "../../worktree-acquisition.js";
import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "../../worktree-names.js";
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
@@ -122,6 +123,12 @@ describe("FN-5820 reliability interactions: shared branch group lifecycle", () =
const secondResult = await aiMergeTask(store, rootDir, second.id);
expect(firstResult.merged).toBe(true);
expect(secondResult.merged).toBe(true);
const firstAfterMerge = await store.getTask(task.id);
const secondAfterMerge = await store.getTask(second.id);
expect(firstAfterMerge?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration");
expect(firstAfterMerge?.mergeDetails?.mergeTargetBranch).toBe(group.branchName);
expect(secondAfterMerge?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration");
expect(secondAfterMerge?.mergeDetails?.mergeTargetBranch).toBe(group.branchName);
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case2A.ts`)).toContain("fn5820Case2A");
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case2B.ts`)).toContain("fn5820Case2B");
@@ -181,10 +188,10 @@ describe("FN-5820 reliability interactions: shared branch group lifecycle", () =
const firstMerge = await aiMergeTask(store, rootDir, task.id);
expect(firstMerge.merged).toBe(true);
await store.updateTask(task.id, {
column: "done",
mergeDetails: { ...(await store.getTask(task.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
} as any);
const firstMergedTask = await store.getTask(task.id);
expect(firstMergedTask?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration");
expect(firstMergedTask?.mergeDetails?.mergeTargetBranch).toBe(group.branchName);
await store.updateTask(task.id, { column: "done" } as any);
const incomplete = await promoteBranchGroup({
store,
@@ -197,10 +204,10 @@ describe("FN-5820 reliability interactions: shared branch group lifecycle", () =
const secondMerge = await aiMergeTask(store, rootDir, second.id);
expect(secondMerge.merged).toBe(true);
await store.updateTask(second.id, {
column: "done",
mergeDetails: { ...(await store.getTask(second.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
} as any);
const secondMergedTask = await store.getTask(second.id);
expect(secondMergedTask?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration");
expect(secondMergedTask?.mergeDetails?.mergeTargetBranch).toBe(group.branchName);
await store.updateTask(second.id, { column: "done" } as any);
const promoteWithMembers = async (recordAudit?: (event: { mutationType: string; metadata?: Record<string, unknown> }) => void) => promoteBranchGroup({
store: {
@@ -269,14 +276,20 @@ describe("FN-5820 reliability interactions: shared branch group lifecycle", () =
expect((await aiMergeTask(store, rootDir, task.id)).merged).toBe(true);
expect((await aiMergeTask(store, rootDir, second.id)).merged).toBe(true);
const firstMergedTask = await store.getTask(task.id);
const secondMergedTask = await store.getTask(second.id);
expect(firstMergedTask?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration");
expect(firstMergedTask?.mergeDetails?.mergeTargetBranch).toBe(group.branchName);
expect(secondMergedTask?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration");
expect(secondMergedTask?.mergeDetails?.mergeTargetBranch).toBe(group.branchName);
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case4A.ts`)).toContain("fn5820Case4A");
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case4B.ts`)).toContain("fn5820Case4B");
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case4A.ts")).toThrow();
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case4B.ts")).toThrow();
await store.updateTask(task.id, { column: "done", mergeDetails: { ...(await store.getTask(task.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" } } as any);
await store.updateTask(second.id, { column: "done", mergeDetails: { ...(await store.getTask(second.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" } } as any);
await store.updateTask(task.id, { column: "done" } as any);
await store.updateTask(second.id, { column: "done" } as any);
const audits: Array<{ mutationType: string; metadata?: Record<string, unknown> }> = [];
const gated = await promoteBranchGroup({
@@ -311,7 +324,45 @@ describe("FN-5820 reliability interactions: shared branch group lifecycle", () =
}
}, 45_000);
it.skipIf(!hasGit)("CASE 5: per-task-derived and ungrouped tasks remain default-branch routed", async () => {
it.skipIf(!hasGit)("CASE 5: self-healing already-merged recovery stamps shared-branch routing metadata", async () => {
const fixture = await makeReliabilityFixture({ taskId: "FN-5820-RI-K", settings: { testMode: true, autoMerge: true } as any });
try {
const { rootDir, store, task } = fixture;
const group = store.createBranchGroup({
sourceType: "planning",
sourceId: "PS-FN5820-CASE5",
branchName: "fusion/groups/fn-5820-self-heal",
});
await stageSharedMember(store, rootDir, { taskId: task.id, groupId: group.id, source: "planning", fileName: "fn5820Case5SelfHeal" });
await store.setTaskBranchGroup(task.id, group.id);
expect((await aiMergeTask(store, rootDir, task.id)).merged).toBe(true);
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case5SelfHeal.ts`)).toContain("fn5820Case5SelfHeal");
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case5SelfHeal.ts")).toThrow();
await store.updateTask(task.id, {
column: "in-review",
status: "failed",
error: "retry exhausted",
mergeRetries: 999,
mergeDetails: undefined,
} as any);
const manager = new SelfHealingManager(store, { rootDir, getExecutingTaskIds: () => new Set<string>() });
await manager.recoverAlreadyMergedReviewTasks();
const recovered = await store.getTask(task.id);
expect(recovered?.column).toBe("done");
expect(recovered?.mergeDetails?.mergeConfirmed).toBe(true);
expect(recovered?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration");
expect(recovered?.mergeDetails?.mergeTargetBranch).toBe(group.branchName);
expect(Number(git(rootDir, `git rev-list --count main..${group.branchName}`).trim())).toBeGreaterThan(0);
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case5SelfHeal.ts")).toThrow();
} finally {
await fixture.cleanup();
}
}, 45_000);
it.skipIf(!hasGit)("CASE 6: per-task-derived and ungrouped tasks remain default-branch routed", async () => {
const fixture = await makeReliabilityFixture({ taskId: "FN-5820-RI-I", settings: { testMode: true } as any });
try {
const { rootDir, store, task } = fixture;

View File

@@ -263,9 +263,12 @@ export async function resolveBranchGroupMergeRouting(input: {
return {
branchGroup,
mergeTarget: resolveTaskMergeTarget(input.task, {
projectDefaultBranch: input.projectDefaultBranch,
branchGroup,
}),
mergeTarget: resolveTaskMergeTarget(
{ ...input.task, baseBranch: undefined },
{
projectDefaultBranch: input.projectDefaultBranch,
branchGroup,
},
),
};
}

View File

@@ -1525,8 +1525,20 @@ export class ProjectEngine {
// silently promote the poisoned row to `done` — exactly the
// false-positive completion class that lost FN-5612/5613/5614/5616/
// 5623/5625 work on 2026-05-27/28.
const branchGroupForFastPath = isSharedBranchGroupMemberIntegration(task)
? (store as any).getBranchGroup?.(task.branchContext?.groupId)
: null;
const routedFastPathTarget = branchGroupForFastPath?.branchName?.trim();
const integrationBranchForGate =
task.mergeDetails.mergeTargetBranch || task.baseBranch || "main";
routedFastPathTarget || task.mergeDetails.mergeTargetBranch || task.baseBranch || "main";
const expectedFastPathTargetSource = routedFastPathTarget
? "branch-group-integration"
: task.mergeDetails.mergeTargetSource;
if (routedFastPathTarget && task.mergeDetails.mergeTargetBranch && task.mergeDetails.mergeTargetBranch !== routedFastPathTarget) {
runtimeLog.warn(
`Auto-merge: ${taskId} merge-confirmed fast-path rerouting shared-group member from ${task.mergeDetails.mergeTargetBranch} to ${routedFastPathTarget}`,
);
}
const reachability = await verifyMergeConfirmedReachability({
commitSha: task.mergeDetails.commitSha,
integrationBranch: integrationBranchForGate,
@@ -1690,6 +1702,40 @@ export class ProjectEngine {
continue;
}
if (routedFastPathTarget && (
task.mergeDetails.mergeTargetBranch !== routedFastPathTarget ||
task.mergeDetails.mergeTargetSource !== "branch-group-integration"
)) {
await store.updateTask(taskId, {
mergeDetails: {
...task.mergeDetails,
mergeTargetBranch: routedFastPathTarget,
mergeTargetSource: expectedFastPathTargetSource,
},
});
task.mergeDetails = {
...task.mergeDetails,
mergeTargetBranch: routedFastPathTarget,
mergeTargetSource: expectedFastPathTargetSource,
} as typeof task.mergeDetails;
}
if (routedFastPathTarget && branchGroupForFastPath?.id) {
try {
await Promise.resolve((store as any).recordBranchGroupMemberLanded?.(branchGroupForFastPath.id, {
taskId,
branchName: routedFastPathTarget,
worktreePath: task.worktree ?? null,
status: "open",
}));
} catch (landingErr) {
runtimeLog.warn(
`Auto-merge: ${taskId} failed to record shared-group member landing: ${
landingErr instanceof Error ? landingErr.message : String(landingErr)
}`,
);
}
}
runtimeLog.log(
`Auto-merge: ${taskId} already has mergeConfirmed — unpausing and moving to done`,
);
@@ -1710,6 +1756,7 @@ export class ProjectEngine {
mergeConfirmed: true,
mergedAt: mergedTask.mergeDetails?.mergedAt,
mergeTargetBranch: mergedTask.mergeDetails?.mergeTargetBranch,
mergeTargetSource: mergedTask.mergeDetails?.mergeTargetSource,
} as MergeResult);
} catch (error) {
if (isInvalidDoneTransitionError(error)) {

View File

@@ -48,6 +48,7 @@ import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
import { resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { resolveBranchGroupMergeRouting } from "./group-merge-coordinator.js";
import type { OwnedLandedClassification } from "./merger.js";
import { regenerateBareMergeSubject } from "./merger-bare-subject.js";
import { recoverForeignOnlyContamination } from "./recovery/foreign-only-contamination.js";
@@ -74,6 +75,15 @@ export const COMPLETION_HANDOFF_LIMBO_GRACE_MS = 5 * 60_000;
export const MAX_COMPLETION_HANDOFF_LIMBO_RECOVERIES = 3;
const MAX_NO_PROGRESS_RESUME_ATTEMPTS = 2;
type BranchGroupLandingRecorder = {
recordBranchGroupMemberLanded?: (groupId: string, payload: {
taskId: string;
branchName: string;
worktreePath: string | null;
status: "open";
}) => unknown;
};
// listTasks already enforces ACTIVE_TASKS_WHERE (`"deletedAt" IS NULL`), but
// deadlock/stall sweeps still defensively skip soft-deleted rows in case a
// future caller bypasses that contract (includeDeleted, fixtures, ad-hoc SQL).
@@ -1392,6 +1402,125 @@ export class SelfHealingManager {
return findAlreadyMergedTaskCommit(input);
}
private async resolveSelfHealingMergeTarget(
task: Task,
settings: Settings | undefined,
phase: string,
): Promise<{ branch: string; source?: MergeDetails["mergeTargetSource"]; groupId?: string; groupBranchName?: string }> {
const projectDefaultBranch = await resolveIntegrationBranch(this.options.rootDir, settings);
const routing = await resolveBranchGroupMergeRouting({
task,
store: this.store,
projectDefaultBranch,
rootDir: this.options.rootDir,
});
if (!routing) {
return { branch: task.baseBranch || task.executionStartBranch || projectDefaultBranch };
}
const targetBranch = routing.branchGroup.branchName;
if (targetBranch === projectDefaultBranch || routing.mergeTarget.source !== "branch-group-integration") {
await this.recordSharedGroupDefaultTargetGuard(task, phase, {
projectDefaultBranch,
resolvedBranch: routing.mergeTarget.branch,
resolvedSource: routing.mergeTarget.source,
groupBranchName: routing.branchGroup.branchName,
});
}
return {
branch: targetBranch,
source: "branch-group-integration",
groupId: routing.branchGroup.id,
groupBranchName: routing.branchGroup.branchName,
};
}
private async recordSharedGroupDefaultTargetGuard(
task: Task,
phase: string,
metadata: Record<string, unknown>,
): Promise<void> {
try {
await createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal-shared-group-routing", task.id),
agentId: "self-healing",
taskId: task.id,
taskLineageId: task.lineageId,
phase,
}).database({
type: "task:shared-group-member-default-target-rerouted" as DatabaseMutationType,
target: task.id,
metadata: {
taskId: task.id,
groupId: task.branchContext?.groupId ?? null,
...metadata,
},
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`Failed to record shared-group routing guard for ${task.id}: ${errorMessage}`);
}
}
private async isCommitReachableFromBranch(commitSha: string | undefined, branch: string): Promise<boolean> {
if (!commitSha) return true;
try {
await execAsync(`git merge-base --is-ancestor ${shellQuote(commitSha)} ${shellQuote(branch)}`, {
cwd: this.options.rootDir,
});
return true;
} catch {
return false;
}
}
private async recordSelfHealingBranchGroupMemberLanding(
task: Task,
target: { groupId?: string; branch: string; source?: MergeDetails["mergeTargetSource"] },
phase: string,
): Promise<void> {
if (!target.groupId || target.source !== "branch-group-integration") {
return;
}
try {
const landingRecorder = this.store as TaskStore & BranchGroupLandingRecorder;
await Promise.resolve(landingRecorder.recordBranchGroupMemberLanded?.(target.groupId, {
taskId: task.id,
branchName: target.branch,
worktreePath: task.worktree ?? null,
status: "open",
}));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`Failed to record branch-group member landing for ${task.id}: ${errorMessage}`);
}
try {
await createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal-shared-group-landed", task.id),
agentId: "self-healing",
taskId: task.id,
taskLineageId: task.lineageId,
phase,
}).database({
type: "task:shared-group-member-self-heal-landed" as DatabaseMutationType,
target: task.id,
metadata: {
taskId: task.id,
groupId: target.groupId,
mergeTargetBranch: target.branch,
mergeTargetSource: target.source,
},
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`Failed to record shared-group self-heal landing audit for ${task.id}: ${errorMessage}`);
}
}
private async cleanupWorktreeOnly(task: Task): Promise<void> {
if (task.worktree && existsSync(task.worktree)) {
try {
@@ -5479,8 +5608,24 @@ export class SelfHealingManager {
let recovered = 0;
for (const task of candidates) {
try {
const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-interrupted-merging");
const landedCommit = await this.findLandedTaskCommit(task);
if (landedCommit && !(await this.isCommitReachableFromBranch(landedCommit.sha, mergeTarget.branch))) {
await this.recordSharedGroupDefaultTargetGuard(task, "recover-interrupted-merging", {
reason: "landed-commit-not-reachable-from-routed-target",
commitSha: landedCommit.sha,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source ?? null,
});
await this.store.updateTask(task.id, { status: null, error: null });
try {
this.options.enqueueMerge?.(task.id);
} catch { /* rely on polling sweep */ }
recovered++;
continue;
}
if (landedCommit) {
const mergeCommitMessage = await regenerateBareMergeSubject({
subject: landedCommit.subject,
@@ -5500,6 +5645,8 @@ export class SelfHealingManager {
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: getPrimaryPrInfo(task)?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
};
await this.store.updateTask(task.id, {
@@ -5508,6 +5655,7 @@ export class SelfHealingManager {
mergeRetries: 0,
mergeDetails,
});
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-interrupted-merging");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.cleanupInterruptedMergeArtifacts(task);
@@ -5795,6 +5943,17 @@ export class SelfHealingManager {
continue;
}
const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-merged-review");
if (!(await this.isCommitReachableFromBranch(task.mergeDetails?.commitSha, mergeTarget.branch))) {
await this.recordSharedGroupDefaultTargetGuard(task, "recover-merged-review", {
reason: "confirmed-commit-not-reachable-from-routed-target",
commitSha: task.mergeDetails?.commitSha ?? null,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source ?? null,
});
continue;
}
const clearedFlags = {
paused: Boolean(task.paused),
status: Boolean(task.status),
@@ -5805,7 +5964,15 @@ export class SelfHealingManager {
status: null,
error: null,
mergeRetries: 0,
...(mergeTarget.source ? {
mergeDetails: {
...(task.mergeDetails || {}),
mergeTargetBranch: task.mergeDetails?.mergeTargetBranch ?? mergeTarget.branch,
mergeTargetSource: task.mergeDetails?.mergeTargetSource ?? mergeTarget.source,
},
} : {}),
});
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-merged-review");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.store.logEntry(
@@ -5825,7 +5992,9 @@ export class SelfHealingManager {
target: task.id,
metadata: {
mergeSha: task.mergeDetails?.commitSha ?? null,
baseBranch: task.baseBranch || task.executionStartBranch || await resolveIntegrationBranch(this.options.rootDir, undefined),
baseBranch: mergeTarget.branch,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source ?? null,
clearedFlags,
},
});
@@ -5904,8 +6073,20 @@ export class SelfHealingManager {
const blockedDependents = dependentsByBlocker.get(task.id) ?? [];
const blockedTaskIds = blockedDependents.map((dep) => dep.id);
try {
const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-stuck-merge-deadlocks");
const landedCommit = await this.findLandedTaskCommit(task);
if (landedCommit) {
const landedOnTarget = landedCommit
? await this.isCommitReachableFromBranch(landedCommit.sha, mergeTarget.branch)
: false;
if (landedCommit && !landedOnTarget) {
await this.recordSharedGroupDefaultTargetGuard(task, "recover-stuck-merge-deadlocks", {
reason: "landed-commit-not-reachable-from-routed-target",
commitSha: landedCommit.sha,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source ?? null,
});
}
if (landedCommit && landedOnTarget) {
const mergeCommitMessage = await regenerateBareMergeSubject({
subject: landedCommit.subject,
commitSha: landedCommit.sha,
@@ -5924,6 +6105,8 @@ export class SelfHealingManager {
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: getPrimaryPrInfo(task)?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
};
await this.store.updateTask(task.id, {
@@ -5934,6 +6117,7 @@ export class SelfHealingManager {
branch: null,
mergeDetails,
});
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-stuck-merge-deadlocks");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.cleanupInterruptedMergeArtifacts(task);
@@ -6078,7 +6262,8 @@ export class SelfHealingManager {
const hasDeclaredOverlap = orphanFiles.some((file) => matchesScope(file, declaredScope));
if (hasDeclaredOverlap) continue;
const baseBranch = task.baseBranch || task.executionStartBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-orphan-only-scope-violations");
const baseBranch = mergeTarget.branch;
const landed = await this.findAlreadyMergedTaskCommit({
taskId: task.id,
lineageId: task.lineageId,
@@ -6094,6 +6279,8 @@ export class SelfHealingManager {
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
resolutionStrategy: "orphan-discard-no-op",
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
};
const hardBlocker = getTaskHardMergeBlocker({
@@ -6126,6 +6313,7 @@ export class SelfHealingManager {
mergeRetries: 0,
mergeDetails,
});
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-orphan-only-scope-violations");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.store.logEntry(
@@ -6148,6 +6336,8 @@ export class SelfHealingManager {
mergeSha: landed.sha,
baseBranch,
mergeStrategy: landed.strategy,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source ?? null,
clearedFlags,
},
});
@@ -6216,7 +6406,8 @@ export class SelfHealingManager {
let recovered = 0;
for (const task of candidates) {
try {
const baseBranch = task.baseBranch || task.executionStartBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-already-merged-review");
const baseBranch = mergeTarget.branch;
if (!baseBranch) continue;
const landed = await this.findAlreadyMergedTaskCommit({
@@ -6234,6 +6425,8 @@ export class SelfHealingManager {
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: getPrimaryPrInfo(task)?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
};
const hardBlocker = getTaskHardMergeBlocker({
@@ -6267,6 +6460,7 @@ export class SelfHealingManager {
mergeDetails,
});
const worktreeHint = task.worktree;
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-already-merged-review");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.store.logEntry(
@@ -6289,6 +6483,8 @@ export class SelfHealingManager {
mergeSha: landed.sha,
mergeStrategy: landed.strategy,
baseBranch,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source ?? null,
mergeRetries: task.mergeRetries ?? 0,
clearedFlags,
},
@@ -6445,7 +6641,8 @@ export class SelfHealingManager {
try {
const branch = task.branch;
if (!branch) continue;
const baseBranch = task.baseBranch || task.executionStartBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-branch-misbound-in-review");
const baseBranch = mergeTarget.branch;
const check = await this.isBranchTipMisboundToTask({
branch,
taskId: task.id,
@@ -6459,6 +6656,8 @@ export class SelfHealingManager {
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: getPrimaryPrInfo(task)?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
};
await this.store.updateTask(task.id, {
@@ -6487,6 +6686,7 @@ export class SelfHealingManager {
// entire merger queue until engine restart. Mirrors the pattern in
// merger.ts completeTask() and project-engine.ts auto-merge already-confirmed path.
await this.store.updateTask(task.id, { status: null, error: null, paused: false });
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-branch-misbound-in-review");
const movedTask = await this.store.moveTask(task.id, "done");
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
await this.store.logEntry(
@@ -6513,6 +6713,8 @@ export class SelfHealingManager {
mergeStrategy: check.landed.strategy,
lineageId: task.lineageId,
baseBranch,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source ?? null,
},
});
} catch (err: unknown) {