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,8 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { loadRunManifestById } from "./state-store.ts";
4
3
  import { projectCrewRoot } from "../utils/paths.ts";
4
+ import { assertSafePathId } from "../utils/safe-paths.ts";
5
5
  import { atomicWriteJson, readJsonFile } from "./atomic-write.ts";
6
+ import { loadRunManifestById } from "./state-store.ts";
6
7
 
7
8
  /**
8
9
  * Run metrics snapshot captured after a run completes (or on demand).
@@ -28,6 +29,7 @@ function metricsDir(cwd: string): string {
28
29
  }
29
30
 
30
31
  function metricsFilePath(cwd: string, runId: string): string {
32
+ assertSafePathId("runId", runId);
31
33
  return path.join(metricsDir(cwd), `${runId}.json`);
32
34
  }
33
35
 
@@ -35,8 +37,12 @@ function metricsFilePath(cwd: string, runId: string): string {
35
37
  * Collect metrics for a run by reading its manifest, tasks, and event log.
36
38
  * Returns undefined if the run cannot be loaded.
37
39
  */
38
- export function collectRunMetrics(cwd: string, runId: string): RunMetrics | undefined {
39
- const result = loadRunManifestById(cwd, runId);
40
+ export function collectRunMetrics(
41
+ cwd: string,
42
+ runId: string,
43
+ ): RunMetrics | undefined {
44
+ assertSafePathId("runId", runId);
45
+ const result = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
40
46
  if (!result) return undefined;
41
47
 
42
48
  const { manifest, tasks } = result;
@@ -63,11 +69,17 @@ export function collectRunMetrics(cwd: string, runId: string): RunMetrics | unde
63
69
  // Duration: from run createdAt to updatedAt (manifest timestamps), or 0 if unavailable.
64
70
  const createdAt = new Date(manifest.createdAt).getTime();
65
71
  const updatedAt = new Date(manifest.updatedAt).getTime();
66
- const durationMs = isNaN(createdAt) || isNaN(updatedAt) ? 0 : Math.max(0, updatedAt - createdAt);
72
+ const durationMs =
73
+ isNaN(createdAt) || isNaN(updatedAt)
74
+ ? 0
75
+ : Math.max(0, updatedAt - createdAt);
67
76
 
68
77
  // Consistency score: proportion of tasks that completed successfully among all non-skipped tasks.
69
78
  const nonSkippedTasks = tasks.filter((t) => t.status !== "skipped");
70
- const consistencyScore = nonSkippedTasks.length > 0 ? completedCount / nonSkippedTasks.length : 1.0;
79
+ const consistencyScore =
80
+ nonSkippedTasks.length > 0
81
+ ? completedCount / nonSkippedTasks.length
82
+ : 1.0;
71
83
 
72
84
  return {
73
85
  runId,
@@ -96,7 +108,10 @@ export function saveRunMetrics(cwd: string, metrics: RunMetrics): void {
96
108
  * Load a previously saved metrics snapshot.
97
109
  * Returns undefined if the file does not exist or cannot be parsed.
98
110
  */
99
- export function loadRunMetrics(cwd: string, runId: string): RunMetrics | undefined {
111
+ export function loadRunMetrics(
112
+ cwd: string,
113
+ runId: string,
114
+ ): RunMetrics | undefined {
100
115
  return readJsonFile<RunMetrics>(metricsFilePath(cwd, runId));
101
116
  }
102
117
 
@@ -125,10 +140,11 @@ export function getRunMetricsSummary(cwd: string, limit = 25): RunMetrics[] {
125
140
 
126
141
  // Sort newest first (by timestamp, then runId as tiebreaker).
127
142
  metrics.sort((a, b) => {
128
- const diff = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
143
+ const diff =
144
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
129
145
  if (diff !== 0) return diff;
130
146
  return b.runId.localeCompare(a.runId);
131
147
  });
132
148
 
133
149
  return metrics.slice(0, limit);
134
- }
150
+ }
@@ -9,10 +9,13 @@ import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
9
9
  import { createRunId, createTaskId } from "../utils/ids.ts";
10
10
  import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
11
11
  import { assertSafePathId, resolveContainedRelativePath, resolveRealContainedPath } from "../utils/safe-paths.ts";
12
- import { withRunLock } from "./locks.ts";
12
+ import { withRunLock, withRunLockSync } from "./locks.ts";
13
+ import { logInternalError } from "../utils/internal-error.ts";
14
+ import { errors } from "../errors.ts";
13
15
  import type { TeamConfig } from "../teams/team-config.ts";
14
16
  import type { WorkflowConfig } from "../workflows/workflow-config.ts";
15
17
  import { toPiSessionId } from "../utils/session-utils.ts";
18
+ import { HealthStore } from "./health-store.ts";
16
19
 
17
20
  export interface RunPaths {
18
21
  runId: string;
@@ -31,19 +34,43 @@ interface ManifestCacheEntry {
31
34
  tasksMtimeMs: number;
32
35
  tasksSize: number;
33
36
  cachedAt?: number;
37
+ generation?: number;
34
38
  }
35
39
 
36
- const MANIFEST_CACHE_TTL_MS = 30 * 1000; // 30 seconds (FIX: reduced from 5 minutes for faster state updates)
40
+ // Global generation counter incremented on each cache invalidation.
41
+ // Detects staleness even when mtime/size are spoofed by an attacker.
42
+ let manifestCacheGeneration = 0;
43
+
44
+ const MANIFEST_CACHE_TTL_MS = 15 * 1000; // 15 seconds (FIX: increased from 5s for read-heavy workloads; 5s was too short causing unnecessary cache invalidation)
45
+ const LOAD_MANIFEST_RETRY_LIMIT = 5; // Configurable retry limit for mtime/size stability checks under contention
37
46
  const manifestCache = new Map<string, ManifestCacheEntry>();
38
47
 
39
48
  function setManifestCache(stateRoot: string, entry: ManifestCacheEntry): void {
40
49
  if (manifestCache.has(stateRoot)) manifestCache.delete(stateRoot);
41
50
  entry.cachedAt = Date.now();
51
+ entry.generation = manifestCacheGeneration;
52
+ // FIX: Evict all stale entries by TTL before adding new entry.
53
+ // This ensures entries that are never accessed still get evicted
54
+ // based on TTL, not just entries that are hit.
55
+ const now = Date.now();
56
+ for (const [key, val] of manifestCache.entries()) {
57
+ if (val.cachedAt && now - val.cachedAt > MANIFEST_CACHE_TTL_MS) {
58
+ manifestCache.delete(key);
59
+ }
60
+ }
42
61
  manifestCache.set(stateRoot, entry);
43
62
  while (manifestCache.size > DEFAULT_CACHE.manifestMaxEntries) {
44
- const oldest = manifestCache.keys().next().value;
45
- if (!oldest) break;
46
- manifestCache.delete(oldest);
63
+ // FIX: Evict oldest entry by cachedAt (LRU), not insertion order.
64
+ // cachedAt is set on both initial insertion and cache hits, so
65
+ // frequently accessed entries bubble to the end and survive longer.
66
+ let oldestKey: string | undefined;
67
+ let oldestTime = Infinity;
68
+ for (const [key, val] of manifestCache.entries()) {
69
+ const t = val.cachedAt ?? 0;
70
+ if (t < oldestTime) { oldestTime = t; oldestKey = key; }
71
+ }
72
+ if (!oldestKey) break;
73
+ manifestCache.delete(oldestKey);
47
74
  }
48
75
  }
49
76
 
@@ -53,6 +80,7 @@ function useProjectState(cwd: string): boolean {
53
80
 
54
81
  function invalidateRunCache(stateRoot: string): void {
55
82
  manifestCache.delete(stateRoot);
83
+ manifestCacheGeneration++;
56
84
  }
57
85
 
58
86
  function scopeBaseRoot(cwd: string): string {
@@ -76,10 +104,19 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
76
104
  }
77
105
 
78
106
  function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunManifest, stateRoot: string, tasksPath: string): boolean {
107
+ // Issue 2 fix: Reject manifests missing status field to prevent undefined
108
+ // behavior in callers like canTransitionRunStatus(manifest.status, newStatus).
109
+ if (!manifest.status || typeof manifest.status !== "string") return false;
79
110
  if (manifest.runId !== runId || manifest.stateRoot !== stateRoot || manifest.tasksPath !== tasksPath || manifest.eventsPath !== path.join(stateRoot, "events.jsonl")) return false;
80
111
  const artifactsParent = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.artifactsSubdir);
81
112
  const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
82
113
  if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
114
+ // Always validate artifactsRoot is not a symlink, even when manifest has
115
+ // no artifacts entries. A symlinked artifactsRoot pointing outside the
116
+ // artifacts parent is a security violation (could write to attacker-
117
+ // controlled locations). This check catches the case where the run was
118
+ // created legitimately but the artifactsRoot was later replaced with a
119
+ // symlink between manifest creation and load.
83
120
  if (fs.existsSync(expectedArtifactsRoot)) {
84
121
  try {
85
122
  if (fs.lstatSync(expectedArtifactsRoot).isSymbolicLink()) return false;
@@ -87,6 +124,10 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
87
124
  } catch {
88
125
  return false;
89
126
  }
127
+ } else if (manifest.artifacts && manifest.artifacts.length > 0) {
128
+ // Has artifacts entries but directory doesn't exist - benign state for
129
+ // runs still in progress.
130
+ return true;
90
131
  }
91
132
  return true;
92
133
  }
@@ -167,8 +208,14 @@ export function createRunManifest(params: {
167
208
  };
168
209
  fs.mkdirSync(paths.stateRoot, { recursive: true });
169
210
  fs.mkdirSync(paths.artifactsRoot, { recursive: true });
170
- atomicWriteJson(paths.manifestPath, manifest);
171
- atomicWriteJson(paths.tasksPath, tasks);
211
+ // FIX: Use saveManifestAndTasksAtomicSync and check result to detect
212
+ // partial failure. If tasksWritten=false when manifestWritten=true,
213
+ // throw to ensure manifest and tasks are always consistent.
214
+ const result = saveManifestAndTasksAtomicSync(manifest, tasks);
215
+ if (!result.manifestWritten || !result.tasksWritten) {
216
+ throw errors.fileWrite(paths.stateRoot, result.error as unknown as NodeJS.ErrnoException)
217
+ .withContext(`saveManifestAndTasksAtomicSync: manifestWritten=${result.manifestWritten}, tasksWritten=${result.tasksWritten}`);
218
+ }
172
219
  appendEvent(paths.eventsPath, {
173
220
  type: "run.created",
174
221
  runId: paths.runId,
@@ -186,18 +233,105 @@ export function createRunManifest(params: {
186
233
  }
187
234
 
188
235
  export function saveRunManifest(manifest: TeamRunManifest): void {
189
- atomicWriteJson(path.join(manifest.stateRoot, "manifest.json"), manifest);
236
+ // FIX: Invalidate cache BEFORE atomic write. The order matters for crash
237
+ // safety: if we invalidated after the write and crashed before invalidation,
238
+ // the stale cache entry (up to MANIFEST_CACHE_TTL_MS old) could be served.
239
+ // By invalidating first, the worst case is a cache miss forcing a disk read,
240
+ // which is always safe.
190
241
  invalidateRunCache(manifest.stateRoot);
242
+ const manifestPath = path.join(manifest.stateRoot, "manifest.json");
243
+ atomicWriteJson(manifestPath, manifest);
244
+ // FIX: Re-populate cache with actual mtime/size so loadRunManifestById
245
+ // doesn't miss the cache on next read. Without this, every load until
246
+ // TTL expires would hit disk because cached 0 !== any real mtime.
247
+ // NOTE: tasks is set to [] here because saveRunManifest only writes the
248
+ // manifest file, not tasks.json. Callers that need tasks should call
249
+ // saveRunTasks or loadRunTasks separately. If loadRunManifestById is called
250
+ // immediately after saveRunManifest, it may return empty tasks even if
251
+ // tasks.json exists on disk — the mtime/size cache check should invalidate
252
+ // on next read, but the returned empty tasks array could confuse callers
253
+ // that don't re-read.
254
+ const manifestStat = fs.statSync(manifestPath);
255
+ setManifestCache(manifest.stateRoot, {
256
+ manifest,
257
+ tasks: [],
258
+ manifestMtimeMs: manifestStat.mtimeMs,
259
+ manifestSize: manifestStat.size,
260
+ tasksMtimeMs: 0,
261
+ tasksSize: 0,
262
+ });
191
263
  }
192
264
 
193
265
  export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<void> {
194
- await atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest);
266
+ // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving
267
+ // after a crash. See saveRunManifest for full explanation.
195
268
  invalidateRunCache(manifest.stateRoot);
269
+ const manifestPath = path.join(manifest.stateRoot, "manifest.json");
270
+ await atomicWriteJsonAsync(manifestPath, manifest);
271
+ // FIX: Re-populate cache with actual mtime/size. See saveRunManifest.
272
+ const manifestStat = await fs.promises.stat(manifestPath);
273
+ setManifestCache(manifest.stateRoot, {
274
+ manifest,
275
+ tasks: [],
276
+ manifestMtimeMs: manifestStat.mtimeMs,
277
+ manifestSize: manifestStat.size,
278
+ tasksMtimeMs: 0,
279
+ tasksSize: 0,
280
+ });
196
281
  }
197
282
 
198
283
  export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
199
- atomicWriteJson(manifest.tasksPath, tasks);
284
+ // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
200
285
  invalidateRunCache(manifest.stateRoot);
286
+
287
+ // Guard: if the run state directory has been removed (prune/forget/cleanup),
288
+ // silently return — there is nothing to persist and the run is gone.
289
+ try {
290
+ fs.statSync(manifest.stateRoot);
291
+ } catch {
292
+ return;
293
+ }
294
+
295
+ atomicWriteJson(manifest.tasksPath, tasks);
296
+ // FIX: Re-populate cache with actual mtime/size for manifest and tasks.
297
+ // Note: We re-read manifest from disk to get its current mtime/size
298
+ // since we only wrote tasks here.
299
+ const manifestPath = path.join(manifest.stateRoot, "manifest.json");
300
+ let manifestStat: fs.Stats;
301
+ let tasksStat: fs.Stats;
302
+ try {
303
+ manifestStat = fs.statSync(manifestPath);
304
+ tasksStat = fs.statSync(manifest.tasksPath);
305
+ } catch {
306
+ // Run state disappeared between the guard above and now — give up gracefully.
307
+ return;
308
+ }
309
+ // FIX: If cache was evicted, re-read manifest from disk rather than using
310
+ // a minimal fallback. A stale minimal manifest with only runId populated
311
+ // would cause manifest.status to be undefined, breaking status checks.
312
+ // If the manifest cannot be read, throw an error — callers using withRunLock
313
+ // should never hit this case, and the error indicates a serious problem.
314
+ // FIX: Also check that the re-read manifest has a status field. If not,
315
+ // fall back to the manifest parameter's status rather than serving a
316
+ // degraded manifest that would break status transition checks.
317
+ const cached = manifestCache.get(manifest.stateRoot);
318
+ const manifestEntry = cached?.manifest ?? readJsonFile<TeamRunManifest>(manifestPath);
319
+ if (!manifestEntry) {
320
+ return; // Run deleted between guard and read
321
+ }
322
+ // Preserve current status from the manifest parameter if the on-disk
323
+ // manifest is missing it (could be a partial write).
324
+ if (!manifestEntry.status) {
325
+ manifestEntry.status = manifest.status;
326
+ }
327
+ setManifestCache(manifest.stateRoot, {
328
+ manifest: manifestEntry,
329
+ tasks,
330
+ manifestMtimeMs: manifestStat.mtimeMs,
331
+ manifestSize: manifestStat.size,
332
+ tasksMtimeMs: tasksStat.mtimeMs,
333
+ tasksSize: tasksStat.size,
334
+ });
201
335
  }
202
336
 
203
337
  /**
@@ -210,32 +344,90 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
210
344
  */
211
345
  /** @internal */
212
346
  function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
213
- atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
347
+ // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
214
348
  invalidateRunCache(manifest.stateRoot);
349
+ try { fs.statSync(manifest.stateRoot); } catch { return; }
350
+ atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
215
351
  }
216
352
 
217
353
  export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
218
- await atomicWriteJsonAsync(manifest.tasksPath, tasks);
354
+ // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
219
355
  invalidateRunCache(manifest.stateRoot);
356
+ try { fs.statSync(manifest.stateRoot); } catch { return; }
357
+ await atomicWriteJsonAsync(manifest.tasksPath, tasks);
220
358
  }
221
359
 
222
360
  /**
223
361
  * Save manifest and tasks files with individual atomic writes.
224
- *
225
- * Note: The two writes are individually atomic (via rename) but not
226
- * jointly atomic a crash between writes can leave them inconsistent.
227
- * This is acceptable because crash recovery detects and repairs
228
- * inconsistent state on next session start.
362
+ * FIX: Changed from Promise.all (parallel, non-jointly-atomic) to sequential
363
+ * writes to ensure manifest is written before tasks. A crash between writes
364
+ * leaves them in a known state (manifest is older, tasks is newer) which
365
+ * loadRunManifestById's retry loop can detect via mtime comparison.
366
+ * NOTE: There is no stale-reconciler component — the retry loop provides
367
+ * best-effort detection of mid-write crashes by re-reading until mtime/size
368
+ * are stable. For strict atomicity, callers should use withRunLock().
369
+ * FIX: Returns a result object so callers know which write step failed.
370
+ * If manifest write succeeds but tasks write fails, the caller can recover.
229
371
  */
230
372
  /** @internal */
231
- async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
232
- await withRunLock(manifest, async () => {
233
- await Promise.all([
234
- atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest),
235
- atomicWriteJsonAsync(manifest.tasksPath, tasks),
236
- ]);
237
- invalidateRunCache(manifest.stateRoot);
238
- });
373
+ interface SaveManifestAndTasksResult {
374
+ manifestWritten: boolean;
375
+ tasksWritten: boolean;
376
+ error?: string;
377
+ }
378
+
379
+ async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<SaveManifestAndTasksResult> {
380
+ let manifestWritten = false;
381
+ let tasksWritten = false;
382
+ try {
383
+ await withRunLock(manifest, async () => {
384
+ // FIX: Invalidate cache BEFORE writes to prevent stale cache serving.
385
+ // Sequential writes instead of Promise.all to ensure manifest is
386
+ // written before tasks. If a crash occurs between writes, manifest is
387
+ // the older timestamp which stale-reconciler uses to detect inconsistency.
388
+ invalidateRunCache(manifest.stateRoot);
389
+ await atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest);
390
+ manifestWritten = true;
391
+ await atomicWriteJsonAsync(manifest.tasksPath, tasks);
392
+ tasksWritten = true;
393
+ });
394
+ } catch (err) {
395
+ return {
396
+ manifestWritten,
397
+ tasksWritten,
398
+ // FIX: Use String(err) to safely convert any thrown value (Error,
399
+ // string, number, null, undefined) to a string. err.message would be
400
+ // undefined for non-Error throwables, losing useful context.
401
+ error: String(err),
402
+ };
403
+ }
404
+ return { manifestWritten: true, tasksWritten: true };
405
+ }
406
+
407
+ /** @internal */
408
+ function saveManifestAndTasksAtomicSync(manifest: TeamRunManifest, tasks: TeamTaskState[]): SaveManifestAndTasksResult {
409
+ let manifestWritten = false;
410
+ let tasksWritten = false;
411
+ try {
412
+ withRunLockSync(manifest, () => {
413
+ // FIX: Invalidate cache BEFORE writes to prevent stale cache serving.
414
+ invalidateRunCache(manifest.stateRoot);
415
+ atomicWriteJson(path.join(manifest.stateRoot, "manifest.json"), manifest);
416
+ manifestWritten = true;
417
+ atomicWriteJson(manifest.tasksPath, tasks);
418
+ tasksWritten = true;
419
+ });
420
+ } catch (err) {
421
+ return {
422
+ manifestWritten,
423
+ tasksWritten,
424
+ // FIX: Use String(err) to safely convert any thrown value (Error,
425
+ // string, number, null, undefined) to a string. err.message would be
426
+ // undefined for non-Error throwables, losing useful context.
427
+ error: String(err),
428
+ };
429
+ }
430
+ return { manifestWritten: true, tasksWritten: true };
239
431
  }
240
432
 
241
433
  export interface UpdateRunStatusOptions {
@@ -245,7 +437,7 @@ export interface UpdateRunStatusOptions {
245
437
 
246
438
  export function updateRunStatus(manifest: TeamRunManifest, status: TeamRunManifest["status"], summary?: string, options: UpdateRunStatusOptions = {}): TeamRunManifest {
247
439
  if (!canTransitionRunStatus(manifest.status, status)) {
248
- throw new Error(`Invalid run status transition: ${manifest.status} -> ${status}`);
440
+ throw errors.invalidStatusTransition(manifest.status, status);
249
441
  }
250
442
  const updated: TeamRunManifest = { ...manifest, status, updatedAt: new Date().toISOString(), summary: summary ?? manifest.summary };
251
443
  saveRunManifest(updated);
@@ -283,11 +475,23 @@ export function __test__clearManifestCache(): void {
283
475
  async function readJsonFileAsync<T>(filePath: string): Promise<T | undefined> {
284
476
  try {
285
477
  return JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as T;
286
- } catch {
478
+ } catch (err) {
479
+ const code = (err as NodeJS.ErrnoException).code;
480
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
481
+ logInternalError("readJsonFileAsync", err, `filePath=${filePath}`);
482
+ }
287
483
  return undefined;
288
484
  }
289
485
  }
290
486
 
487
+ /**
488
+ * Load a run manifest and its tasks by runId.
489
+ * WARNING: This function provides best-effort consistency only. The sentinel-based
490
+ * retry loop does NOT guarantee manifest/tasks consistency under contention —
491
+ * a concurrent writer can complete a full write cycle between the final stat
492
+ * and the read. For strict consistency, callers MUST wrap load+modify+save in
493
+ * withRunLock(). Callers that need guaranteed consistency should use the lock.
494
+ */
291
495
  export function loadRunManifestById(cwd: string, runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
292
496
  const stateRoot = resolveRunStateRoot(cwd, runId);
293
497
  if (!stateRoot) return undefined;
@@ -301,6 +505,12 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
301
505
  return undefined;
302
506
  }
303
507
  const cached = manifestCache.get(stateRoot);
508
+ // Issue 1 fix: Note that the cache read-check-use sequence below is NOT atomic
509
+ // under concurrent modification. While individual JS Map operations are atomic,
510
+ // the sequence spanning multiple filesystem operations is not atomic. Callers MUST
511
+ // NOT invoke loadRunManifestById concurrently with cache-modifying operations
512
+ // (setManifestCache, invalidateRunCache) for the same stateRoot. The generation
513
+ // counter provides some protection, but does not make the read-check-use atomic.
304
514
  let tasksStat: fs.Stats | undefined;
305
515
  try {
306
516
  tasksStat = fs.statSync(tasksPath);
@@ -314,28 +524,43 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
314
524
  && cached.manifestSize === manifestStat.size
315
525
  && cached.tasksMtimeMs === tasksMtimeMs
316
526
  && cached.tasksSize === (tasksStat?.size ?? 0)
527
+ && cached.generation === manifestCacheGeneration
317
528
  ) {
318
529
  // TTL eviction: expire stale entries even if mtime matches
319
- if (cached.cachedAt && Date.now() - cached.cachedAt > MANIFEST_CACHE_TTL_MS) {
530
+ // FIX: Also evict entries where cachedAt is undefined — such entries are
531
+ // effectively immortal otherwise (the `cached.cachedAt &&` check would skip
532
+ // them every time). This can happen if a cache entry was created by
533
+ // setManifestCache that didn't set cachedAt (shouldn't happen in current
534
+ // code, but defensive against future regressions).
535
+ if (!cached.cachedAt || Date.now() - cached.cachedAt > MANIFEST_CACHE_TTL_MS) {
320
536
  manifestCache.delete(stateRoot);
321
537
  } else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
322
538
  manifestCache.delete(stateRoot);
323
539
  return undefined;
540
+ } else if (!fs.existsSync(tasksPath)) {
541
+ // Tasks file was deleted after cache was populated — this is a cache miss,
542
+ // not a manifest inconsistency. The cache check passes because
543
+ // tasksMtimeMs=0 and tasksSize=0 match a cache entry written when tasks.json
544
+ // didn't exist. We fall through to the retry loop which will correctly
545
+ // detect the missing file and return undefined. The alternative (treating
546
+ // this as an inconsistency) would require failing the cache hit path, which
547
+ // is unnecessary since the retry loop handles it correctly anyway.
548
+ manifestCache.delete(stateRoot);
549
+ return undefined;
324
550
  } else {
325
- return { manifest: cached.manifest, tasks: cached.tasks };
551
+ return { manifest: cached.manifest, tasks: cached.tasks ?? [] };
326
552
  }
327
553
  }
328
554
 
329
- // FIX: Re-stat and re-read inside a single synchronous block to close the
330
- // TOCTOU window. We use a sentinel-based re-read: if mtime/size changed
331
- // between the initial stat and the read, re-read until stable. With file
332
- // sizes typically small (<5MB), the extra cost is negligible. Note: this
333
- // doesn't fully prevent torn writes callers needing strict consistency
334
- // should use withRunLock() around the whole load+modify+save sequence.
555
+ // FIX: Sentinel-based retry loop for best-effort consistency. Re-stat and
556
+ // re-read until mtime/size are stable. The retry limit is now configurable
557
+ // via LOAD_MANIFEST_RETRY_LIMIT (was hardcoded 3). High contention can still
558
+ // cause non-convergence for strict consistency, callers MUST use withRunLock().
559
+ // Issue 3 fix: Made retry limit configurable instead of hardcoded 3.
335
560
  let attempts = 0;
336
561
  let manifest: TeamRunManifest | undefined;
337
562
  let tasks: TeamTaskState[] | undefined;
338
- while (attempts < 3) {
563
+ while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
339
564
  const freshStat = fs.statSync(manifestPath);
340
565
  manifest = readJsonFile<TeamRunManifest>(manifestPath);
341
566
  const freshTasksStat = fs.existsSync(tasksPath) ? fs.statSync(tasksPath) : undefined;
@@ -349,6 +574,21 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
349
574
  manifestStat = freshStat;
350
575
  tasksStat = freshTasksStat;
351
576
  }
577
+ // WARNING: Best-effort consistency only — retry loop detected mtime/size
578
+ // instability. A concurrent writer can still complete a full write cycle
579
+ // between the final stat and the read. Callers needing strict consistency
580
+ // MUST use withRunLock() around load+modify+save.
581
+ if (attempts > 0) {
582
+ console.warn(`[state-store] loadRunManifestById: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
583
+ }
584
+ // NOTE: manifest mtime may legitimately be >= tasks mtime because
585
+ // saveManifestAndTasksAtomicSync writes manifest before tasks. However,
586
+ // if a crash occurs AFTER tasks is written but BEFORE manifest is written,
587
+ // tasks mtime would be > manifest mtime (the opposite). The retry loop
588
+ // above detects this crash state by re-reading until mtime/size are stable
589
+ // — the final stable state is what gets used. Because the retry loop
590
+ // handles the crash case, we do NOT fail based on this comparison alone.
591
+ // It does not indicate corruption on its own.
352
592
  if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
353
593
  setManifestCache(stateRoot, {
354
594
  manifest,
@@ -366,6 +606,7 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
366
606
  if (!stateRoot) return undefined;
367
607
  const manifestPath = path.join(stateRoot, "manifest.json");
368
608
  const tasksPath = path.join(stateRoot, "tasks.json");
609
+
369
610
  let manifestStat: fs.Stats;
370
611
  try {
371
612
  manifestStat = await fs.promises.stat(manifestPath);
@@ -373,6 +614,8 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
373
614
  return undefined;
374
615
  }
375
616
  const cached = manifestCache.get(stateRoot);
617
+ // Issue 1 fix: Note that the cache read-check-use sequence below is NOT atomic
618
+ // under concurrent modification. Same as sync version — see loadRunManifestById.
376
619
  let tasksStat: fs.Stats | undefined;
377
620
  try {
378
621
  tasksStat = await fs.promises.stat(tasksPath);
@@ -380,20 +623,64 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
380
623
  tasksStat = undefined;
381
624
  }
382
625
  const tasksMtimeMs = tasksStat?.mtimeMs ?? 0;
383
- if (cached && cached.manifestMtimeMs === manifestStat.mtimeMs && cached.manifestSize === manifestStat.size && cached.tasksMtimeMs === tasksMtimeMs && cached.tasksSize === (tasksStat?.size ?? 0)) {
626
+ if (cached && cached.manifestMtimeMs === manifestStat.mtimeMs && cached.manifestSize === manifestStat.size && cached.tasksMtimeMs === tasksMtimeMs && cached.tasksSize === (tasksStat?.size ?? 0) && cached.generation === manifestCacheGeneration) {
384
627
  // TTL eviction: expire stale entries even if mtime matches
385
- if (cached.cachedAt && Date.now() - cached.cachedAt > MANIFEST_CACHE_TTL_MS) {
628
+ // FIX: Also evict entries where cachedAt is undefined — such entries are
629
+ // effectively immortal otherwise (the `cached.cachedAt &&` check would skip
630
+ // them every time). This can happen if a cache entry was created by
631
+ // setManifestCache that didn't set cachedAt (shouldn't happen in current
632
+ // code, but defensive against future regressions).
633
+ if (!cached.cachedAt || Date.now() - cached.cachedAt > MANIFEST_CACHE_TTL_MS) {
386
634
  manifestCache.delete(stateRoot);
387
635
  } else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
388
636
  manifestCache.delete(stateRoot);
389
637
  return undefined;
638
+ } else if (!fs.existsSync(tasksPath)) {
639
+ // Tasks file was deleted after cache was populated — do not serve stale cache.
640
+ manifestCache.delete(stateRoot);
641
+ return undefined;
390
642
  } else {
391
- return { manifest: cached.manifest, tasks: cached.tasks };
643
+ return { manifest: cached.manifest, tasks: cached.tasks ?? [] };
644
+ }
645
+ }
646
+
647
+ // FIX: Sentinel-based retry loop to close TOCTOU window between stat and read.
648
+ // Matches the pattern used in the sync loadRunManifestById.
649
+ // Issue 3 fix: Made retry limit configurable instead of hardcoded 3.
650
+ let manifest: TeamRunManifest | undefined;
651
+ let tasks: TeamTaskState[] | undefined;
652
+ let attempts = 0;
653
+ while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
654
+ const freshStat = await fs.promises.stat(manifestPath);
655
+ manifest = await readJsonFileAsync<TeamRunManifest>(manifestPath);
656
+ const freshTasksStat = await fs.promises.stat(tasksPath).catch(() => undefined);
657
+ tasks = (await readJsonFileAsync<TeamTaskState[]>(tasksPath)) ?? [];
658
+ // If size/mtime didn't change between stat and read, we're consistent.
659
+ if (freshStat.mtimeMs === manifestStat.mtimeMs && freshStat.size === manifestStat.size
660
+ && (!freshTasksStat || (freshTasksStat.mtimeMs === tasksStat?.mtimeMs && freshTasksStat.size === tasksStat?.size))) {
661
+ break;
392
662
  }
663
+ attempts += 1;
664
+ manifestStat = freshStat;
665
+ tasksStat = freshTasksStat;
666
+ }
667
+ // WARNING: Best-effort consistency only — retry loop detected mtime/size
668
+ // instability. A concurrent writer can still complete a full write cycle
669
+ // between the final stat and the read. Callers needing strict consistency
670
+ // MUST use withRunLock() around load+modify+save.
671
+ if (attempts > 0) {
672
+ console.warn(`[state-store] loadRunManifestByIdAsync: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
393
673
  }
394
- const manifest = await readJsonFileAsync<TeamRunManifest>(manifestPath);
674
+ // NOTE: manifest mtime may legitimately be >= tasks mtime because
675
+ // saveManifestAndTasksAtomicSync writes manifest before tasks. However,
676
+ // if a crash occurs AFTER tasks is written but BEFORE manifest is written,
677
+ // tasks mtime would be > manifest mtime (the opposite). The retry loop
678
+ // above detects this crash state by re-reading until mtime/size are stable
679
+ // — the final stable state is what gets used. Because the retry loop
680
+ // handles the crash case, we do NOT fail based on this comparison alone.
681
+ // It does not indicate corruption on its own.
682
+
395
683
  if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
396
- const tasks = await readJsonFileAsync<TeamTaskState[]>(tasksPath) ?? [];
397
- setManifestCache(stateRoot, { manifest, tasks, manifestMtimeMs: manifestStat.mtimeMs, manifestSize: manifestStat.size, tasksMtimeMs, tasksSize: tasksStat?.size ?? 0 });
398
- return { manifest, tasks };
684
+ setManifestCache(stateRoot, { manifest, tasks: tasks ?? [], manifestMtimeMs: manifestStat.mtimeMs, manifestSize: manifestStat.size, tasksMtimeMs, tasksSize: tasksStat?.size ?? 0 });
685
+ return { manifest, tasks: tasks ?? [] };
399
686
  }
@@ -109,7 +109,7 @@ export class LiveRunSidebar {
109
109
 
110
110
  render(width: number): string[] {
111
111
  const w = Math.max(36, width);
112
- const loaded = loadRunManifestById(this.cwd, this.runId);
112
+ const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
113
113
  if (!loaded) {
114
114
  return renderLines(
115
115
  [
package/src/ui/loaders.ts CHANGED
@@ -131,6 +131,10 @@ export class CountdownTimer {
131
131
  this.emitExpire();
132
132
  }
133
133
  }, 1000);
134
+ // Defense-in-depth: never let the countdown timer keep the event loop
135
+ // alive. If dispose() is missed (e.g. UI unmount race), the timer must
136
+ // not block process exit.
137
+ if (typeof this.timer.unref === "function") this.timer.unref();
134
138
  }
135
139
 
136
140
  private emitExpire(): void {
@@ -15,7 +15,7 @@ export class AgentPickerOverlay {
15
15
  private selected = 0;
16
16
 
17
17
  constructor(opts: { cwd: string; runId: string; done: (selection: AgentPickerSelection | undefined) => void; theme?: unknown }) {
18
- const loaded = loadRunManifestById(opts.cwd, opts.runId);
18
+ const loaded = loadRunManifestById(opts.cwd, opts.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
19
19
  this.agents = loaded ? readCrewAgents(loaded.manifest) : [];
20
20
  this.done = opts.done;
21
21
  this.theme = asCrewTheme(opts.theme ?? {});
@@ -32,7 +32,7 @@ export class MailboxDetailOverlay {
32
32
  }
33
33
 
34
34
  private refresh(): void {
35
- const loaded = loadRunManifestById(this.cwd, this.runId);
35
+ const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
36
36
  if (!loaded) return;
37
37
  // Track task count changes to trigger re-render
38
38
  const taskCount = loaded.tasks.length;