Files
fusion/packages/dashboard/app/hooks/useTasks.ts
gsxdsm 31e49b684a TAKING default-workflow-hooks.ts + executor.ts + live-agent-count.ts + 6 dashboard files: reopen semantics by role, and the census's blind spot in both directions (13 sites) (#2628)
Batched conversion of every lifecycle-column guard I hold, plus the
three the census could not see. **Six files to zero, repo-wide 60 → 49
by a comment-stripped unanchored sweep.** Each conversion has an
isolated revert proof and a paired negative case, and the one code move
is a separate commit from the behavior changes.

## Per-file before → after

Counts from a comment-stripped, unanchored `(===|!==) ["']triage["']`
sweep over `packages/*/src` + `plugins/*/src`, excluding tests.

| file | before | after | note |
|---|---:|---:|---|
| `core/default-workflow-hooks.ts` | 4 | **0** | |
| `core/task-store/moves.ts` | 5 | **4** | only the flag-ON mirror
converted; the flag-OFF inline block is the parity reference and stays |
| `engine/executor.ts` | 3 | **0** | **absent from the 45-guard list** —
see below |
| `core/live-agent-count.ts` | 2 | **0** | duplication removed; answer
deliberately unchanged |
| `engine/replan-target.ts` | 2 | **0** | both were comment prose, not
guards |
| `core/agent-prompts.ts` | 3 | **0** | ROLE comparisons, never column
guards |
| `engine/usage-limit-detector.ts` | 2 | **0** | ROLE comparisons |
| `dashboard/app/components/DocumentsView.tsx` | 1 | **0** | real column
guard |
| `dashboard/app/components/TaskChatTab.tsx` | 2 | **0** | ROLE |
| `dashboard/app/components/AgentLogViewer.tsx` | 1 | **0** | ROLE |
| `dashboard/app/components/effective-model-resolution.ts` | 1 | **0** |
ROLE |
| `dashboard/app/hooks/useTasks.ts` | 1 | **0** | ROLE |
| `dashboard/…/command-center/MissionControlPanel.tsx` | 1 | 1 | alias
table, marked `DELIBERATE-LITERAL` with its reason |

## The census errs in BOTH directions

This is the finding I would most like carried into the remaining work.

- It **flagged 10 sites that were never column guards.** `role ===
"triage"` / `agentType === "triage"` compare an **AGENT ROLE**. The
planner *lane* is named `triage` and keeps that name — U11 removed the
*column*. Worse than noise: the obvious "finish the migration" edit is
to rename the role, and that silently empties the planner's prompt
template and mis-binds its model markers. `PLANNER_AGENT_ROLE` now names
it, so the two vocabularies are distinguishable by grep and a rename
fails loudly (revert proof: 4 tests, two of them pre-existing).
- It **missed 3 real guards in `executor.ts`**, because the pattern
matches `column`/`toColumn`/`fromColumn` and those locals are named
`from` and `originColumn`. A census keyed on variable names will keep
missing guards wherever a local was named for its role in the function.

## Two real defects, not tidying

**1. A renamed board could merge with its re-review never run.**
`default-workflow-hooks.ts` is named for the default workflow, but the
store runs it on the flag-ON path for *every* workflow — the trait
registry resolves hooks by trait id, not by workflow. Its reopen
predicates listed the default lineage's column names, so on a renamed
board **no reopen effect fired at all**. One of them clears
`workflowStepResults`, which `getTaskMergeBlocker` reads: a card bounced
out of review carried its old `passed` result back in, and that
satisfies the merge gate. Same regression the graph-owned-crossing
carve-out exists to prevent, arriving through the other door. (Two
smaller ones rode along: failure state never cleared on a renamed
reopen, and an operator dragging a card back to the queue never parked
it, so the scheduler re-dispatched what they had just pulled back.)

**I forgot the carve-out on my first pass, and that was worse than not
converting.** A role-resolved clear plus a *name*-matched exemption
means a renamed board takes the clear and never the exemption,
destroying the remediation input the graph had just written. My own
paired negative test caught it.

**2. The last-resort recovery for completed-but-stranded work did not
exist off the default lineage.** In `recoverCompletedTask`,
`promotedFromPlannerColumn` was false on a renamed board, so finished
work resting in the planning lane was never promoted — the code fell
through to `handoffTaskToReview` straight from the planning column, and
role adjacency has no planning → review edge, so the handoff was
rejected and the card stayed stuck with its work complete. I converted
the promotion **target** too: resolving the lane and then moving to a
literal `in-progress` is the half-conversion I have already been burned
by twice this program, where the guard starts admitting cards and the
move then sends them to a column the board does not declare.

## E2E evidence

`renamed-board-reopen.pg.test.ts` drives a **real PostgreSQL store** and
a real `moveTask` on a workflow whose columns carry the standard traits
under non-default names. The unit tests cannot show this: if `moves.ts`
passed `undefined`, every unit case still passes via the no-basis
fallback while the real board keeps the old behavior. **Proof it is
load-bearing: forcing `moveLifecycleColumns` to `undefined` fails 2 of
3.** The executor suite covers both the split-role and the MERGED
post-U11 shape.

## Revert proofs, isolated per site

| change reverted | result |
|---|---|
| reopen predicate → literal names | 4 of 10 fail |
| reopen field clears → literal names | 2 of 10 fail |
| `userPaused` hold lane → literal `todo` | 1 of 10 fail |
| graph carve-out → literal names | 1 of 10 fail |
| store passes `undefined` lifecycle columns | 2 of 3 fail (real PG) |
| `promotedFromPlannerColumn` → literals | 3 of 7 fail |
| two-hop condition → `=== "triage"` | 1 of 7 fails |
| promotion target → `"in-progress"` | 3 of 7 fail |
| `isPlannerColumnFor` → literals | 1 of 7 fails |
| live-agent-count: one arm dropped | 2 of 11 fail |
| DocumentsView: trait branch removed | 3 of 7 fail |
| planner role renamed to `"planner"` | 4 fail (2 pre-existing) |

Every conversion is paired with a negative case (a forward move, a
not-a-planner-lane card, a default-lineage card, a renamed column with
no traits), so neither "always fire" nor "never fire" can pass for
"resolve the role".

## Deliberately NOT converted, with reasons

- **`moves.ts` flag-OFF inline block (4).** That branch *is* the legacy
path, kept verbatim so the two can be parity-checked. Converting it
erases the reference implementation.
- **`live-agent-count.ts`'s no-flags fallback.** Reachable, and there is
nothing to resolve from — `enrich…FromFlags` exists for callers with
board flags rather than an IR, so a column missing from that map is the
renamed case. "Not intake" is as much a guess as "todo is intake", and
Running/Waiting are complements, so a card matching neither arm is
reported as neither and the footer's queued total under-reports it. The
real fix is at the caller; four new cases pin that flags override the
legacy answer **in both directions**. What did change is the
duplication: two hand-written copies of one rule now call one named
function.
- **`MissionControlPanel`'s `FUNNEL_STAGES`.** An alias table of column
*names* where `triage` sits beside `signal` and `backlog`. Command
Center aggregates across projects, so there is no single workflow to
resolve traits from — the honest conversion is a data change, not a
predicate change.
- **`DocumentsView` with no traits.** Same no-basis rule; the documents
list is full of historical columns absent from the current board. A case
asserts a renamed column with no traits still reads as "working",
documenting the gap rather than hiding it.

## Fixture findings

Each cost a red run that looked like the code under test:

- a `merge-blocker` column needs a reachable merge-class node, or
`parseWorkflowIr` rejects the workflow;
- a back-edge must be `kind: "rework"`, and a rework edge is legal only
**into** a node with `config.reworkRegion: true`;
- a workflow gets role-level transitions only when it declares wip +
review + complete + **archived** plus a planning lane — without the
archived column, adjacency falls back to order-derived neighbours and
`checking -> queued` is not a legal move at all;
- `recoverCompletedTask` only *reaches* the promotion seam when nothing
is left to gate; without passed `plan-review`/`code-review` rows it
re-enters the workflow graph and returns first, so a naive fixture
silently tests the wrong branch and every assertion reads "no moves
happened" for an unrelated reason.

## Verification

- `pnpm test:gate` **71/71**
- new suites: 10/10 reopen-semantics, 3/3 renamed-board-reopen (real
PG), 7/7 executor-planner-lanes, 7/7 documents-status-dot, 4/4
planner-role-is-not-a-column
- neighbours: 132 + 10 + 482 (gate shards), 350/351 engine
planning/replan suites, 64/64 agent-prompts, 51/51 usage-limit-detector,
11/11 live-agent-count, 11/11 dashboard hook/log suites
- the single engine failure (`executor-fast-mode-workflows.test.ts` ›
"raw fast mode still invokes non-executable review seam nodes")
**reproduces with my changes stashed** — pre-existing on `origin/main`
- typechecks clean for core, engine, and dashboard-app
(`tsconfig.app.json`; `tsconfig.json` checks nothing under `app/`);
`pnpm lint` clean

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:39:14 -07:00

1335 lines
62 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
import { PLANNER_AGENT_ROLE, normalizeColumnId } from "@fusion/core";
import * as api from "../api";
import { subscribeSse } from "../sse-bus";
import { clearCache, readCache, readCacheSavedAt, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { pushTrace } from "../utils/dashboardTraceBuffer";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { isLikelyTabSuspensionError } from "./visibilitySuspension";
const loggedTaskCacheHitProjects = new Set<string>();
/*
FNXC:MobileTabDiscard 2026-07-26-10:34:
In-app task-view re-entry freshness is deliberately NOT the hydration TTL. `SWR_TASKS_MAX_AGE_MS` was
raised to hours so a discarded mobile tab can repaint its last board instantly; this bound answers a
different question — "is the LIVE in-memory snapshot recent enough to skip the catch-up fetch when the
user returns to Board/List within the same page session?" — and must stay short, because task SSE is
disabled off task-list views and missed events need server confirmation.
*/
const TASK_VIEW_REENTRY_FRESHNESS_MS = 60_000;
/*
FNXC:MobileTabDiscard 2026-07-26-16:40:
Destroying the task snapshot is only justified when the server actually answered and disagreed with it
(non-2xx, or a response that would not parse). A transport-level rejection on a waking mobile radio —
"Load failed" / "Failed to fetch" / an offline navigator — proves nothing about the snapshot's contents.
Before this guard, the mount revalidation's `clearOnError` catch ran on those rejections and both blanked
the hydrated board and deleted the cache entry, so the NEXT restore also started empty: a suspension
failure permanently defeated the feature the raised `SWR_TASKS_MAX_AGE_MS` exists to serve. Nine sibling
hooks (useProjects, useNodes, useMeshState, useExecutorStats, useUsageData, useMeshEngines,
useManagedDockerNodes, useProjectHealth) already guard this with the same `visibilitySuspension`
predicate; this reuses it rather than adding a tenth copy.
`navigator.onLine === false` is authoritative for "no request left the device"; `true` is not evidence of
reachability, so the message check still has to run. `lastRefreshErrorAt` is set either way, so the
re-entry/visibility retry paths still fire.
*/
function didFailureReachServer(error: unknown): boolean {
if (typeof navigator !== "undefined" && navigator.onLine === false) {
return false;
}
const message = error instanceof Error ? error.message : String(error ?? "");
return !isLikelyTabSuspensionError(message);
}
/*
FNXC:MobileTabDiscard 2026-07-26-10:40:
Snapshot-write budget. `writeCache` drops any payload over `maxBytes`, and it dropped SILENTLY: a board
with many long-lived tasks (each carrying an unbounded `log` array) serialized past 500KB, so nothing
was ever cached and the mobile-discard restore had no snapshot to hydrate from at all — the cache
appeared to "work" while being a no-op exactly on the boards that need it most. The snapshot is a render
seed, not a data mirror, so `log` is stripped (the board never renders it; task detail fetches its own),
and on a still-over-budget payload the row count is shrunk until the write lands.
*/
const TASK_CACHE_MAX_BYTES = 500_000;
const TASK_CACHE_ROW_LIMITS = [500, 250, 100, 50] as const;
function toCachedTaskRow(task: Task): Task {
const log = (task as Task & { log?: unknown }).log;
if (!Array.isArray(log) || log.length === 0) {
return task;
}
const { log: _droppedLog, ...rest } = task as Task & { log?: unknown };
return rest as Task;
}
/** Persist the board snapshot, shrinking row count until it fits the quota budget. Returns whether anything was written. */
function writeTaskCacheSnapshot(cacheKey: string, tasks: Task[]): boolean {
for (const limit of TASK_CACHE_ROW_LIMITS) {
const payload = tasks.length > limit ? tasks.slice(0, limit).map(toCachedTaskRow) : tasks.map(toCachedTaskRow);
// `!== false` so a test double that returns undefined is treated as a successful write
// rather than driving the shrink loop down to its smallest tier.
if (writeCache(cacheKey, payload, { maxBytes: TASK_CACHE_MAX_BYTES }) !== false) {
return true;
}
}
return false;
}
/*
FNXC:WorkflowColumns 2026-07-19-2b:05 (U12 / R2 / R11):
Every task the dashboard ingests — initial list, SWR revalidation, and each SSE event — passes
through here, so this one line decided whether custom columns exist in the UI at all. It used
`normalizeColumn` (since DELETED in U12), which kept only the six legacy ids and rewrote everything else to `triage`:
a card sitting in a user-authored `Merging` column rendered in Triage, and dragging it appeared to
do nothing. The move handler below already worked around this for its own `to` id ("normalizeColumn
alone would drop custom ids"), which fixed the symptom for one event and left the ingest path lossy.
`normalizeColumnId` sanitizes structurally (non-string/empty -> fallback) and passes real ids
through; membership belongs to the task's resolved workflow, not to a client-side enum.
*/
function normalizeTask(task: Task): Task {
return {
...task,
column: normalizeColumnId((task as Task & { column?: unknown }).column),
dependencies: Array.isArray(task.dependencies) ? task.dependencies : [],
steps: Array.isArray(task.steps) ? task.steps : [],
log: Array.isArray((task as Task & { log?: unknown }).log)
? (task as Task & { log?: Task["log"] }).log!
: [],
};
}
function isSoftDeleted(task: Task): boolean {
return Boolean(task.deletedAt);
}
function filterActiveTasks(tasks: Task[]): Task[] {
return tasks.filter((task) => !isSoftDeleted(task));
}
type AgentLogActivityEvent = Pick<AgentLogEntry, "taskId" | "timestamp" | "type" | "agent">;
function hasFreshAgentLog(task: Task, entry: AgentLogActivityEvent): boolean {
if (task.id !== entry.taskId) return false;
const logTimestampMs = Date.parse(entry.timestamp);
const taskUpdatedAtMs = Date.parse(task.updatedAt);
return Number.isFinite(logTimestampMs)
&& Number.isFinite(taskUpdatedAtMs)
&& logTimestampMs > taskUpdatedAtMs;
}
function clearInReviewStallForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task {
if (task.column !== "in-review" || !hasFreshAgentLog(task, entry)) return task;
if (!task.inReviewStall && !task.inReviewStalled && !task.stalledReview) return task;
/*
FNXC:DashboardStallBadges 2026-07-01-23:44:
Board cards must not show Stalled/Merge stalled while an in-review agent is actively writing logs. The task row can remain unchanged during merger/reviewer work, so fresh agent-log metadata clears only derived stall badge fields until the next authoritative task refresh.
*/
return {
...task,
inReviewStall: undefined,
inReviewStalled: undefined,
stalledReview: undefined,
};
}
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
This is the SOURCE of the planner-activity signal, and it was the narrowest gate of all:
it only stamped `recentAgentActivityAt` for cards literally in `triage`. #2515 removed
that column from the default lineage, so after that merge the stamp never happened for a
default-workflow card — and every consumer downstream (the pulsing Planning badge, the
agent-active row border, the column's executing count) had NO DATA to act on, however
correctly they resolved their own column traits.
Converting the consumers without this would have been cosmetic: they would ask the right
question of a field nothing ever set.
This hook processes SSE and has no resolved column metadata, so the lane is matched by id
against BOTH shapes — pre-merge `triage` and post-merge `todo`. Over-stamping a legacy
hold-lane card is harmless: every consumer additionally requires the column to be an
INTAKE lane before showing anything, so the extra timestamps are filtered downstream.
*/
const PLANNER_ACTIVITY_COLUMN_IDS = new Set(["triage", "todo"]);
function addRecentPlannerActivityForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task {
if (
!PLANNER_ACTIVITY_COLUMN_IDS.has(task.column)
|| task.status === "planning"
|| entry.agent !== PLANNER_AGENT_ROLE
|| !hasFreshAgentLog(task, entry)
) {
return task;
}
/*
FNXC:TaskActivity 2026-07-28-12:00:
A Planning card's border and pulsing badge must agree with the live planner
timeline. A fresh triage log can arrive before its status row, so retain this
client-only render signal until an authoritative task update replaces the row.
*/
return task.recentAgentActivityAt === entry.timestamp
? task
: { ...task, recentAgentActivityAt: entry.timestamp };
}
/**
* Compare two ISO timestamp strings.
* Returns positive if a is newer than b, negative if b is newer, 0 if equal.
*/
function compareTimestamps(a: string | undefined, b: string | undefined): number {
if (!a && !b) return 0;
if (!a) return -1; // b is newer if a has no timestamp
if (!b) return 1; // a is newer if b has no timestamp
return a.localeCompare(b);
}
/*
FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
`awaitingPlanning` is attached by `GET /api/tasks` only — SSE task payloads come straight from the
store, so a status-only live update would otherwise wipe it and flip TaskCard's badge back to its
step-count fallback mid-stall. Carry it across same-column updates, but ONLY while the step count is
unchanged: planning finishing is exactly a step-count change, and a stale `true` surviving that would
keep claiming "Queued to plan" for a card that is now Ready until the next full board fetch. When it
is dropped the fallback answers correctly in both directions (steps landed -> Ready, steps cleared ->
queued), so the degraded state is never the wrong label.
*/
function carryAwaitingPlanning(current: Task, incoming: Task): boolean | undefined {
if (incoming.awaitingPlanning !== undefined) return incoming.awaitingPlanning;
const stepCountUnchanged = (current.steps?.length ?? 0) === (incoming.steps?.length ?? 0);
return stepCountUnchanged ? current.awaitingPlanning : undefined;
}
function mergeSameColumnTask(current: Task, incoming: Task): Task {
return {
...incoming,
awaitingPlanning: carryAwaitingPlanning(current, incoming),
// Preserve stable execution metadata when a same-column live update arrives
// without the full task payload (common during status/log-only SSE updates).
columnMovedAt: current.columnMovedAt ?? incoming.columnMovedAt,
executionStartedAt: current.executionStartedAt ?? incoming.executionStartedAt,
executionCompletedAt: current.executionCompletedAt ?? incoming.executionCompletedAt,
firstExecutionAt: current.firstExecutionAt ?? incoming.firstExecutionAt,
cumulativeActiveMs: incoming.cumulativeActiveMs ?? current.cumulativeActiveMs,
worktree: incoming.worktree ?? current.worktree,
modifiedFiles: incoming.modifiedFiles ?? current.modifiedFiles,
timedExecutionMs: incoming.timedExecutionMs ?? current.timedExecutionMs,
workflowStepResults: incoming.workflowStepResults ?? current.workflowStepResults,
tokenUsage: incoming.tokenUsage ?? current.tokenUsage,
mergeDetails: incoming.mergeDetails ?? current.mergeDetails,
};
}
function mergeIncomingTask(current: Task, incoming: Task): Task {
const updatedAtCompare = compareTimestamps(incoming.updatedAt, current.updatedAt);
if (updatedAtCompare < 0) {
return current;
}
if (current.column === incoming.column) {
return mergeSameColumnTask(current, incoming);
}
const columnTimestampCompare = compareTimestamps(current.columnMovedAt, incoming.columnMovedAt);
if (current.columnMovedAt && !incoming.columnMovedAt) {
return { ...incoming, column: current.column, columnMovedAt: current.columnMovedAt };
}
if (columnTimestampCompare >= 0) {
return { ...incoming, column: current.column, columnMovedAt: current.columnMovedAt };
}
return incoming;
}
export interface UseTasksOptions {
/**
* When provided, fetches tasks only for this project.
* SSE events from other project contexts are ignored.
*/
projectId?: string;
/**
* When provided, fetches tasks matching this search query.
* Server-side full-text search across title, ID, description, and comments.
*/
searchQuery?: string;
/**
* When false, disables SSE live-update subscription to free browser
* HTTP/1.1 connection slots for other operations (e.g., mission detail fetches).
* Initial fetch and visibility-change refresh remain active regardless.
* Defaults to true.
*/
sseEnabled?: boolean;
}
export function useTasks(options?: UseTasksOptions) {
const projectId = options?.projectId;
const searchQuery = options?.searchQuery;
const sseEnabled = options?.sseEnabled ?? true;
/*
FNXC:MobileTabDiscard 2026-07-26-10:48:
First paint after a mobile tab discard must show the last known board, not an empty one. This
initializer is the only thing standing between the restore and a blank board, so it hydrates from a
snapshot that may be hours old (`SWR_TASKS_MAX_AGE_MS`). That is safe only because hydration is
always paired with revalidation: `isStale` starts true (App renders <TopProgressBar visible> off it)
and the mount effect below unconditionally issues one `refreshTasks({ clearOnError: true })`.
FNXC:MobileTabDiscard 2026-07-26-16:40:
CORRECTION to the sentence this note used to end with ("whose failure branch CLEARS this cache entry
so a wrong snapshot cannot survive into the next restore"): that is true only of a failure that
REACHED THE SERVER. A transport-level rejection (suspended tab, offline radio) now leaves the entry
and the painted rows alone, because it is not evidence the snapshot is wrong — and deleting on it made
every flaky-radio restore blank, permanently. See the catch branch of `refreshTasks`.
*/
/*
FNXC:MobileTabDiscard 2026-07-26-14:12:
Captured by the `tasks` initializer below and consumed by the `lastFetchTimeMs` ref initializer on
the SAME first render, so the hydrated board is described by the snapshot's real write time from its
very first paint. A `useRef` initial value is only honored on first render, which is exactly when the
`useState` initializer runs — the two stay in lockstep with no effect-ordering gap. Reading the
timestamp in an effect instead would be wrong: the value returned to consumers is `.current` read
during render, so the first (restore) frame would still ship `undefined`.
It is only read when a snapshot actually hydrated, so a cache miss leaves the clock `undefined`.
*/
let hydratedSnapshotSavedAtMs: number | undefined;
const [tasks, setTasks] = useState<Task[]>(() => {
if (!projectId) {
return [];
}
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<Task[]>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
hydratedSnapshotSavedAtMs = readCacheSavedAt(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (cachedTasks.length > 0 && !loggedTaskCacheHitProjects.has(projectId)) {
loggedTaskCacheHitProjects.add(projectId);
console.info("[swr-cache] hit tasks=", cachedTasks.length, "projectId=", projectId);
}
}
return Array.isArray(cachedTasks) ? filterActiveTasks(cachedTasks.map(normalizeTask)) : [];
});
const [isStale, setIsStale] = useState(true);
const [lastRefreshErrorAt, setLastRefreshErrorAt] = useState<number | null>(null);
// Once the user expands the archived column, we keep including archived tasks
// in subsequent refreshes for the lifetime of this hook instance.
/*
FNXC:ArchivePagination 2026-07-08-00:00:
FN-7659 retired the merged-refresh path this flag used to drive (see the
loadArchivedTasks note below): nothing sets it true anymore, so it is kept
as a stable `false` constant purely for return-type/back-compat rather
than reactive state.
*/
const includeArchived = false;
const includeArchivedRef = useRef(includeArchived);
const tasksRef = useRef(tasks);
const fetchVersionRef = useRef(0);
// Tracks the project context version to detect stale SSE events after project switches.
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
const projectContextVersionRef = useRef(0);
const lastVisibilityRefreshRef = useRef<number>(0);
const contextVersionAtLastVisibilityRef = useRef(projectContextVersionRef.current);
const droppedStaleEventsRef = useRef(0);
const searchQueryRef = useRef(searchQuery);
const refreshTasksRef = useRef<typeof refreshTasks>(null!);
const prevSseEnabledRef = useRef(sseEnabled);
/*
FNXC:MobileTabDiscard 2026-07-26-14:12:
"Data as of" clock for everything derived from `tasks` (isTaskStuck / countStuckTasks, TaskCard's
isStuck + isAgentActive, Column's activeTaskCount, ExecutorStatusBar's stuck counters, and the
taskRecovery affordances). It describes the AGE OF THE ROWS CURRENTLY IN `tasks`, not the age of
this hook instance.
It is seeded from the hydrated snapshot's envelope `savedAt` rather than left `undefined`. When it
is `undefined` every consumer falls back to `Date.now()`; combined with the raised
`SWR_TASKS_MAX_AGE_MS`, an iOS-PWA restore that hydrated a 2-hour-old board measured hours-old
`updatedAt` values against NOW and rendered every in-progress card 'stuck' (and forced
isAgentActive false, killing the live pulse) until the mount revalidation resolved — seconds on a
waking mobile radio, precisely the restore this cache exists to improve. Seeding makes the first
paint honest; `refreshTasks` overwrites it with `Date.now()` the moment real server data lands.
*/
const lastFetchTimeMs = useRef<number | undefined>(hydratedSnapshotSavedAtMs);
/*
FNXC:MobileTabDiscard 2026-07-26-16:40:
True only after a successful full-board fetch has confirmed EVERY row currently in `tasks`. A hydrated
snapshot does not confirm anything, so this starts false on mount and is reset to false whenever a
project switch repaints from cache.
It gates the live-update writers of `lastFetchTimeMs` below. Those writers (task:created / task:moved /
task:updated, plus `ingestCreatedTasks`) each learn about ONE row, but `lastFetchTimeMs` is read as the
as-of time of ALL rows. While a hydrated hours-old board is still waiting for the mount revalidation on
a waking radio, a single unrelated task:created event used to stamp the whole board as measured-from-
now — re-creating, through a sibling path, the exact "every in-progress card renders stuck" regression
the savedAt seeding was added to fix.
Once a fetch HAS confirmed the board, advancing on a live event stays honest and is deliberately kept:
the stream reports every task mutation in the project, so a row that produced no event really has not
changed and `now - updatedAt` is its true idle time. Freezing the clock at the last fetch instead would
make stuck detection silently stop firing for the rest of a long SSE session (there is no periodic
poll in this hook) — a false negative traded for the false positive.
*/
const boardFetchConfirmedRef = useRef(false);
/*
FNXC:MobileTabDiscard 2026-07-26-16:40:
Single seam for the live-update writers of the freshness clock; see `boardFetchConfirmedRef`. Kept as
one function so a future fifth writer cannot reintroduce the ungated `lastFetchTimeMs.current =
Date.now()` pattern by copy-paste.
*/
const advanceFreshnessClockForLiveUpdate = useCallback(() => {
if (!boardFetchConfirmedRef.current) {
return;
}
lastFetchTimeMs.current = Date.now();
}, []);
const lastConfirmedProjectIdRef = useRef<string | undefined>(undefined);
const lastConfirmedSearchQueryRef = useRef<string | undefined>(undefined);
const lastConfirmedIncludeArchivedRef = useRef(false);
// Track previous projectId to detect changes
const previousProjectIdRef = useRef<string | undefined>(projectId);
/*
FNXC:ArchivePagination 2026-07-08-01:30:
Declared ahead of `refreshTasks` (rather than alongside the rest of the
archived-pagination state below) because `refreshTasks` reads it on every
generic refresh to decide whether to carry merged archived rows forward.
Code review (FN-7659) found `refreshTasks`'s unconditional
`setTasks(normalizedFetchedTasks)` silently discarded the archived page(s)
merged in by `loadArchivedTasks`/`loadMoreArchivedTasks` on the very next
SSE reconnect, tab-visibility regain, delete-invalidation refresh, or
search-then-clear — making `loadArchivedTasks` a permanent no-op
(`archivedLoadedRef.current` stays true) and silently emptying the
Archived column for the rest of the session.
*/
const archivedLoadedRef = useRef(false);
tasksRef.current = tasks;
searchQueryRef.current = searchQuery;
// Detect project changes and invalidate SSE context.
// Keep previous tasks visible while the new project's fetch is in flight
// (stale-while-revalidate) to avoid a blank flash and a full empty→populated
// re-reconcile of the board. The refreshTasks fetch guard (requestProjectId)
// rejects late responses from the previous project, and SSE handlers check
// projectContextVersionRef before applying events.
if (previousProjectIdRef.current !== projectId) {
previousProjectIdRef.current = projectId;
projectContextVersionRef.current++;
}
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean; searchQueryOverride?: string; includeArchivedOverride?: boolean }) => {
const requestVersion = ++fetchVersionRef.current;
const requestProjectId = projectId; // Capture the projectId for this request
const query = options?.searchQueryOverride ?? searchQueryRef.current;
/*
FNXC:ArchivePagination 2026-07-08-01:30:
When a search query is active and the user has expanded the Archived
column at least once this session, include archived rows in the
search-scoped fetch by default (unless the caller explicitly overrides).
This is bounded — the merged `listTasks`/`searchTasks` archived branch
already runs through `archiveDb.search()`'s own limit, not a full-table
load — and restores the pre-FN-7659 behavior where, once expanded,
search results included archived matches. A cleared/empty query falls
back to the narrow legacy `includeArchivedRef` (always false) so an
ordinary refresh never re-triggers a merged archived fetch; the Archived
column's own rows are instead carried forward below.
*/
const wantArchived = options?.includeArchivedOverride ?? (query ? archivedLoadedRef.current : includeArchivedRef.current);
try {
const fetchedTasks = await api.fetchTasks(undefined, undefined, requestProjectId, query, wantArchived);
// Reject if project changed (compare against the projectId at request time) or version is stale
if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
return;
}
const normalizedFetchedTasks = filterActiveTasks(fetchedTasks.map(normalizeTask));
/*
FNXC:ArchivePagination 2026-07-08-01:30:
A generic refresh (SSE reconnect resync, tab-visibility regain, delete-
fetch invalidation, project switch, or a search that has been cleared
back to "") always fetches with `includeArchived=false` and would
otherwise blow away any archived page(s) already merged in by
`loadArchivedTasks`/`loadMoreArchivedTasks`, making the Archived column
go silently empty and `loadArchivedTasks` a permanent no-op for the
rest of the session (code review finding, FN-7659). When this fetch
did not itself request archived rows and there is no active search
filter, carry the previously merged archived rows (`column ===
"archived"`) forward from the latest task state instead of discarding
them; active/non-archived rows from the fresh fetch stay authoritative
by id. A non-empty search query intentionally skips carry-over: `wantArchived`
is already derived above from `archivedLoadedRef` for query-bearing
fetches, so search results include fresh, query-matched archived rows
directly and boundedly (via `archiveDb.search()`'s own limit) rather
than re-showing stale, query-unfiltered archived cards from this branch.
Carry-over reads from `archivedTasksRef` (the canonical accumulator
maintained by `mergeArchivedPage`), not from the previous `tasks`
state, so a search that temporarily narrowed `tasks` to only its
matches cannot cause previously loaded archived rows to be lost once
the query is cleared.
*/
const shouldCarryOverArchived = !wantArchived && !query && archivedLoadedRef.current;
if (shouldCarryOverArchived) {
const freshIds = new Set(normalizedFetchedTasks.map((task) => task.id));
const archivedCarryOver = archivedTasksRef.current.filter((task) => !freshIds.has(task.id));
setTasks(archivedCarryOver.length > 0 ? [...normalizedFetchedTasks, ...archivedCarryOver] : normalizedFetchedTasks);
} else {
setTasks(normalizedFetchedTasks);
}
if (requestProjectId) {
writeTaskCacheSnapshot(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`, fetchedTasks);
}
setIsStale(false);
setLastRefreshErrorAt(null);
// Record when we received fresh server data for stuck detection
lastFetchTimeMs.current = Date.now();
// FNXC:MobileTabDiscard 2026-07-26-16:40: every row in `tasks` is now server-confirmed, so live
// single-row updates may advance the clock from here on.
boardFetchConfirmedRef.current = true;
lastConfirmedProjectIdRef.current = requestProjectId;
lastConfirmedSearchQueryRef.current = query;
lastConfirmedIncludeArchivedRef.current = wantArchived;
} catch (error) {
// Reject if project changed or version is stale
if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
return;
}
setLastRefreshErrorAt(Date.now());
/*
FNXC:MobileTabDiscard 2026-07-26-10:52:
Load-bearing for the long hydration TTL: a snapshot is only allowed to outlive a tab discard
because a failed revalidation deletes it here. Without this, an unverifiable board could be
re-hydrated on every subsequent restore for the whole TTL window. Do not weaken.
FNXC:MobileTabDiscard 2026-07-26-16:40:
CORRECTION — the 10:52 note above claimed "a failed revalidation deletes it", and the code did
exactly that for EVERY failure. That was wrong, and it broke the case the cache exists for: the
mount revalidation runs on a just-woken mobile radio, where the first fetch routinely rejects at
the transport layer. Such a rejection carries no information about the snapshot, yet it deleted
the entry AND (with `clearOnError`) blanked the freshly hydrated board — so the restore went white
and the next restore had nothing left to hydrate. Only a failure that actually reached the server
is allowed to destroy the snapshot; see `didFailureReachServer`. Suspension/offline failures leave
both the cache and the on-screen rows intact and rely on the retry paths keyed off
`lastRefreshErrorAt`.
*/
if (!didFailureReachServer(error)) {
return;
}
if (requestProjectId) {
clearCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`);
}
if (options?.clearOnError) {
setTasks([]);
return;
}
}
}, [projectId]);
refreshTasksRef.current = refreshTasks;
const shouldRefreshOnTaskViewReentry = useCallback(() => {
if (lastRefreshErrorAt !== null) return true;
if (searchQueryRef.current) return true;
if (includeArchivedRef.current) return true;
if (lastConfirmedProjectIdRef.current !== projectId) return true;
if (lastConfirmedSearchQueryRef.current !== searchQueryRef.current) return true;
if (lastConfirmedIncludeArchivedRef.current !== includeArchivedRef.current) return true;
const lastFetchAt = lastFetchTimeMs.current;
if (lastFetchAt === undefined) return true;
return Date.now() - lastFetchAt > TASK_VIEW_REENTRY_FRESHNESS_MS;
}, [lastRefreshErrorAt, projectId]);
/*
FNXC:DashboardTaskCache 2026-06-29-22:35:
Brief Board/List returns should reuse fresh in-memory task state instead of issuing another all-task fetch, so the existing task array renders immediately without an empty/loading shell. Stale, missing, failed, project/search, or archived snapshots still perform one catch-up because task SSE is disabled off task-list views and missed events need server confirmation.
FNXC:DashboardTaskCache 2026-06-29-23:12:
The freshness shortcut is scoped only to in-app task-view re-entry. Initial mount, tab visibility recovery, SSE reconnect resync, search refreshes, and delete fetch-version invalidation remain independent safety paths because each represents either a new browser/server gap or a changed query context.
*/
useEffect(() => {
const previous = prevSseEnabledRef.current;
prevSseEnabledRef.current = sseEnabled;
if (previous === false && sseEnabled === true && shouldRefreshOnTaskViewReentry()) {
void refreshTasksRef.current();
}
}, [shouldRefreshOnTaskViewReentry, sseEnabled]);
/*
FNXC:ArchivePagination 2026-07-08-00:00:
FN-7659 — the Archived column must load newest-first (`archivedAt DESC`) in
server-backed pages of 100 with an explicit "Show more" affordance, and the
full archive must never load into memory in one pass. The prior
implementation flipped `includeArchived` and re-ran the merged `refreshTasks`
(backed by `listTasks({includeArchived:true})`), which (a) sorted archived
rows oldest-first alongside active rows and (b) fetched the ENTIRE archive
on every subsequent refresh (SSE reconnect, tab-visibility recovery,
search) once the column had ever been expanded. `loadArchivedTasks`/
`loadMoreArchivedTasks` now call the dedicated `GET /tasks/archived` page
read and merge only the fetched page into `tasks` (de-duplicated by id,
active SQLite rows authoritative — mirrors the existing collapse-by-id
invariant). `includeArchived` is intentionally left untouched here so it
keeps its narrow legacy meaning (an explicit search override) instead of
being repurposed to gate a full-archive refetch.
*/
const [archivedHasMore, setArchivedHasMore] = useState(false);
const [archivedLoadingMore, setArchivedLoadingMore] = useState(false);
const archivedOffsetRef = useRef(0);
// Note: archivedLoadedRef is declared earlier (near tasksRef) so refreshTasks can read it.
const archivedLoadingMoreRef = useRef(false);
/*
FNXC:ArchivePagination 2026-07-08-01:30:
Canonical store of every archived row merged in so far via
`loadArchivedTasks`/`loadMoreArchivedTasks`, independent of the transient
`tasks` state. A search-scoped `refreshTasks` fetch can temporarily
replace `tasks` with only the query-matched rows (active + matching
archived); if the generic-refresh carry-over in `refreshTasks` read
archived rows back out of `tasks` at that point, clearing the query would
"carry over" only the narrower search-result set and permanently lose any
previously loaded archived rows that did not match the last query. Keeping
a dedicated accumulator means carry-over always restores the full set of
archived rows loaded so far, regardless of what the last fetch's result
shape happened to be.
*/
const archivedTasksRef = useRef<Task[]>([]);
const mergeArchivedPage = useCallback((page: Task[]) => {
const normalizedPage = page.map(normalizeTask);
const knownArchivedIds = new Set(archivedTasksRef.current.map((task) => task.id));
const newArchived = normalizedPage.filter((task) => !knownArchivedIds.has(task.id));
if (newArchived.length > 0) {
archivedTasksRef.current = [...archivedTasksRef.current, ...newArchived];
}
setTasks((prev) => {
const existingIds = new Set(prev.map((task) => task.id));
const additions = normalizedPage.filter((task) => !existingIds.has(task.id));
if (additions.length === 0) return prev;
return [...prev, ...additions];
});
}, []);
/** Lazy-load archived tasks, page 1 (100, newest-first). Called by the Board when the archived column is first expanded. */
const loadArchivedTasks = useCallback(async () => {
if (archivedLoadedRef.current) return;
archivedLoadedRef.current = true;
try {
const { tasks: page, hasMore } = await api.fetchArchivedTasks(projectId, 100, 0);
mergeArchivedPage(page);
archivedOffsetRef.current = page.length;
setArchivedHasMore(hasMore);
} catch {
// Allow a future expand attempt to retry the first page.
archivedLoadedRef.current = false;
}
}, [projectId, mergeArchivedPage]);
/** Fetch the next 100-item page of archived tasks. No-op when there is no further page or a fetch is already in flight. */
const loadMoreArchivedTasks = useCallback(async () => {
if (!archivedLoadedRef.current || archivedLoadingMoreRef.current) return;
if (!archivedHasMore) return;
archivedLoadingMoreRef.current = true;
setArchivedLoadingMore(true);
try {
const { tasks: page, hasMore } = await api.fetchArchivedTasks(projectId, 100, archivedOffsetRef.current);
mergeArchivedPage(page);
archivedOffsetRef.current += page.length;
setArchivedHasMore(hasMore);
} finally {
archivedLoadingMoreRef.current = false;
setArchivedLoadingMore(false);
}
}, [projectId, archivedHasMore, mergeArchivedPage]);
// Debounced search effect - separate from refreshTasks to avoid dependency cycle
const prevSearchQueryRef = useRef<string | undefined>(searchQuery);
useEffect(() => {
// Skip only the initial mount when query has never been set; the visibility
// effect handles the first fetch. Going from a defined value back to
// undefined/"" must still trigger a refetch so the filter is cleared.
if (searchQuery === undefined && prevSearchQueryRef.current === undefined) return;
prevSearchQueryRef.current = searchQuery;
const timer = setTimeout(() => {
void refreshTasks({ searchQueryOverride: searchQuery });
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]); // intentionally NOT including refreshTasks in deps
useEffect(() => {
if (!projectId) {
return;
}
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<Task[]>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
if (cachedTasks.length > 0 && !loggedTaskCacheHitProjects.has(projectId)) {
loggedTaskCacheHitProjects.add(projectId);
console.info("[swr-cache] hit tasks=", cachedTasks.length, "projectId=", projectId);
}
setTasks(filterActiveTasks(cachedTasks.map(normalizeTask)));
/*
FNXC:MobileTabDiscard 2026-07-26-14:18:
A project switch replaces `tasks` with the new project's snapshot, so the freshness clock must
be replaced too — the previous project's fetch time no longer describes these rows. Only set it
when a snapshot was actually hydrated: on a cache miss the previous project's rows stay on
screen (stale-while-revalidate), and their real fetch time remains the honest answer. This runs
before the mount/refresh effect below, so the fetch that resolves next still wins.
*/
lastFetchTimeMs.current = readCacheSavedAt(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
// FNXC:MobileTabDiscard 2026-07-26-16:40: these rows come from cache, not the server — an SSE
// event must not stamp them as measured-from-now until this project's fetch confirms them.
boardFetchConfirmedRef.current = false;
}
setIsStale(true);
}, [projectId]);
// Fetch initial tasks and recover when the tab becomes visible again.
/*
FNXC:MobileTabDiscard 2026-07-26-10:55:
The mandatory half of stale-while-revalidate. Runs once per mount (and per projectId change) with no
freshness shortcut, so a board hydrated from an hours-old snapshot is always corrected by exactly one
immediate fetch, and `isStale` marks the window so the revalidating indicator is visible meanwhile.
*/
useEffect(() => {
setIsStale(true);
void refreshTasks({ clearOnError: true });
// FNXC:ArchivePagination 2026-07-08-00:00: reset archived-page state on
// project switch so a new project's Archived column starts collapsed
// and re-fetches its own page 1 rather than reusing the previous
// project's offset/hasMore.
archivedLoadedRef.current = false;
archivedOffsetRef.current = 0;
archivedLoadingMoreRef.current = false;
archivedTasksRef.current = [];
setArchivedHasMore(false);
setArchivedLoadingMore(false);
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") {
contextVersionAtLastVisibilityRef.current = projectContextVersionRef.current;
return;
}
const previousContextVersion = contextVersionAtLastVisibilityRef.current;
const contextChangedWhileHidden = previousContextVersion !== projectContextVersionRef.current;
contextVersionAtLastVisibilityRef.current = projectContextVersionRef.current;
if (contextChangedWhileHidden) {
lastVisibilityRefreshRef.current = Date.now();
pushTrace("useTasks", "visibility-context-version-changed", {
projectId,
previousContextVersion,
currentContextVersion: projectContextVersionRef.current,
});
recordResumeEvent({
view: "useTasks",
trigger: "visibility",
projectId,
replayAttempted: false,
reason: "context-version-changed",
});
void refreshTasks();
return;
}
const now = Date.now();
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
return;
}
lastVisibilityRefreshRef.current = now;
recordResumeEvent({
view: "useTasks",
trigger: "visibility",
projectId,
replayAttempted: false,
reason: "debounced-refresh",
});
void refreshTasks();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [refreshTasks]);
// SSE live updates
// Note: SSE events from stale project contexts are ignored via projectContextVersionRef.
// This prevents tasks from the previous project from appearing during project switches.
// Connection lifecycle (reconnect + heartbeat) is owned by sse-bus so all
// /api/events consumers share one underlying EventSource.
// When sseEnabled is false, the subscription is skipped to free browser connection slots.
useEffect(() => {
if (sseEnabled === false) return;
let contextVersionAtStart = projectContextVersionRef.current;
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const isStale = () => projectContextVersionRef.current !== contextVersionAtStart;
const traceDroppedStaleEvent = () => {
droppedStaleEventsRef.current += 1;
pushTrace("useTasks", "dropped-stale-event", {
count: droppedStaleEventsRef.current,
contextVersionAtStart,
currentContextVersion: projectContextVersionRef.current,
projectId,
});
};
// Guards against reconnect callbacks firing after the effect has cleaned up
// (e.g., sseEnabled flipped to false during a pending reconnect timer in sse-bus).
let active = true;
// Guard against stale callbacks: when sseEnabled flips false or the
// effect unmounts, these handlers must not fire refreshTasks into a
// missions-only view where the SSE should be inactive.
const handleCreated = (e: MessageEvent) => {
if (isStale()) {
traceDroppedStaleEvent();
return;
}
const task = normalizeTask(JSON.parse(e.data) as Task);
if (searchQueryRef.current) {
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
return;
}
if (isSoftDeleted(task)) {
setTasks((prev) => prev.filter((candidate) => candidate.id !== task.id));
pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:created", id: task.id });
return;
}
setTasks((prev) => {
const existingIndex = prev.findIndex((candidate) => candidate.id === task.id);
if (existingIndex === -1) {
return [...prev, task];
}
const current = prev[existingIndex]!;
const merged = mergeIncomingTask(current, task);
if (merged === current) {
return prev;
}
const next = [...prev];
next[existingIndex] = merged;
return next;
});
advanceFreshnessClockForLiveUpdate();
};
const handleMoved = (e: MessageEvent) => {
if (isStale()) {
traceDroppedStaleEvent();
return;
}
if (searchQueryRef.current) {
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
return;
}
// #1403: the move event carries `ColumnId` (custom column ids admitted).
const { task, to }: { task: Task; from: ColumnId; to: ColumnId } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task);
if (isSoftDeleted(normalizedTask)) {
setTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id));
pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:moved", id: normalizedTask.id });
return;
}
// Preserve a custom (non-legacy) target id verbatim; only coerce empty/garbage
// back to the task's current column. The old normalizeColumn (deleted in U12) would drop custom ids.
const nextColumn: ColumnId = typeof to === "string" && to ? to : normalizedTask.column;
const movedTask = { ...normalizedTask, column: nextColumn };
setTasks((prev) => {
const existingIndex = prev.findIndex((t) => t.id === movedTask.id);
if (existingIndex === -1) {
// SSE created event was missed (e.g., reconnect gap); upsert so the
// task becomes visible instead of being silently dropped.
return [...prev, movedTask];
}
const next = [...prev];
next[existingIndex] = movedTask;
return next;
});
advanceFreshnessClockForLiveUpdate();
};
const handleUpdated = (e: MessageEvent) => {
if (isStale()) {
traceDroppedStaleEvent();
return;
}
if (searchQueryRef.current) {
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
return;
}
const incoming = normalizeTask(JSON.parse(e.data) as Task);
if (isSoftDeleted(incoming)) {
// FN-5135: treat deletedAt-bearing task:updated payloads as delete-equivalent.
setTasks((prev) => prev.filter((candidate) => candidate.id !== incoming.id));
pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:updated", id: incoming.id });
return;
}
setTasks((prev) => {
const existingIndex = prev.findIndex((t) => t.id === incoming.id);
if (existingIndex === -1) {
return [...prev, incoming];
}
const current = prev[existingIndex]!;
const merged = mergeIncomingTask(current, incoming);
if (merged === current) return prev;
const next = [...prev];
next[existingIndex] = merged;
return next;
});
advanceFreshnessClockForLiveUpdate();
};
const handleDeleted = (e: MessageEvent) => {
if (isStale()) {
traceDroppedStaleEvent();
return;
}
if (searchQueryRef.current) {
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
return;
}
const task = normalizeTask(JSON.parse(e.data) as Task);
setTasks((prev) => prev.filter((t) => t.id !== task.id));
};
const handleMerged = (e: MessageEvent) => {
if (isStale()) {
traceDroppedStaleEvent();
return;
}
if (searchQueryRef.current) {
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
return;
}
const { task }: { task: Task } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task);
if (isSoftDeleted(normalizedTask)) {
setTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id));
pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:merged", id: normalizedTask.id });
return;
}
const mergedTask = { ...normalizedTask, column: "done" as Column };
setTasks((prev) => {
const existingIndex = prev.findIndex((t) => t.id === mergedTask.id);
if (existingIndex === -1) {
return [...prev, mergedTask];
}
const next = [...prev];
next[existingIndex] = mergedTask;
return next;
});
};
const handleAgentLog = (e: MessageEvent) => {
if (isStale()) {
traceDroppedStaleEvent();
return;
}
if (searchQueryRef.current) {
return;
}
const entry = JSON.parse(e.data) as AgentLogActivityEvent;
if (!entry.taskId || !entry.timestamp) return;
setTasks((prev) => {
let changed = false;
const next = prev.map((task) => {
const cleared = clearInReviewStallForFreshAgentLog(task, entry);
const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry);
if (updated !== task) changed = true;
return updated;
});
return changed ? next : prev;
});
};
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"task:created": handleCreated,
"task:moved": handleMoved,
"task:updated": handleUpdated,
"task:deleted": handleDeleted,
"task:merged": handleMerged,
"agent:log": handleAgentLog,
},
// Guard onReconnect against stale SSE callbacks: do not call refreshTasks
// if the SSE was disabled or the effect unmounted while reconnect was pending.
onReconnect: () => {
contextVersionAtStart = projectContextVersionRef.current;
if (!active) return;
if (isStale()) {
traceDroppedStaleEvent();
return;
}
recordResumeEvent({
view: "useTasks",
trigger: "sse-reconnect",
projectId,
replayAttempted: false,
});
void refreshTasksRef.current();
},
});
return () => {
active = false;
unsubscribe();
};
}, [projectId, sseEnabled]);
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
const task = normalizeTask(await api.createTask(input, projectId));
setTasks((prev) => {
if (prev.some((t) => t.id === task.id)) return prev;
return [...prev, task];
});
return task;
}, [projectId]);
const moveTask = useCallback(async (
id: string,
column: ColumnId,
optionsOrPosition?: { preserveProgress?: boolean } | number,
): Promise<Task> => {
return normalizeTask(await api.moveTask(id, column, projectId, optionsOrPosition));
}, [projectId]);
/*
FNXC:DashboardPauseState 2026-07-12-00:00:
FN-7861 makes pause and unpause user-visible state boundaries. After the API confirms either transition, patch shared hook state and the project SWR task cache immediately, mirroring retryTask/bypassReview, so Board/List/right-dock task renderers do not wait for SSE or polling to clear stale paused rendering.
*/
const pauseTask = useCallback(async (id: string): Promise<Task> => {
const updatedTask = normalizeTask(await api.pauseTask(id, projectId));
fetchVersionRef.current++;
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
if (cacheContainsOnlyTaskRows) {
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => {
const next = projectUpdatedTasks(prev);
tasksRef.current = next;
return next;
});
return updatedTask;
}, [projectId]);
const unpauseTask = useCallback(async (id: string): Promise<Task> => {
const updatedTask = normalizeTask(await api.unpauseTask(id, projectId));
fetchVersionRef.current++;
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
if (cacheContainsOnlyTaskRows) {
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => {
const next = projectUpdatedTasks(prev);
tasksRef.current = next;
return next;
});
return updatedTask;
}, [projectId]);
const deleteTask = useCallback(async (
id: string,
options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
allowResurrection?: boolean;
},
): Promise<Task> => {
const deletedTask = normalizeTask(await api.deleteTask(id, projectId, options));
/*
FNXC:TaskDeletion 2026-06-29-18:52:
Local deletes must update the shared useTasks array immediately because the Board and right-dock Tasks list both render from this state and should not wait for SSE or a refetch after the API confirms deletion.
FNXC:TaskDeletionCache 2026-06-29-20:11:
Project-scoped SWR hydration must remove the deleted task after the API confirms deletion, otherwise an immediate remount can hydrate a stale row before the next fetch. Only the active project's task cache key is touched; if the cached envelope has an unexpected shape, clear that key instead of writing possibly stale data.
FNXC:TaskDeletionCache 2026-06-29-21:04:
Delete success must also invalidate refreshes that began before the API call completed; otherwise a late pre-delete snapshot can rehydrate the removed card in Board and the right-dock Tasks list until the next live update.
*/
// Invalidate refreshes that started before the delete succeeded so an older
// server snapshot cannot overwrite the locally removed row after this point.
fetchVersionRef.current++;
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const nextCachedTasks = cachedTasks.filter((task): task is Task => {
return Boolean(task && typeof task === "object" && (task as Task).id !== id);
});
writeCache(cacheKey, nextCachedTasks, { maxBytes: 500_000 });
} else if (cachedTasks === null) {
const nextCurrentTasks = tasksRef.current.filter((task) => task.id !== id);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => prev.filter((task) => task.id !== id));
return deletedTask;
}, [projectId]);
const mergeTask = useCallback(async (id: string): Promise<MergeResult> => {
return api.mergeTask(id, projectId);
}, [projectId]);
const retryTask = useCallback(async (id: string): Promise<Task> => {
const retriedTask = normalizeTask(await api.retryTask(id, projectId));
/*
FNXC:DashboardTaskRetry 2026-06-30-12:57:
Manual retry success is a user-visible state boundary. Replace matching rows in shared hook state and the project SWR cache as soon as the retry API returns so Board/List/detail/right-dock retry affordances do not depend on later SSE, polling, remount, or route re-entry to clear stale failed/stuck state.
FNXC:DashboardTaskRetry 2026-06-30-12:58:
Retry success also invalidates refreshes that began before the API returned; a late pre-retry fetch snapshot must not rehydrate the failed card after the operator has already received server confirmation for the retry.
*/
fetchVersionRef.current++;
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? retriedTask : task));
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
if (cacheContainsOnlyTaskRows) {
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? retriedTask : normalizeTask(task as Task)));
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => {
const next = projectUpdatedTasks(prev);
tasksRef.current = next;
return next;
});
return retriedTask;
}, [projectId]);
/*
FNXC:ReviewLaneBypass 2026-07-09-00:00:
Operator review-lane bypass action (FN-7720), mirroring retryTask's success-state
wiring so the affordance does not depend on SSE/polling to clear the stale
failed-step indicator after the operator receives server confirmation.
*/
const bypassReview = useCallback(async (id: string, reason: string): Promise<Task> => {
const bypassedTask = normalizeTask(await api.bypassReview(id, reason, projectId));
fetchVersionRef.current++;
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? bypassedTask : task));
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
if (cacheContainsOnlyTaskRows) {
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? bypassedTask : normalizeTask(task as Task)));
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => {
const next = projectUpdatedTasks(prev);
tasksRef.current = next;
return next;
});
return bypassedTask;
}, [projectId]);
const resetTask = useCallback(async (id: string): Promise<Task> => {
return normalizeTask(await api.resetTask(id, projectId));
}, [projectId]);
const duplicateTask = useCallback(async (id: string): Promise<Task> => {
const task = normalizeTask(await api.duplicateTask(id, projectId));
setTasks((prev) => {
if (prev.some((t) => t.id === task.id)) return prev;
return [...prev, task];
});
return task;
}, [projectId]);
const updateTask = useCallback(async (
id: string,
updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean; githubTracking?: { enabled?: boolean } }
): Promise<Task> => {
const previousTask = tasksRef.current.find((t) => t.id === id);
const optimisticTask = previousTask
? { ...previousTask, ...updates, updatedAt: new Date().toISOString() }
: undefined;
if (optimisticTask) {
setTasks((prev) =>
prev.map((t) => (t.id === id ? optimisticTask : t))
);
}
try {
const updatedTask = normalizeTask(await api.updateTask(id, updates, projectId));
setTasks((prev) =>
prev.map((t) => (t.id === id ? updatedTask : t))
);
return updatedTask;
} catch (err) {
if (previousTask) {
setTasks((prev) =>
prev.map((t) => (t.id === id ? previousTask : t))
);
}
throw err;
}
}, [projectId]);
const archiveTask = useCallback(async (
id: string,
options?: { removeLineageReferences?: boolean },
): Promise<Task> => {
const task = normalizeTask(await api.archiveTask(id, projectId, options));
setTasks((prev) =>
prev.map((t) => (t.id === id ? task : t))
);
return task;
}, [projectId]);
const unarchiveTask = useCallback(async (id: string): Promise<Task> => {
const task = normalizeTask(await api.unarchiveTask(id, projectId));
setTasks((prev) =>
prev.map((t) => (t.id === id ? task : t))
);
return task;
}, [projectId]);
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Client-side `revertTask` op. Deliberately does NOT patch the source task's
column/status in local state — the git/AI-undo route never moves the
source task backward (see the `FNXC:TaskRevert` route contract). On success
(either a clean git revert producing a new commit, or an AI-undo task being
created) we re-fetch via `refreshTasksRef` so the board picks up the new
AI-undo task / any lineage changes without us guessing at the shape of the
update ourselves.
*/
const revertTask = useCallback(async (
id: string,
body?: api.RevertTaskOptions,
): Promise<api.RevertTaskResult> => {
const result = await api.revertTask(id, projectId, body);
void refreshTasksRef.current?.();
return result;
}, [projectId]);
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
const archived = await api.archiveAllDone(projectId);
const normalized = archived.map(normalizeTask);
setTasks((prev) =>
prev.map((t) => {
const updated = normalized.find((archived) => archived.id === t.id);
return updated || t;
})
);
return normalized;
}, [projectId]);
const ingestCreatedTasks = useCallback((incomingTasks: Task[]): void => {
if (incomingTasks.length === 0) {
return;
}
if (searchQueryRef.current) {
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
return;
}
const normalizedTasks = filterActiveTasks(incomingTasks.map(normalizeTask));
setTasks((prev) => {
let next = prev;
for (const task of normalizedTasks) {
const existingIndex = next.findIndex((candidate) => candidate.id === task.id);
if (existingIndex === -1) {
if (next === prev) {
next = [...prev];
}
next.push(task);
continue;
}
const current = next[existingIndex]!;
const merged = mergeIncomingTask(current, task);
if (merged === current) {
continue;
}
if (next === prev) {
next = [...prev];
}
next[existingIndex] = merged;
}
return next;
});
advanceFreshnessClockForLiveUpdate();
}, [advanceFreshnessClockForLiveUpdate]);
return { tasks, isStale, lastRefreshErrorAt, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, bypassReview, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, loadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current };
}