pi-crew 0.6.0 → 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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  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/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. package/test-integration-check.ts +4 -0
@@ -4,9 +4,16 @@ import * as path from "node:path";
4
4
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
5
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
6
6
  import { checkProcessLiveness } from "./process-status.ts";
7
+ import { saveRunManifest } from "../state/state-store.ts";
7
8
 
8
9
  /** Age threshold for orphaned temp directory cleanup: 1 hour. */
9
10
  const ORPHAN_TEMP_DIR_AGE_THRESHOLD_MS = 60 * 60 * 1000;
11
+ /** Defense-in-depth: cap the number of /tmp/pi-crew-* entries processed per
12
+ * reconcile tick. With a few thousand accumulated dirs, processing them all
13
+ * synchronously can block the main thread for many seconds, causing the
14
+ * terminal to appear hung. We process in batches; the rest are handled on
15
+ * subsequent ticks. */
16
+ const ORPHAN_TEMP_SCAN_BATCH_SIZE = 50;
10
17
 
11
18
  /**
12
19
  * Result of reconciling a single stale run.
@@ -53,6 +60,10 @@ function checkResultFile(
53
60
  t.status === "needs_attention",
54
61
  );
55
62
  if (allTerminal) {
63
+ // All tasks are terminal but manifest status was not updated — repair it.
64
+ // Persist manifest status change immediately to make checkResultFile self-contained.
65
+ manifest.status = "completed";
66
+ saveRunManifest(manifest);
56
67
  // Sync agent records even when tasks are already terminal
57
68
  // (e.g., a previous reconcile fixed tasks but crashed before updating agents)
58
69
  for (const task of tasks) {
@@ -70,12 +81,40 @@ function checkResultFile(
70
81
  return { found: false, repaired: false };
71
82
  }
72
83
 
84
+ /**
85
+ * Get process start time in milliseconds since boot from /proc/<pid>/stat.
86
+ * Returns undefined if the process is gone or /proc is unavailable.
87
+ *
88
+ * Platform limitation: Relies on Linux's /proc filesystem. On macOS/Windows,
89
+ * startTime will be undefined and PID reuse detection is weaker.
90
+ *
91
+ * The start time is in the 22nd field (index 21 after comm) of /proc/<pid>/stat.
92
+ */
93
+ function getProcessStartTime(pid: number): number | undefined {
94
+ try {
95
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
96
+ const lastParen = stat.lastIndexOf(")");
97
+ if (lastParen === -1) return undefined;
98
+ const fieldsAfterComm = stat.slice(lastParen + 1).trim().split(/\s+/);
99
+ // starttime is at index 19 (the 20th field after comm)
100
+ const startTimeClockTicks = Number(fieldsAfterComm[19]);
101
+ if (!Number.isFinite(startTimeClockTicks)) return undefined;
102
+ // Convert clock ticks to ms using ~100 ticks/sec (CLK_TCK).
103
+ // The absolute value matters less than uniqueness per PID lifecycle.
104
+ return Math.floor(startTimeClockTicks * 10);
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
73
110
  /**
74
111
  * Phase 2: Check PID liveness.
75
- * Uses process.kill(pid, 0) for the authoritative check, but also checks
76
- * the heartbeat file as corroborating evidence. If a heartbeat was recently
77
- * written, treat the PID as alive even if process.kill returns false
78
- * (handles SIGKILL race where PID hasn't been recycled yet).
112
+ * Uses process.kill(pid, 0) for the authoritative check, but also verifies
113
+ * startTime before and after to detect PID recycling. If the OS recycles
114
+ * the PID between the kill(0) call and the repair action, a newly spawned
115
+ * unrelated process could be incorrectly identified as the async worker.
116
+ * The heartbeat file is used as corroborating evidence when the process
117
+ * appears dead per kill(0).
79
118
  */
80
119
  function checkPidLiveness(
81
120
  pid: number | undefined,
@@ -87,7 +126,20 @@ function checkPidLiveness(
87
126
  if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
88
127
  return { alive: false, detail: "no pid recorded" };
89
128
  }
129
+ // Capture startTime before kill(0) to detect PID recycling.
130
+ const startTimeBefore = getProcessStartTime(pid);
90
131
  const liveness = checkProcessLiveness(pid);
132
+ // Re-verify startTime after kill(0) to close the TOCTOU window.
133
+ // If startTime changed, the PID was recycled to a different process.
134
+ const startTimeAfter = getProcessStartTime(pid);
135
+ if (
136
+ startTimeBefore !== undefined &&
137
+ startTimeAfter !== undefined &&
138
+ startTimeBefore !== startTimeAfter
139
+ ) {
140
+ // PID was recycled — treat as dead to avoid acting on wrong process.
141
+ return { alive: false, detail: "pid_recycled" };
142
+ }
91
143
  // If process is alive per kill(0), we're done.
92
144
  if (liveness.alive) return { alive: true, detail: liveness.detail };
93
145
  // Process is dead per kill(0). Check heartbeat as corroborating evidence.
@@ -165,46 +217,56 @@ function hasRecentActiveEvidence(tasks: TeamTaskState[], now: number): boolean {
165
217
  });
166
218
  }
167
219
 
220
+ /**
221
+ * Check if a single task is stale (heartbeat AND activity both stale beyond
222
+ * NO_PID_HEARTBEAT_STALE_MS). Tasks with no heartbeat AND no agent progress
223
+ * are considered NOT stale (they may be newly spawned and haven't reported yet).
224
+ */
225
+ function isTaskHeartbeatStale(task: TeamTaskState, now: number): boolean {
226
+ const heartbeatAt = task.heartbeat?.lastSeenAt
227
+ ? new Date(task.heartbeat.lastSeenAt).getTime()
228
+ : Number.NaN;
229
+ const activityAt = task.agentProgress?.lastActivityAt
230
+ ? new Date(task.agentProgress.lastActivityAt).getTime()
231
+ : Number.NaN;
232
+ // If no heartbeat AND no activity, we can't determine staleness — not stale
233
+ if (!Number.isFinite(heartbeatAt) && !Number.isFinite(activityAt))
234
+ return false;
235
+ // Compute elapsed from both sources and use the fresher (minimum) one,
236
+ // mirroring heartbeat-watcher logic: if either source has recent activity,
237
+ // the task is not stale even if the other is stale.
238
+ const heartbeatAge = Number.isFinite(heartbeatAt) ? now - heartbeatAt : Infinity;
239
+ const activityAge = Number.isFinite(activityAt) ? now - activityAt : Infinity;
240
+ const elapsed = Math.min(heartbeatAge, activityAge);
241
+ return elapsed > NO_PID_HEARTBEAT_STALE_MS;
242
+ }
243
+
168
244
  /**
169
245
  * For no-PID runs: check if ALL running tasks have heartbeats stale beyond
170
- * the no-PID heartbeat threshold. This detects zombie tasks where the worker
171
- * process died but no PID was recorded (e.g. live-session /tmp/ workspaces).
172
- * Tasks with no heartbeat AND no agent progress are considered NOT stale
173
- * (they may be newly spawned and haven't reported yet).
246
+ * the no-PID heartbeat threshold, and return the list of individually stale
247
+ * task IDs. This detects zombie tasks where the worker process died but no
248
+ * PID was recorded (e.g. live-session /tmp/ workspaces).
249
+ * Merged from the former allRunningTasksHeartbeatStale and
250
+ * findIndividuallyStaleTaskIds to avoid redundant duplicate checks.
174
251
  */
175
- function allRunningTasksHeartbeatStale(
252
+ function getRunningTaskStaleness(
176
253
  tasks: TeamTaskState[],
177
254
  now: number,
178
- ): boolean {
255
+ ): { allStale: boolean; staleTaskIds: string[] } {
179
256
  const runningTasks = tasks.filter(
180
257
  (t) => t.status === "running" || t.status === "waiting",
181
258
  );
182
- if (runningTasks.length === 0) return false;
183
- return runningTasks.every((task) => {
184
- const heartbeatAt = task.heartbeat?.lastSeenAt
185
- ? new Date(task.heartbeat.lastSeenAt).getTime()
186
- : Number.NaN;
187
- const activityAt = task.agentProgress?.lastActivityAt
188
- ? new Date(task.agentProgress.lastActivityAt).getTime()
189
- : Number.NaN;
190
- // If no heartbeat AND no activity, we can't determine staleness — assume not stale
191
- if (!Number.isFinite(heartbeatAt) && !Number.isFinite(activityAt))
192
- return false;
193
- // If heartbeat is recent enough, not stale
194
- if (
195
- Number.isFinite(heartbeatAt) &&
196
- now - heartbeatAt <= NO_PID_HEARTBEAT_STALE_MS
197
- )
198
- return false;
199
- // If agent progress is recent enough, not stale
200
- if (
201
- Number.isFinite(activityAt) &&
202
- now - activityAt <= NO_PID_HEARTBEAT_STALE_MS
203
- )
204
- return false;
205
- // Both present and both stale → this task is stale
206
- return true;
207
- });
259
+ if (runningTasks.length === 0) return { allStale: false, staleTaskIds: [] };
260
+ const staleTaskIds: string[] = [];
261
+ for (const task of runningTasks) {
262
+ if (isTaskHeartbeatStale(task, now)) {
263
+ staleTaskIds.push(task.id);
264
+ }
265
+ }
266
+ return {
267
+ allStale: staleTaskIds.length === runningTasks.length,
268
+ staleTaskIds,
269
+ };
208
270
  }
209
271
 
210
272
  /**
@@ -251,6 +313,13 @@ function repairStaleRun(
251
313
  * 1. Check if result already exists → use it
252
314
  * 2. Check PID liveness
253
315
  * 3. Dead PID → repair immediately; alive PID → only fail if stale > 24h
316
+ *
317
+ * NOTE: Callers must provide locking via withRunLock/withRunLockSync when
318
+ * calling from contexts where concurrent reconciliation of the same runId
319
+ * could occur (e.g., the auto-repair timer). The crash-recovery.ts caller
320
+ * already provides this. The reconcileOrphanedTempWorkspaces caller handles
321
+ * /tmp workspaces where concurrent access is a known benign race (separate
322
+ * dirs, low consequence of redundant repair).
254
323
  */
255
324
  export function reconcileStaleRun(
256
325
  manifest: TeamRunManifest,
@@ -262,6 +331,10 @@ export function reconcileStaleRun(
262
331
  // Phase 1: Check if results already exist
263
332
  const phase1 = checkResultFile(manifest, tasks);
264
333
  if (phase1.found) {
334
+ // checkResultFile sets manifest.status='completed' and saves it,
335
+ // but we re-save to ensure the completed status is persisted
336
+ // before returning (avoids TOCTOU where caller might re-read stale data)
337
+ saveRunManifest(manifest);
265
338
  return {
266
339
  runId,
267
340
  verdict: "result_exists",
@@ -289,7 +362,9 @@ export function reconcileStaleRun(
289
362
  // (beyond NO_PID_HEARTBEAT_STALE_MS = 5min), repair immediately — the worker
290
363
  // process is dead but we have no PID to check. This handles /tmp/ live-session
291
364
  // workspaces where agents exit without calling submit_result.
292
- if (allRunningTasksHeartbeatStale(tasks, now)) {
365
+ // Merged with individual stale task check to avoid redundant duplicate checks.
366
+ const { allStale, staleTaskIds } = getRunningTaskStaleness(tasks, now);
367
+ if (allStale) {
293
368
  const repaired = repairStaleRun(
294
369
  manifest,
295
370
  tasks,
@@ -303,6 +378,25 @@ export function reconcileStaleRun(
303
378
  repairedTasks: repaired,
304
379
  };
305
380
  }
381
+ // Check for individually stale tasks even when not all are stale.
382
+ // This handles the case where task A is healthy but task B is a zombie.
383
+ // We repair only the zombie tasks, not the whole run.
384
+ // NOTE: Individual stale task repair (this branch) is intentionally
385
+ // separate from run-level repair (the allStale branch above). Both
386
+ // paths can coexist — the allStale path cancels the entire run when
387
+ // ALL tasks are zombies, while this path repairs only the zombie
388
+ // subset when some tasks are still healthy.
389
+ if (staleTaskIds.length > 0) {
390
+ const repaired = repairStaleRun(manifest, tasks, "no_pid_individual_stale_task");
391
+ // Only return the individually repaired tasks in detail
392
+ return {
393
+ runId,
394
+ verdict: "no_status",
395
+ repaired: true,
396
+ detail: `No PID; ${staleTaskIds.length} individually stale task(s) repaired: ${staleTaskIds.join(", ")}`,
397
+ repairedTasks: repaired.filter((t) => staleTaskIds.includes(t.id)),
398
+ };
399
+ }
306
400
  // Fall through: no recent activity but not all tasks stale enough yet.
307
401
  // Check the longer STALE_ALIVE_PID_MS threshold for very old runs.
308
402
  const updatedAt = new Date(manifest.updatedAt).getTime();
@@ -380,7 +474,14 @@ export function reconcileOrphanedTempWorkspaces(
380
474
  let cleanedDirs = 0;
381
475
  try {
382
476
  const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
383
- for (const entry of entries) {
477
+ // Sort for deterministic order; cap to ORPHAN_TEMP_SCAN_BATCH_SIZE per
478
+ // tick to avoid main-thread stalls when /tmp has thousands of
479
+ // pi-crew-* dirs from past interrupted test runs.
480
+ const candidates = entries
481
+ .filter((e) => e.isDirectory() && e.name.startsWith("pi-crew-"))
482
+ .sort((a, b) => a.name.localeCompare(b.name))
483
+ .slice(0, ORPHAN_TEMP_SCAN_BATCH_SIZE);
484
+ for (const entry of candidates) {
384
485
  if (!entry.isDirectory() || !entry.name.startsWith("pi-crew-"))
385
486
  continue;
386
487
  const workspaceDir = path.join(tmpDir, entry.name);
@@ -449,27 +550,95 @@ export function reconcileOrphanedTempWorkspaces(
449
550
  }
450
551
  repaired++;
451
552
  }
452
- // If still running after reconciliation attempt, mark for dir-preserving
453
- if (
454
- result.verdict === "healthy" ||
455
- (result.verdict === "no_status" && !result.repaired)
456
- ) {
457
- hasRunning = true;
458
- }
459
- } catch {
460
- /* skip corrupt manifests */
553
+ // Persist result_exists manifest status update
554
+ if (result.verdict === "result_exists") {
555
+ // checkResultFile (called by reconcileStaleRun at line 518)
556
+ // already saved manifest.status='completed' at line 66.
557
+ // No additional manifest write needed here — avoids TOCTOU
558
+ // window between checkResultFile save and re-write.
559
+ // Sync agent records (checkResultFile also does this but
560
+ // we sync here for consistency)
561
+ for (const task of tasks) {
562
+ try {
563
+ upsertCrewAgent(
564
+ manifest,
565
+ recordFromTask(manifest, task, "scaffold"),
566
+ );
567
+ } catch {
568
+ /* non-critical */
569
+ }
570
+ }
571
+ }
572
+ // If still running after reconciliation attempt, mark for dir-preserving
573
+ if (
574
+ result.verdict === "healthy" ||
575
+ (result.verdict === "no_status" && !result.repaired)
576
+ ) {
577
+ hasRunning = true;
578
+ }
579
+ } catch (err) {
580
+ // Log warning when skipping a directory due to error.
581
+ // Note: manifestPath here refers to the variable defined in the
582
+ // for loop at line 442 (outer scope of this catch block).
583
+ const scanManifestPath = manifestPath;
584
+ console.warn(
585
+ `[stale-reconciler] Skipping manifest due to parse error: ${scanManifestPath}: ${err}`,
586
+ );
461
587
  }
462
588
  }
463
- } catch {
464
- /* skip unreadable dirs */
589
+ } catch (err) {
590
+ // Cannot determine running state — treat as if running to prevent
591
+ // premature cleanup of a potentially active workspace.
592
+ hasRunning = true;
593
+ console.warn(
594
+ `[stale-reconciler] Skipping unreadable runs dir: ${stateRunsDir}: ${err}`,
595
+ );
465
596
  }
466
597
 
467
598
  // Post-loop: check if this workspace dir can be cleaned up.
468
599
  // Eligible when cleanup is enabled, no running manifests remain, and
469
600
  // the directory is older than the age threshold.
470
- if (!hasRunning) {
471
- // Re-scan manifests to confirm no running runs remain
472
- // (some may have been cancelled on a previous pass)
601
+ // Re-scan manifests to confirm no running runs remain BEFORE the
602
+ // cleanup decision (fixes TOCTOU race where a manifest may have
603
+ // transitioned from 'running' to 'completed' between the main loop
604
+ // and this re-scan).
605
+ //
606
+ // TOCTOU fix: Create a sentinel file before the re-scan. New run
607
+ // creation should check for this sentinel and refuse/wait if present.
608
+ // Additionally, do a final check right before cleanup to catch any
609
+ // new runs created between the re-scan and the cleanup decision.
610
+ const sentinelPath = path.join(workspaceDir, ".cleanup-in-progress");
611
+ let canCleanup = !hasRunning;
612
+
613
+ // FIX: Capture dirAge BEFORE creating the sentinel file, because writing
614
+ // .cleanup-in-progress inside workspaceDir updates its mtime, making the
615
+ // directory appear fresh and defeating the age check.
616
+ const cleanupEnabled = options?.cleanupOrphanedTempDirs !== false;
617
+ let dirAge = 0;
618
+ if (cleanupEnabled && !hasRunning) {
619
+ try {
620
+ const stat = fs.statSync(workspaceDir);
621
+ dirAge = now - stat.mtimeMs;
622
+ } catch {
623
+ // Directory disappeared
624
+ }
625
+ }
626
+ if (canCleanup) {
627
+ // Create sentinel file before re-scan to signal cleanup in progress.
628
+ // O_EXCL requires workspaceDir to exist — ensure it before writing.
629
+ try {
630
+ if (!fs.existsSync(workspaceDir)) {
631
+ fs.mkdirSync(workspaceDir, { recursive: true });
632
+ }
633
+ fs.writeFileSync(sentinelPath, JSON.stringify({ startedAt: now }), {
634
+ flag: "wx",
635
+ });
636
+ } catch {
637
+ // Sentinel already exists (another cleanup in progress) — skip
638
+ canCleanup = false;
639
+ }
640
+ }
641
+ if (canCleanup) {
473
642
  if (fs.existsSync(stateRunsDir)) {
474
643
  try {
475
644
  for (const runDir of fs.readdirSync(stateRunsDir)) {
@@ -479,38 +648,110 @@ export function reconcileOrphanedTempWorkspaces(
479
648
  "manifest.json",
480
649
  );
481
650
  if (!fs.existsSync(manifestPath)) continue;
651
+ let manifest: TeamRunManifest | undefined;
482
652
  try {
483
- const manifest: TeamRunManifest = JSON.parse(
653
+ manifest = JSON.parse(
484
654
  fs.readFileSync(manifestPath, "utf-8"),
485
655
  );
486
- if (manifest.status === "running") {
487
- hasRunning = true;
488
- break;
489
- }
490
- } catch {
491
- /* skip corrupt */
656
+ } catch (err) {
657
+ // Log warning when skipping a directory due to error
658
+ console.warn(
659
+ `[stale-reconciler] Skipping manifest due to parse error: ${manifestPath}: ${err}`,
660
+ );
661
+ continue;
662
+ }
663
+ if (manifest?.status === "running") {
664
+ canCleanup = false;
665
+ break;
492
666
  }
493
667
  }
494
- } catch {
495
- /* skip unreadable */
668
+ } catch (err) {
669
+ console.warn(
670
+ `[stale-reconciler] Skipping unreadable runs dir: ${stateRunsDir}: ${err}`,
671
+ );
496
672
  }
497
673
  }
498
674
  }
499
-
500
- const cleanupEnabled = options?.cleanupOrphanedTempDirs !== false;
501
- if (cleanupEnabled && !hasRunning) {
502
- try {
503
- const stat = fs.statSync(workspaceDir);
504
- const dirAge = now - stat.mtimeMs;
505
- if (dirAge > ORPHAN_TEMP_DIR_AGE_THRESHOLD_MS) {
675
+ if (canCleanup) {
676
+ if (dirAge > ORPHAN_TEMP_DIR_AGE_THRESHOLD_MS) {
677
+ // Final check: re-verify no running manifests appeared since re-scan.
678
+ let stillClean = true;
679
+ if (fs.existsSync(stateRunsDir)) {
680
+ try {
681
+ for (const runDir of fs.readdirSync(stateRunsDir)) {
682
+ const manifestPath = path.join(
683
+ stateRunsDir,
684
+ runDir,
685
+ "manifest.json",
686
+ );
687
+ if (!fs.existsSync(manifestPath)) continue;
688
+ try {
689
+ const manifest: TeamRunManifest = JSON.parse(
690
+ fs.readFileSync(manifestPath, "utf-8"),
691
+ );
692
+ if (manifest.status === "running") {
693
+ stillClean = false;
694
+ break;
695
+ }
696
+ } catch {
697
+ /* skip on parse error */
698
+ }
699
+ }
700
+ } catch {
701
+ stillClean = false;
702
+ }
703
+ }
704
+ if (stillClean) {
506
705
  fs.rmSync(workspaceDir, {
507
706
  recursive: true,
508
707
  force: true,
509
708
  });
510
709
  cleanedDirs++;
511
710
  }
711
+ }
712
+ // Clean up sentinel file regardless of outcome
713
+ try {
714
+ fs.unlinkSync(sentinelPath);
512
715
  } catch {
513
- /* skip if stat or rm fails */
716
+ /* ignore if already gone */
717
+ }
718
+ } else if (canCleanup) {
719
+ // Sentinel already existed — another cleanup attempt is in progress.
720
+ // Perform a simplified TOCTOU check: if any manifest is running,
721
+ // do NOT remove the sentinel (let the first attempt finish its scan).
722
+ let hasRunningManifest = false;
723
+ if (fs.existsSync(stateRunsDir)) {
724
+ try {
725
+ for (const runDir of fs.readdirSync(stateRunsDir)) {
726
+ const manifestPath = path.join(
727
+ stateRunsDir,
728
+ runDir,
729
+ "manifest.json",
730
+ );
731
+ if (!fs.existsSync(manifestPath)) continue;
732
+ try {
733
+ const m = JSON.parse(
734
+ fs.readFileSync(manifestPath, "utf-8"),
735
+ ) as TeamRunManifest;
736
+ if (m.status === "running") {
737
+ hasRunningManifest = true;
738
+ break;
739
+ }
740
+ } catch {
741
+ /* skip on parse error */
742
+ }
743
+ }
744
+ } catch {
745
+ /* skip if runs dir unreadable */
746
+ }
747
+ }
748
+ // Only clean up sentinel if no running manifests exist
749
+ if (!hasRunningManifest) {
750
+ try {
751
+ fs.unlinkSync(sentinelPath);
752
+ } catch {
753
+ /* ignore if already gone */
754
+ }
514
755
  }
515
756
  }
516
757
  }