pi-crew 0.2.20 → 0.2.22

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +23 -10
  2. package/README.md +4 -2
  3. package/docs/PROJECT_REVIEW.md +271 -0
  4. package/docs/PROJECT_REVIEW_FIXES.md +343 -0
  5. package/docs/PROJECT_REVIEW_ROUND4.md +156 -0
  6. package/docs/PROJECT_REVIEW_ROUND5.md +86 -0
  7. package/docs/fixes/BATCH_A_H1_H2.md +86 -0
  8. package/docs/fixes/bug-006-foreground-cancel-concurrent.md +78 -0
  9. package/docs/fixes/bug-007-async-notifier-stale-ctx.md +112 -0
  10. package/docs/fixes/bug-008-child-process-silent-timeout.md +100 -0
  11. package/docs/fixes/bug-009-executor-yield-limit-needs-attention.md +75 -0
  12. package/docs/fixes/bug-010-child-process-api-key-filtered.md +109 -0
  13. package/docs/fixes/bug-011-spawn-pi-enoent.md +92 -0
  14. package/docs/fixes/bug-012-essential-env-stripped.md +89 -0
  15. package/docs/fixes/bug-013-background-runner-death.md +84 -0
  16. package/docs/fixes/bug-014-infinite-retry-loop-needs-attention.md +82 -0
  17. package/docs/fixes/bug-015-background-runner-sigterm.md +65 -0
  18. package/docs/fixes/bug-017-background-runner-session-shutdown.md +66 -0
  19. package/docs/fixes/bug-017-background-runner-sigkill-double-fork.md +28 -0
  20. package/docs/fixes/bug-018-child-pi-worker-stdin-hang.md +61 -0
  21. package/docs/fixes/bug-019-phantom-runs-temp-workspace.md +52 -0
  22. package/docs/pi-crew-bugs.md +954 -0
  23. package/docs/pi-crew-investigation-report.md +411 -0
  24. package/docs/pi-crew-test-final.md +120 -0
  25. package/docs/pi-crew-test-results.md +260 -0
  26. package/docs/pi-crew-test-round2.md +136 -0
  27. package/docs/pi-crew-test-round4.md +100 -0
  28. package/docs/pi-crew-test-round5.md +70 -0
  29. package/docs/pi-crew-test-round6.md +110 -0
  30. package/docs/usage.md +14 -0
  31. package/package.json +4 -2
  32. package/src/adapters/export-util.ts +12 -6
  33. package/src/agents/agent-config.ts +2 -0
  34. package/src/config/defaults.ts +1 -1
  35. package/src/config/markers.ts +22 -17
  36. package/src/config/resilient-parser.ts +1 -1
  37. package/src/extension/async-notifier.ts +4 -2
  38. package/src/extension/management.ts +52 -0
  39. package/src/extension/register.ts +47 -10
  40. package/src/extension/run-index.ts +20 -2
  41. package/src/extension/run-maintenance.ts +2 -2
  42. package/src/extension/team-tool/parallel-dispatch.ts +1 -1
  43. package/src/extension/team-tool/run.ts +3 -6
  44. package/src/extension/team-tool.ts +67 -11
  45. package/src/observability/event-to-metric.ts +2 -1
  46. package/src/runtime/async-runner.ts +42 -34
  47. package/src/runtime/background-runner.ts +165 -7
  48. package/src/runtime/child-pi.ts +111 -18
  49. package/src/runtime/code-summary.ts +1 -1
  50. package/src/runtime/crash-recovery.ts +1 -1
  51. package/src/runtime/crew-agent-runtime.ts +2 -1
  52. package/src/runtime/heartbeat-watcher.ts +4 -0
  53. package/src/runtime/live-agent-manager.ts +1 -1
  54. package/src/runtime/live-session-runtime.ts +2 -1
  55. package/src/runtime/manifest-cache.ts +2 -2
  56. package/src/runtime/model-fallback.ts +2 -1
  57. package/src/runtime/phase-progress.ts +1 -1
  58. package/src/runtime/pi-args.ts +3 -1
  59. package/src/runtime/pi-spawn.ts +6 -0
  60. package/src/runtime/prose-compressor.ts +1 -1
  61. package/src/runtime/result-extractor.ts +0 -1
  62. package/src/runtime/retry-executor.ts +1 -1
  63. package/src/runtime/runtime-resolver.ts +8 -3
  64. package/src/runtime/skill-instructions.ts +0 -1
  65. package/src/runtime/stale-reconciler.ts +30 -3
  66. package/src/runtime/subagent-manager.ts +2 -0
  67. package/src/runtime/task-display.ts +1 -1
  68. package/src/runtime/task-graph-scheduler.ts +1 -1
  69. package/src/runtime/task-runner/live-executor.ts +15 -0
  70. package/src/runtime/task-runner/tail-read.ts +26 -0
  71. package/src/runtime/task-runner.ts +1007 -383
  72. package/src/runtime/team-runner.ts +9 -5
  73. package/src/runtime/worker-startup.ts +3 -1
  74. package/src/schema/team-tool-schema.ts +2 -1
  75. package/src/state/active-run-registry.ts +8 -2
  76. package/src/state/atomic-write.ts +17 -0
  77. package/src/state/contracts.ts +5 -2
  78. package/src/state/event-log-rotation.ts +118 -31
  79. package/src/state/event-log.ts +33 -5
  80. package/src/state/event-reconstructor.ts +4 -2
  81. package/src/state/mailbox.ts +5 -1
  82. package/src/state/schedule.ts +146 -0
  83. package/src/state/types.ts +40 -0
  84. package/src/state/usage.ts +20 -0
  85. package/src/ui/crew-widget.ts +2 -2
  86. package/src/ui/run-event-bus.ts +1 -1
  87. package/src/ui/run-snapshot-cache.ts +2 -1
  88. package/src/ui/snapshot-types.ts +1 -0
  89. package/src/utils/gh-protocol.ts +2 -2
  90. package/src/utils/names.ts +1 -1
  91. package/src/utils/sse-parser.ts +0 -2
  92. package/src/worktree/branch-freshness.ts +1 -1
  93. package/src/worktree/cleanup.ts +54 -14
  94. package/src/worktree/worktree-manager.ts +19 -9
@@ -157,7 +157,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
157
157
  }
158
158
 
159
159
  function loadManifest(runId: string, rootsToCheck: string[]): CachedManifest | undefined {
160
- let cached = manifestIndex.get(runId);
160
+ const cached = manifestIndex.get(runId);
161
161
  if (!isSafePathId(runId)) return undefined;
162
162
  const activeEntry = activeRunEntries().find((entry) => entry.runId === runId);
163
163
  if (activeEntry) {
@@ -211,7 +211,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
211
211
 
212
212
 
213
213
  const runs = [...unique.values()].filter((value): value is CachedManifest => value !== undefined).map((value) => value.manifest);
214
- const sorted = runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
214
+ const sorted = runs.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
215
215
  const limited = sorted.slice(0, Math.max(0, limit));
216
216
  if (manifestIndex.size > maxEntries) {
217
217
  const removeCount = manifestIndex.size - maxEntries;
@@ -161,9 +161,10 @@ export function resolveModelCandidate(
161
161
  }
162
162
 
163
163
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
164
- /rate\s*limit/i,
164
+ /rate.?limit/i,
165
165
  /too many requests/i,
166
166
  /\b429\b/,
167
+ /rate_limit_error/i,
167
168
  /quota/i,
168
169
  /provider.*unavailable/i,
169
170
  /model.*unavailable/i,
@@ -48,7 +48,7 @@ export interface RunProgress {
48
48
  // Helpers
49
49
  // ---------------------------------------------------------------------------
50
50
 
51
- const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "skipped"]);
51
+ const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "skipped", "needs_attention"]);
52
52
 
53
53
  /**
54
54
  * Extract the phase label for a task.
@@ -99,8 +99,10 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
99
99
  }
100
100
 
101
101
  if (input.agent.tools?.length) args.push("--tools", input.agent.tools.join(","));
102
+ // Always add --no-extensions before --extension to prevent user extensions from being auto-loaded.
103
+ // User extensions in ~/.pi/agent/extensions/ may fail due to missing dependencies.
104
+ args.push("--no-extensions");
102
105
  if (input.agent.extensions !== undefined) {
103
- args.push("--no-extensions");
104
106
  for (const extension of [PROMPT_RUNTIME_EXTENSION_PATH, ...input.agent.extensions]) args.push("--extension", extension);
105
107
  } else {
106
108
  args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
@@ -160,8 +160,14 @@ export function getPiSpawnCommand(args: string[]): PiSpawnCommand {
160
160
  }
161
161
  }
162
162
  if (process.platform === "win32") {
163
+ // Windows: resolve via resolvePiCliScript to find the bundled .js entry point
163
164
  const script = resolvePiCliScript();
164
165
  if (script) return { command: process.execPath, args: [script, ...args] };
165
166
  }
167
+ // Linux/macOS: also resolve the full path so child processes can find 'pi' even if
168
+ // PATH is minimal (e.g. in detached background-runner processes). Fall back to "pi"
169
+ // only if resolution fails.
170
+ const script = resolvePiCliScript();
171
+ if (script) return { command: process.execPath, args: [script, ...args] };
166
172
  return { command: "pi", args };
167
173
  }
@@ -17,7 +17,7 @@ const PROTECTED_PATTERNS: readonly RegExp[] = [
17
17
  /```[\s\S]*?```/g, // fenced code blocks
18
18
  /`[^`\n]+`/g, // inline code
19
19
  /\bhttps?:\/\/\S+/gi, // URLs
20
- /\b[\w.-]*[\/\\][\w.\/\\\-]+/g, // paths with / or \
20
+ /\b[\w.-]*[/\\][\w./\\-]+/g, // paths with / or \
21
21
  /\b[A-Z][A-Z0-9]*(?:_[A-Z][A-Z0-9]*)+\b/g, // CONSTANT_CASE
22
22
  /\b\w+(?:\.\w+)+\(\)/g, // dotted.method() calls
23
23
  /[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)/g, // function calls: name(args)
@@ -81,7 +81,6 @@ function tryMarkerExtraction(text: string): unknown | undefined {
81
81
  try {
82
82
  return JSON.parse(after.slice(0, jsonEnd));
83
83
  } catch {
84
- continue;
85
84
  }
86
85
  }
87
86
  }
@@ -39,7 +39,7 @@ function isRetryable(error: Error, policy: RetryPolicy): boolean {
39
39
  }
40
40
 
41
41
  export function calculateRetryDelay(attempt: number, policy: RetryPolicy = DEFAULT_RETRY_POLICY, random = Math.random): number {
42
- const base = policy.backoffMs * Math.pow(policy.exponentialFactor, Math.max(0, attempt - 1));
42
+ const base = policy.backoffMs * policy.exponentialFactor ** Math.max(0, attempt - 1);
43
43
  const jitter = (random() * 2 - 1) * policy.jitterRatio * base;
44
44
  return Math.max(0, base + jitter);
45
45
  }
@@ -71,13 +71,18 @@ export async function resolveCrewRuntime(config: PiTeamsConfig, env: NodeJS.Proc
71
71
  if (requestedMode === "child-process") return childCaps(requestedMode);
72
72
  // When a child-process mock is active (tests), force auto-mode to child-process where the mock is active.
73
73
  if (requestedMode === "auto" && env.PI_TEAMS_MOCK_CHILD_PI) return childCaps(requestedMode, "PI_TEAMS_MOCK_CHILD_PI mock forces child-process runtime in auto mode.");
74
- if (requestedMode === "live-session" || requestedMode === "auto") {
74
+ if (requestedMode === "live-session") {
75
75
  const live = await isLiveSessionRuntimeAvailable(1500, env);
76
76
  if (live.available) return liveCaps(requestedMode);
77
- if (requestedMode === "live-session" && config.runtime?.allowChildProcessFallback === false)
77
+ if (config.runtime?.allowChildProcessFallback === false)
78
78
  return scaffoldCaps(requestedMode, live.reason, "blocked");
79
79
  return { ...childCaps(requestedMode), fallback: "child-process", reason: live.reason };
80
80
  }
81
+ // auto mode: use child-process unless preferLiveSession is explicitly enabled
82
+ if (requestedMode === "auto" && config.runtime?.preferLiveSession === true) {
83
+ const live = await isLiveSessionRuntimeAvailable(1500, env);
84
+ if (live.available) return liveCaps(requestedMode);
85
+ }
81
86
  return childCaps(requestedMode);
82
87
  }
83
88
 
@@ -86,7 +91,7 @@ function scaffoldCaps(requestedMode: CrewRuntimeMode, reason?: string, safety: C
86
91
  }
87
92
 
88
93
  function childCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
89
- return { kind: "child-process", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: true, safety: "trusted", ...(reason ? { reason } : {}) };
94
+ return { kind: "child-process", requestedMode, available: true, steer: true, resume: false, liveToolActivity: false, transcript: true, safety: "trusted", ...(reason ? { reason } : {}) };
90
95
  }
91
96
 
92
97
  function liveCaps(requestedMode: CrewRuntimeMode): CrewRuntimeCapabilities {
@@ -142,7 +142,6 @@ function readSkillMarkdown(cwd: string, name: string): { path: string; source: "
142
142
  const stat = fs.statSync(filePath);
143
143
  return rememberSkill(cacheKey, { path: filePath, source: entry.source, content: fs.readFileSync(filePath, "utf-8"), mtimeMs: stat.mtimeMs, size: stat.size });
144
144
  } catch {
145
- continue;
146
145
  }
147
146
  }
148
147
  return undefined;
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
2
4
  import { checkProcessLiveness } from "./process-status.ts";
3
5
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
@@ -30,7 +32,7 @@ function checkResultFile(
30
32
  ): { found: boolean; repaired: boolean } {
31
33
  // Check if all tasks already have terminal status (result was written but manifest wasn't updated)
32
34
  const allTerminal = tasks.length > 0 && tasks.every(
33
- (t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled" || t.status === "skipped",
35
+ (t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled" || t.status === "skipped" || t.status === "needs_attention",
34
36
  );
35
37
  if (allTerminal) {
36
38
  // Sync agent records even when tasks are already terminal
@@ -45,8 +47,12 @@ function checkResultFile(
45
47
 
46
48
  /**
47
49
  * Phase 2: Check PID liveness.
50
+ * Uses process.kill(pid, 0) for the authoritative check, but also checks
51
+ * the heartbeat file as corroborating evidence. If a heartbeat was recently
52
+ * written, treat the PID as alive even if process.kill returns false
53
+ * (handles SIGKILL race where PID hasn't been recycled yet).
48
54
  */
49
- function checkPidLiveness(pid: number | undefined): {
55
+ function checkPidLiveness(pid: number | undefined, stateRoot?: string): {
50
56
  alive: boolean;
51
57
  detail: string;
52
58
  } {
@@ -54,6 +60,27 @@ function checkPidLiveness(pid: number | undefined): {
54
60
  return { alive: false, detail: "no pid recorded" };
55
61
  }
56
62
  const liveness = checkProcessLiveness(pid);
63
+ // If process is alive per kill(0), we're done.
64
+ if (liveness.alive) return { alive: true, detail: liveness.detail };
65
+ // Process is dead per kill(0). Check heartbeat as corroborating evidence.
66
+ if (stateRoot) {
67
+ const heartbeatPath = path.join(stateRoot, "heartbeat.json");
68
+ try {
69
+ if (fs.existsSync(heartbeatPath)) {
70
+ const hb = JSON.parse(fs.readFileSync(heartbeatPath, "utf-8")) as { pid?: number; at?: number };
71
+ if (hb?.pid === pid && hb?.at) {
72
+ const ageMs = Date.now() - hb.at;
73
+ // Heartbeat written < 5 min ago → process was alive recently.
74
+ // Don't repair yet; let the next reconciliation cycle catch it.
75
+ if (ageMs < 5 * 60_000) {
76
+ return { alive: true, detail: `process dead but heartbeat ${Math.round(ageMs / 1000)}s old` };
77
+ }
78
+ }
79
+ }
80
+ } catch {
81
+ /* ignore — best-effort */
82
+ }
83
+ }
57
84
  return { alive: liveness.alive, detail: liveness.detail };
58
85
  }
59
86
 
@@ -143,7 +170,7 @@ export function reconcileStaleRun(
143
170
 
144
171
  // Phase 2: Check PID liveness
145
172
  const pid = manifest.async?.pid;
146
- const pidStatus = checkPidLiveness(pid);
173
+ const pidStatus = checkPidLiveness(pid, manifest.stateRoot);
147
174
 
148
175
  if (pidStatus.detail === "no pid recorded") {
149
176
  // No async PID may be a foreground/live run. Preserve it if task heartbeat
@@ -45,6 +45,8 @@ export interface SubagentRecord {
45
45
  turnCount?: number;
46
46
  terminated?: boolean;
47
47
  durationMs?: number;
48
+ /** Lifetime token usage accumulated via message_end events. Survives compaction. */
49
+ lifetimeUsage?: { input: number; output: number; cacheWrite: number };
48
50
  }
49
51
 
50
52
  type SpawnRunner = (options: SubagentSpawnOptions, signal?: AbortSignal) => Promise<PiTeamsToolResult>;
@@ -31,7 +31,7 @@ export function waitingReason(task: TeamTaskState, tasks: TeamTaskState[]): stri
31
31
  export function formatTaskGraphLines(tasks: TeamTaskState[]): string[] {
32
32
  if (tasks.length === 0) return ["- (none)"];
33
33
  return tasks.map((task) => {
34
- const icon = task.status === "completed" ? "✓" : task.status === "running" ? "⠋" : task.status === "failed" ? "✗" : task.status === "cancelled" || task.status === "skipped" ? "■" : "◦";
34
+ const icon = task.status === "completed" ? "✓" : task.status === "running" ? "⠋" : task.status === "failed" ? "✗" : task.status === "cancelled" || task.status === "skipped" ? "■" : task.status === "needs_attention" ? "⚠" : "◦";
35
35
  const wait = waitingReason(task, tasks);
36
36
  return `- ${icon} ${task.id} [${task.status}] ${task.role}->${task.agent}${wait && wait !== "ready" ? ` (${wait})` : ""}`;
37
37
  });
@@ -43,7 +43,7 @@ function withQueue(task: TeamTaskState, index: TaskGraphIndex): TeamTaskState {
43
43
  if (task.status === "running") {
44
44
  return { ...task, graph: task.graph ? { ...task.graph, queue: "running" } : task.graph };
45
45
  }
46
- if (task.status === "completed" || task.status === "skipped") {
46
+ if (task.status === "completed" || task.status === "skipped" || task.status === "needs_attention") {
47
47
  return { ...task, graph: task.graph ? { ...task.graph, queue: "done" } : task.graph };
48
48
  }
49
49
  return { ...task, graph: task.graph ? { ...task.graph, queue: "blocked" } : task.graph };
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { AgentConfig } from "../../agents/agent-config.ts";
3
4
  import type { CrewRuntimeConfig } from "../../config/config.ts";
4
5
  import { writeArtifact } from "../../state/artifact-store.ts";
@@ -7,6 +8,7 @@ import { loadRunManifestById } from "../../state/state-store.ts";
7
8
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
8
9
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
9
10
  import { appendCrewAgentEvent, appendCrewAgentOutput, emptyCrewAgentProgress, recordFromTask, upsertCrewAgent } from "../crew-agent-records.ts";
11
+ import { createWorkerHeartbeat, touchWorkerHeartbeat } from "../worker-heartbeat.ts";
10
12
  import { createStartupEvidence, type WorkerStartupEvidence } from "../worker-startup.ts";
11
13
  import { runLiveSessionTask } from "../live-session-runtime.ts";
12
14
  import { shouldAppendProgressEventUpdate, type ProgressEventSummary } from "../progress-event-coalescer.ts";
@@ -64,6 +66,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
64
66
  });
65
67
  let lastAgentRecordPersistedAt = 0;
66
68
  let lastRunProgressPersistedAt = 0;
69
+ let lastHeartbeatPersistedAt = 0;
67
70
  let lastRunProgressSummary: ProgressEventSummary | undefined;
68
71
  const persistLiveProgress = (event: unknown, force = false): void => {
69
72
  if (!isCurrent()) return;
@@ -72,6 +75,18 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
72
75
  upsertCrewAgent(manifest, recordFromTask(manifest, task, "live-session"));
73
76
  lastAgentRecordPersistedAt = now;
74
77
  }
78
+ // Bug #5 fix: also persist heartbeat to tasks.json so heartbeat-watcher can detect live-session activity
79
+ if (force || now - lastHeartbeatPersistedAt >= 1000) {
80
+ task = {
81
+ ...task,
82
+ heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id)),
83
+ };
84
+ tasks = updateTask(tasks, task);
85
+ // Persist to tasks.json using the same pattern as task-runner.ts
86
+ const tasksPath = path.join(manifest.stateRoot, "tasks.json");
87
+ try { fs.writeFileSync(tasksPath, JSON.stringify({ ...loaded, tasks }, null, 2)); } catch {}
88
+ lastHeartbeatPersistedAt = now;
89
+ }
75
90
  const summary = progressEventSummary(task, event);
76
91
  const decision = shouldAppendProgressEventUpdate({ previous: lastRunProgressSummary, next: summary, nowMs: now, lastAppendMs: lastRunProgressPersistedAt || undefined, minIntervalMs: 1000, force });
77
92
  if (decision.shouldAppend) {
@@ -0,0 +1,26 @@
1
+ import * as fs from "node:fs";
2
+
3
+ /**
4
+ * Read the tail of a file, capped at maxBytes.
5
+ * If the file exceeds maxBytes, reads only the last maxBytes and snaps
6
+ * to the nearest newline boundary to avoid partial JSONL lines.
7
+ */
8
+ export function tailReadWithLineSnap(
9
+ filePath: string,
10
+ maxBytes: number,
11
+ fallbackContent: string,
12
+ ): string {
13
+ if (!fs.existsSync(filePath)) return fallbackContent;
14
+ const stat = fs.statSync(filePath);
15
+ if (stat.size <= maxBytes) return fs.readFileSync(filePath, "utf-8");
16
+ const fd = fs.openSync(filePath, "r");
17
+ try {
18
+ const buf = Buffer.alloc(maxBytes);
19
+ const bytesRead = fs.readSync(fd, buf, 0, maxBytes, stat.size - maxBytes);
20
+ const raw = buf.slice(0, bytesRead).toString("utf-8");
21
+ const firstNewline = raw.indexOf("\n");
22
+ return firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw;
23
+ } finally {
24
+ fs.closeSync(fd);
25
+ }
26
+ }