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>
112 lines
3.6 KiB
TypeScript
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 };
|
|
}
|