test(FN-7249): cover phantom worktree recovery
This commit is contained in:
7
.changeset/phantom-reservation-worktree-preserve.md
Normal file
7
.changeset/phantom-reservation-worktree-preserve.md
Normal file
@@ -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.
|
||||||
@@ -124,6 +124,22 @@ describe("TaskStore phantom committed-reservation reconciliation", () => {
|
|||||||
expect(store.getRunAuditEvents({ taskId: phantom.id, mutationType: "task:reconcile-phantom-committed-reservation" })).toHaveLength(1);
|
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 () => {
|
it("is a safe no-op for in-memory stores", async () => {
|
||||||
store.close();
|
store.close();
|
||||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||||
|
|||||||
@@ -250,6 +250,12 @@ describe("workflow routes (U4)", () => {
|
|||||||
// which carry no icon. The retired legacy `ir.optionalSteps` declaration
|
// which carry no icon. The retired legacy `ir.optionalSteps` declaration
|
||||||
// supplied `icon: "globe"`; the group node instead yields `description: ""`
|
// supplied `icon: "globe"`; the group node instead yields `description: ""`
|
||||||
// and `phase: "pre-merge"`. Assert the current group-sourced shape.
|
// 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(builtin.body).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
templateId: "plan-review",
|
templateId: "plan-review",
|
||||||
@@ -272,6 +278,13 @@ describe("workflow routes (U4)", () => {
|
|||||||
phase: "pre-merge",
|
phase: "pre-merge",
|
||||||
defaultOn: true,
|
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() });
|
const custom = await post("/api/workflows", { name: "A", ir: linearIr() });
|
||||||
|
|||||||
@@ -153,10 +153,14 @@ describeIfGit("U1 KTD2 — activeWorktrees Set + every enumerated consumer", ()
|
|||||||
const pB = repoBPath(fx);
|
const pB = repoBPath(fx);
|
||||||
(executor as any).addActiveWorktree("FN-WS-1", pA);
|
(executor as any).addActiveWorktree("FN-WS-1", pA);
|
||||||
(executor as any).addActiveWorktree("FN-WS-1", pB);
|
(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");
|
const ok = (executor as any).clearPhantomExecutorBinding("FN-WS-1");
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false);
|
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 () => {
|
it("clearPhantomExecutorBinding (FN-7249) preserveWorktrees keeps session-registry paths for re-dispatch", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user