feat(FN-4523): complete Step 6 — docs, audit type, and verification fixes
Fusion-Task-Id: FN-4523 Fusion-Task-Lineage: c7a02071-f911-4c42-a174-cdcf2453fd23
This commit is contained in:
5
.changeset/FN-4523-completion-fanout.md
Normal file
5
.changeset/FN-4523-completion-fanout.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Engine: when an in-review task moves to done (auto-merger, self-healing, or manual move), the engine now fans out blockedBy reconciliation and residual branch/worktree cleanup in the same pass instead of waiting for the next periodic self-healing sweep. Prevents FN-4008-class stranded-task incidents.
|
||||
@@ -196,7 +196,7 @@ Port 4040 is the production dashboard port. A user's live dashboard session is t
|
||||
|
||||
## Architecture
|
||||
|
||||
- Merge deadlock self-healing now layers `recoverAlreadyMergedReviewTasks()`, `clearStaleBlockedBy()`, and `reclaimSelfOwnedBranchConflicts()` in `packages/engine/src/self-healing.ts`, plus the paused-aware in-review scope filter in `packages/engine/src/scheduler.ts` (`inReviewWithWorktree` excludes `paused` tasks). `reclaimSelfOwnedBranchConflicts()` now also recovers paused `branch-conflict-unrecoverable` review rows when ownership is self-proven, auto-reclaims `fusion/<task-id>` branches that are still live-mapped but have zero unique commits vs main by force-removing the stale worktree and deleting the branch, and clears `task.worktree`/`task.branch` so retries recreate a fresh checkout. `inspectBranchConflict()` backs this with a patch-id fallback for degraded/empty `git cherry` output before declaring zero-unique-commit subsumption; orphan `fusion/*` branches are still resolved by prune-or-rescue logic (subsumed branches pruned; unique-commit branches rescued into triage tasks instead of force delete).
|
||||
- Merge deadlock self-healing now layers `recoverAlreadyMergedReviewTasks()`, `clearStaleBlockedBy()`, and `reclaimSelfOwnedBranchConflicts()` in `packages/engine/src/self-healing.ts`, plus the paused-aware in-review scope filter in `packages/engine/src/scheduler.ts` (`inReviewWithWorktree` excludes `paused` tasks). `reclaimSelfOwnedBranchConflicts()` now also recovers paused `branch-conflict-unrecoverable` review rows when ownership is self-proven, auto-reclaims `fusion/<task-id>` branches that are still live-mapped but have zero unique commits vs main by force-removing the stale worktree and deleting the branch, and clears `task.worktree`/`task.branch` so retries recreate a fresh checkout. `inspectBranchConflict()` backs this with a patch-id fallback for degraded/empty `git cherry` output before declaring zero-unique-commit subsumption; orphan `fusion/*` branches are still resolved by prune-or-rescue logic (subsumed branches pruned; unique-commit branches rescued into triage tasks instead of force delete). Completion fan-out now runs synchronously on `in-review → done` via `SelfHealingManager.reconcileCompletedTask()`, so downstream stale `blockedBy` links and residual `fusion/<task-id>` branch/worktree artifacts are reconciled immediately instead of waiting for periodic sweeps.
|
||||
- Restart recovery is coordinated through `RestartRecoveryCoordinator` (`packages/engine/src/restart-recovery-coordinator.ts`), which classifies interrupted `in-progress` runs at runtime startup: no-progress `fn_task_done` failures are safely requeued to `todo`, then remaining orphaned work is resumed via the executor.
|
||||
|
||||
## Engine Process Rules
|
||||
|
||||
@@ -127,7 +127,7 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", (
|
||||
expect(task.mergeDetails?.mergeConfirmed).toBe(true);
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(git(repo, "git worktree list")).not.toContain(worktreePath);
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledTimes(1);
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledTimes(2);
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
domain: "database",
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("self-healing completion fan-out", () => {
|
||||
});
|
||||
|
||||
it("removes worktree from hint and is idempotent when missing", async () => {
|
||||
existsSyncMock.mockImplementation((p: string) => p === "/wt/fn-b");
|
||||
(existsSyncMock as any).mockImplementation((p: string) => p === "/wt/fn-b");
|
||||
const blocker = makeTask("FN-B", { column: "done", branch: "fusion/fn-b" });
|
||||
const store = createStore([blocker]);
|
||||
const mgr = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
@@ -127,8 +127,8 @@ describe("self-healing completion fan-out", () => {
|
||||
});
|
||||
|
||||
it("derives worktree from worktree list and skips branch delete when unique commits exist", async () => {
|
||||
existsSyncMock.mockImplementation((p: string) => String(p).includes("/wt/fn-c"));
|
||||
uniqueCommitsMock.mockResolvedValue({ commits: [{ sha: "abc", subject: "x" }], mainRef: "main", degraded: false });
|
||||
(existsSyncMock as any).mockImplementation((p: string) => String(p).includes("/wt/fn-c"));
|
||||
uniqueCommitsMock.mockResolvedValue({ commits: [{ sha: "abc", subject: "x" }] as any, mainRef: "main", degraded: false });
|
||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, stdout: string, stderr: string) => void) => {
|
||||
cb(null, "", "");
|
||||
});
|
||||
|
||||
@@ -3847,7 +3847,7 @@ describe("SelfHealingManager", () => {
|
||||
const recovered = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
|
||||
|
||||
expect(recovered).toBe(1);
|
||||
expect(recordRunAuditEvent).toHaveBeenCalledTimes(1);
|
||||
expect(recordRunAuditEvent).toHaveBeenCalledTimes(2);
|
||||
expect(recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
domain: "database",
|
||||
@@ -3923,7 +3923,7 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
expect(recovered).toBe(1);
|
||||
expect(storeWithAudit.moveTask).toHaveBeenCalledWith("FN-audit-throw", "done");
|
||||
expect(recordRunAuditEvent).toHaveBeenCalledTimes(1);
|
||||
expect(recordRunAuditEvent).toHaveBeenCalledTimes(2);
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
@@ -99,6 +99,7 @@ export type DatabaseMutationType =
|
||||
| "task:unpause"
|
||||
| "task:dependency:add"
|
||||
| "task:auto-recover-already-merged"
|
||||
| "task:auto-recover-completion-fanout"
|
||||
| "document:write"
|
||||
| "workflow-step:result"
|
||||
| "agent:create:requested"
|
||||
|
||||
@@ -1680,21 +1680,21 @@ export class SelfHealingManager {
|
||||
*
|
||||
* @returns Number of tasks unblocked
|
||||
*/
|
||||
private async findWorktreePathForBranch(branchName: string): Promise<string | null> {
|
||||
private async findWorktreePathForBranch(branchName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const lines = stdout.split("\n");
|
||||
let currentWorktree: string | null = null;
|
||||
let currentBranch: string | null = null;
|
||||
let currentWorktree: string | undefined;
|
||||
let currentBranch: string | undefined;
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
if (currentWorktree && currentBranch === branchName) return currentWorktree;
|
||||
currentWorktree = null;
|
||||
currentBranch = null;
|
||||
currentWorktree = undefined;
|
||||
currentBranch = undefined;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("worktree ")) {
|
||||
@@ -1706,11 +1706,11 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
if (currentWorktree && currentBranch === branchName) return currentWorktree;
|
||||
return null;
|
||||
return undefined;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`[self-healing] reconcileCompletedTask: failed to read worktree list for ${branchName}: ${errorMessage}`);
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1829,7 +1829,7 @@ export class SelfHealingManager {
|
||||
runId: generateSyntheticRunId("self-heal", taskId),
|
||||
agentId: "self-healing",
|
||||
taskId,
|
||||
taskLineageId: task?.lineageId,
|
||||
taskLineageId: task?.lineageId ?? undefined,
|
||||
phase: "completion-fanout",
|
||||
});
|
||||
await auditor.database({
|
||||
|
||||
Reference in New Issue
Block a user