per-run graph reveal: refetch the snapshot when a run leaves the status feed, not when the feed empties
The old refresh trigger was the some→none transition of the /api/graph/status poll — with parallel runs under the serialized audit, run A's merge landed while B was still queued, but the feed still held B, so nothing refreshed until B finished too: both runs' nodes appeared in one batched reveal (owner's live-test finding, 2026-09-28). The ledgers were correct the whole time (each merge is a synchronous append inside the gate; the serialization test pins B's auditor seeing A's node) — only the reveal was batched. Now: refetch the snapshot whenever a run DISAPPEARS from the feed (finished / aborted / failed). A run leaves the registry only after its merge + settle + usage persist complete, so disappearance implies the graph change is already on disk; each run surfaces within poll latency (~2s). Redundant fetches on aborted runs are free — the deletion-refresh doctrine. The costs pill follows the same disappearance rule (usage persists per run). Confirmed the status pills already render the raw phase, so the 'waiting' stage displays as Waiting with no change.
This commit is contained in:
+24
-11
@@ -1058,20 +1058,28 @@ export function GraphView() {
|
||||
.finally(() => setImporting(false));
|
||||
};
|
||||
|
||||
// Background pipeline status: poll /api/graph/status; when a run
|
||||
// finishes (some -> none), refetch the graph.
|
||||
// Background pipeline status: poll /api/graph/status; whenever a run
|
||||
// LEAVES the feed (finished / aborted / failed), refetch the graph.
|
||||
// A run only leaves the registry after its merge + settle + usage
|
||||
// persist complete, so disappearance ⇒ the graph change is already on
|
||||
// disk. The old some→none transition batched PARALLEL runs into one
|
||||
// reveal (owner 2026-09-28): with serialized audits, the first run's
|
||||
// merge is its own graph change and must surface immediately, not wait
|
||||
// for the whole feed to drain. A redundant fetch on aborted runs is
|
||||
// free — the same doctrine as the deletion-refresh path below.
|
||||
const [pipelineRuns, setPipelineRuns] = useState<{ chatId: string; title: string; phase: string; startedAt: number }[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let hadRuns = false;
|
||||
let prevIds = new Set<string>();
|
||||
const poll = () =>
|
||||
fetch("/api/graph/status")
|
||||
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
|
||||
.then(({ runs }) => {
|
||||
if (cancelled) return;
|
||||
setPipelineRuns(runs);
|
||||
if (hadRuns && runs.length === 0) setRefreshTick((t) => t + 1);
|
||||
hadRuns = runs.length > 0;
|
||||
const ids = new Set(runs.map((r) => r.chatId));
|
||||
if ([...prevIds].some((id) => !ids.has(id))) setRefreshTick((t) => t + 1);
|
||||
prevIds = ids;
|
||||
})
|
||||
.catch(() => {}); // status is cosmetic — never surface poll errors
|
||||
poll();
|
||||
@@ -1099,18 +1107,23 @@ export function GraphView() {
|
||||
useEffect(() => {
|
||||
refreshCosts();
|
||||
}, [refreshCosts]);
|
||||
const costsHadRunning = useRef(false);
|
||||
// The same disappearance rule as the snapshot refresh (owner
|
||||
// 2026-09-28): usage persists per run, so costs refresh per run —
|
||||
// not only when the whole feed drains.
|
||||
const prevRunIds = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
if (costsHadRunning.current && pipelineRuns.length === 0) refreshCosts();
|
||||
costsHadRunning.current = pipelineRuns.length > 0;
|
||||
const gone = [...prevRunIds.current].some((id) => !pipelineRuns.some((r) => r.chatId === id));
|
||||
if (gone && prevRunIds.current.size > 0) refreshCosts();
|
||||
prevRunIds.current = new Set(pipelineRuns.map((r) => r.chatId));
|
||||
}, [pipelineRuns, refreshCosts]);
|
||||
|
||||
/* Deletion refresh (wave 6 chunk A): the DELETE /api/chats/:id response
|
||||
carries the new graphRevision, and the sidebar (which owns the delete
|
||||
flow) announces it as lh:graph-revision. The status poll above can't
|
||||
see a deletion — no pipeline run ever transitions some→none for it —
|
||||
so this event is the one seam. Refetch the snapshot and the costs on
|
||||
every announcement: deletions are rare, a redundant fetch is free. */
|
||||
see a deletion — no pipeline run ever disappears from the feed for
|
||||
it — so this event is the one seam. Refetch the snapshot and the
|
||||
costs on every announcement: deletions are rare, a redundant fetch
|
||||
is free. */
|
||||
useEffect(() => {
|
||||
const onRevision = () => {
|
||||
setRefreshTick((t) => t + 1);
|
||||
|
||||
Reference in New Issue
Block a user