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:
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user