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>
194 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|