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
@@ -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
  /**
@@ -208,32 +342,92 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
208
342
  * intended use case. Single-update + read-update loops (e.g.
209
343
  * persistSingleTaskUpdate) should keep using saveRunTasks.
210
344
  */
211
- export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
212
- atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
345
+ /** @internal */
346
+ function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
347
+ // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
213
348
  invalidateRunCache(manifest.stateRoot);
349
+ try { fs.statSync(manifest.stateRoot); } catch { return; }
350
+ atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
214
351
  }
215
352
 
216
353
  export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
217
- await atomicWriteJsonAsync(manifest.tasksPath, tasks);
354
+ // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
218
355
  invalidateRunCache(manifest.stateRoot);
356
+ try { fs.statSync(manifest.stateRoot); } catch { return; }
357
+ await atomicWriteJsonAsync(manifest.tasksPath, tasks);
219
358
  }
220
359
 
221
360
  /**
222
361
  * Save manifest and tasks files with individual atomic writes.
223
- *
224
- * Note: The two writes are individually atomic (via rename) but not
225
- * jointly atomic a crash between writes can leave them inconsistent.
226
- * This is acceptable because crash recovery detects and repairs
227
- * 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.
228
371
  */
229
- export async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
230
- await withRunLock(manifest, async () => {
231
- await Promise.all([
232
- atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest),
233
- atomicWriteJsonAsync(manifest.tasksPath, tasks),
234
- ]);
235
- invalidateRunCache(manifest.stateRoot);
236
- });
372
+ /** @internal */
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 };
237
431
  }
238
432
 
239
433
  export interface UpdateRunStatusOptions {
@@ -243,7 +437,7 @@ export interface UpdateRunStatusOptions {
243
437
 
244
438
  export function updateRunStatus(manifest: TeamRunManifest, status: TeamRunManifest["status"], summary?: string, options: UpdateRunStatusOptions = {}): TeamRunManifest {
245
439
  if (!canTransitionRunStatus(manifest.status, status)) {
246
- throw new Error(`Invalid run status transition: ${manifest.status} -> ${status}`);
440
+ throw errors.invalidStatusTransition(manifest.status, status);
247
441
  }
248
442
  const updated: TeamRunManifest = { ...manifest, status, updatedAt: new Date().toISOString(), summary: summary ?? manifest.summary };
249
443
  saveRunManifest(updated);
@@ -281,11 +475,23 @@ export function __test__clearManifestCache(): void {
281
475
  async function readJsonFileAsync<T>(filePath: string): Promise<T | undefined> {
282
476
  try {
283
477
  return JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as T;
284
- } 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
+ }
285
483
  return undefined;
286
484
  }
287
485
  }
288
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
+ */
289
495
  export function loadRunManifestById(cwd: string, runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
290
496
  const stateRoot = resolveRunStateRoot(cwd, runId);
291
497
  if (!stateRoot) return undefined;
@@ -299,6 +505,12 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
299
505
  return undefined;
300
506
  }
301
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.
302
514
  let tasksStat: fs.Stats | undefined;
303
515
  try {
304
516
  tasksStat = fs.statSync(tasksPath);
@@ -312,28 +524,43 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
312
524
  && cached.manifestSize === manifestStat.size
313
525
  && cached.tasksMtimeMs === tasksMtimeMs
314
526
  && cached.tasksSize === (tasksStat?.size ?? 0)
527
+ && cached.generation === manifestCacheGeneration
315
528
  ) {
316
529
  // TTL eviction: expire stale entries even if mtime matches
317
- 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) {
318
536
  manifestCache.delete(stateRoot);
319
537
  } else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
320
538
  manifestCache.delete(stateRoot);
321
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;
322
550
  } else {
323
- return { manifest: cached.manifest, tasks: cached.tasks };
551
+ return { manifest: cached.manifest, tasks: cached.tasks ?? [] };
324
552
  }
325
553
  }
326
554
 
327
- // FIX: Re-stat and re-read inside a single synchronous block to close the
328
- // TOCTOU window. We use a sentinel-based re-read: if mtime/size changed
329
- // between the initial stat and the read, re-read until stable. With file
330
- // sizes typically small (<5MB), the extra cost is negligible. Note: this
331
- // doesn't fully prevent torn writes callers needing strict consistency
332
- // 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.
333
560
  let attempts = 0;
334
561
  let manifest: TeamRunManifest | undefined;
335
562
  let tasks: TeamTaskState[] | undefined;
336
- while (attempts < 3) {
563
+ while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
337
564
  const freshStat = fs.statSync(manifestPath);
338
565
  manifest = readJsonFile<TeamRunManifest>(manifestPath);
339
566
  const freshTasksStat = fs.existsSync(tasksPath) ? fs.statSync(tasksPath) : undefined;
@@ -347,6 +574,21 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
347
574
  manifestStat = freshStat;
348
575
  tasksStat = freshTasksStat;
349
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.
350
592
  if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
351
593
  setManifestCache(stateRoot, {
352
594
  manifest,
@@ -364,6 +606,7 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
364
606
  if (!stateRoot) return undefined;
365
607
  const manifestPath = path.join(stateRoot, "manifest.json");
366
608
  const tasksPath = path.join(stateRoot, "tasks.json");
609
+
367
610
  let manifestStat: fs.Stats;
368
611
  try {
369
612
  manifestStat = await fs.promises.stat(manifestPath);
@@ -371,6 +614,8 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
371
614
  return undefined;
372
615
  }
373
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.
374
619
  let tasksStat: fs.Stats | undefined;
375
620
  try {
376
621
  tasksStat = await fs.promises.stat(tasksPath);
@@ -378,20 +623,64 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
378
623
  tasksStat = undefined;
379
624
  }
380
625
  const tasksMtimeMs = tasksStat?.mtimeMs ?? 0;
381
- 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) {
382
627
  // TTL eviction: expire stale entries even if mtime matches
383
- 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) {
384
634
  manifestCache.delete(stateRoot);
385
635
  } else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
386
636
  manifestCache.delete(stateRoot);
387
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;
388
642
  } else {
389
- return { manifest: cached.manifest, tasks: cached.tasks };
643
+ return { manifest: cached.manifest, tasks: cached.tasks ?? [] };
390
644
  }
391
645
  }
392
- const manifest = await readJsonFileAsync<TeamRunManifest>(manifestPath);
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;
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`);
673
+ }
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
+
393
683
  if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
394
- const tasks = await readJsonFileAsync<TeamTaskState[]>(tasksPath) ?? [];
395
- setManifestCache(stateRoot, { manifest, tasks, manifestMtimeMs: manifestStat.mtimeMs, manifestSize: manifestStat.size, tasksMtimeMs, tasksSize: tasksStat?.size ?? 0 });
396
- 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 ?? [] };
397
686
  }
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import type { TeamTaskState } from "./types.ts";
3
3
 
4
4
  export interface TaskClaimState {
@@ -18,8 +18,15 @@ export function isTaskClaimExpired(claim: TaskClaimState | undefined, now = new
18
18
  return Number.isFinite(parsed) ? parsed <= now.getTime() : true;
19
19
  }
20
20
 
21
+ export function timingSafeTokenMatch(a: string, b: string): boolean {
22
+ const bufA = Buffer.from(String(a));
23
+ const bufB = Buffer.from(String(b));
24
+ if (bufA.length !== bufB.length) return false;
25
+ return timingSafeEqual(bufA, bufB);
26
+ }
27
+
21
28
  export function canUseTaskClaim(task: Pick<TeamTaskState, "claim">, owner: string, token: string, now = new Date()): boolean {
22
- return task.claim?.owner === owner && task.claim.token === token && !isTaskClaimExpired(task.claim, now);
29
+ return task.claim?.owner === owner && timingSafeTokenMatch(task.claim.token, token) && !isTaskClaimExpired(task.claim, now);
23
30
  }
24
31
 
25
32
  export function claimTask<T extends TeamTaskState>(task: T, owner: string, leaseMs?: number, now = new Date()): T {