test(engine): prove BOTH rebound paths on a renamed board — 2 more ledger entries closed (#2510)
Stacked on #2508. Test-only. ## Why this site matters more than most `resolveReboundTarget` answers one question: **where does a recovered card go back to?** Keyed on the literal `todo`, a recovered card on a renamed board is requeued to a column that board **does not declare**. That is not cosmetic — an undeclared column carries no trait flags, so `findColumn` returns undefined and the card becomes invisible to every trait-driven sweep: nothing schedules it, nothing releases it, the board does not draw the column. **The "recovery" strands the card harder than the failure it was recovering from.** One of the two covered paths, `reconcileUndeclaredTaskColumns`, exists *specifically* to repair that state — which makes it the worst possible place for this bug to live. ## Covered, each mutation-verified independently | site | mutation | result | |---|---|---| | `reconcileUndeclaredTaskColumns` | target → `"todo"` | exactly the 2 renamed cases fail | | `autoRecoverWorktreeSessionStartFailure` | rebound → `"todo"` | exactly the renamed requeue fails | Neither needs git — the corrected ledger lens from #2508 (*what the function touches*, not *what family it sits in*) made that obvious rather than assumed. ## Both negatives included "Re-home anything whose column looks wrong" would be a louder failure than the strand it repairs, so: a card whose column **is** declared is left alone, and an operator `userPaused` park is never undone. ## Fixture finding, kept in-file `updateTask({ userPaused: true })` leaves the field `undefined` on both `getTask` and `listTasks({slim:true})`. Seeding it that way produced a card the sweep **correctly** saw as unpaused — a broken fixture that would have read as a broken guard, and would have looked like a real safety hole in the paused-park protection. Found by probing the persisted row rather than trusting the write. Now seeded through the integer column directly, and the test asserts the seed took effect *before* exercising the sweep, so this cannot silently regress into a vacuous pass. ## Verification - three live-E2E suites green together (lifecycle, merge-family, rebound-family) - engine `tsc --noEmit` clean - `pnpm test:gate` green (307 + 10 + 71) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -678,6 +678,10 @@ two self-healing sweeps verified PER SITE — reverting one fails exactly its ow
|
||||
- recovery-reconciler.ts column-declared policy lookup (table row, returned-decision — WEAKER)
|
||||
- hold-release.ts isHeldTask / the capacity release (spine)
|
||||
- the graph column boundary + store.moveTask + the post-commit bus (spine)
|
||||
- self-healing.ts resolveReboundTarget, BOTH paths — the undeclared-column repair
|
||||
(reconcileUndeclaredTaskColumns) and the session-start requeue
|
||||
(autoRecoverWorktreeSessionStartFailure); workflow-rebound-family-live-e2e.pg.test.ts,
|
||||
each mutation-verified independently
|
||||
- auto-merge-finalization.ts completeColumn / mergeColumn / isCompleteColumn
|
||||
(workflow-merge-family-live-e2e.pg.test.ts; each of the three mutation-verified
|
||||
INDEPENDENTLY — the mergeColumn one needed its own case, see below)
|
||||
@@ -694,7 +698,6 @@ NOT PROVEN end to end — real callers this suite does not reach:
|
||||
- merger.ts:324-326 resolveCompleteColumn / resolveMergeOrchestrationColumn / resolveReboundTarget
|
||||
- merger-ai.ts:1022,1039 resolveReboundTarget, resolveLifecycleColumns
|
||||
- executor.ts:1763,6339,6341 rebound target, merge-orchestration probe, complete column
|
||||
- self-healing.ts:713,6732 resolveReboundTarget (two distinct rebound paths)
|
||||
- mesh-lease-manager.ts:61 resolveReboundTarget
|
||||
- task-agent-sync.ts:59 resolveTaskLifecycleColumns
|
||||
- core/task-store/reads.ts:130 listTasks hydration
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-28-12:40 (E2E — closing rebound-family ledger entries):
|
||||
|
||||
`resolveReboundTarget` answers ONE question — "where does a recovered card go back
|
||||
to?" — and the ledger listed four unproven callers of it. Re-checked with the lens
|
||||
the previous slice corrected ("what does the function actually touch?", not "what
|
||||
family does it sit in"): the self-healing pair needs NO git, so it is covered here.
|
||||
|
||||
WHAT A WRONG ANSWER COSTS. Keyed on the literal `todo`, a recovered card on a
|
||||
renamed board is requeued to a column that board does not declare. That is not a
|
||||
cosmetic mismatch — an undeclared column carries NO trait flags, so
|
||||
`findColumn` returns undefined and the card is invisible to every trait-driven
|
||||
sweep: nothing schedules it, nothing releases it, and the board does not draw the
|
||||
column. The "recovery" strands the card more thoroughly than the failure it was
|
||||
recovering from. `reconcileUndeclaredTaskColumns` exists precisely to repair that
|
||||
state, which makes it the worst possible place for the bug to live.
|
||||
|
||||
Covered here:
|
||||
self-healing.ts reconcileUndeclaredTaskColumns — the undeclared-column repair
|
||||
self-healing.ts autoRecoverWorktreeSessionStartFailure — the session-start requeue
|
||||
|
||||
Assertions read the PERSISTED row back through `getTask`; the audit rows are read
|
||||
back through the store's own reader.
|
||||
*/
|
||||
import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it } from "vitest";
|
||||
import "@fusion/core"; // registers the built-in column traits
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
|
||||
import {
|
||||
pgDescribe,
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
type SharedPgTaskStoreHarness,
|
||||
} from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { SelfHealingManager, autoRecoverWorktreeSessionStartFailure } from "../self-healing.js";
|
||||
import { DEFAULT_VOCAB, RENAMED_VOCAB, lifecycleIr, type Vocabulary } from "./_workflow-vocabulary-fixture.js";
|
||||
|
||||
pgDescribe("live rebound E2E: where a recovered card goes back to", () => {
|
||||
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||
prefix: "fusion_rebound_family_e2e",
|
||||
});
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
/** Persist the workflow and return the id the STORE assigned — it allocates its own
|
||||
* `WF-###` and ignores the one in the input; binding to the id we passed in would
|
||||
* silently resolve to the DEFAULT builtin IR instead. */
|
||||
async function seedWorkflow(v: Vocabulary, key: string): Promise<string> {
|
||||
const created = await h.store().createWorkflowDefinition({
|
||||
name: `Rebound ${key}`,
|
||||
kind: "workflow",
|
||||
ir: lifecycleIr(v, `custom:${key}`),
|
||||
} as never);
|
||||
return (created as { id: string }).id;
|
||||
}
|
||||
|
||||
async function persistedColumn(taskId: string): Promise<string> {
|
||||
const store = h.store();
|
||||
store.taskCache.delete(taskId);
|
||||
return (await store.getTask(taskId)).column as string;
|
||||
}
|
||||
|
||||
async function seedTask(taskId: string, column: string, workflowId: string): Promise<Task> {
|
||||
const store = h.store();
|
||||
const task = await store.createTaskWithReservedId(
|
||||
{ description: `rebound ${taskId}`, column } as never,
|
||||
{ taskId, applyDefaultWorkflowSteps: false } as never,
|
||||
);
|
||||
await store.writeTaskWorkflowSelection(taskId, workflowId, []);
|
||||
store.taskCache.delete(taskId);
|
||||
return task as Task;
|
||||
}
|
||||
|
||||
describe("reconcileUndeclaredTaskColumns — the undeclared-column repair", () => {
|
||||
/** Park a card in a column NO workflow declares. `moveTask` will not take it there
|
||||
* (that is the point of the transition policy), so the row is written directly —
|
||||
* this is a corrupt-state repair test, and the corrupt state is the fixture. */
|
||||
async function strandInUndeclaredColumn(taskId: string, workflowId: string): Promise<void> {
|
||||
const store = h.store();
|
||||
await seedTask(taskId, "todo", workflowId);
|
||||
await h.adminSql()`UPDATE project.tasks SET "column" = 'a-column-no-workflow-declares' WHERE id = ${taskId}`;
|
||||
store.taskCache.delete(taskId);
|
||||
}
|
||||
|
||||
it("re-homes a stranded card to the RENAMED workflow's own rebound column", async () => {
|
||||
const workflowId = await seedWorkflow(RENAMED_VOCAB, "undeclared-renamed");
|
||||
await strandInUndeclaredColumn("FN-RB-1", workflowId);
|
||||
expect(await persistedColumn("FN-RB-1")).toBe("a-column-no-workflow-declares");
|
||||
|
||||
const rehomed = await new SelfHealingManager(h.store(), {} as never).reconcileUndeclaredTaskColumns();
|
||||
|
||||
expect(rehomed).toBe(1);
|
||||
// `backlog` — the renamed board's hold column — NOT the legacy `todo`, which
|
||||
// this workflow does not declare and which would leave the card stranded again.
|
||||
expect(await persistedColumn("FN-RB-1")).toBe(RENAMED_VOCAB.hold);
|
||||
});
|
||||
|
||||
it("records the repair with the resolved target, not a legacy literal", async () => {
|
||||
const workflowId = await seedWorkflow(RENAMED_VOCAB, "undeclared-audit");
|
||||
await strandInUndeclaredColumn("FN-RB-2", workflowId);
|
||||
|
||||
await new SelfHealingManager(h.store(), {} as never).reconcileUndeclaredTaskColumns();
|
||||
|
||||
const audit = await h.store().getRunAuditEventsAsync({ taskId: "FN-RB-2" });
|
||||
const repair = audit.find((e) => e.mutationType === "task:reconcile-undeclared-column");
|
||||
const metadata = (typeof repair?.metadata === "string" ? JSON.parse(repair.metadata) : repair?.metadata) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(metadata?.toColumn).toBe(RENAMED_VOCAB.hold);
|
||||
expect(metadata?.priorColumn).toBe("a-column-no-workflow-declares");
|
||||
});
|
||||
|
||||
it("still re-homes a default-vocabulary card to `todo` (regression floor)", async () => {
|
||||
const workflowId = await seedWorkflow(DEFAULT_VOCAB, "undeclared-default");
|
||||
await strandInUndeclaredColumn("FN-RB-3", workflowId);
|
||||
|
||||
await new SelfHealingManager(h.store(), {} as never).reconcileUndeclaredTaskColumns();
|
||||
|
||||
expect(await persistedColumn("FN-RB-3")).toBe(DEFAULT_VOCAB.hold);
|
||||
});
|
||||
|
||||
it("leaves a card alone when its column IS declared by its workflow", async () => {
|
||||
/* The negative half. "Re-home anything whose column looks wrong" would drag
|
||||
every healthy card on a renamed board back to its hold column — a far louder
|
||||
failure than the strand it repairs. */
|
||||
const workflowId = await seedWorkflow(RENAMED_VOCAB, "declared-renamed");
|
||||
await seedTask("FN-RB-4", RENAMED_VOCAB.wip, workflowId);
|
||||
|
||||
const rehomed = await new SelfHealingManager(h.store(), {} as never).reconcileUndeclaredTaskColumns();
|
||||
|
||||
expect(rehomed).toBe(0);
|
||||
expect(await persistedColumn("FN-RB-4")).toBe(RENAMED_VOCAB.wip);
|
||||
});
|
||||
|
||||
it("leaves an operator-paused card stranded rather than moving it", async () => {
|
||||
/* `userPaused` is an operator park; the sweep must not undo it even to repair a
|
||||
genuinely broken column. */
|
||||
const workflowId = await seedWorkflow(RENAMED_VOCAB, "undeclared-paused");
|
||||
await strandInUndeclaredColumn("FN-RB-5", workflowId);
|
||||
/* Written directly rather than through `updateTask`: a probe showed
|
||||
`updateTask({ userPaused: true })` leaves the field `undefined` on both `getTask`
|
||||
and `listTasks({slim:true})`, so seeding it that way produced a card the sweep
|
||||
correctly saw as unpaused — a broken fixture that would have read as a broken
|
||||
guard. `user_paused` is an integer column. */
|
||||
await h.adminSql()`UPDATE project.tasks SET user_paused = 1 WHERE id = 'FN-RB-5'`;
|
||||
h.store().taskCache.delete("FN-RB-5");
|
||||
expect((await h.store().getTask("FN-RB-5")).userPaused).toBe(true);
|
||||
|
||||
await new SelfHealingManager(h.store(), {} as never).reconcileUndeclaredTaskColumns();
|
||||
|
||||
expect(await persistedColumn("FN-RB-5")).toBe("a-column-no-workflow-declares");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoRecoverWorktreeSessionStartFailure — the session-start requeue", () => {
|
||||
async function recover(taskId: string, v: Vocabulary, key: string) {
|
||||
const workflowId = await seedWorkflow(v, key);
|
||||
const task = await seedTask(taskId, v.wip, workflowId);
|
||||
return autoRecoverWorktreeSessionStartFailure(h.store() as TaskStore, task, {
|
||||
failure: new Error("worktree path does not exist"),
|
||||
source: "executor-session-start",
|
||||
auditor: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("requeues a recovered card to the RENAMED workflow's rebound column", async () => {
|
||||
const result = await recover("FN-RB-6", RENAMED_VOCAB, "session-renamed");
|
||||
|
||||
expect(result.outcome).toBe("requeue-todo"); // the outcome NAME is legacy; the column is not
|
||||
expect(await persistedColumn("FN-RB-6")).toBe(RENAMED_VOCAB.hold);
|
||||
});
|
||||
|
||||
it("still requeues a default-vocabulary card to `todo` (regression floor)", async () => {
|
||||
await recover("FN-RB-7", DEFAULT_VOCAB, "session-default");
|
||||
|
||||
expect(await persistedColumn("FN-RB-7")).toBe(DEFAULT_VOCAB.hold);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user