test(notifications): cover the wedge-episode renamed-lane clear that main's conversion lacked (#3096)

**Rebased onto current `main`, and it shrank to a test.** Was "close the
wedge-episode race, then resolve its lanes."

## What happened

Another worker landed **both halves of this PR independently** while it
was open. Rebasing showed their versions are better, so I took theirs
and dropped mine:

- **The serialisation** — theirs is `enqueueWedgeHandling(taskId, run)`,
a general callback; mine was wedge-specific.
- **The conversion** — theirs is **project-union membership** over the
four roles; mine was first-match-per-role via `resolveLifecycleColumns`.
Membership is correct: more than one lane can fill a role on a renamed
board, and first-match silently ignores the rest.

My rebased branch initially compiled to a **duplicate
`wedgeHandlingChains` field and duplicate method** — caught by `tsc`,
removed. Nothing of my implementation survives, and it shouldn't.

## What's left is worth landing

Their conversion has **no renamed-board test**. This adds one.

A card recovering into a renamed hold lane must **clear** its episode.
Asserted through the *second* notification, because a stale active
episode also **refuses the next genuine wedge its claim** — so the
visible symptom is a real wedge going unannounced, not merely a stale
alert.

The fixture needed `listWorkflowDefinitions`:
`resolveProjectColumnsForRoles` unions across the project's workflows,
so the per-task selection readers alone leave it resolving nothing and
the test would pass for the wrong reason. That's how I found the
mismatch — my original test failed against their implementation.

## Verification

- Green as written against **their** implementation
- **Revert-proof against theirs:** restoring the four literals fails it
— 1 delivered, 2 expected
- 7 notification suites — **80 green**; `tsc` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed wedge notifications so they can trigger again after a task
recovers into a renamed workflow’s hold lane.

* **Tests**
* Added regression coverage confirming that recovered tasks correctly
clear their wedge state and support subsequent notifications.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 05:09:36 -07:00
committed by GitHub
parent 6050d6eb83
commit 56b5cdfeed

View File

@@ -67,6 +67,53 @@ describe("task wedge notifications", () => {
await service.stop();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-18:50 (fleet):
Clearing a wedge episode asks "has the card moved on?", which was four column literals. On a renamed
board none matched, so a RECOVERED card never cleared its episode and the operator kept an alert for
work that had already progressed — and, because the stale episode stays active, the NEXT genuine wedge
is refused its claim and never announced at all.
The board below recovers into `backlog` (hold). The assertion is the second message: with the literals
it never arrives, because the first episode was never resolved.
*/
it("clears a wedge episode when the card recovers into a RENAMED hold lane", async () => {
const RENAMED_IR = {
version: "v2",
name: "renamed-notify",
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "signoff", name: "Signoff", traits: [{ trait: "merge" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
nodes: [],
edges: [],
};
const { store, service, sendMessageOnce, task } = fixture();
/* `resolveProjectColumnsForRoles` unions lanes across the PROJECT's workflows, so the fake needs
`listWorkflowDefinitions` — the per-task selection readers alone leave it resolving nothing. */
Object.assign(store, {
getTaskWorkflowSelection: () => ({ workflowId: "custom:renamed", stepIds: [] }),
getTaskWorkflowSelectionAsync: async () => ({ workflowId: "custom:renamed", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: RENAMED_IR }),
listWorkflowDefinitions: async () => [{ id: "custom:renamed", ir: RENAMED_IR }],
});
await service.start();
store.emit(task({ column: "signoff" }));
await vi.waitFor(() => expect(sendMessageOnce).toHaveBeenCalledTimes(1));
// Recovered into the board's own hold lane — the episode must close.
/* status stays "failed" ON PURPOSE: the status disjunct (`status !== "failed"`) would otherwise
satisfy hasProgressed on its own and the column term would never decide anything. */
store.emit(task({ status: "failed", error: undefined, column: "backlog", updatedAt: "2026-07-22T12:02:00.000Z" }));
// Wedged again: only claimable if the previous episode actually cleared.
store.emit(task({ column: "signoff", updatedAt: "2026-07-22T12:03:00.000Z" }));
await vi.waitFor(() => expect(sendMessageOnce).toHaveBeenCalledTimes(2));
await service.stop();
});
it("does not re-deliver an unchanged durable episode after service restart", async () => {
const { store, service, sendNotification, sendMessageOnce, task } = fixture();
await service.start();