pi-crew 0.11.42 → 0.11.43

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.43",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -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,33 @@ 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
+
95
122
  export function isLikelyOrphanedActiveRun(
96
123
  run: TeamRunManifest,
97
124
  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 { hasFreshTaskHeartbeatEvidence, isActiveRunStatus, 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,14 @@ 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 =
508
+ isActiveRunStatus(run.status) && !run.async && hasFreshTaskHeartbeatEvidence(readLegacyRunTasks(run), nowMs);
509
+ if (isDisplayActiveRun(run, agents, nowMs) || heartbeatLive) runs.push({ run, agents });
480
510
  } catch {
481
511
  /* unreadable — skip */
482
512
  }
@@ -10,7 +10,12 @@ 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 {
14
+ hasFreshTaskHeartbeatEvidence,
15
+ isActiveRunStatus,
16
+ isDisplayActiveRun,
17
+ isLikelyOrphanedActiveRun,
18
+ } from "../runtime/process-status.ts";
14
19
  import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
15
20
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
16
21
  import { aggregateUsage } from "../state/usage.ts";
@@ -363,6 +368,19 @@ function taskForAgent(tasks: TeamTaskState[], agent: CrewAgentRecord): TeamTaskS
363
368
  return tasks.find((task) => task.id === agent.taskId);
364
369
  }
365
370
 
371
+ /**
372
+ * Wave 15 (F2, GAP-0): tight heartbeat-liveness guard for ACTIVE display —
373
+ * mirrors the orphan predicate's own preconditions (active status, non-async)
374
+ * so a terminal or async run is never resurrected by a heartbeat. A healthy
375
+ * direct run mid-long-tool freezes manifest `updatedAt` and agent progress
376
+ * (event-driven writes) but keeps stamping `task.heartbeat.lastSeenAt`, so
377
+ * fresh evidence means the run is alive. Call sites OR this in ONLY — every
378
+ * non-stale branch and layout stays untouched.
379
+ */
380
+ function heartbeatLiveForDisplay(run: TeamRunManifest, tasks: TeamTaskState[] | undefined, now = Date.now()): boolean {
381
+ return isActiveRunStatus(run.status) && !run.async && hasFreshTaskHeartbeatEvidence(tasks, now);
382
+ }
383
+
366
384
  function modelForTask(task: TeamTaskState | undefined): string | undefined {
367
385
  const attempts = task?.modelAttempts;
368
386
  if (!attempts?.length) return undefined;
@@ -467,7 +485,9 @@ function runLabel(
467
485
  resolve?: SnapshotResolver,
468
486
  ): string {
469
487
  const agents = agentsFor(run, snapshotCache, resolve);
470
- const stale = isLikelyOrphanedActiveRun(run, agents);
488
+ // Wave 15 (F2): a stale-manifest verdict is VETOED by fresh task-heartbeat
489
+ // evidence (lazy read — only consulted when the orphan predicate fires).
490
+ const stale = isLikelyOrphanedActiveRun(run, agents) && !heartbeatLiveForDisplay(run, readRunTasks(run, snapshotCache, resolve));
471
491
  const running = agents.find((agent) => agent.status === "running");
472
492
  const queued = agents.find((agent) => agent.status === "queued");
473
493
  const step = stale
@@ -523,7 +543,12 @@ function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache,
523
543
  const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
524
544
  const agents = snapshot?.agents ?? agentsFor(run, snapshotCache, resolve);
525
545
  const displayRun = snapshot?.manifest ?? run;
526
- const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, agents) ? "stale" : (displayRun.status as RunStatus);
546
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label.
547
+ const status: RunStatus =
548
+ isLikelyOrphanedActiveRun(displayRun, agents) &&
549
+ !heartbeatLiveForDisplay(displayRun, snapshot?.tasks ?? readRunTasks(run, snapshotCache, resolve))
550
+ ? "stale"
551
+ : (displayRun.status as RunStatus);
527
552
  map.set(run.runId, { manifest: run, snapshot, agents, status });
528
553
  }
529
554
  return map;
@@ -536,12 +561,19 @@ function groupedRuns(
536
561
  ): Array<{ label: string; run?: TeamRunManifest }> {
537
562
  const resolved = resolveRuns(runs, snapshotCache, resolve);
538
563
  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
- );
564
+ // Wave 15 (F2): a run with fresh task-heartbeat evidence stays display-active
565
+ // even when the manifest froze mid-long-tool (tight guard — never resurrects
566
+ // terminal/async runs).
567
+ const displayActive = (run: TeamRunManifest): boolean => {
568
+ const entry = resolved.get(run.runId);
569
+ const displayRun = entry?.snapshot?.manifest ?? run;
570
+ return (
571
+ isDisplayActiveRun(displayRun, entry?.agents ?? []) ||
572
+ heartbeatLiveForDisplay(displayRun, entry?.snapshot?.tasks ?? readRunTasks(run, snapshotCache, resolve))
573
+ );
574
+ };
575
+ const active = runs.filter((run) => displayActive(run));
576
+ const rest = runs.filter((run) => !displayActive(run));
545
577
  if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
546
578
  if (rest.length) rows.push({ label: "Recent" }, ...rest.map((run) => ({ label: run.runId, run })));
547
579
  return rows;
@@ -754,7 +786,10 @@ export class RunDashboard implements DashboardComponent {
754
786
  const snapshot = resolve ? resolve(run) : snapshotFor(run, this.options.snapshotCache);
755
787
  const displayRun = snapshot?.manifest ?? run;
756
788
  const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache, resolve);
757
- const stale = isLikelyOrphanedActiveRun(displayRun, agents);
789
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label.
790
+ const stale =
791
+ isLikelyOrphanedActiveRun(displayRun, agents) &&
792
+ !heartbeatLiveForDisplay(displayRun, snapshot?.tasks ?? readRunTasks(run, this.options.snapshotCache, resolve));
758
793
  const status: RunStatus = stale ? "stale" : (displayRun.status as RunStatus);
759
794
  if (status === "running" || agents.some((agent) => agent.status === "running")) hasRunning = true;
760
795
  return snapshot?.signature ?? `${displayRun.runId}:${displayRun.status}:${displayRun.updatedAt}:${status}`;
@@ -963,7 +998,15 @@ export class RunDashboard implements DashboardComponent {
963
998
  const snap = snapshotOnce(rowItem.run);
964
999
  const run = snap?.manifest ?? rowItem.run;
965
1000
  const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
966
- const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
1001
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label (status line).
1002
+ const status: RunStatus =
1003
+ isLikelyOrphanedActiveRun(run, agents) &&
1004
+ !heartbeatLiveForDisplay(
1005
+ run,
1006
+ snap?.tasks ?? readRunTasks(rowItem.run, this.options.snapshotCache, snapshotOnce),
1007
+ )
1008
+ ? "stale"
1009
+ : (run.status as RunStatus);
967
1010
  const label = runLabel(run, idx === this.selected, this.options.snapshotCache, budget, snapshotOnce);
968
1011
  lines.push(row(applyStatusColor(this.theme, status, label)));
969
1012
  }
@@ -977,7 +1020,15 @@ export class RunDashboard implements DashboardComponent {
977
1020
  const snap = snapshotOnce(rowItem.run);
978
1021
  const run = snap?.manifest ?? rowItem.run;
979
1022
  const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
980
- const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
1023
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label (windowed status line).
1024
+ const status: RunStatus =
1025
+ isLikelyOrphanedActiveRun(run, agents) &&
1026
+ !heartbeatLiveForDisplay(
1027
+ run,
1028
+ snap?.tasks ?? readRunTasks(rowItem.run, this.options.snapshotCache, snapshotOnce),
1029
+ )
1030
+ ? "stale"
1031
+ : (run.status as RunStatus);
981
1032
  const label = runLabel(run, gi === this.selected, this.options.snapshotCache, budget, snapshotOnce);
982
1033
  lines.push(row(applyStatusColor(this.theme, status, label)));
983
1034
  }
@@ -996,8 +1047,12 @@ export class RunDashboard implements DashboardComponent {
996
1047
  const snap = snapshotOnce(selectedRun);
997
1048
  const r = snap?.manifest ?? selectedRun;
998
1049
  const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache, snapshotOnce);
999
- const statusStr: RunStatus = isLikelyOrphanedActiveRun(r, agents) ? "stale" : (r.status as RunStatus);
1000
1050
  const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache, snapshotOnce);
1051
+ // Wave 15 (F2): fresh task heartbeats veto the "stale" label (detail header).
1052
+ const statusStr: RunStatus =
1053
+ isLikelyOrphanedActiveRun(r, agents) && !heartbeatLiveForDisplay(r, selectedTasks)
1054
+ ? "stale"
1055
+ : (r.status as RunStatus);
1001
1056
  lines.push(section("run"));
1002
1057
  lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeUntrustedLine(r.goal), budget - 4)}`));
1003
1058
  // L-2: surface the failure/cancellation reason inline for terminal runs.