Files
fusion/packages/dashboard/app/hooks/useTasks.ts
gsxdsm f6518b4275 FN-9029: keep release-gate verdicts transient
Keep release-gate verdicts restricted to fresh task-list responses.

- Strip release-gate data from SSE and non-board task paths.
- Reject stale initial verdicts before rendering promote controls.
- Cover server persistence, SSE payload, and client freshness boundaries.

Files changed:
 .../dashboard/app/hooks/__tests__/useTasks.test.ts | 115 +++++++++++++++++++++
 packages/dashboard/app/hooks/useTasks.ts           |  80 +++++++++-----
 .../app/utils/__tests__/releaseGate.test.ts        |   9 ++
 .../routes-tasks-release-gate-transient.test.ts    | 103 ++++++++++++++++++
 .../src/__tests__/sse-task-deleted-payload.test.ts |  10 +-
 packages/dashboard/src/sse.ts                      |  26 +++--
 6 files changed, 305 insertions(+), 38 deletions(-)

Fusion-Task-Id: FN-9029

Fusion-Task-Lineage: cf764f3e-c6f5-4fa1-b608-ebb8d547cfdd

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-13 15:43:16 -07:00

1752 lines
83 KiB
TypeScript

import {
isReleaseGateVerdictFresh,
RELEASE_GATE_VERDICT_MAX_AGE_MS,
releaseGateEvidenceFingerprint,
} from "../utils/releaseGate";
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";
import { isIntakeColumnRole, isHoldColumnRole, type ColumnRoleFlags } from "../utils/columnRoles";
const loggedTaskCacheHitProjects = new Set<string>();
/*
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) && task.releaseGate === undefined) return task;
const { log: _droppedLog, releaseGate: _transientReleaseGate, ...rest } = task as Task & { log?: unknown };
/*
FNXC:PromoteVisibility 2026-08-11-21:06:
A cached release verdict has no hook-local evidence provenance and may already be expired. Persist
the task snapshot without it so cache hydration immediately takes the conservative Promote fallback.
*/
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!
: [],
};
}
/*
FNXC:PromoteVisibility 2026-08-13-22:23:
Only GET /api/tasks pairs a release verdict with the complete hold-lane evidence used to evaluate it.
SSE is a store-derived lifecycle channel, so remove any verdict defensively before every SSE path,
including reconnect-gap upserts; a producer regression must resolve to the conservative fallback.
*/
function stripTransientReleaseGate(task: Task): Task {
if (!Object.prototype.hasOwnProperty.call(task, "releaseGate")) return task;
const { releaseGate: _transientReleaseGate, ...taskWithoutReleaseGate } = task;
return taskWithoutReleaseGate as Task;
}
function normalizeNonBoardTask(task: Task): Task {
return normalizeTask(stripTransientReleaseGate(task));
}
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 {
/*
FNXC:WorkflowResolvedColumns 2026-07-30-06:20 (batch-dashboard-app — the column check is REDUNDANT,
and removing it fixes a live bug):
THE COLUMN CHECK IS DELETED, not converted, because the line below already implies it. All three
stall fields are produced ONLY for review-lane cards — every assignment in `task-store/reads.ts`
routes through a producer that gates on review itself:
inReviewStall getInReviewStallReason (gates on the review lane)
inReviewStalled getInReviewStalledSignal (gates on the review ROLE — already trait-converted)
stalledReview detectStalledReview (gates on the review lane)
So `!task.inReviewStall && !task.inReviewStalled && !task.stalledReview` already means "not a
review card with a stall badge", and re-asserting the column added nothing a correct board could
observe.
WHAT IT DID ADD WAS A BUG, and it is live today rather than pending anything. `inReviewStalled` is
ALREADY resolved by role, so a renamed board DOES produce that badge — and this literal then
refused to clear it while a review agent was actively writing logs. The card read "stalled" for
the whole time work was visibly happening, which is exactly what this function exists to prevent.
I previously recorded this as a cross-batch coupling that cancelled out and had to be ordered
against core. That was wrong on one of the three signals: the trait-converted one never cancelled.
Deleting the check is correct before OR after any core change, and removes the ordering hazard
instead of scheduling around it.
*/
if (!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"]);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-03:45:
THE STAMP MISSED RENAMED INTAKE LANES ENTIRELY, which the note above does not cover.
That note argues over-stamping is harmless because every consumer re-checks for an INTAKE lane
before showing anything. True, and it only protects against false POSITIVES. On a board whose intake
and hold lanes are renamed, `{triage, todo}` matches nothing, so no stamp is ever written — and a
correct downstream role check has nothing to filter. The planning border and pulsing badge never
appear while the planner is actively working the card.
Resolved traits win; the legacy pair stays as the no-flags fallback, so an unconverted caller and the
remote-node path are byte-identical. Intake OR hold, mirroring what the pair meant: pre-merge
`triage` was intake and post-merge `todo` is the hold lane.
*/
function isPlannerActivityLane(task: Task, flags: ColumnRoleFlags | undefined): boolean {
if (!flags) return PLANNER_ACTIVITY_COLUMN_IDS.has(task.column);
return isIntakeColumnRole(flags, task.column) || isHoldColumnRole(flags, task.column);
}
function addRecentPlannerActivityForFreshAgentLog(
task: Task,
entry: AgentLogActivityEvent,
flags: ColumnRoleFlags | undefined,
): Task {
if (
!isPlannerActivityLane(task, flags)
|| 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;
}
/*
FNXC:PromoteVisibility 2026-08-11-20:38:
A wrong badge self-corrects, but a wrong Promote decision can start execution. SSE lacks the IR,
continuation, and prompt inputs, so retain a REST verdict only across identical visible evidence and
its server row clock, bounded by TTL; every doubt drops to the conservative fallback.
*/
function carryReleaseGate(
current: Task,
incoming: Task,
merged: Task,
provenance: Map<string, import("../utils/releaseGate").ReleaseGateProvenance> | undefined,
now = Date.now(),
): Task["releaseGate"] {
if (incoming.releaseGate !== undefined) {
const freshProvenance = { fingerprint: releaseGateEvidenceFingerprint(merged), capturedAt: now };
/*
FNXC:PromoteVisibility 2026-08-11-21:06:
A complete response may arrive after a newer row. Its verdict is evidence only when it was
evaluated for the row being rendered, rather than merely being a defined payload field.
*/
if (!isReleaseGateVerdictFresh(incoming.releaseGate, merged, freshProvenance, now)) {
provenance?.delete(current.id);
return undefined;
}
provenance?.set(current.id, freshProvenance);
return incoming.releaseGate;
}
if (!current.releaseGate) return undefined;
const retained = isReleaseGateVerdictFresh(current.releaseGate, merged, provenance?.get(current.id), now);
if (!retained) provenance?.delete(current.id);
return retained ? current.releaseGate : undefined;
}
/*
FNXC:TaskDetailStateStability 2026-08-05-02:55:
The scheduler can refresh the board while a task-detail host holds a newer queued dependency or
file-overlap snapshot. Providers (SWR, SSE, fetchTaskDetail, and local mutations) do not share an
arrival order, so lifecycle rendering must use the existing timestamp evidence: a later `columnMovedAt`
owns a real column transition; within that column, a later `updatedAt` owns status. Equal or absent
clock evidence retains the already-visible known lifecycle state unless a complete server snapshot is
newer than the row and resolves an equal legacy move clock; sparse SSE patches never receive that tie-break.
This helper intentionally merges only defined sparse fields and retains a fetched detail's prompt/log
when a slim board row arrives. Every open-detail host and useTasks ingestion uses this one boundary so
one provider cannot regress a modal, main panel, split detail, dock, or popup independently.
FNXC:TaskDetailStateStability 2026-08-09-07:13:
`mergeTaskSnapshot` arbitrates server snapshots only. Locally-authored detail patches must use
`applyLocalTaskPatch`: FN-5148 requires mismatched ids to be ignored while accepting an absent id, and
FN-8796 showed that an absent or equal local clock is not evidence of staleness.
*/
export interface TaskSnapshotMergeOptions {
/** Hook-local, non-persisted evidence captured when GET /api/tasks supplied a release verdict. */
releaseGateProvenance?: Map<string, import("../utils/releaseGate").ReleaseGateProvenance>;
/** A complete board/detail fetch can resolve an otherwise ambiguous legacy column clock. */
fullSnapshot?: boolean;
/** A canonical task:moved SSE payload names its destination, even when its clock ties the visible row. */
authoritativeMove?: boolean;
/** A canonical task event owns pause/status fields even when JSON omission represents a cleared value. */
authoritativeLifecycle?: boolean;
}
export function mergeTaskSnapshot<T extends Task>(
current: T,
incoming: Task,
options: TaskSnapshotMergeOptions = {},
): T {
if (current.id !== incoming.id) return current;
const merged = { ...current } as Record<string, unknown>;
const updatedAtCompare = compareTimestamps(incoming.updatedAt, current.updatedAt);
/*
FNXC:TaskStatusConsistency 2026-08-05-04:14:
Missing clocks are not authority: a legacy detail row and a sparse SSE patch with neither clock
must retain populated metadata rather than letting their arrival order erase queue/workflow state.
Only a strictly newer update clock, or an explicitly complete equal-clock fetch below, may replace it.
*/
const acceptsIncomingSnapshot = updatedAtCompare > 0;
// A fetch is explicitly marked complete at its call site. Equal clocks can only fill an absent
// field from a sparse event; they replace populated fields only for a complete fetch.
const acceptsEqualClockFields = options.fullSnapshot === true && updatedAtCompare === 0;
for (const [key, value] of Object.entries(incoming)) {
const canMergeField = acceptsIncomingSnapshot
|| acceptsEqualClockFields
|| (updatedAtCompare === 0 && merged[key] === undefined);
if (value !== undefined && canMergeField) {
merged[key] = value;
}
}
/*
FNXC:DashboardPauseState 2026-08-07-14:48:
TaskStore clears optional pause lifecycle fields with `undefined`, so JSON omits them from REST and
`task:updated` payloads. A newer full task row therefore needs omission to mean "cleared" for these
fields; otherwise a passive dashboard retains an older `paused: true` forever. Keep this narrow to
pause-owned fields so genuinely sparse payloads still preserve unrelated detail metadata.
*/
const incomingOwnsLifecycleField = (field: "paused" | "userPaused" | "pausedByAgentId" | "pausedReason" | "status") =>
options.authoritativeLifecycle === true
|| options.fullSnapshot === true
|| Object.prototype.hasOwnProperty.call(incoming, field);
const acceptsEqualClockLifecycle = options.authoritativeLifecycle === true && updatedAtCompare === 0;
if (acceptsIncomingSnapshot || acceptsEqualClockFields || acceptsEqualClockLifecycle) {
if (incomingOwnsLifecycleField("paused")) merged.paused = incoming.paused;
if (incomingOwnsLifecycleField("userPaused")) merged.userPaused = incoming.userPaused;
if (incomingOwnsLifecycleField("pausedByAgentId")) merged.pausedByAgentId = incoming.pausedByAgentId;
if (incomingOwnsLifecycleField("pausedReason")) merged.pausedReason = incoming.pausedReason;
}
const columnMovedAtCompare = compareTimestamps(incoming.columnMovedAt, current.columnMovedAt);
/*
FNXC:BoardBadgeFreshness 2026-08-05-05:26:
`task:moved` is the post-commit lifecycle authority and includes its explicit destination. A board
fetch can observe the task immediately before the move event, leaving identical clocks when one
transition shares the engine's operation timestamp. Accept that equal-clock canonical move so cards,
list rows, and open details change promptly; older clocks remain rejected, so delayed stale events
cannot roll a newer badge backward.
*/
const incomingMovesColumn = incoming.column !== undefined
&& (current.column === undefined
|| columnMovedAtCompare > 0
|| (options.authoritativeMove === true && columnMovedAtCompare === 0)
// A full server snapshot is more complete than an SSE patch, so its newer task clock can
// resolve a legacy equal move clock without letting a sparse event move the card.
|| (options.fullSnapshot === true && columnMovedAtCompare === 0 && updatedAtCompare > 0)
// Older rows have no column-move clock. A newer task timestamp is still evidence for a real move.
|| (!current.columnMovedAt && !incoming.columnMovedAt && updatedAtCompare > 0));
// An equal-clock fetch may resolve a stale client-only status only when it describes the same
// lifecycle row. Otherwise accepting its status while rejecting its column would tear the pair.
const acceptsEqualClockStatus = acceptsEqualClockFields
&& (incoming.column === undefined || incoming.column === current.column);
const incomingUpdatesStatus = incomingOwnsLifecycleField("status")
&& (acceptsIncomingSnapshot
|| acceptsEqualClockStatus
|| acceptsEqualClockLifecycle
|| (incoming.status !== undefined
&& (current.status === undefined || incomingMovesColumn)));
// The lifecycle fields are evidence-owned rather than object-spread-owned.
merged.column = incomingMovesColumn ? incoming.column : current.column;
merged.columnMovedAt = columnMovedAtCompare > 0 ? incoming.columnMovedAt : current.columnMovedAt;
merged.status = incomingUpdatesStatus ? incoming.status : current.status;
merged.updatedAt = updatedAtCompare > 0 ? incoming.updatedAt : current.updatedAt;
merged.awaitingPlanning = acceptsIncomingSnapshot
? carryAwaitingPlanning(current, incoming)
: current.awaitingPlanning;
/*
FNXC:PromoteVisibility 2026-08-11-21:06:
A delayed snapshot cannot attach a defined verdict over a newer task row.
*/
const acceptsReleaseGateSnapshot = acceptsIncomingSnapshot || acceptsEqualClockFields;
merged.releaseGate = acceptsReleaseGateSnapshot
? carryReleaseGate(current, incoming, merged as unknown as Task, options.releaseGateProvenance)
: current.releaseGate;
/*
FNXC:TaskStatusConsistency 2026-08-07-06:10:
`recentAgentActivityAt` is a client-only bridge from an agent-log event to the next task snapshot.
Preserve it while a stale payload is rejected so live Planning does not flash back to Queued. A
complete equal-clock fetch clears it only when that same lifecycle row proves the status changed;
otherwise an agent-log that arrived while the fetch was in flight remains newer evidence.
*/
const equalClockStatusChanged = (acceptsEqualClockStatus || acceptsEqualClockLifecycle)
&& incoming.status !== current.status;
merged.recentAgentActivityAt = acceptsIncomingSnapshot || equalClockStatusChanged
? incoming.recentAgentActivityAt
: current.recentAgentActivityAt;
if ("prompt" in current && incoming.prompt === undefined) {
merged.prompt = current.prompt;
merged.log = current.log;
}
return merged as T;
}
/*
FNXC:TaskDetailStateStability 2026-08-09-07:13:
Open detail views author sparse patches after a PATCH response or derived PR/review refresh. Unlike
server snapshots, these patches are applied by intent: FN-5148 ignores an explicit foreign id but
accepts an absent id. FN-8796's stale lifecycle protection remains only when both sides provide a
clock and the local patch is strictly older; absent and equal clocks are not stale evidence.
*/
export function applyLocalTaskPatch<T extends Task>(current: T, patch: Partial<Task>): T {
if (patch.id !== undefined && patch.id !== current.id) return current;
const merged = { ...current } as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) merged[key] = value;
}
const hasClock = (value: unknown): value is string => typeof value === "string" && value.length > 0;
if (
hasClock(patch.columnMovedAt)
&& hasClock(current.columnMovedAt)
&& compareTimestamps(patch.columnMovedAt, current.columnMovedAt) < 0
) {
merged.column = current.column;
merged.columnMovedAt = current.columnMovedAt;
}
if (
hasClock(patch.updatedAt)
&& hasClock(current.updatedAt)
&& compareTimestamps(patch.updatedAt, current.updatedAt) < 0
) {
merged.status = current.status;
merged.updatedAt = current.updatedAt;
}
const mergedKeys = Object.keys(merged);
if (mergedKeys.length === Object.keys(current).length && mergedKeys.every((key) => merged[key] === (current as Record<string, unknown>)[key])) {
return current;
}
return merged as T;
}
export interface UseTasksOptions {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-03:40:
Resolves a task's own column traits, so the planner-activity stamp below is a ROLE question.
Supplied by App from the board-workflow payload. Absent (remote nodes, pre-load) the stamp falls
back to the legacy id pair, which is the behaviour that shipped.
*/
resolveColumnFlags?: (task: Task) => ColumnRoleFlags | undefined;
/**
* 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 resolveColumnFlags = options?.resolveColumnFlags;
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).map(({ releaseGate: _releaseGate, ...task }) => task as Task))
: [];
});
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);
/*
FNXC:PromoteVisibility 2026-08-11-20:53:
This is deliberately hook-local rather than Task state or persistent cache. It records only the
browser-visible evidence paired with a REST verdict, so an SSE patch can retain that verdict only
while it remains provably valid; task removal and failed retention prune it.
*/
const releaseGateProvenanceRef = useRef(new Map<string, import("../utils/releaseGate").ReleaseGateProvenance>());
const fetchVersionRef = useRef(0);
const mergeIncomingTask = (current: Task, incoming: Task, mergeOptions?: TaskSnapshotMergeOptions): Task =>
mergeTaskSnapshot(current, incoming, { ...mergeOptions, releaseGateProvenance: releaseGateProvenanceRef.current });
/*
FNXC:PromoteVisibility 2026-08-11-21:06:
Freshness cannot be checked only when SSE or fetch merges a row: an idle board can receive no
further snapshots for longer than the verdict TTL. Wake at the earliest expiry and remove each
expired or unverifiable verdict, ensuring TaskCard never renders a stale server decision.
*/
useEffect(() => {
const now = Date.now();
let earliestExpiry = Number.POSITIVE_INFINITY;
let needsPrune = false;
for (const task of tasks) {
if (!task.releaseGate) continue;
if (!isReleaseGateVerdictFresh(task.releaseGate, task, releaseGateProvenanceRef.current.get(task.id), now)) {
needsPrune = true;
continue;
}
const evaluatedAt = Date.parse(task.releaseGate.evaluatedAt);
earliestExpiry = Math.min(earliestExpiry, evaluatedAt + RELEASE_GATE_VERDICT_MAX_AGE_MS);
}
const prune = () => {
setTasks((previous) => {
let changed = false;
const checkedAt = Date.now();
const next = previous.map((task) => {
if (!task.releaseGate || isReleaseGateVerdictFresh(task.releaseGate, task, releaseGateProvenanceRef.current.get(task.id), checkedAt)) {
return task;
}
changed = true;
releaseGateProvenanceRef.current.delete(task.id);
return { ...task, releaseGate: undefined };
});
if (changed) tasksRef.current = next;
return changed ? next : previous;
});
};
if (needsPrune) {
prune();
return;
}
if (!Number.isFinite(earliestExpiry)) return;
const timer = window.setTimeout(prune, Math.max(0, earliestExpiry - now) + 1);
return () => window.clearTimeout(timer);
}, [tasks]);
/*
FNXC:DashboardResume 2026-08-05-18:17:
A resumed list request is a point-in-time server snapshot, while task SSE is a later committed
mutation. Track the task ids changed after each request begins so its delayed response retains live
creates and excludes live deletes instead of replacing the entire board with its older membership.
A later successful fetch prunes mutations it already observed; newer mutations remain fenced until
their own authoritative response arrives.
*/
const liveMutationVersionRef = useRef(0);
const liveTaskMutationsRef = useRef(new Map<string, { version: number; deleted: boolean; task?: Task }>());
const mountedRef = useRef(true);
const resumeRefreshRef = useRef<{ identity: string; promise: Promise<void> } | null>(null);
// 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);
// Coordinates the earlier re-entry effect with the project-change fetch effect below.
const projectChangeRefreshPendingRef = useRef(false);
/*
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;
// A request begun by the prior render still closes over its old project id. Invalidate it
// synchronously, before effects install this context's fetch, so it cannot paint old cards.
fetchVersionRef.current++;
projectContextVersionRef.current++;
projectChangeRefreshPendingRef.current = true;
}
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean; searchQueryOverride?: string; includeArchivedOverride?: boolean }) => {
const requestVersion = ++fetchVersionRef.current;
const requestLiveMutationVersion = liveMutationVersionRef.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 fetchedAt = Date.now();
const normalizedFetchedTasks = filterActiveTasks(fetchedTasks.map(normalizeTask)).map((task) => {
if (task.releaseGate === undefined) return task;
const provenance = { fingerprint: releaseGateEvidenceFingerprint(task), capturedAt: fetchedAt };
/*
FNXC:PromoteVisibility 2026-08-13-22:02:
A first-seen REST row has no current snapshot to merge against. Validate its verdict before
render too, so a response evaluated for an older row never flashes an enabled Promote action.
*/
if (!isReleaseGateVerdictFresh(task.releaseGate, task, provenance, fetchedAt)) {
releaseGateProvenanceRef.current.delete(task.id);
const { releaseGate: _staleReleaseGate, ...taskWithoutReleaseGate } = task;
return taskWithoutReleaseGate as Task;
}
releaseGateProvenanceRef.current.set(task.id, provenance);
return task;
});
/*
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;
/*
FNXC:DashboardResume 2026-08-05-18:36:
React may defer a state updater, but the cache and mutation-fence cleanup run in this same
callback. Reconcile from the synchronous task/mutation refs before either side effect, so a
remount cannot hydrate the older response after an intervening SSE create or delete.
*/
const previousById = new Map(tasksRef.current.map((task) => [task.id, task]));
const fetchedIds = new Set(normalizedFetchedTasks.map((task) => task.id));
const reconciledFetchedTasks = normalizedFetchedTasks.flatMap((fetched) => {
const liveMutation = liveTaskMutationsRef.current.get(fetched.id);
if (liveMutation && liveMutation.version > requestLiveMutationVersion) {
return liveMutation.deleted ? [] : [liveMutation.task ?? previousById.get(fetched.id) ?? fetched];
}
const current = previousById.get(fetched.id);
return [current ? mergeIncomingTask(current, fetched, { fullSnapshot: true }) : fetched];
});
for (const [taskId, liveMutation] of liveTaskMutationsRef.current) {
if (!fetchedIds.has(taskId) && liveMutation.version > requestLiveMutationVersion && !liveMutation.deleted) {
const task = liveMutation.task ?? previousById.get(taskId);
if (task) reconciledFetchedTasks.push(task);
}
}
const freshIds = new Set(reconciledFetchedTasks.map((task) => task.id));
const archivedCarryOver = shouldCarryOverArchived
? archivedTasksRef.current.filter((task) => !freshIds.has(task.id))
: [];
const tasksForCache = reconciledFetchedTasks;
const nextTasks = archivedCarryOver.length > 0
? [...reconciledFetchedTasks, ...archivedCarryOver]
: reconciledFetchedTasks;
const retainedTaskIds = new Set(nextTasks.map((task) => task.id));
for (const taskId of releaseGateProvenanceRef.current.keys()) {
if (!retainedTaskIds.has(taskId)) releaseGateProvenanceRef.current.delete(taskId);
}
tasksRef.current = nextTasks;
setTasks(nextTasks);
for (const [taskId, mutation] of liveTaskMutationsRef.current) {
if (mutation.version <= requestLiveMutationVersion) {
liveTaskMutationsRef.current.delete(taskId);
}
}
if (requestProjectId) {
writeTaskCacheSnapshot(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`, tasksForCache);
}
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;
/*
FNXC:DashboardResume 2026-08-05-18:00:
Visibility, focus, pageshow, and an SSE reconnect are independent browser resume signals; any one
may be the only signal delivered by a desktop tab, bfcache restore, mobile PWA, or resumed socket.
They all enter this seam, which deduplicates only an overlapping request for the same captured
project/search/archive identity. A changed context, an unmounted hook, or an older request version
cannot write cards after newer server or live-event state, and a failed request clears the in-flight
marker so the next resume signal retries without blanking the usable SWR snapshot.
*/
const revalidateAfterResume = useCallback((trigger: "visibility" | "focus" | "pageshow" | "sse-reconnect", reason?: string) => {
if (!mountedRef.current) return;
const query = searchQueryRef.current;
const wantArchived = query ? archivedLoadedRef.current : includeArchivedRef.current;
const identity = `${projectContextVersionRef.current}:${projectId ?? "default"}:${query ?? ""}:${wantArchived}`;
const existing = resumeRefreshRef.current;
if (existing?.identity === identity) return;
recordResumeEvent({ view: "useTasks", trigger, projectId, replayAttempted: false, reason });
const promise = refreshTasksRef.current();
resumeRefreshRef.current = { identity, promise };
void promise.finally(() => {
if (resumeRefreshRef.current?.promise === promise) {
resumeRefreshRef.current = null;
}
});
}, [projectId]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
fetchVersionRef.current++;
resumeRefreshRef.current = null;
};
}, []);
/*
FNXC:DashboardLiveUpdates 2026-08-04-08:12:
Task SSE is disabled outside Board/List, so elapsed time cannot prove that the in-memory snapshot is
current: any task lifecycle event may have been missed during that lossy interval. Every genuine
false→true task-view return therefore reconciles once with the server, regardless of snapshot age.
The project-change effect is deliberately later in this hook. When a project switch and false→true
return occur in one render, it owns the single new-project request; this effect skips that coincident
transition rather than issuing a duplicate. Initial true mounts, false→false renders, and Board↔List
true→true renders are not re-entries, while a later same-project false→true transition remains eligible.
*/
useEffect(() => {
const previous = prevSseEnabledRef.current;
prevSseEnabledRef.current = sseEnabled;
if (previous === false && sseEnabled === true && !projectChangeRefreshPendingRef.current) {
void refreshTasksRef.current();
}
}, [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(normalizeNonBoardTask);
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(normalizeNonBoardTask)));
/*
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 });
projectChangeRefreshPendingRef.current = false;
// 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,
});
revalidateAfterResume("visibility", "context-version-changed");
return;
}
const now = Date.now();
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
return;
}
lastVisibilityRefreshRef.current = now;
revalidateAfterResume("visibility", "debounced-refresh");
};
const handleFocus = () => {
if (document.visibilityState === "visible") {
revalidateAfterResume("focus", "focus-return");
}
};
const handlePageShow = (event: PageTransitionEvent) => {
if (document.visibilityState !== "hidden") {
revalidateAfterResume("pageshow", event.persisted ? "bfcache-restore" : "browser-restore");
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleFocus);
window.addEventListener("pageshow", handlePageShow);
return () => {
// Effects clean up before a project replacement or unmount. Invalidate the captured request
// so a late server response cannot write to the next context (or a disposed hook).
fetchVersionRef.current++;
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleFocus);
window.removeEventListener("pageshow", handlePageShow);
};
}, [refreshTasks, revalidateAfterResume]);
// 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 recordLiveMutation = (task: Task, deleted: boolean) => {
const version = ++liveMutationVersionRef.current;
liveTaskMutationsRef.current.set(task.id, { version, deleted, task: deleted ? undefined : task });
};
const applyLiveTasks = (update: (current: Task[]) => Task[]) => {
const nextTasks = update(tasksRef.current);
tasksRef.current = nextTasks;
setTasks(nextTasks);
};
const handleCreated = (e: MessageEvent) => {
if (isStale()) {
traceDroppedStaleEvent();
return;
}
const task = normalizeTask(stripTransientReleaseGate(JSON.parse(e.data) as Task));
recordLiveMutation(task, isSoftDeleted(task));
if (searchQueryRef.current) {
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
return;
}
if (isSoftDeleted(task)) {
applyLiveTasks((prev) => prev.filter((candidate) => candidate.id !== task.id));
pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:created", id: task.id });
return;
}
applyLiveTasks((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(stripTransientReleaseGate(task));
if (isSoftDeleted(normalizedTask)) {
recordLiveMutation(normalizedTask, true);
applyLiveTasks((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 };
recordLiveMutation(movedTask, false);
applyLiveTasks((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 current = prev[existingIndex]!;
const merged = mergeIncomingTask(current, movedTask, {
authoritativeMove: true,
authoritativeLifecycle: true,
});
if (merged === current) return prev;
const next = [...prev];
next[existingIndex] = merged;
return next;
});
advanceFreshnessClockForLiveUpdate();
};
const handleUpdated = (e: MessageEvent) => {
if (isStale()) {
traceDroppedStaleEvent();
return;
}
if (searchQueryRef.current) {
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
return;
}
const incoming = normalizeTask(stripTransientReleaseGate(JSON.parse(e.data) as Task));
recordLiveMutation(incoming, isSoftDeleted(incoming));
if (isSoftDeleted(incoming)) {
// FN-5135: treat deletedAt-bearing task:updated payloads as delete-equivalent.
applyLiveTasks((prev) => prev.filter((candidate) => candidate.id !== incoming.id));
pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:updated", id: incoming.id });
return;
}
applyLiveTasks((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, { authoritativeLifecycle: true });
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(stripTransientReleaseGate(JSON.parse(e.data) as Task));
recordLiveMutation(task, true);
applyLiveTasks((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(stripTransientReleaseGate(task));
if (isSoftDeleted(normalizedTask)) {
recordLiveMutation(normalizedTask, true);
applyLiveTasks((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 };
recordLiveMutation(mergedTask, false);
applyLiveTasks((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, resolveColumnFlags?.(cleared));
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;
}
revalidateAfterResume("sse-reconnect", "stream-reopened");
},
});
return () => {
active = false;
unsubscribe();
};
}, [projectId, sseEnabled, revalidateAfterResume]);
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
const task = normalizeNonBoardTask(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 normalizeNonBoardTask(await api.moveTask(id, column, projectId, optionsOrPosition));
}, [projectId]);
/*
FNXC:DashboardPauseState 2026-08-05-07:18:
Every lifecycle surface must publish the server-confirmed pause row to shared state before
waiting on SSE or polling. One reconciliation seam advances the fetch version, replaces only
the matching task, and safely refreshes the project cache, so detail, board, list, and dock
hosts cannot diverge after pause or unpause.
*/
const reconcileConfirmedTask = useCallback((confirmedTask: Task): Task => {
const normalizedConfirmedRow = normalizeNonBoardTask(confirmedTask);
// Preserve cleared lifecycle fields as own `undefined` properties so every downstream
// snapshot host can distinguish the confirmed deletion from an unrelated sparse update.
const confirmedRow: Task = {
...normalizedConfirmedRow,
paused: normalizedConfirmedRow.paused,
userPaused: normalizedConfirmedRow.userPaused,
pausedByAgentId: normalizedConfirmedRow.pausedByAgentId,
pausedReason: normalizedConfirmedRow.pausedReason,
status: normalizedConfirmedRow.status,
};
const currentTask = tasksRef.current.find((task) => task.id === confirmedRow.id);
// A live event that arrived while the mutation was pending may be newer than its response.
// Start from the confirmed row so equal clocks retain the mutation, then admit only newer state.
const updatedTask = currentTask ? mergeIncomingTask(confirmedRow, currentTask) : confirmedRow;
fetchVersionRef.current++;
const replaceConfirmedTask = (currentTasks: Task[]) =>
currentTasks.map((task) => task.id === updatedTask.id ? mergeIncomingTask(updatedTask, task) : 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 === updatedTask.id ? updatedTask : normalizeTask(task as Task),
);
writeTaskCacheSnapshot(cacheKey, nextCachedTasks);
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
writeTaskCacheSnapshot(cacheKey, replaceConfirmedTask(tasksRef.current));
} else {
clearCache(cacheKey);
}
}
setTasks((previousTasks) => {
const nextTasks = replaceConfirmedTask(previousTasks);
tasksRef.current = nextTasks;
return nextTasks;
});
return updatedTask;
}, [projectId]);
const pauseTask = useCallback(async (id: string): Promise<Task> => {
return reconcileConfirmedTask(await api.pauseTask(id, projectId));
}, [projectId, reconcileConfirmedTask]);
const unpauseTask = useCallback(async (id: string): Promise<Task> => {
return reconcileConfirmedTask(await api.unpauseTask(id, projectId));
}, [projectId, reconcileConfirmedTask]);
const deleteTask = useCallback(async (
id: string,
options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
allowResurrection?: boolean;
},
): Promise<Task> => {
const deletedTask = normalizeNonBoardTask(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 = normalizeNonBoardTask(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 = normalizeNonBoardTask(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 normalizeNonBoardTask(await api.resetTask(id, projectId));
}, [projectId]);
const duplicateTask = useCallback(async (id: string): Promise<Task> => {
const task = normalizeNonBoardTask(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 = normalizeNonBoardTask(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 = normalizeNonBoardTask(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 = normalizeNonBoardTask(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(normalizeNonBoardTask);
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(normalizeNonBoardTask));
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 };
}