Files
fusion/packages/dashboard/app/hooks/useSessionFiles.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

112 lines
3.6 KiB
TypeScript

import { useEffect, useState } from "react";
import { fetchSessionFiles } from "../api";
import {
isCompleteColumnRole,
isReviewColumnRole,
isWipColumnRole,
type ColumnRoleFlags,
} from "../utils/columnRoles";
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-07:30 (dashboard-app feed):
Session files load for cards that HAVE a worktree — wip, review, and complete.
CENSUS-INVISIBLE: a `Set` literal is a definition, not a comparison, so nothing in the lifecycle
backlog pointed at this hook. Found by grepping for lane-shaped list literals.
On a renamed board the set matched nothing, so the Files tab was permanently EMPTY for every card
that had one — the fetch simply never fired. An empty file list is indistinguishable from a task
that touched no files, which is why this would never be reported as a bug.
DELIBERATE-LITERAL — the unresolved-flags default, reviewed 2026-07-31-07:30. `columnFlags` is
optional so every existing caller (and the hook's own test) is byte-identical; the role helpers in
`columnRoles.ts` own the legacy-id degraded mode.
*/
const LEGACY_ACTIVE_COLUMNS = new Set(["in-progress", "in-review", "done"]);
function hasWorktreeBearingRole(column: string, flags: ColumnRoleFlags | undefined): boolean {
if (!flags) return LEGACY_ACTIVE_COLUMNS.has(column);
return isWipColumnRole(flags, column)
|| isReviewColumnRole(flags, column)
|| isCompleteColumnRole(flags, column);
}
interface UseSessionFilesResult {
files: string[];
loading: boolean;
}
interface UseSessionFilesOptions {
/** Enable fetching when true (default). Suppresses fetches for offscreen cards. */
enabled?: boolean;
/** Resolved trait flags for `column`. Omitted → the legacy ids, i.e. today's behaviour. */
columnFlags?: ColumnRoleFlags;
}
/**
* Fetches session files for tasks with active worktrees.
*
* @param taskId - Task identifier
* @param worktree - Worktree path (undefined = no worktree)
* @param column - Current task column
* @param projectId - Optional project identifier
* @param options.enabled - When false, no fetch is made and returns empty/stable state
*/
export function useSessionFiles(
taskId: string,
worktree: string | undefined,
column: string,
projectId?: string,
options: UseSessionFilesOptions = {},
): UseSessionFilesResult {
const enabled = options.enabled ?? true;
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-07:30 (dashboard-app feed):
Collapsed to a BOOLEAN before the effect, deliberately. `columnFlags` is an object, and a caller
building it inline hands this hook a new identity every render — putting it in the dep array below
would refetch on every parent render. The derived boolean is stable for a stable answer.
*/
const columnBearsWorktree = hasWorktreeBearingRole(column, options.columnFlags);
const [files, setFiles] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
// Disabled state: return stable empty state without fetching
if (!enabled) {
setFiles([]);
setLoading(false);
return;
}
if (!taskId || !worktree || !columnBearsWorktree) {
setFiles([]);
setLoading(false);
return;
}
const cancelled = false;
async function load() {
setLoading(true);
try {
const result = await fetchSessionFiles(taskId, projectId);
if (!cancelled) {
setFiles(result);
}
} catch {
if (!cancelled) {
setFiles([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
}, [taskId, worktree, column, projectId, enabled, columnBearsWorktree]);
return { files, loading };
}