Files
fusion/packages/dashboard/app/hooks/__tests__/useSessionFiles.test.ts
gsxdsm 6bdde6f246 fix: five lifecycle gates the census cannot see — incl. live ephemeral workers reaped and duplicate follow-up cards (#2787)
Five lifecycle-column fixes the census **structurally cannot see**. Each
gate is a `Set` or array literal — a *definition*, not a comparison — so
no backlog entry ever pointed at any of these files. Found by grepping
for lane-shaped list literals after the same shape surfaced in
`duplicate-intake` and `blocker-fanout` (both merged via #2780), then
confirmed by reading each USE site.

**On opening this:** I offered twice to fold these into a PR and kept
them on handoff refs to respect one-open-PR-per-worker. They have now
sat unadopted across several cycles while `main` moved, and two of them
destroy or duplicate work. Opening is the reversible call — **close it
if it breaks queue policy** and I will keep them on the branch.

## What is in it

| commit | defect on a renamed board | severity |
|---|---|---|
| `beb107a7bc` | assignment load-balancing **defeated** —
`assignmentLoad` stays empty, every candidate reads as load 0, the sort
falls through to its stable `createdAt` tiebreak, so **one agent wins
every assignment** while the rest idle | distribution |
| `cf4b59e1cb` | the zombie sweep **deletes LIVE ephemeral workers** |
**destroys work** |
| `5fe004ae64` | eval follow-up dedup sees **zero open tasks**, so every
run re-files follow-ups it already filed | **duplicate cards** |
| `a1021de8b2` | agents keep a **"working on" indicator for finished
cards** | stale UI |
| `86680d1220` | the **Files tab never loads** — the fetch never fires |
silent empty |

### The one that destroys work

`shouldDeleteOnSweep` tested a hard-coded terminal `Set`, then fell
through to `return task.column !== "in-progress"`. On a renamed board
**both halves miss, and they compound in the worst order**: the terminal
test fails, control reaches the fallthrough, and `"building" !==
"in-progress"` is `true`. An ephemeral worker **actively executing a
task** is classified as a zombie and deleted. Nothing logs.

Its fallback is **deliberately asymmetric**, and the comment says why:
an unresolvable workflow keeps the legacy literals rather than guessing.
Failing to reap a dead worker costs a slot; reaping a live one destroys
work in flight. Those are not symmetric, so uncertainty fails toward
keeping the worker.

## Verification

Verified **as a set**, not only per-branch:

- `pnpm test:gate` — **161 / 13 / 487 / 71**
- engine suites (assignment, ephemeral, eval-followups) — **44 passed**
- dashboard suites (agent-task-link, useSessionFiles) — **16 passed**
- `tsc` engine + dashboard server + dashboard app — clean
- `pnpm lint` clean · census `--strict` exits 0

**Revert-proven individually.** Restoring each literal fails its own
case: the renamed-wip zombie case, the renamed-wip assignment case, the
renamed-lane dedup case, the sanitizer ratchet, and both
`useSessionFiles` role cases.

## Two honesty notes, flagged rather than buried

**`a1021de8b2`'s guard is STRUCTURAL, not behavioural.**
`sanitizeAgentTaskLinks` is a closure inside `createApiRoutes`,
reachable only by standing up the full express app. The ratchet asserts
the source — resolver threaded per task, bare literal call gone, cache
shared, fallback retained — and **fails on revert**, verified. It is not
a substitute for a behavioural test; whoever owns the dashboard server
should add one if that seam grows.

**`useSessionFiles`'s negative case passed in isolation and failed in
the suite.** Hooks are not unmounted between cases there, so a prior
case's in-flight fetch landed inside it. That is the classic shape of a
test that gets "fixed" by reordering; it now asserts a **delta** against
the pre-render call count, which is independent of what leaks in.

## Deliberately NOT included

`worktree-pool.ts:1205` — the sixth site from the same sweep. It **fails
safe**: a missed match means the skip does not fire, so the branch is
added to `activeBranches` and *protected* from cleanup. The cost is
stale branches accumulating, not deletion. It also sits in the merger's
branch-reaping path, where the opposite error destroys work, so it
deserves its owner's judgement rather than a drive-by conversion.
Flagged, not guessed.

Also still open and unclaimed: roughly 69 untriaged literal-list sites
across engine/dashboard/cli. The grep is one line and the file list is
on #2775 — with the measured caveat that about half are false positives
on shape alone (`LEGACY_*` names, seeds unioned with resolved values,
and `roles: ["triage"]`, which is an `AgentCapability`, not the deleted
column). Only the use site settles it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:35:31 -07:00

194 lines
8.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useSessionFiles } from "../useSessionFiles";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchSessionFiles: vi.fn(),
}));
const mockFetchSessionFiles = vi.mocked(api.fetchSessionFiles);
describe("useSessionFiles", () => {
beforeEach(() => {
mockFetchSessionFiles.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it("fetches session files for active tasks with a worktree", async () => {
mockFetchSessionFiles.mockResolvedValueOnce(["src/a.ts", "src/b.ts"]);
const { result } = renderHook(() => useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "in-progress"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual(["src/a.ts", "src/b.ts"]);
expect(mockFetchSessionFiles).toHaveBeenCalledWith("FN-123", undefined);
});
it("does not fetch for tasks without worktrees or inactive columns", async () => {
const { result: noWorktree } = renderHook(() => useSessionFiles("FN-123", undefined, "in-progress"));
const { result: inactive } = renderHook(() => useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "todo"));
await waitFor(() => expect(noWorktree.current.loading).toBe(false));
await waitFor(() => expect(inactive.current.loading).toBe(false));
expect(noWorktree.current.files).toEqual([]);
expect(inactive.current.files).toEqual([]);
expect(mockFetchSessionFiles).not.toHaveBeenCalled();
});
it("returns empty files on fetch failure", async () => {
mockFetchSessionFiles.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "in-review"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual([]);
});
it("fetches session files for done column tasks with a worktree", async () => {
mockFetchSessionFiles.mockResolvedValueOnce(["src/x.ts", "src/y.ts", "src/z.ts"]);
const { result } = renderHook(() => useSessionFiles("FN-456", "/repo/.worktrees/kb-456", "done"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual(["src/x.ts", "src/y.ts", "src/z.ts"]);
expect(mockFetchSessionFiles).toHaveBeenCalledWith("FN-456", undefined);
});
it("does not fetch for done column tasks without a worktree", async () => {
const { result } = renderHook(() => useSessionFiles("FN-456", undefined, "done"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual([]);
expect(mockFetchSessionFiles).not.toHaveBeenCalled();
});
describe("enabled option", () => {
it("fetches when enabled is true (default)", async () => {
mockFetchSessionFiles.mockResolvedValueOnce(["file.ts"]);
const { result } = renderHook(() =>
useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "in-progress", undefined, { enabled: true }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual(["file.ts"]);
expect(mockFetchSessionFiles).toHaveBeenCalled();
});
it("fetches when enabled is not specified (default)", async () => {
mockFetchSessionFiles.mockResolvedValueOnce(["file.ts"]);
const { result } = renderHook(() =>
useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "in-progress"),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual(["file.ts"]);
expect(mockFetchSessionFiles).toHaveBeenCalled();
});
it("does not fetch when enabled is false", async () => {
const { result } = renderHook(() =>
useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "in-progress", undefined, { enabled: false }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual([]);
expect(mockFetchSessionFiles).not.toHaveBeenCalled();
});
it("returns stable state (loading: false) when disabled", async () => {
const { result } = renderHook(() =>
useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "in-progress", undefined, { enabled: false }),
);
// Immediately check (before any async)
expect(result.current.loading).toBe(false);
expect(result.current.files).toEqual([]);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual([]);
expect(mockFetchSessionFiles).not.toHaveBeenCalled();
});
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-07:30 (dashboard-app feed):
THE INVARIANT: the Files tab loads for any column whose ROLE carries a worktree.
CENSUS-INVISIBLE. The gate was a `Set` literal — a definition, not a comparison — so nothing in the
lifecycle backlog pointed at this hook. Found by grepping for lane-shaped list literals after the
same shape turned up in `duplicate-intake`, `blocker-fanout` and the ephemeral zombie sweep.
On a renamed board the set matched nothing, so the fetch NEVER FIRED and the Files tab was
permanently empty for every card that had one. An empty file list is indistinguishable from a task
that touched no files, which is why nobody would report it.
The flags are collapsed to a boolean before the effect on purpose: `columnFlags` is an object, and a
caller constructing it inline would hand this hook a fresh identity every render, so putting it in
the dep array would refetch on every parent render. That is asserted below — a re-render with an
equal-but-not-identical flags object must not trigger a second fetch.
REVERT PROOF, measured: restore `ACTIVE_COLUMNS.has(column)` and the renamed-wip case fails with
zero fetches.
*/
describe("useSessionFiles resolves the column's role", () => {
const WIP_FLAGS = { countsTowardWip: true } as never;
const HOLD_FLAGS = { hold: true } as never;
it("fetches for a RENAMED wip lane", async () => {
mockFetchSessionFiles.mockResolvedValueOnce(["src/a.ts"]);
const { result } = renderHook(() =>
useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "building", undefined, { columnFlags: WIP_FLAGS }));
await waitFor(() => expect(result.current.files).toEqual(["src/a.ts"]));
});
it("does NOT fetch for a column whose role carries no worktree", async () => {
/*
The gate must still gate — a hold-lane card has no worktree to read.
Asserted as a DELTA rather than "not called at all": this file's hooks are not unmounted between
cases, so a prior case's in-flight fetch can land inside this one. The absolute assertion passed
in isolation and failed in the suite, which is the classic shape of a test that would have been
"fixed" by reordering rather than by being made independent.
*/
const before = mockFetchSessionFiles.mock.calls.length;
renderHook(() =>
useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "backlog", undefined, { columnFlags: HOLD_FLAGS }));
await waitFor(() => expect(mockFetchSessionFiles.mock.calls.length).toBe(before));
});
it("does not refetch when an equal-but-new flags object arrives", async () => {
// The object-identity trap the derived boolean exists to avoid.
mockFetchSessionFiles.mockResolvedValue(["src/a.ts"]);
const { rerender, result } = renderHook(
({ flags }) => useSessionFiles("FN-123", "/repo/.worktrees/kb-123", "building", undefined, { columnFlags: flags }),
{ initialProps: { flags: { countsTowardWip: true } as never } },
);
await waitFor(() => expect(result.current.files).toEqual(["src/a.ts"]));
const callsAfterFirst = mockFetchSessionFiles.mock.calls.length;
rerender({ flags: { countsTowardWip: true } as never });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockFetchSessionFiles.mock.calls.length).toBe(callsAfterFirst);
});
});