pi-crew 0.11.42 → 0.11.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.42",
3
+ "version": "0.11.44",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -1,9 +1,34 @@
1
+ import { existsSync, readFileSync } from "node:fs";
1
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
3
- import { isDisplayActiveRun } from "../runtime/process-status.ts";
4
+ import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../runtime/process-status.ts";
5
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
+ import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
4
7
  import { extractSessionId } from "../utils/session-utils.ts";
5
8
  import { listRuns } from "./run-index.ts";
6
9
 
10
+ /** Tasks-read TTL for the one-shot toast's heartbeat check (coalesced, live-run-sidebar pattern). */
11
+ const SUMMARY_TASKS_READ_TTL_MS = 1000;
12
+
13
+ /**
14
+ * Wave 16 (residual render wiring, GAP-4): coalesced tasks.json read for the
15
+ * active-runs toast — clones agents-jobs-browser's readLegacyRunTasks. Returns
16
+ * undefined on unreadable so a broken tasks.json can NEVER fabricate liveness
17
+ * evidence (fail-closed).
18
+ */
19
+ function readSummaryRunTasks(run: TeamRunManifest): TeamTaskState[] | undefined {
20
+ const parse = (): TeamTaskState[] => {
21
+ if (!existsSync(run.tasksPath)) return [];
22
+ const parsed = JSON.parse(readFileSync(run.tasksPath, "utf-8")) as unknown;
23
+ return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
24
+ };
25
+ try {
26
+ return readJsonFileCoalesced(run.tasksPath, SUMMARY_TASKS_READ_TTL_MS, parse);
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
7
32
  export function notifyActiveRuns(ctx: ExtensionContext): void {
8
33
  const sid = extractSessionId(ctx);
9
34
  const active = listRuns(ctx.cwd)
@@ -15,7 +40,12 @@ export function notifyActiveRuns(ctx: ExtensionContext): void {
15
40
  // Use the same display filter as the widget/powerbar — runs without
16
41
  // real agent evidence (e.g. integration test fixtures) must not appear.
17
42
  const agents = readCrewAgents(run);
18
- return isDisplayActiveRun(run, agents);
43
+ if (isDisplayActiveRun(run, agents)) return true;
44
+ // Wave 16: this is a ONE-SHOT toast, so the lazy coalesced tasks read is
45
+ // in-pattern I/O — fetched ONLY when the display predicate is about to
46
+ // call the run inactive, so a manifest frozen mid-long-tool (fresh task
47
+ // heartbeats, no agent progress) still gets advertised.
48
+ return heartbeatLiveForDisplay(run, readSummaryRunTasks(run));
19
49
  })
20
50
  .slice(0, 5);
21
51
  if (active.length === 0) return;
@@ -5,7 +5,12 @@ import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
8
- import { hasStaleAsyncProcess, isActiveRunStatus, isLikelyOrphanedActiveRun } from "../../runtime/process-status.ts";
8
+ import {
9
+ hasFreshTaskHeartbeatEvidence,
10
+ hasStaleAsyncProcess,
11
+ isActiveRunStatus,
12
+ isLikelyOrphanedActiveRun,
13
+ } from "../../runtime/process-status.ts";
9
14
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
10
15
  import { loadTasksWithRecovery } from "../../state/stores/state-store.ts";
11
16
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
@@ -374,10 +379,15 @@ export function buildHealthReport(
374
379
  continue;
375
380
  }
376
381
 
377
- // 3. Stuck task detection for active runs
382
+ // 3. Stuck task detection for active runs. Wave 16 (residual render
383
+ // wiring): the tasks loaded here are HOISTED — step 6 reuses them so the
384
+ // orphan guard consults fresh task heartbeats with ZERO extra disk reads
385
+ // (parity with run-dashboard's stale predicate).
386
+ let tasks: TeamTaskState[] | undefined;
378
387
  if (isActiveRunStatus(run.status)) {
379
- const stuck = detectStuckTasks(run, now);
380
- allStuck.push(...stuck);
388
+ const loaded = readRunTasks(run.stateRoot);
389
+ tasks = loaded;
390
+ allStuck.push(...stuckTasksFromRecords(loaded, run.runId, now));
381
391
  }
382
392
 
383
393
  // 4. Only check orphan status for active runs
@@ -393,9 +403,12 @@ export function buildHealthReport(
393
403
  continue;
394
404
  }
395
405
 
396
- // 6. Orphaned: non-async active run with no recent update
406
+ // 6. Orphaned: non-async active run with no recent update. Wave 16: a
407
+ // run whose manifest froze mid-long-tool (worker busy, event-driven
408
+ // writes paused) keeps stamping task heartbeats — fresh evidence
409
+ // suppresses the stale-no-progress label (run-dashboard parity).
397
410
  const agents = readCrewAgents(run as TeamRunManifest);
398
- if (isLikelyOrphanedActiveRun(run as TeamRunManifest, agents, now)) {
411
+ if (isLikelyOrphanedActiveRun(run as TeamRunManifest, agents, now) && !hasFreshTaskHeartbeatEvidence(tasks, now)) {
399
412
  orphaned.push({
400
413
  runId: run.runId,
401
414
  reason: "stale-no-progress",
@@ -1,4 +1,4 @@
1
- import type { TeamRunManifest } from "../state/types.ts";
1
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
2
2
  import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
3
3
 
4
4
  export { hasAsyncStartMarker } from "./async-marker.ts";
@@ -92,6 +92,49 @@ export function hasRecentAgentActivityEvidence(agent: CrewAgentRecord, now = Dat
92
92
  return Number.isFinite(at) && now - at < livenessMs;
93
93
  }
94
94
 
95
+ /**
96
+ * Wave 15 (F1, GAP-0): does any running/waiting task carry FRESH live
97
+ * evidence — a `heartbeat.lastSeenAt` (with `alive !== false`) or the
98
+ * `agentProgress.lastActivityAt` fallback — within `ttlMs`? Behavioral
99
+ * mirror of the watchdog's private helper (foreground-watchdog.ts) and
100
+ * hasRecentActiveEvidence (stale-reconciler.ts), so watchdog, reconciler
101
+ * and display stop growing a third divergent private copy. PURE (GAP-4:
102
+ * the shared display predicates keep their signatures — call sites that
103
+ * already hold the tasks OR this in). Default TTL 5 min keeps the constant
104
+ * family aligned (AGENT_ACTIVITY_LIVENESS_MS = TASK_HEARTBEAT_EVIDENCE_MS
105
+ * = ACTIVE_EVIDENCE_TTL_MS). Undefined/empty/all-terminal ⇒ false.
106
+ */
107
+ export function hasFreshTaskHeartbeatEvidence(
108
+ tasks: TeamTaskState[] | undefined,
109
+ now = Date.now(),
110
+ ttlMs = AGENT_ACTIVITY_LIVENESS_MS,
111
+ ): boolean {
112
+ if (!Array.isArray(tasks)) return false;
113
+ return tasks.some((task) => {
114
+ if (task.status !== "running" && task.status !== "waiting") return false;
115
+ const heartbeatAt = task.heartbeat?.lastSeenAt ? new Date(task.heartbeat.lastSeenAt).getTime() : Number.NaN;
116
+ if (task.heartbeat?.alive !== false && Number.isFinite(heartbeatAt) && now - heartbeatAt <= ttlMs) return true;
117
+ const activityAt = task.agentProgress?.lastActivityAt ? new Date(task.agentProgress.lastActivityAt).getTime() : Number.NaN;
118
+ return Number.isFinite(activityAt) && now - activityAt <= ttlMs;
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Wave 16 (residual render wiring, GAP-4): tight heartbeat-liveness guard for
124
+ * ACTIVE display — mirrors the orphan predicate's own preconditions (active
125
+ * status, non-async) so a terminal or async run is never resurrected by a
126
+ * heartbeat. A healthy direct run mid-long-tool freezes manifest `updatedAt`
127
+ * and agent progress (event-driven writes) but keeps stamping
128
+ * `task.heartbeat.lastSeenAt`, so fresh evidence means the run is alive.
129
+ * Single home for the guard that run-dashboard previously kept private and
130
+ * agents-jobs-browser inlined; call sites OR this in ONLY — every non-stale
131
+ * branch and layout stays untouched. PURE: takes the tasks the caller already
132
+ * holds in scope (no disk read here).
133
+ */
134
+ export function heartbeatLiveForDisplay(run: TeamRunManifest, tasks: TeamTaskState[] | undefined, now = Date.now()): boolean {
135
+ return isActiveRunStatus(run.status) && !run.async && hasFreshTaskHeartbeatEvidence(tasks, now);
136
+ }
137
+
95
138
  export function isLikelyOrphanedActiveRun(
96
139
  run: TeamRunManifest,
97
140
  agents: CrewAgentRecord[] = [],
@@ -49,7 +49,7 @@
49
49
  * dashboard overlay, so headless sessions never reach it.
50
50
  */
51
51
 
52
- import { existsSync, readdirSync } from "node:fs";
52
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
53
53
  import { join } from "node:path";
54
54
  import { fileURLToPath } from "node:url";
55
55
  import { matchesKey } from "@earendil-works/pi-tui";
@@ -60,13 +60,14 @@ import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
60
60
  import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
61
61
  import type { ManifestCache } from "../runtime/manifest-cache.ts";
62
62
  import { createManifestCache } from "../runtime/manifest-cache.ts";
63
- import { isDisplayActiveRun } from "../runtime/process-status.ts";
63
+ import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../runtime/process-status.ts";
64
64
  import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
65
65
  import { surfaceGateEnvSnapshot } from "../runtime/surface/resolve-surface.ts";
66
66
  import { getTaskUsage } from "../runtime/usage-tracker.ts";
67
67
  import { readJsonFile } from "../state/atomic-write.ts";
68
68
  import { loadRunManifestById } from "../state/stores/state-store.ts";
69
- import type { TeamRunManifest } from "../state/types.ts";
69
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
70
+ import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
70
71
  import { logInternalError } from "../utils/internal-error.ts";
71
72
  import { formatRelativeTime } from "../utils/relative-time.ts";
72
73
  import { pad, sanitizeLine, truncate, visibleWidth } from "../utils/visual.ts";
@@ -202,6 +203,28 @@ function watchAgentTranscriptScript(): string | undefined {
202
203
  return candidates.find((c) => existsSync(c));
203
204
  }
204
205
 
206
+ /** Tasks-read TTL for the legacy-discovery heartbeat check (coalesced, live-run-sidebar pattern). */
207
+ const LEGACY_TASKS_READ_TTL_MS = 1000;
208
+
209
+ /**
210
+ * Wave 15 (F2, GAP-0): coalesced tasks.json read for the legacy-discovery
211
+ * loop — mirrors run-dashboard/live-run-sidebar's readRunTasks. Returns
212
+ * undefined on unreadable so a broken tasks.json can NEVER fabricate
213
+ * liveness evidence (fail-closed).
214
+ */
215
+ function readLegacyRunTasks(run: TeamRunManifest): TeamTaskState[] | undefined {
216
+ const parse = (): TeamTaskState[] => {
217
+ if (!existsSync(run.tasksPath)) return [];
218
+ const parsed = JSON.parse(readFileSync(run.tasksPath, "utf-8")) as unknown;
219
+ return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
220
+ };
221
+ try {
222
+ return readJsonFileCoalesced(run.tasksPath, LEGACY_TASKS_READ_TTL_MS, parse);
223
+ } catch {
224
+ return undefined;
225
+ }
226
+ }
227
+
205
228
  /** tok/s for record-based entries (no live handle in this process). */
206
229
  function recordTokPerSec(record: CrewAgentRecord, nowMs: number): number | undefined {
207
230
  if (record.status !== "running") return undefined;
@@ -476,7 +499,13 @@ export class AgentsJobsBrowser {
476
499
  if (run.status !== "running" && run.status !== "queued") continue;
477
500
  try {
478
501
  const agents = readCrewAgents(run);
479
- if (isDisplayActiveRun(run, agents, nowMs)) runs.push({ run, agents });
502
+ // Wave 15 (F2, GAP-0): tight heartbeat guard — a legacy-uncovered run
503
+ // whose manifest froze mid-long-tool stays listed while its task
504
+ // heartbeats are fresh (this loop already does per-run I/O — one more
505
+ // coalesced read is in-pattern). Terminal/async runs are never
506
+ // resurrected (isActiveRunStatus && !run.async).
507
+ const heartbeatLive = heartbeatLiveForDisplay(run, readLegacyRunTasks(run), nowMs);
508
+ if (isDisplayActiveRun(run, agents, nowMs) || heartbeatLive) runs.push({ run, agents });
480
509
  } catch {
481
510
  /* unreadable — skip */
482
511
  }
@@ -5,7 +5,7 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
5
5
  import { listLiveAgents, listLiveAgentsByWorkspace } from "../runtime/live-session/live-agent-manager.ts";
6
6
  import type { ManifestCache } from "../runtime/manifest-cache.ts";
7
7
  import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
8
- import { isDisplayActiveRun } from "../runtime/process-status.ts";
8
+ import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../runtime/process-status.ts";
9
9
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
10
10
  import { aggregateUsage } from "../state/usage.ts";
11
11
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
@@ -307,7 +307,11 @@ class PowerbarPublisher {
307
307
  }
308
308
  return { run, agents, tasks: readTasks(run.tasksPath), snapshot };
309
309
  })
310
- .filter((item) => isDisplayActiveRun(item.run, item.agents));
310
+ // Wave 16 (residual render wiring): item.tasks is already in scope
311
+ // (snapshot.tasks | readTasks fallback) — OR-ing the heartbeat guard in
312
+ // adds ZERO extra disk reads; a frozen-manifest run stays in the
313
+ // powerbar while its task heartbeats stay fresh.
314
+ .filter((item) => isDisplayActiveRun(item.run, item.agents) || heartbeatLiveForDisplay(item.run, item.tasks));
311
315
  if (!active.length) {
312
316
  this.#lastActiveKey = undefined;
313
317
  this.#lastProgressKey = undefined;
@@ -10,7 +10,7 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
10
10
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
11
11
  import { getLiveAgentContextPercent } from "../runtime/live-session/live-agent-manager.ts";
12
12
  import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
13
- import { isActiveRunStatus, isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
13
+ import { heartbeatLiveForDisplay, isActiveRunStatus, isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
14
14
  import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
15
15
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
16
16
  import { aggregateUsage } from "../state/usage.ts";
@@ -467,7 +467,9 @@ function runLabel(
467
467
  resolve?: SnapshotResolver,
468
468
  ): string {
469
469
  const agents = agentsFor(run, snapshotCache, resolve);
470
- const stale = isLikelyOrphanedActiveRun(run, agents);
470
+ // Wave 15 (F2): a stale-manifest verdict is VETOED by fresh task-heartbeat
471
+ // evidence (lazy read — only consulted when the orphan predicate fires).
472
+ const stale = isLikelyOrphanedActiveRun(run, agents) && !heartbeatLiveForDisplay(run, readRunTasks(run, snapshotCache, resolve));
471
473
  const running = agents.find((agent) => agent.status === "running");
472
474
  const queued = agents.find((agent) => agent.status === "queued");
473
475
  const step = stale
@@ -523,7 +525,12 @@ function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache,
523
525
  const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
524
526
  const agents = snapshot?.agents ?? agentsFor(run, snapshotCache, resolve);
525
527
  const displayRun = snapshot?.manifest ?? run;
526
- const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, agents) ? "stale" : (displayRun.status as RunStatus);
528
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label.
529
+ const status: RunStatus =
530
+ isLikelyOrphanedActiveRun(displayRun, agents) &&
531
+ !heartbeatLiveForDisplay(displayRun, snapshot?.tasks ?? readRunTasks(run, snapshotCache, resolve))
532
+ ? "stale"
533
+ : (displayRun.status as RunStatus);
527
534
  map.set(run.runId, { manifest: run, snapshot, agents, status });
528
535
  }
529
536
  return map;
@@ -536,12 +543,19 @@ function groupedRuns(
536
543
  ): Array<{ label: string; run?: TeamRunManifest }> {
537
544
  const resolved = resolveRuns(runs, snapshotCache, resolve);
538
545
  const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
539
- const active = runs.filter((run) =>
540
- isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []),
541
- );
542
- const rest = runs.filter(
543
- (run) => !isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []),
544
- );
546
+ // Wave 15 (F2): a run with fresh task-heartbeat evidence stays display-active
547
+ // even when the manifest froze mid-long-tool (tight guard — never resurrects
548
+ // terminal/async runs).
549
+ const displayActive = (run: TeamRunManifest): boolean => {
550
+ const entry = resolved.get(run.runId);
551
+ const displayRun = entry?.snapshot?.manifest ?? run;
552
+ return (
553
+ isDisplayActiveRun(displayRun, entry?.agents ?? []) ||
554
+ heartbeatLiveForDisplay(displayRun, entry?.snapshot?.tasks ?? readRunTasks(run, snapshotCache, resolve))
555
+ );
556
+ };
557
+ const active = runs.filter((run) => displayActive(run));
558
+ const rest = runs.filter((run) => !displayActive(run));
545
559
  if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
546
560
  if (rest.length) rows.push({ label: "Recent" }, ...rest.map((run) => ({ label: run.runId, run })));
547
561
  return rows;
@@ -754,7 +768,10 @@ export class RunDashboard implements DashboardComponent {
754
768
  const snapshot = resolve ? resolve(run) : snapshotFor(run, this.options.snapshotCache);
755
769
  const displayRun = snapshot?.manifest ?? run;
756
770
  const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache, resolve);
757
- const stale = isLikelyOrphanedActiveRun(displayRun, agents);
771
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label.
772
+ const stale =
773
+ isLikelyOrphanedActiveRun(displayRun, agents) &&
774
+ !heartbeatLiveForDisplay(displayRun, snapshot?.tasks ?? readRunTasks(run, this.options.snapshotCache, resolve));
758
775
  const status: RunStatus = stale ? "stale" : (displayRun.status as RunStatus);
759
776
  if (status === "running" || agents.some((agent) => agent.status === "running")) hasRunning = true;
760
777
  return snapshot?.signature ?? `${displayRun.runId}:${displayRun.status}:${displayRun.updatedAt}:${status}`;
@@ -963,7 +980,15 @@ export class RunDashboard implements DashboardComponent {
963
980
  const snap = snapshotOnce(rowItem.run);
964
981
  const run = snap?.manifest ?? rowItem.run;
965
982
  const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
966
- const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
983
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label (status line).
984
+ const status: RunStatus =
985
+ isLikelyOrphanedActiveRun(run, agents) &&
986
+ !heartbeatLiveForDisplay(
987
+ run,
988
+ snap?.tasks ?? readRunTasks(rowItem.run, this.options.snapshotCache, snapshotOnce),
989
+ )
990
+ ? "stale"
991
+ : (run.status as RunStatus);
967
992
  const label = runLabel(run, idx === this.selected, this.options.snapshotCache, budget, snapshotOnce);
968
993
  lines.push(row(applyStatusColor(this.theme, status, label)));
969
994
  }
@@ -977,7 +1002,15 @@ export class RunDashboard implements DashboardComponent {
977
1002
  const snap = snapshotOnce(rowItem.run);
978
1003
  const run = snap?.manifest ?? rowItem.run;
979
1004
  const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
980
- const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
1005
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label (windowed status line).
1006
+ const status: RunStatus =
1007
+ isLikelyOrphanedActiveRun(run, agents) &&
1008
+ !heartbeatLiveForDisplay(
1009
+ run,
1010
+ snap?.tasks ?? readRunTasks(rowItem.run, this.options.snapshotCache, snapshotOnce),
1011
+ )
1012
+ ? "stale"
1013
+ : (run.status as RunStatus);
981
1014
  const label = runLabel(run, gi === this.selected, this.options.snapshotCache, budget, snapshotOnce);
982
1015
  lines.push(row(applyStatusColor(this.theme, status, label)));
983
1016
  }
@@ -996,8 +1029,12 @@ export class RunDashboard implements DashboardComponent {
996
1029
  const snap = snapshotOnce(selectedRun);
997
1030
  const r = snap?.manifest ?? selectedRun;
998
1031
  const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache, snapshotOnce);
999
- const statusStr: RunStatus = isLikelyOrphanedActiveRun(r, agents) ? "stale" : (r.status as RunStatus);
1000
1032
  const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache, snapshotOnce);
1033
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label (detail header).
1034
+ const statusStr: RunStatus =
1035
+ isLikelyOrphanedActiveRun(r, agents) && !heartbeatLiveForDisplay(r, selectedTasks)
1036
+ ? "stale"
1037
+ : (r.status as RunStatus);
1001
1038
  lines.push(section("run"));
1002
1039
  lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeUntrustedLine(r.goal), budget - 4)}`));
1003
1040
  // L-2: surface the failure/cancellation reason inline for terminal runs.
@@ -9,7 +9,7 @@ import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
9
9
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
10
10
  import { evictStaleLiveAgentHandles } from "../../runtime/live-session/live-agent-manager.ts";
11
11
  import type { ManifestCache } from "../../runtime/manifest-cache.ts";
12
- import { isDisplayActiveRun } from "../../runtime/process-status.ts";
12
+ import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../../runtime/process-status.ts";
13
13
  import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts";
14
14
  import type { TeamRunManifest } from "../../state/types.ts";
15
15
  import { goalFlagSuffix } from "../goal-flag.ts";
@@ -61,42 +61,55 @@ export function activeWidgetRuns(
61
61
  // workspaceId stays in scope: crash recovery below is still session-scoped.
62
62
  const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
63
63
 
64
- return runs
65
- .map((run) => {
66
- try {
67
- // U7 (spec 2026-10-09): prefer the committed-state read when the
68
- // cache exposes it. `readForRender` returns the SAME cached
69
- // snapshot `get` would (identical render — parity), but a lapsed
70
- // TTL schedules the coalesced ASYNC refresh (80ms batch → stamp
71
- // check → rebuild only when files changed) instead of waiting for
72
- // an external poller to notice. Structural check: legacy callers
73
- // and test doubles may implement only `get`.
74
- const committed = snapshotCache as
75
- | { readForRender?: (runId: string) => RunUiSnapshot | undefined; get?: (runId: string) => RunUiSnapshot | undefined }
76
- | undefined;
77
- const snapshot =
78
- typeof committed?.readForRender === "function" ? committed.readForRender(run.runId) : snapshotCache?.get(run.runId);
79
- if (snapshot) {
80
- return {
81
- run: snapshot.manifest,
82
- agents: snapshot.agents,
83
- snapshot,
84
- };
64
+ return (
65
+ runs
66
+ .map((run) => {
67
+ try {
68
+ // U7 (spec 2026-10-09): prefer the committed-state read when the
69
+ // cache exposes it. `readForRender` returns the SAME cached
70
+ // snapshot `get` would (identical render — parity), but a lapsed
71
+ // TTL schedules the coalesced ASYNC refresh (80ms batch → stamp
72
+ // check → rebuild only when files changed) instead of waiting for
73
+ // an external poller to notice. Structural check: legacy callers
74
+ // and test doubles may implement only `get`.
75
+ const committed = snapshotCache as
76
+ | {
77
+ readForRender?: (runId: string) => RunUiSnapshot | undefined;
78
+ get?: (runId: string) => RunUiSnapshot | undefined;
79
+ }
80
+ | undefined;
81
+ const snapshot =
82
+ typeof committed?.readForRender === "function" ? committed.readForRender(run.runId) : snapshotCache?.get(run.runId);
83
+ if (snapshot) {
84
+ return {
85
+ run: snapshot.manifest,
86
+ agents: snapshot.agents,
87
+ snapshot,
88
+ };
89
+ }
90
+ // P0-6: render from snapshots only — never read disk on every
91
+ // render tick. When the snapshot cache is provided but hasn't
92
+ // populated yet (first frame after `updateCrewWidget`), drop the
93
+ // run from the result so the widget paints "(loading…)" instead
94
+ // of calling `agentsFor(run) → readCrewAgents`. Legacy callers
95
+ // (no snapshotCache) keep the disk-read fallback for tests/dev.
96
+ if (snapshotCache) return null;
97
+ return { run, agents: agentsFor(run) };
98
+ } catch {
99
+ if (snapshotCache) return null;
100
+ return { run, agents: agentsFor(run) };
85
101
  }
86
- // P0-6: render from snapshots only — never read disk on every
87
- // render tick. When the snapshot cache is provided but hasn't
88
- // populated yet (first frame after `updateCrewWidget`), drop the
89
- // run from the result so the widget paints "(loading…)" instead
90
- // of calling `agentsFor(run) → readCrewAgents`. Legacy callers
91
- // (no snapshotCache) keep the disk-read fallback for tests/dev.
92
- if (snapshotCache) return null;
93
- return { run, agents: agentsFor(run) };
94
- } catch {
95
- if (snapshotCache) return null;
96
- return { run, agents: agentsFor(run) };
97
- }
98
- })
99
- .filter((item): item is WidgetRun => item !== null && isDisplayActiveRun(item.run, item.agents));
102
+ })
103
+ // Wave 16 (residual render wiring): the dock's high-frequency tick NEVER
104
+ // gains a disk read here — only the snapshot the render path already
105
+ // holds. OR-in ONLY: a run hidden because its manifest froze mid-long-tool
106
+ // stays visible while its task heartbeats are fresh (GAP-4 mix-A).
107
+ .filter(
108
+ (item): item is WidgetRun =>
109
+ item !== null &&
110
+ (isDisplayActiveRun(item.run, item.agents, now) || heartbeatLiveForDisplay(item.run, item.snapshot?.tasks, now)),
111
+ )
112
+ );
100
113
  }
101
114
 
102
115
  /**