pi-crew 0.6.1 → 0.6.3

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 (102) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +70 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/config/config.ts +106 -15
  7. package/src/errors.ts +107 -0
  8. package/src/extension/async-notifier.ts +6 -2
  9. package/src/extension/crew-cleanup.ts +8 -5
  10. package/src/extension/management.ts +464 -109
  11. package/src/extension/register.ts +194 -34
  12. package/src/extension/registration/commands.ts +2 -2
  13. package/src/extension/registration/subagent-helpers.ts +2 -2
  14. package/src/extension/registration/subagent-tools.ts +1 -1
  15. package/src/extension/registration/team-tool.ts +1 -1
  16. package/src/extension/registration/viewers.ts +2 -2
  17. package/src/extension/team-tool/api.ts +3 -3
  18. package/src/extension/team-tool/cancel.ts +3 -3
  19. package/src/extension/team-tool/explain.ts +1 -1
  20. package/src/extension/team-tool/handle-schedule.ts +40 -0
  21. package/src/extension/team-tool/inspect.ts +3 -3
  22. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  23. package/src/extension/team-tool/respond.ts +2 -2
  24. package/src/extension/team-tool/run.ts +60 -11
  25. package/src/extension/team-tool/status.ts +1 -1
  26. package/src/extension/team-tool.ts +173 -46
  27. package/src/hooks/registry.ts +77 -13
  28. package/src/hooks/types.ts +9 -0
  29. package/src/plugins/plugin-define.ts +6 -0
  30. package/src/plugins/plugin-registry.ts +32 -0
  31. package/src/plugins/plugins/index.ts +3 -0
  32. package/src/plugins/plugins/nextjs.ts +19 -0
  33. package/src/plugins/plugins/vite.ts +14 -0
  34. package/src/plugins/plugins/vitest.ts +14 -0
  35. package/src/runtime/async-runner.ts +35 -7
  36. package/src/runtime/background-runner.ts +529 -144
  37. package/src/runtime/chain-parser.ts +5 -1
  38. package/src/runtime/chain-runner.ts +9 -3
  39. package/src/runtime/checkpoint.ts +262 -180
  40. package/src/runtime/child-pi.ts +164 -37
  41. package/src/runtime/crash-recovery.ts +25 -14
  42. package/src/runtime/crew-agent-records.ts +2 -0
  43. package/src/runtime/diagnostic-export.ts +1 -1
  44. package/src/runtime/dynamic-script-runner.ts +10 -7
  45. package/src/runtime/foreground-watchdog.ts +1 -1
  46. package/src/runtime/heartbeat-watcher.ts +19 -2
  47. package/src/runtime/intercom-bridge.ts +7 -0
  48. package/src/runtime/iteration-hooks.ts +1 -1
  49. package/src/runtime/manifest-cache.ts +4 -0
  50. package/src/runtime/orphan-worker-registry.ts +444 -0
  51. package/src/runtime/parent-guard.ts +70 -16
  52. package/src/runtime/pi-args.ts +437 -14
  53. package/src/runtime/pi-spawn.ts +1 -0
  54. package/src/runtime/post-checks.ts +1 -1
  55. package/src/runtime/retry-runner.ts +9 -3
  56. package/src/runtime/run-tracker.ts +38 -10
  57. package/src/runtime/sandbox.ts +17 -2
  58. package/src/runtime/scheduler.ts +25 -2
  59. package/src/runtime/skill-effectiveness.ts +105 -62
  60. package/src/runtime/skill-instructions.ts +110 -32
  61. package/src/runtime/stale-reconciler.ts +310 -69
  62. package/src/runtime/subagent-manager.ts +251 -57
  63. package/src/runtime/task-health.ts +76 -0
  64. package/src/runtime/task-id.ts +20 -0
  65. package/src/runtime/task-runner/live-executor.ts +1 -1
  66. package/src/runtime/task-runner/progress.ts +1 -1
  67. package/src/runtime/task-runner/state-helpers.ts +110 -6
  68. package/src/runtime/task-runner.ts +92 -70
  69. package/src/runtime/team-runner.ts +186 -20
  70. package/src/schema/team-tool-schema.ts +27 -9
  71. package/src/skills/discover-skills.ts +9 -3
  72. package/src/state/active-run-registry.ts +170 -38
  73. package/src/state/artifact-store.ts +25 -13
  74. package/src/state/atomic-write-v2.ts +86 -0
  75. package/src/state/atomic-write.ts +346 -55
  76. package/src/state/blob-store.ts +178 -10
  77. package/src/state/crew-init.ts +161 -28
  78. package/src/state/decision-ledger.ts +172 -111
  79. package/src/state/event-log-rotation.ts +82 -52
  80. package/src/state/event-log.ts +254 -70
  81. package/src/state/health-store.ts +71 -0
  82. package/src/state/locks.ts +102 -20
  83. package/src/state/mailbox.ts +45 -7
  84. package/src/state/observation-store.ts +4 -1
  85. package/src/state/run-graph.ts +141 -130
  86. package/src/state/run-metrics.ts +24 -8
  87. package/src/state/state-store.ts +330 -43
  88. package/src/ui/live-run-sidebar.ts +1 -1
  89. package/src/ui/loaders.ts +4 -0
  90. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  91. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  92. package/src/ui/run-action-dispatcher.ts +2 -2
  93. package/src/ui/run-snapshot-cache.ts +1 -1
  94. package/src/utils/env-filter.ts +66 -0
  95. package/src/utils/file-coalescer.ts +9 -1
  96. package/src/utils/paths.ts +80 -5
  97. package/src/utils/redaction.ts +6 -1
  98. package/src/utils/safe-paths.ts +281 -10
  99. package/src/worktree/cleanup.ts +112 -24
  100. package/src/worktree/worktree-manager.ts +128 -15
  101. package/test-bugs-all.mjs +10 -6
  102. package/test-integration-check.ts +4 -0
@@ -1,22 +1,126 @@
1
+ import * as fs from "node:fs";
1
2
  import type { TaskCheckpointState, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
2
3
  import { loadRunManifestById, saveRunTasks } from "../../state/state-store.ts";
3
4
  import { recordFromTask, upsertCrewAgent } from "../crew-agent-records.ts";
5
+ import { logInternalError } from "../../utils/internal-error.ts";
6
+ import { withRunLockSync } from "../../state/locks.ts";
4
7
 
5
8
  export function updateTask(tasks: TeamTaskState[], updated: TeamTaskState): TeamTaskState[] {
6
9
  return tasks.map((task) => task.id === updated.id ? updated : task);
7
10
  }
8
11
 
9
- export function persistSingleTaskUpdate(manifest: TeamRunManifest, fallbackTasks: TeamTaskState[], updated: TeamTaskState): TeamTaskState[] {
10
- const latest = loadRunManifestById(manifest.cwd, manifest.runId)?.tasks ?? fallbackTasks;
11
- const merged = updateTask(latest, updated);
12
- saveRunTasks(manifest, merged);
13
- return merged;
12
+ /**
13
+ * Persist a single task update using compare-and-swap under the run lock.
14
+ *
15
+ * Problem: The naive read-merge-write pattern is vulnerable to a read-modify-write
16
+ * race. When two parallel task completions race:
17
+ * 1. Task A loads tasks [A(running), B(running)], writes [A(completed), B(running)]
18
+ * 2. Task B loads [A(running), B(running)] (stale, before A's write), writes [A(running), B(completed)]
19
+ * Result: Task A's completed status is clobbered.
20
+ *
21
+ * Solution: Use mtime-based CAS under the run lock. Before writing, stat the tasks file
22
+ * to record its mtime. After merging, re-stat — if mtime changed, another writer
23
+ * committed first; retry with the fresh state. This is O(retry) under contention but
24
+ * converges in the normal single-writer case.
25
+ *
26
+ * @param checkpointPhase - Optional checkpoint phase to include in the task state alongside the update.
27
+ */
28
+ export function persistSingleTaskUpdate(manifest: TeamRunManifest, fallbackTasks: TeamTaskState[], updated: TeamTaskState, checkpointPhase?: TaskCheckpointState["phase"]): TeamTaskState[] {
29
+ let baseMtime = 0;
30
+ try {
31
+ baseMtime = fs.statSync(manifest.tasksPath).mtimeMs;
32
+ } catch {
33
+ // File doesn't exist yet — baseMtime=0 means "anything is fine"
34
+ baseMtime = 0;
35
+ }
36
+
37
+ let merged: TeamTaskState[] | undefined;
38
+
39
+ // Build the task with optional checkpoint phase
40
+ const taskWithCheckpoint = checkpointPhase
41
+ ? { ...updated, checkpoint: { phase: checkpointPhase, updatedAt: new Date().toISOString() } }
42
+ : updated;
43
+
44
+ try {
45
+ return withRunLockSync(manifest, () => {
46
+ retryLoop: for (let attempt = 0; attempt < 100; attempt++) {
47
+ const latest = loadRunManifestById(manifest.cwd, manifest.runId)?.tasks ?? fallbackTasks;
48
+ merged = updateTask(latest, taskWithCheckpoint);
49
+
50
+ // Re-stat to detect concurrent writes
51
+ let currentMtime: number;
52
+ try {
53
+ currentMtime = fs.statSync(manifest.tasksPath).mtimeMs;
54
+ } catch {
55
+ currentMtime = 0;
56
+ }
57
+
58
+ if (currentMtime !== baseMtime) {
59
+ // Another writer committed — their update is in latest, re-merge on top
60
+ baseMtime = currentMtime;
61
+ continue retryLoop;
62
+ }
63
+
64
+ // No concurrent writer — check that our merged result is based on the
65
+ // same base we observed (no intermediate writer between our load and check)
66
+ let recheckMtime: number;
67
+ try {
68
+ recheckMtime = fs.statSync(manifest.tasksPath).mtimeMs;
69
+ } catch {
70
+ // Run state deleted (prune/forget) — nothing to persist.
71
+ return fallbackTasks;
72
+ }
73
+ if (recheckMtime !== baseMtime) {
74
+ baseMtime = recheckMtime;
75
+ continue retryLoop;
76
+ }
77
+
78
+ // Final pre-write mtime check to catch any concurrent writer that completed
79
+ // between the recheck and saveRunTasks
80
+ let preWriteMtime: number;
81
+ try {
82
+ preWriteMtime = fs.statSync(manifest.tasksPath).mtimeMs;
83
+ } catch {
84
+ preWriteMtime = 0;
85
+ }
86
+ if (preWriteMtime !== baseMtime) {
87
+ // Another writer committed — retry
88
+ baseMtime = preWriteMtime;
89
+ continue retryLoop;
90
+ }
91
+
92
+ break retryLoop;
93
+ }
94
+
95
+ if (merged === undefined) {
96
+ logInternalError("persistSingleTaskUpdate", new Error("failed to converge after 50 attempts"));
97
+ throw new Error("persistSingleTaskUpdate: failed to converge after 50 attempts");
98
+ }
99
+
100
+ try {
101
+ saveRunTasks(manifest, merged);
102
+ } catch (err) {
103
+ logInternalError("persistSingleTaskUpdate", err);
104
+ throw err;
105
+ }
106
+ return merged;
107
+ });
108
+ } catch (err) {
109
+ if (merged === undefined) {
110
+ logInternalError("persistSingleTaskUpdate", err);
111
+ }
112
+ throw err;
113
+ }
14
114
  }
15
115
 
16
116
  export function checkpointTask(manifest: TeamRunManifest, tasks: TeamTaskState[], task: TeamTaskState, phase: TaskCheckpointState["phase"], childPid?: number): { task: TeamTaskState; tasks: TeamTaskState[] } {
17
117
  const checkpoint: TaskCheckpointState = { phase, updatedAt: new Date().toISOString(), ...(childPid ? { childPid } : task.checkpoint?.childPid ? { childPid: task.checkpoint.childPid } : {}) };
18
118
  const nextTask = { ...task, checkpoint };
19
119
  const nextTasks = persistSingleTaskUpdate(manifest, updateTask(tasks, nextTask), nextTask);
20
- upsertCrewAgent(manifest, recordFromTask(manifest, nextTask, "child-process"));
120
+ try {
121
+ upsertCrewAgent(manifest, recordFromTask(manifest, nextTask, "child-process"));
122
+ } catch (err) {
123
+ logInternalError("checkpointTask", err);
124
+ }
21
125
  return { task: nextTask, tasks: nextTasks };
22
126
  }
@@ -220,7 +220,7 @@ export async function runTeamTask(
220
220
  tasks: updateTask(tasks, cancelledTask),
221
221
  };
222
222
  }
223
- tasks = persistSingleTaskUpdate(manifest, tasks, task);
223
+ tasks = persistSingleTaskUpdate(manifest, tasks, task, "started");
224
224
  if (runtimeKind === "child-process")
225
225
  ({ task, tasks } = checkpointTask(
226
226
  manifest,
@@ -261,7 +261,7 @@ export async function runTeamTask(
261
261
  teamRole: { skills: input.teamRoleSkills },
262
262
  step: input.step,
263
263
  override: input.skillOverride,
264
- runId: manifest.runId, // ECC INSTINCT: Enable skill confidence tracking
264
+ runId: manifest.runId,
265
265
  })
266
266
  : undefined;
267
267
  const skillBlock = input.skillBlock ?? renderedSkills?.block;
@@ -386,15 +386,29 @@ export async function runTeamTask(
386
386
  let lastRunProgressSummary: ProgressEventSummary | undefined;
387
387
  const persistHeartbeat = (force = false): void => {
388
388
  const now = Date.now();
389
+ // Skip disk write if throttled (unless forced).
389
390
  if (!force && now - lastHeartbeatPersistedAt < 1000) return;
390
- lastHeartbeatPersistedAt = now;
391
+ try {
392
+ // Write to disk first, then update in-memory.
393
+ // Disk state is always <= in-memory state, so a crash never produces
394
+ // a fresher in-memory heartbeat than what's on disk. This prevents the
395
+ // stale reconciler from seeing a live heartbeat paired with stale task state
396
+ // (which could cause false zombie detection).
397
+ tasks = persistSingleTaskUpdate(manifest, tasks, task);
398
+ } catch (err) {
399
+ // Run state may have been deleted by prune/forget/cleanup.
400
+ // This is not fatal — the run is gone, no point persisting.
401
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
402
+ throw err;
403
+ }
404
+ // Now update in-memory heartbeat so it is always >= persisted state.
391
405
  task = {
392
406
  ...task,
393
407
  heartbeat: touchWorkerHeartbeat(
394
408
  task.heartbeat ?? createWorkerHeartbeat(task.id),
395
409
  ),
396
410
  };
397
- tasks = persistSingleTaskUpdate(manifest, tasks, task);
411
+ lastHeartbeatPersistedAt = now;
398
412
  };
399
413
  const persistChildProgress = (
400
414
  event: unknown,
@@ -438,6 +452,10 @@ export async function runTeamTask(
438
452
  for (let i = 0; i < attemptModels.length; i++) {
439
453
  // M1 fix: set transcript path per attempt to avoid mixing across fallback attempts.
440
454
  transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.attempt-${i}.jsonl`;
455
+ // Ensure transcripts/ subdirectory exists before child-pi appends
456
+ // to it. appendTranscript uses O_APPEND (no mkdir) for security,
457
+ // so the caller must create the directory.
458
+ fs.mkdirSync(path.join(manifest.artifactsRoot, "transcripts"), { recursive: true });
441
459
  const model = attemptModels[i];
442
460
  const attemptStartedAt = new Date();
443
461
  const pendingAttempt: ModelAttemptSummary = {
@@ -472,6 +490,7 @@ export async function runTeamTask(
472
490
  role: task.role,
473
491
  runId: manifest.runId,
474
492
  agentId: task.id,
493
+ artifactsRoot: manifest.artifactsRoot,
475
494
  onSpawn: (pid) => {
476
495
  try {
477
496
  ({ task, tasks } = checkpointTask(
@@ -521,89 +540,90 @@ export async function runTeamTask(
521
540
  // Errors are logged but processing continues so subsequent events still update state.
522
541
  try {
523
542
  appendCrewAgentEvent(manifest, task.id, event);
524
- } catch (err) {
525
- logInternalError("task-runner.append-crew-agent-event", err, `taskId=${task.id}`);
526
- }
527
- if (
528
- event &&
529
- typeof event === "object" &&
530
- !Array.isArray(event)
531
- )
532
- collectedJsonEvents.push(
533
- event as Record<string, unknown>,
534
- );
535
- if (collectedJsonEvents.length > 1000) {
536
- collectedJsonEvents.splice(0, collectedJsonEvents.length - 1000);
537
- }
538
- // Accumulate lifetime usage via message_end events (survives compaction)
539
- if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
540
- const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
541
- if (msg?.role === "assistant") {
542
- const usage = msg.usage as Record<string, number> | undefined;
543
- if (usage) {
544
- task.lifetimeUsage = {
545
- input: (task.lifetimeUsage?.input ?? 0) + (usage.input ?? 0),
546
- output: (task.lifetimeUsage?.output ?? 0) + (usage.output ?? 0),
547
- cacheWrite: (task.lifetimeUsage?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0),
548
- };
543
+ if (
544
+ event &&
545
+ typeof event === "object" &&
546
+ !Array.isArray(event)
547
+ )
548
+ collectedJsonEvents.push(
549
+ event as Record<string, unknown>,
550
+ );
551
+ if (collectedJsonEvents.length > 1000) {
552
+ collectedJsonEvents.splice(0, collectedJsonEvents.length - 1000);
553
+ }
554
+ // Accumulate lifetime usage via message_end events (survives compaction)
555
+ if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
556
+ const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
557
+ if (msg?.role === "assistant") {
558
+ const usage = msg.usage as Record<string, number> | undefined;
559
+ if (usage) {
560
+ task.lifetimeUsage = {
561
+ input: (task.lifetimeUsage?.input ?? 0) + (usage.input ?? 0),
562
+ output: (task.lifetimeUsage?.output ?? 0) + (usage.output ?? 0),
563
+ cacheWrite: (task.lifetimeUsage?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0),
564
+ };
565
+ }
549
566
  }
550
567
  }
551
- }
552
- persistHeartbeat();
553
- // Bug #3 fix: Write worker JSON events to background.log for debugging when running in background mode.
554
- // This supplements the event log so developers can see what the child Pi worker produced.
555
- if (process.env.PI_CREW_BACKGROUND_MODE === "1" && event) {
556
- try {
568
+ persistHeartbeat();
569
+ // Bug #3 fix: Write worker JSON events to background.log for debugging when running in background mode.
570
+ // This supplements the event log so developers can see what the child Pi worker produced.
571
+ if (process.env.PI_CREW_BACKGROUND_MODE === "1" && event) {
557
572
  const bgLogPath = `${manifest.stateRoot}/background.log`;
558
573
  const eventLine = typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
559
574
  fs.appendFileSync(bgLogPath, `${eventLine}\n`);
560
- } catch { /* background log write failures should not affect task */ }
561
- }
562
- task = {
563
- ...task,
564
- agentProgress: applyAgentProgressEvent(
565
- task.agentProgress ?? emptyCrewAgentProgress(),
566
- event,
567
- task.startedAt,
568
- ),
569
- };
570
- tasks = updateTask(tasks, task);
571
- // Bridge event to UI event bus for near-instant updates
572
- try {
575
+ }
576
+ // Apply agentProgress update first, then persist, then update in-memory array.
577
+ // This ensures disk state is always >= in-memory state, preventing
578
+ // fresher in-memory state from being lost on crash.
579
+ tasks = persistSingleTaskUpdate(manifest, tasks, {
580
+ ...task,
581
+ agentProgress: applyAgentProgressEvent(
582
+ task.agentProgress ?? emptyCrewAgentProgress(),
583
+ event,
584
+ task.startedAt,
585
+ ),
586
+ });
587
+ task = {
588
+ ...task,
589
+ agentProgress: applyAgentProgressEvent(
590
+ task.agentProgress ?? emptyCrewAgentProgress(),
591
+ event,
592
+ task.startedAt,
593
+ ),
594
+ };
595
+ tasks = updateTask(tasks, task);
596
+ // Bridge event to UI event bus for near-instant updates
573
597
  const bridgeEvent = bridgeEventFromJsonEvent(
574
598
  manifest.runId,
575
599
  task.id,
576
600
  event,
577
601
  );
578
602
  if (bridgeEvent) streamBridge?.handler(bridgeEvent);
579
- } catch {
580
- /* bridge errors should not affect task */
581
- }
582
- // Feed overflow recovery tracker
583
- if (input.onJsonEvent) {
584
- try {
603
+ // Feed overflow recovery tracker
604
+ if (input.onJsonEvent) {
585
605
  input.onJsonEvent(
586
606
  task.id,
587
607
  manifest.runId,
588
608
  event,
589
609
  );
590
- } catch {
591
- /* overflow tracking errors should not affect task */
592
610
  }
611
+ if (
612
+ !finalCheckpointWritten &&
613
+ isFinalChildEvent(event)
614
+ ) {
615
+ finalCheckpointWritten = true;
616
+ ({ task, tasks } = checkpointTask(
617
+ manifest,
618
+ tasks,
619
+ task,
620
+ "child-stdout-final",
621
+ ));
622
+ }
623
+ persistChildProgress(event);
624
+ } catch (err) {
625
+ logInternalError("task-runner.on-json-event", err as Error, `taskId=${task.id}`);
593
626
  }
594
- if (
595
- !finalCheckpointWritten &&
596
- isFinalChildEvent(event)
597
- ) {
598
- finalCheckpointWritten = true;
599
- ({ task, tasks } = checkpointTask(
600
- manifest,
601
- tasks,
602
- task,
603
- "child-stdout-final",
604
- ));
605
- }
606
- persistChildProgress(event);
607
627
  },
608
628
  });
609
629
  const evidenceStatus = childResult.exitStatus?.cancelled
@@ -881,6 +901,8 @@ export async function runTeamTask(
881
901
  } else {
882
902
  resultArtifact = live.resultArtifact;
883
903
  }
904
+ // Sync task.resultArtifact with the re-written artifact (if liveText was truthy)
905
+ task = { ...task, resultArtifact };
884
906
  logArtifact = live.logArtifact;
885
907
  transcriptArtifact = live.transcriptArtifact;
886
908
  } else {
@@ -1065,7 +1087,7 @@ export async function runTeamTask(
1065
1087
  // 2. Verification contract has commands
1066
1088
  // 3. Not in scaffold mode (scaffold mode intentionally skips execution)
1067
1089
  let verificationEvidence: VerificationEvidence = baseEvidence;
1068
- if (!error && runtimeKind !== "scaffold" && taskPacket.verification?.commands?.length) {
1090
+ if (runtimeKind !== "scaffold" && taskPacket.verification?.commands?.length) {
1069
1091
  try {
1070
1092
  const commandResults = await executeVerificationCommands(
1071
1093
  taskPacket.verification,