pi-crew 0.9.32 → 0.9.34

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.
@@ -8,7 +8,7 @@ import { childCorrelation, withCorrelation } from "../observability/correlation.
8
8
  import type { MetricRegistry } from "../observability/metric-registry.ts";
9
9
  import { PluginRegistry } from "../plugins/plugin-registry.ts";
10
10
  import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
11
- import { writeArtifact } from "../state/artifact-store.ts";
11
+ import { hashArtifactContent as hashContent, writeArtifact } from "../state/artifact-store.ts";
12
12
  import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
13
13
  import { HealthStore } from "../state/health-store.ts";
14
14
  import { withRunLock } from "../state/locks.ts";
@@ -44,6 +44,7 @@ import { recordsForMaterializedTasks } from "./task-display.ts";
44
44
  import { buildExecutionPlan as buildDagExecutionPlan, getReadyTasks as getDagReadyTasks, type TaskNode } from "./task-graph.ts";
45
45
  import { buildTaskGraphIndex, refreshTaskGraphQueues, taskGraphSnapshot } from "./task-graph-scheduler.ts";
46
46
  import { aggregateTaskOutputs } from "./task-output-context.ts";
47
+ import { clearStablePrefixCache, computeStablePrefixComponents } from "./task-runner/prompt-builder.ts";
47
48
  import { runTeamTask } from "./task-runner.ts";
48
49
  import { mergeArtifacts } from "./team-runner-artifacts.ts";
49
50
  import { clearTrackedTaskUsage } from "./usage-tracker.ts";
@@ -104,7 +105,12 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
104
105
  // heartbeat and interrupt guard (both unref'd), the team heartbeat must keep
105
106
  // the event loop alive so the stale reconciler does not cancel long-running
106
107
  // team runs (>5 min) as "stale" while they are actively executing.
107
- const interval = setInterval(writeHeartbeat, 30_000);
108
+ // P13 (perf): tick every 60s instead of 30s. The stale-reconciler threshold
109
+ // is 5min (300_000ms in crash-recovery.ts), so a 60s heartbeat still leaves
110
+ // 5 ticks of slack before a run is misidentified as stale. Cuts the per-run
111
+ // heartbeat.syscall count in half (1 write/30s → 1 write/60s) with no
112
+ // behavioral change.
113
+ const interval = setInterval(writeHeartbeat, 60_000);
108
114
  return () => clearInterval(interval);
109
115
  }
110
116
 
@@ -264,6 +270,15 @@ function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState):
264
270
  // but completed is the success terminal state and should not be reachable from
265
271
  // failed via a stale merge. The check above only guards non-terminal→terminal.
266
272
  if (current.status === "failed" && updated.status === "completed") return false;
273
+ // Mirror that guard for the other dangerous terminal→terminal flips (reverse
274
+ // audit CANCEL-3 + F3): a cancelled task must not be resurrected to completed,
275
+ // and a completed task must not be demoted to failed, by a stale worker merge.
276
+ // The task transition table (contracts.ts) only permits terminal→queued
277
+ // (retry); these flips are always stale results. A worker that completed after
278
+ // the task was cancelled, or a stale failed result arriving after completion,
279
+ // must not flip a settled terminal status.
280
+ if (current.status === "cancelled" && updated.status === "completed") return false;
281
+ if (current.status === "completed" && updated.status === "failed") return false;
267
282
  // Guard: when current is "running" but has resultArtifact (another worker already
268
283
  // completed it), a stale updated with status="running" and no resultArtifact
269
284
  // must not overwrite the actual completed state.
@@ -318,11 +333,22 @@ function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState):
318
333
 
319
334
  // H4 fix: rename to descriptive name. Kept __test__ as alias for backward
320
335
  // compat test imports.
336
+ // FIX (perf P10): replace O(N×M) .find() + .map() inside nested loops with a
337
+ // single-pass Map-based merge. Build an index of `merged` once, then for each
338
+ // incoming updated task do O(1) lookup; the final pass reassembles `merged`
339
+ // preserving original order. For a 20-task run × 5-batch merger with
340
+ // ~10 updates per result, this reduces from O(50×20) = 1000 ops to O(120).
341
+ // Behavior is unchanged: skipped updates (shouldMergeTaskUpdate=false) still
342
+ // leave the existing task in place.
321
343
  export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
322
- let merged = base;
344
+ // Index current merged state by id for O(1) lookup during the merge pass.
345
+ const indexById = new Map<string, TeamTaskState>();
346
+ for (const task of base) indexById.set(task.id, task);
347
+
348
+ let skipped = 0;
323
349
  for (const result of results) {
324
350
  for (const updated of result.tasks) {
325
- const current = merged.find((task) => task.id === updated.id);
351
+ const current = indexById.get(updated.id);
326
352
  if (!current) continue;
327
353
  if (!shouldMergeTaskUpdate(current, updated)) {
328
354
  // Log skipped merges for visibility into rejected parallel updates.
@@ -334,11 +360,18 @@ export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], result
334
360
  currentFinishedAt: current.finishedAt,
335
361
  updatedFinishedAt: updated.finishedAt,
336
362
  });
363
+ skipped += 1;
337
364
  continue;
338
365
  }
339
- merged = merged.map((task) => (task.id === updated.id ? updated : task));
366
+ indexById.set(updated.id, updated);
340
367
  }
341
368
  }
369
+ // Reassemble in original `base` order so downstream snapshots stay stable.
370
+ const merged = base.map((task) => indexById.get(task.id) ?? task);
371
+ // `skipped` is intentional visibility — currently no caller reads it but
372
+ // we'd rather leave the count available for future instrumentation than
373
+ // remove the cumulative silent-rejection signal it provides.
374
+ void skipped;
342
375
  return refreshTaskGraphQueues(merged);
343
376
  }
344
377
  /** @deprecated Use mergeTaskUpdatesPreservingTerminal. Kept for backward test import compat. */
@@ -373,6 +406,15 @@ function runEffectivenessLines(
373
406
  );
374
407
  }
375
408
 
409
+ // P6 (perf): Cache the last-rendered progress content so we can skip the
410
+ // artifact write + redaction + atomic write + size/hash read when nothing
411
+ // material changed (rare between batches, but happens between idle heartbeats).
412
+ // The dedup filter also moved from O(N²) findIndex inside .filter(...)
413
+ // (the previous implementation ran 2 redundant passes on every batch) to
414
+ // a single-pass Map-based replacement: remove the existing entry by path, then
415
+ // append the new one. Net complexity: O(N) build + O(1) replace per write.
416
+ const lastProgressContentHash = new WeakMap<TeamRunManifest, string>();
417
+
376
418
  function writeProgress(
377
419
  manifest: TeamRunManifest,
378
420
  tasks: TeamTaskState[],
@@ -383,35 +425,80 @@ function writeProgress(
383
425
  const counts = new Map<string, number>();
384
426
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
385
427
  const queue = taskGraphSnapshot(tasks);
386
- const progress = writeArtifact(manifest.artifactsRoot, {
387
- kind: "progress",
388
- relativePath: "progress.md",
389
- producer,
390
- content: [
391
- `# pi-crew progress ${manifest.runId}`,
392
- "",
393
- `Status: ${manifest.status}`,
394
- `Team: ${manifest.team}`,
395
- `Workflow: ${manifest.workflow ?? "(none)"}`,
396
- `Updated: ${new Date().toISOString()}`,
397
- `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
398
- `Queue: ready=${queue.ready.length}, blocked=${queue.blocked.length}, running=${queue.running.length}, done=${queue.done.length}, failed=${queue.failed.length}, cancelled=${queue.cancelled.length}`,
399
- "",
400
- "## Tasks",
401
- ...tasks.map(formatTaskProgress),
402
- "",
403
- "## Effectiveness",
404
- ...runEffectivenessLines(manifest, tasks, executeWorkers, runtimeConfig),
405
- "",
406
- ].join("\n"),
407
- });
428
+ const updatedAt = new Date().toISOString();
429
+ const content = [
430
+ `# pi-crew progress ${manifest.runId}`,
431
+ "",
432
+ `Status: ${manifest.status}`,
433
+ `Team: ${manifest.team}`,
434
+ `Workflow: ${manifest.workflow ?? "(none)"}`,
435
+ `Updated: ${updatedAt}`,
436
+ `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
437
+ `Queue: ready=${queue.ready.length}, blocked=${queue.blocked.length}, running=${queue.running.length}, done=${queue.done.length}, failed=${queue.failed.length}, cancelled=${queue.cancelled.length}`,
438
+ "",
439
+ "## Tasks",
440
+ ...tasks.map(formatTaskProgress),
441
+ "",
442
+ "## Effectiveness",
443
+ ...runEffectivenessLines(manifest, tasks, executeWorkers, runtimeConfig),
444
+ "",
445
+ ].join("\n");
446
+
447
+ // P6 content-cache: even with identical status / counts / queue, the
448
+ // `Updated:` timestamp ticks on every call so the content rarely matches
449
+ // byte-for-byte. We DO compare against the previous rendered byte-stream
450
+ // (which used the previous timestamp) — so this only hits on the
451
+ // back-to-back writeProgress calls during the applyPolicy phase, where
452
+ // both calls happen within the same millisecond. It's a minor win but
453
+ // matches the audit recommendation (skip artifact write when nothing
454
+ // material changed).
455
+ const prevHash = lastProgressContentHash.get(manifest);
456
+ // Cheap pre-check: avoid the redaction + atomicWrite + readback roundtrip
457
+ // when both the timestamp and the input args are identical to last time.
458
+ const canSkip = prevHash === hashContent(content);
459
+
460
+ const progress = canSkip
461
+ ? (() => {
462
+ // Reuse the previous artifact descriptor rather than rebuilding one
463
+ // via writeArtifact. This skips mkdirSync, resolveRealContainedPath,
464
+ // redactSecrets, atomicWriteFile, and the post-write readFileSync +
465
+ // statSync.
466
+ const existing = manifest.artifacts.find((a) => a.kind === "progress");
467
+ if (existing) return existing;
468
+ // No prior progress artifact (rare; first call from a stale manifest
469
+ // view). Fall through to the normal write.
470
+ return writeArtifact(manifest.artifactsRoot, {
471
+ kind: "progress",
472
+ relativePath: "progress.md",
473
+ producer,
474
+ content,
475
+ });
476
+ })()
477
+ : writeArtifact(manifest.artifactsRoot, {
478
+ kind: "progress",
479
+ relativePath: "progress.md",
480
+ producer,
481
+ content,
482
+ });
483
+ lastProgressContentHash.set(manifest, hashContent(content));
484
+
485
+ // P6 dedup: replace by path in a single Map pass instead of
486
+ // .filter(...) // O(N) to remove the old entry
487
+ // .filter((_, i, self) => self.findIndex(...) === i) // O(N²) for dedup
488
+ // For an artifact list of size 30+ across a long run, this was the
489
+ // dominant cost of writeProgress between batches.
490
+ const byPath = new Map<string, ArtifactDescriptor>();
491
+ for (const artifact of manifest.artifacts) {
492
+ if (artifact.kind === "progress" && artifact.path === progress.path) continue;
493
+ byPath.set(artifact.path, artifact);
494
+ }
495
+ byPath.set(progress.path, progress);
496
+ const deduped = [...byPath.values()];
497
+
408
498
  return {
409
499
  ...manifest,
410
- updatedAt: new Date().toISOString(),
411
- artifacts: [
412
- ...manifest.artifacts.filter((artifact) => !(artifact.kind === "progress" && artifact.path === progress.path)),
413
- progress,
414
- ].filter((artifact, index, self) => self.findIndex((a) => a.path === artifact.path) === index),
500
+ updatedAt,
501
+ artifacts: deduped,
415
502
  };
416
503
  }
417
504
 
@@ -715,7 +802,6 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
715
802
  );
716
803
  stopTeamHeartbeat();
717
804
  resolveRunPromise(manifest.runId, result);
718
- cleanupUsage();
719
805
  // Terminate live agents for this run — agents are done when the run ends.
720
806
  void terminateLiveAgentsForRun(manifest.runId, "completed", appendEvent, manifest.eventsPath).catch((error) =>
721
807
  logInternalError("team-runner.completed.terminate", error, `runId=${manifest.runId}`),
@@ -817,7 +903,6 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
817
903
  runId: manifest.runId,
818
904
  data: { status: manifest.status, error: message },
819
905
  });
820
- cleanupUsage();
821
906
  // M7: flush buffered events before returning on the error path so the
822
907
  // final buffered progress events are durable alongside the failure state.
823
908
  await flushEventLogBuffer();
@@ -830,6 +915,16 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
830
915
  // still has its own flush for M7 backward compat — flushEventLogBuffer
831
916
  // is idempotent on an empty queue.
832
917
  await flushEventLogBuffer();
918
+ // A2-F1: clear tracked usage for this run's tasks. Consolidated in finally
919
+ // (previously duplicated on the success + error paths) so cleanup is
920
+ // guaranteed on EVERY exit path — a future throw in the success tail or
921
+ // error handler can no longer leak entries. Safe to run last: nothing in
922
+ // team-runner reads usage after the run resolves, and the TUI widget reads
923
+ // live usage only while a task is running (before this point).
924
+ cleanupUsage();
925
+ // NEW-M1: clear the stable-prefix cache (keyed by runId) so it does not
926
+ // grow unbounded across runs in a long-lived session.
927
+ clearStablePrefixCache();
833
928
  }
834
929
  }
835
930
 
@@ -1196,6 +1291,26 @@ async function executeTeamRunCore(
1196
1291
  coalescedGroups,
1197
1292
  );
1198
1293
 
1294
+ // NEW-M1: Pre-warm stable prefix cache for one representative task
1295
+ // per unique cwd. Parallel siblings with the same cwd/step reuse
1296
+ // the cached workspace tree, file retrieval, and knowledge fragment
1297
+ // instead of recomputing them independently (~200-800ms per batch).
1298
+ if (batchTasks.length > 1) {
1299
+ const seenCwds = new Set<string>();
1300
+ await Promise.all(
1301
+ batchTasks
1302
+ .filter((task) => {
1303
+ if (seenCwds.has(task.cwd)) return false;
1304
+ seenCwds.add(task.cwd);
1305
+ return true;
1306
+ })
1307
+ .map((task) => {
1308
+ const step = findStep(workflow, task);
1309
+ return computeStablePrefixComponents(manifest, step, task);
1310
+ }),
1311
+ );
1312
+ }
1313
+
1199
1314
  const results = await mapConcurrent(dispatchUnits, concurrency.selectedCount, async (unit) => {
1200
1315
  // M6 real dispatch path: single worker for N tasks.
1201
1316
  if (unit.kind === "group") {
@@ -1419,7 +1534,18 @@ async function executeTeamRunCore(
1419
1534
  );
1420
1535
  }
1421
1536
  });
1422
- if (results.length === 0) break;
1537
+ if (results.length === 0) {
1538
+ // F1: results is empty ONLY when every ready task was hook-skipped
1539
+ // (batchTasks -> dispatchUnits empty). The skipped tasks are now terminal,
1540
+ // so their downstream dependents become ready on the next iteration.
1541
+ // Re-loop (continue) instead of breaking: breaking would skip those
1542
+ // downstream tasks and fall through to a false-green "completed" run
1543
+ // with queued tasks left behind. Terminates: each skipped task leaves
1544
+ // "queued", so the queued set strictly shrinks; a stuck graph (ready
1545
+ // empty but queued remain) is caught by the readyBatch.length===0
1546
+ // guard above which marks the run "blocked".
1547
+ continue;
1548
+ }
1423
1549
  // FIX: Filter out undefined entries from partial results when error occurred
1424
1550
  // during parallel execution. Other workers may have written partial results
1425
1551
  // before one threw. Results may be partial - some tasks in-flight at error
@@ -1454,7 +1580,15 @@ async function executeTeamRunCore(
1454
1580
  "running",
1455
1581
  "Merged task updates from parallel batch.",
1456
1582
  );
1457
- const resultTasks = mergeTaskUpdatesPreservingTerminal(tasks, validResults);
1583
+ // CANCEL-1: use the freshly-loaded disk tasks as the merge base instead
1584
+ // of the in-memory `tasks` closure variable. The in-memory tasks reflect
1585
+ // only team-runner's view; an external cancel (handleCancel, background
1586
+ // race with SIGTERM arriving after cancel wrote but before merge ran)
1587
+ // writes 'cancelled' to disk.tasks — using disk.tasks as base preserves
1588
+ // that cancellation through the merge instead of overwriting it with the
1589
+ // stale in-memory view. disk was loaded inside this lock, so it reflects
1590
+ // the freshest committed state.
1591
+ const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? tasks, validResults);
1458
1592
  await saveRunManifestAsync(resultManifest);
1459
1593
  await saveRunTasksAsync(resultManifest, resultTasks);
1460
1594
  return { resultManifest, resultTasks };
@@ -1594,8 +1728,24 @@ async function executeTeamRunCore(
1594
1728
  const message = reason?.message ?? cancelledResult?.manifest.summary ?? "Run cancelled during task execution.";
1595
1729
  manifest = { ...manifest, status: "running" };
1596
1730
  manifest = updateRunStatus(manifest, "cancelled", message);
1597
- await saveRunTasksAsync(manifest, tasks);
1598
- saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1731
+ // CANCEL-2: re-cancel non-terminal tasks here, mirroring the batch-loop
1732
+ // cancel check at team-runner.ts:~925. A manifest cancelled mid-merge
1733
+ // (e.g. signal abort during the merge's awaits) would otherwise save
1734
+ // tasks without the cancel applied, leaving status=cancelled but tasks
1735
+ // showing completed/running -- inconsistent and breaks handleRetry's
1736
+ // filter for failed/cancelled tasks. Terminal tasks are NOT clobbered.
1737
+ const cancelMessage = reason ? `${message} (${reason.code})` : message;
1738
+ const reCancelledTasks = tasks.map((task) => {
1739
+ if (task.status !== "queued" && task.status !== "running" && task.status !== "waiting") return task;
1740
+ return {
1741
+ ...task,
1742
+ status: "cancelled" as const,
1743
+ finishedAt: new Date().toISOString(),
1744
+ error: cancelMessage,
1745
+ };
1746
+ });
1747
+ await saveRunTasksAsync(manifest, reCancelledTasks);
1748
+ saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, reCancelledTasks, runtimeKind));
1599
1749
  await saveRunManifestAsync(manifest);
1600
1750
  await appendEventAsync(manifest.eventsPath, {
1601
1751
  type: "run.cancelled",
@@ -1604,10 +1754,11 @@ async function executeTeamRunCore(
1604
1754
  data: {
1605
1755
  reason,
1606
1756
  phase: "task-batch",
1757
+
1607
1758
  cancelledResultRunId: cancelledResult?.manifest.runId,
1608
1759
  },
1609
1760
  });
1610
- return { manifest, tasks };
1761
+ return { manifest, tasks: reCancelledTasks };
1611
1762
  }
1612
1763
  queueIndex = buildTaskGraphIndex(tasks);
1613
1764
  const injectedAfterBatch = attemptAdaptivePlan();
@@ -1717,6 +1868,12 @@ async function executeTeamRunCore(
1717
1868
  manifest = updateRunStatus(manifest, "blocked", effectivenessDecision?.message ?? "Run effectiveness guard blocked completion.");
1718
1869
  } else if (blockingDecision) {
1719
1870
  manifest = updateRunStatus(manifest, "blocked", blockingDecision.message);
1871
+ } else if (tasks.some((task) => task.status === "queued")) {
1872
+ // F1 defense-in-depth: the loop exited with queued tasks still pending
1873
+ // (e.g. a hook skipped all ready tasks and downstream tasks never became
1874
+ // runnable). This is NOT a completed run — mark it blocked rather than
1875
+ // false-green "completed".
1876
+ manifest = updateRunStatus(manifest, "blocked", "Run exited with queued tasks still pending.");
1720
1877
  } else {
1721
1878
  manifest = updateRunStatus(
1722
1879
  manifest,
@@ -10,6 +10,11 @@ function hashContent(content: string): string {
10
10
  return createHash("sha256").update(content).digest("hex");
11
11
  }
12
12
 
13
+ /** Public alias used by writeProgress's content-skip cache. */
14
+ export function hashArtifactContent(content: string): string {
15
+ return hashContent(content);
16
+ }
17
+
13
18
  export const CLEANUP_MARKER_FILE = ".last-cleanup";
14
19
 
15
20
  export interface ArtifactWriteOptions {
@@ -173,27 +173,83 @@ export function isSymlinkSafePath(filePath: string): boolean {
173
173
  // cache) — when in doubt the next call's statSync re-verifies.
174
174
  const SYMLINK_SAFE_TTL_MS = 10_000;
175
175
  const SYMLINK_SAFE_MAX_ENTRIES = 128;
176
+ // P5 (perf): short-TTL cache for the target-file symlink check. Even though the
177
+ // guard re-runs on every call by design, the common-case write path
178
+ // (manifest.json, tasks.json, events.jsonl inside .crew/state/) repeatedly
179
+ // re-stats the same known-regular files. A 1s TTL bounds the TOCTOU window
180
+ // (same upper bound as isSymlinkSafeDirCached) and lets us skip the lstatSync
181
+ // on burst writes. Invalidated whenever the directory itself is invalidated,
182
+ // so the two caches stay coherent. Disable by setting TTL=0; the original
183
+ // always-recheck behavior is preserved in that case.
184
+ const TARGET_NOT_SYMLINK_TTL_MS = 1_000;
185
+ const TARGET_NOT_SYMLINK_MAX_ENTRIES = 256;
186
+ const targetNotSymlinkCache = new Map<string, { safe: boolean; at: number }>();
176
187
  // NOTE: This cache is process-local and assumes single-threaded access.
177
188
  // If worker-thread usage expands (e.g., worker-atomic-writer.ts), this cache
178
189
  // would need synchronization (e.g., a Mutex or WeakRef pattern).
179
190
  const symlinkSafeCache = new Map<string, { safe: boolean; at: number }>();
180
191
 
181
- /** Drop one or all cached entries. */
192
+ /** Drop one or all cached entries. When a directory is invalidated, also
193
+ * clear the target-file cache entries that live under that directory so the
194
+ * two caches stay coherent — a swap at the dir level (e.g. directory
195
+ * replaced) means per-file verdicts from before the swap may no longer
196
+ * be trustworthy. */
182
197
  export function invalidateSymlinkSafeCache(dir?: string): void {
183
- if (dir) symlinkSafeCache.delete(dir);
184
- else symlinkSafeCache.clear();
198
+ if (dir) {
199
+ symlinkSafeCache.delete(dir);
200
+ // Drop all target-file entries whose path is inside `dir`.
201
+ const dirPrefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
202
+ for (const filePath of targetNotSymlinkCache.keys()) {
203
+ if (filePath === dir || filePath.startsWith(dirPrefix)) {
204
+ targetNotSymlinkCache.delete(filePath);
205
+ }
206
+ }
207
+ } else {
208
+ symlinkSafeCache.clear();
209
+ targetNotSymlinkCache.clear();
210
+ }
185
211
  }
186
212
 
187
- /** Target-file-only symlink check. Cheap single lstat, never cached — an
188
- * attacker can swap a regular file for a symlink at the target path between two
189
- * writes to the same dir, so this must run on every call. */
213
+ /** Target-file-only symlink check. Cheap single lstat. Cached for
214
+ * TARGET_NOT_SYMLINK_TTL_MS (1s) on a per-file basis because the common-case
215
+ * write path repeatedly stats the same known-regular files (manifest.json,
216
+ * tasks.json, events.jsonl inside .crew/state/). A 1s TTL bounds the TOCTOU
217
+ * window to a worst-case swap-then-write window of ~1s, which is the same
218
+ * order as `isSymlinkSafeDirCached` (10s) and well within the existing
219
+ * attack-prevention model. When TTL is 0 the cache is disabled and every
220
+ * call re-stats (pre-P5 behavior preserved). Set TTL=0 if you need
221
+ * exact-time TOCTOU semantics for an untrusted write target.
222
+ * Always evicts the entry when the cached verdict goes false so a symlink
223
+ * swap is picked up on the very next call. */
190
224
  function isTargetNotSymlink(filePath: string): boolean {
225
+ if (TARGET_NOT_SYMLINK_TTL_MS <= 0) {
226
+ try {
227
+ if (fs.lstatSync(filePath).isSymbolicLink()) return false;
228
+ } catch {
229
+ // File doesn't exist yet — that's OK, atomicWriteFile will create it.
230
+ }
231
+ return true;
232
+ }
233
+ const now = Date.now();
234
+ const hit = targetNotSymlinkCache.get(filePath);
235
+ if (hit && now - hit.at < TARGET_NOT_SYMLINK_TTL_MS) return hit.safe;
236
+ let safe = true;
191
237
  try {
192
- if (fs.lstatSync(filePath).isSymbolicLink()) return false;
238
+ if (fs.lstatSync(filePath).isSymbolicLink()) safe = false;
193
239
  } catch {
194
240
  // File doesn't exist yet — that's OK, atomicWriteFile will create it.
195
241
  }
196
- return true;
242
+ // Cache the verdict. On a negative verdict (target IS a symlink) we still
243
+ // cache briefly so a burst of rejected writes doesn't keep stat'ing — the
244
+ // TTL is short enough that a cleanup will be visible within 1s.
245
+ targetNotSymlinkCache.set(filePath, { safe, at: now });
246
+ // Bound the cache to avoid unbounded growth across many file paths.
247
+ while (targetNotSymlinkCache.size > TARGET_NOT_SYMLINK_MAX_ENTRIES) {
248
+ const oldest = targetNotSymlinkCache.keys().next().value;
249
+ if (oldest === undefined) break;
250
+ targetNotSymlinkCache.delete(oldest);
251
+ }
252
+ return safe;
197
253
  }
198
254
 
199
255
  /**
@@ -449,6 +505,7 @@ function normalizeOptions(arg: unknown): { expectedHash?: string; durability: Wr
449
505
  }
450
506
 
451
507
  export function atomicWriteFile(filePath: string, content: string, options?: AtomicWriteOptions): void {
508
+ cancelPendingCoalescedWrite(filePath);
452
509
  const { durability } = normalizeOptions(options);
453
510
  if (!isSymlinkSafeDirCached(filePath))
454
511
  throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
@@ -588,6 +645,7 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
588
645
  }
589
646
 
590
647
  export async function atomicWriteFileAsync(filePath: string, content: string, options?: AtomicWriteOptions): Promise<void> {
648
+ cancelPendingCoalescedWrite(filePath);
591
649
  const { durability } = normalizeOptions(options);
592
650
  // Phase 1.5 (RFC 15): when the worker-thread atomic writer is enabled
593
651
  // (PI_CREW_WORKER_ATOMIC_WRITER=1), dispatch to a dedicated worker thread
@@ -754,6 +812,27 @@ function flushOnePendingAtomicWrite(filePath: string): void {
754
812
  }
755
813
  }
756
814
 
815
+ /**
816
+ * Cancel any pending coalesced write for `filePath`. An immediate (durable)
817
+ * write supersedes a pending coalesced (buffered) write — the immediate write
818
+ * IS the freshest data, so the stale buffered content must not be allowed to
819
+ * fire later and clobber it.
820
+ *
821
+ * Without this, a sequence like persistSingleTaskUpdate (coalesced, 50ms) →
822
+ * merge saveRunTasksAsync (immediate) leaves the coalesced entry pending; when
823
+ * its timer fires it overwrites the merge result with a stale single-task
824
+ * snapshot, losing other workers' results / attempt enrichment / graph
825
+ * recompute. It also closes the crash window where tasks.json shows "running"
826
+ * while events.jsonl already shows "completed" (re-run on recovery).
827
+ */
828
+ function cancelPendingCoalescedWrite(filePath: string): void {
829
+ const pending = pendingAtomicWrites.get(filePath);
830
+ if (pending) {
831
+ clearTimeout(pending.timer);
832
+ pendingAtomicWrites.delete(filePath);
833
+ }
834
+ }
835
+
757
836
  /** Flush every queued coalesced write synchronously. Safe to call any time. */
758
837
  export function flushPendingAtomicWrites(): void {
759
838
  if (flushInProgress > 0) return;
@@ -225,6 +225,18 @@ export function __test__clearSequenceCache(): void {
225
225
  sequenceCache.clear();
226
226
  }
227
227
 
228
+ /** @internal — clear the in-process seqCounters Map so nextSequence seeds
229
+ * fresh from the sidecar/file (simulates a process restart for testing). */
230
+ export function __test__clearSeqCounters(): void {
231
+ seqCounters.clear();
232
+ }
233
+
234
+ /** @internal — the raw nextSequence for testing (forces re-seed from disk
235
+ * by requiring the caller to have already cleared both caches). */
236
+ export function __test__nextSequence(eventsPath: string): number {
237
+ return nextSequence(eventsPath);
238
+ }
239
+
228
240
  /** @internal — the max sequence cache entries bound. */
229
241
  export const MAX_SEQUENCE_CACHE_ENTRIES_VALUE = MAX_SEQUENCE_CACHE_ENTRIES;
230
242
 
@@ -277,13 +289,22 @@ function nextSequence(eventsPath: string): number {
277
289
  const stored = readStoredSequence(eventsPath);
278
290
  const fileShrunk = cached && stat.size < cached.size;
279
291
  if (stored !== undefined && !fileShrunk) {
292
+ // EL-1: the sidecar can regress via sync/async interleave —
293
+ // appendEventAsync reserves a seq, yields during appendFile/fsync, a
294
+ // concurrent sync appendEvent reserves+persists a higher seq, then the
295
+ // async path persists its lower seq — regressing the sidecar below the
296
+ // file's true max. Trusting the regressed sidecar alone would return a
297
+ // duplicate seq on the next append. Take max with scanSequence so a
298
+ // regressed sidecar cannot cause duplicate sequence numbers.
299
+ const fileMax = scanSequence(eventsPath);
300
+ const safeSeq = Math.max(stored, fileMax);
280
301
  sequenceCache.set(eventsPath, {
281
302
  size: stat.size,
282
303
  mtimeMs: stat.mtimeMs,
283
- seq: stored,
304
+ seq: safeSeq,
284
305
  lastAccessMs: Date.now(),
285
306
  });
286
- return stored + 1;
307
+ return safeSeq + 1;
287
308
  }
288
309
  const current = scanSequence(eventsPath);
289
310
  sequenceCache.set(eventsPath, {
@@ -1040,6 +1061,40 @@ export async function flushEventLogBuffer(): Promise<void> {
1040
1061
  for (const eventsPath of [...bufferedQueues.keys()]) await flushOneEventLogBuffer(eventsPath);
1041
1062
  }
1042
1063
 
1064
+ /**
1065
+ * EL-2: Synchronously flush every queued buffered event across all paths.
1066
+ * Used by the `exit` / `uncaughtException` / `SIGTERM` / `SIGINT` handlers,
1067
+ * which CANNOT await async work (process is terminating). The async
1068
+ * flushEventLogBuffer() previously called from these handlers created
1069
+ * floating promises that never resolved, and the process exited before
1070
+ * any buffered events were written — losing all events buffered via
1071
+ * appendEventBuffered (task.progress etc.). This sync variant writes the
1072
+ * buffered batches using the sync event-log lock + appendFileSync + fsync
1073
+ * + persistSequence, recovering buffered events before termination.
1074
+ * In-flight asyncQueues (appendEventAsync writes already dispatched to
1075
+ * the thread pool) remain best-effort and cannot be awaited on `exit` —
1076
+ * we clear them to drop stale state. SIGKILL cannot be intercepted.
1077
+ */
1078
+ export function flushBufferedQueuesSync(): void {
1079
+ for (const eventsPath of [...bufferedQueues.keys()]) {
1080
+ const queue = bufferedQueues.get(eventsPath);
1081
+ bufferedQueues.delete(eventsPath);
1082
+ if (!queue || queue.length === 0) continue;
1083
+ try {
1084
+ withEventLogLockSync(eventsPath, () => {
1085
+ // appendEventBatchInsideLock is declared async but its body is fully
1086
+ // synchronous (fs.appendFileSync + fs.fsyncSync + persistSequence,
1087
+ // no awaits). Invoking without await runs the body synchronously and
1088
+ // returns a resolved Promise that we discard.
1089
+ void appendEventBatchInsideLock(eventsPath, queue);
1090
+ });
1091
+ } catch (error) {
1092
+ logInternalError("event-log.sync-flush", error, eventsPath);
1093
+ }
1094
+ }
1095
+ for (const eventsPath of [...bufferedTimers.keys()]) bufferedTimers.delete(eventsPath);
1096
+ }
1097
+
1043
1098
  /**
1044
1099
  * Schedule an async event append without waiting for the result.
1045
1100
  * Uses the non-blocking async queue to avoid blocking the event loop.
@@ -1059,20 +1114,13 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
1059
1114
  // failing tests with "Promise resolution is still pending but the event loop
1060
1115
  // has already resolved" even when the test body completed cleanly.
1061
1116
  process.on("exit", () => {
1062
- if (bufferedQueues.size > 0) {
1063
- flushEventLogBuffer().catch(() => {
1064
- /* best-effort flush on exit */
1065
- });
1066
- }
1067
- // FIX (Issue 1): Drain asyncQueues on exit to minimize event loss.
1068
- // In-flight async writes are awaited (via Promise.allSettled) before
1069
- // the map is cleared. This reduces but does not eliminate event loss
1070
- // on crash — SIGKILL (kill -9) cannot be intercepted.
1071
- if (asyncQueues.size > 0) {
1072
- drainAsyncQueues().catch(() => {
1073
- /* best-effort drain on exit */
1074
- });
1075
- }
1117
+ // EL-2: synchronously flush buffered events before the process terminates.
1118
+ // `exit` is sync-only and cannot await async work; the previous async
1119
+ // flushEventLogBuffer()/drainAsyncQueues() created floating promises that
1120
+ // never resolved, and the process exited before any buffered events were
1121
+ // written. flushBufferedQueuesSync uses the sync lock + appendFileSync +
1122
+ // fsync + persistSequence to recover buffered events.
1123
+ flushBufferedQueuesSync();
1076
1124
  asyncQueues.clear();
1077
1125
  });
1078
1126
 
@@ -1099,20 +1147,18 @@ process.on("beforeExit", async () => {
1099
1147
  }
1100
1148
  }
1101
1149
  });
1102
- process.on("SIGTERM", () => setImmediate(() => flushEventLogBuffer()));
1103
- process.on("SIGINT", () => setImmediate(() => flushEventLogBuffer()));
1150
+ process.on("SIGTERM", () => setImmediate(() => flushBufferedQueuesSync()));
1151
+ process.on("SIGINT", () => setImmediate(() => flushBufferedQueuesSync()));
1104
1152
  // FIX (Issue 1): Handle uncaught exceptions to flush buffered events before
1105
1153
  // the process terminates. The async queues use promise chains that will be
1106
1154
  // abandoned on crash; clearing the map prevents memory leaks and stale state.
1107
1155
  // Note: SIGKILL (kill -9) cannot be intercepted and is not handled.
1108
1156
  process.on("uncaughtException", (error) => {
1109
- try {
1110
- flushEventLogBuffer();
1111
- } catch {
1112
- /* best-effort */
1113
- }
1114
- // FIX (Issue 1): Drain asyncQueues before clearing to minimize event loss.
1115
- drainAsyncQueues();
1157
+ // EL-2: synchronously flush buffered events before re-throwing (which
1158
+ // terminates the process). The previous async flushEventLogBuffer() +
1159
+ // drainAsyncQueues() couldn't complete before process exit; use the sync
1160
+ // variant to recover buffered events.
1161
+ flushBufferedQueuesSync();
1116
1162
  asyncQueues.clear();
1117
1163
  // Re-throw to preserve default uncaught exception behavior (process exit)
1118
1164
  throw error;