diff --git a/.changeset/phantom-reservation-worktree-preserve.md b/.changeset/phantom-reservation-worktree-preserve.md new file mode 100644 index 0000000000..f50a584fb2 --- /dev/null +++ b/.changeset/phantom-reservation-worktree-preserve.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop repeat no-op phantom-reservation audit writes and preserve the worktree across phantom binding reclaim. +category: fix +dev: reconcilePhantomCommittedReservations now emits the task:reconcile-phantom-committed-reservation audit row only when orphaned child rows were actually pruned, instead of every maintenance tick (~19k wasted writes/day); the committed reservation stays committed so the ID is never reused. clearPhantomExecutorBinding gains a preserveWorktrees option the self-healing phantom reclaim uses so moveTask(preserveWorktree:true) re-dispatch reattaches to the same worktree instead of orphaning it and acquiring a new one (FN-7249). Regression: store-phantom-reservation-reconcile.test.ts, executor-workspace.test.ts. diff --git a/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts b/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts index 4d30f6a6a7..6e528e7178 100644 --- a/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts +++ b/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts @@ -124,6 +124,22 @@ describe("TaskStore phantom committed-reservation reconciliation", () => { expect(store.getRunAuditEvents({ taskId: phantom.id, mutationType: "task:reconcile-phantom-committed-reservation" })).toHaveLength(1); }); + it("does not re-emit the reconcile audit row on a second tick once orphaned rows are pruned (idempotency)", async () => { + const phantom = await createCommittedReservationPhantom("Phantom committed reservation (idempotency)"); + seedOrphanedChildRows(phantom.id); + + const first = await store.reconcilePhantomCommittedReservations(); + expect(first.reconciled).toContain(phantom.id); + + // Second maintenance tick: the phantom still re-matches (committed reservation, no row/dir), + // but the orphaned child rows are already gone, so no new audit row is written. + const second = await store.reconcilePhantomCommittedReservations(); + expect(second.reconciled).toContain(phantom.id); + + expect(store.getRunAuditEvents({ taskId: phantom.id, mutationType: "task:reconcile-phantom-committed-reservation" })).toHaveLength(1); + expect(reservationStatus(phantom.id)).toBe("committed"); + }); + it("is a safe no-op for in-memory stores", async () => { store.close(); store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); diff --git a/packages/dashboard/src/__tests__/workflow-routes.test.ts b/packages/dashboard/src/__tests__/workflow-routes.test.ts index 0b8e1ddfc7..797cd4526a 100644 --- a/packages/dashboard/src/__tests__/workflow-routes.test.ts +++ b/packages/dashboard/src/__tests__/workflow-routes.test.ts @@ -250,6 +250,12 @@ describe("workflow routes (U4)", () => { // which carry no icon. The retired legacy `ir.optionalSteps` declaration // supplied `icon: "globe"`; the group node instead yields `description: ""` // and `phase: "pre-merge"`. Assert the current group-sourced shape. + // + // FNXC:WorkflowOptionalGroup 2026-06-29-17:40: builtin:coding now also declares the + // default-off `post-merge-verification` optional group (FN-7039/FN-7233 routed + // post-merge lifecycle policy through graph-native nodes). The optional-steps + // resolver exposes it as a fourth `phase: "post-merge"` step; assert it here so the + // route contract tracks the workflow IR instead of a stale three-step snapshot. expect(builtin.body).toEqual([ expect.objectContaining({ templateId: "plan-review", @@ -272,6 +278,13 @@ describe("workflow routes (U4)", () => { phase: "pre-merge", defaultOn: true, }), + expect.objectContaining({ + templateId: "post-merge-verification", + name: "Post-merge verification", + description: "", + phase: "post-merge", + defaultOn: false, + }), ]); const custom = await post("/api/workflows", { name: "A", ir: linearIr() }); diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts index f245abdfb8..e6971790d7 100644 --- a/packages/engine/src/__tests__/executor-workspace.test.ts +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -153,10 +153,14 @@ describeIfGit("U1 KTD2 — activeWorktrees Set + every enumerated consumer", () const pB = repoBPath(fx); (executor as any).addActiveWorktree("FN-WS-1", pA); (executor as any).addActiveWorktree("FN-WS-1", pB); + activeSessionRegistry.registerPath(pA, { taskId: "FN-WS-1", kind: "executor", ownerKey: "exec:FN-WS-1:a" }); + activeSessionRegistry.registerPath(pB, { taskId: "FN-WS-1", kind: "executor", ownerKey: "exec:FN-WS-1:b" }); const ok = (executor as any).clearPhantomExecutorBinding("FN-WS-1"); expect(ok).toBe(true); expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false); + // Default path must sweep the session-registry entries (inverse of the preserveWorktrees branch). + expect(activeSessionRegistry.pathsForTask("FN-WS-1")).toEqual([]); }); it("clearPhantomExecutorBinding (FN-7249) preserveWorktrees keeps session-registry paths for re-dispatch", async () => {