pi-crew 0.11.1 → 0.11.2

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 (121) hide show
  1. package/CHANGELOG.md +39 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +193 -36
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +23 -0
  20. package/src/extension/help.ts +21 -10
  21. package/src/extension/knowledge-injection.ts +2 -1
  22. package/src/extension/management.ts +8 -3
  23. package/src/extension/notification-sink.ts +17 -0
  24. package/src/extension/registration/command-utils.ts +28 -2
  25. package/src/extension/registration/commands/dashboard.ts +11 -1
  26. package/src/extension/registration/commands/manage.ts +31 -15
  27. package/src/extension/registration/commands/run.ts +24 -2
  28. package/src/extension/registration/commands/shared.ts +23 -0
  29. package/src/extension/registration/commands/status.ts +25 -2
  30. package/src/extension/registration/context-builder.ts +8 -2
  31. package/src/extension/registration/health-notify-policy.ts +100 -0
  32. package/src/extension/registration/lazy-configurers.ts +35 -0
  33. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  34. package/src/extension/registration/lifecycle.ts +75 -10
  35. package/src/extension/registration/observability.ts +98 -35
  36. package/src/extension/registration/registration-types.ts +7 -5
  37. package/src/extension/registration/runtime-cleanup.ts +9 -3
  38. package/src/extension/registration/subagent-helpers.ts +38 -0
  39. package/src/extension/registration/wire-cross-extension.ts +28 -0
  40. package/src/extension/run-compare.ts +220 -0
  41. package/src/extension/run-export.ts +37 -5
  42. package/src/extension/run-maintenance.ts +155 -5
  43. package/src/extension/team-tool/dispatch/index.ts +3 -2
  44. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  45. package/src/extension/team-tool/goal.ts +4 -1
  46. package/src/extension/team-tool/handle-settings.ts +19 -2
  47. package/src/extension/team-tool/health-monitor.ts +21 -7
  48. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  49. package/src/extension/team-tool/plan.ts +10 -0
  50. package/src/extension/team-tool/routing-hint.ts +63 -0
  51. package/src/extension/team-tool/status.ts +4 -0
  52. package/src/extension/team-tool.ts +52 -6
  53. package/src/extension/webhook-notify.ts +382 -0
  54. package/src/observability/metric-sink.ts +12 -2
  55. package/src/prompt/prompt-runtime.ts +82 -31
  56. package/src/prompt/worker-events-channel.ts +12 -0
  57. package/src/runtime/README.md +1 -1
  58. package/src/runtime/async-runner.ts +87 -1
  59. package/src/runtime/background-runner.ts +313 -234
  60. package/src/runtime/broker/crew-broker.ts +17 -11
  61. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  62. package/src/runtime/broker/wait-status-cache.ts +1 -1
  63. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  64. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  65. package/src/runtime/crew-agent-records.ts +337 -45
  66. package/src/runtime/deadletter.ts +43 -1
  67. package/src/runtime/delegate-spawn.ts +5 -1
  68. package/src/runtime/dispatch-batch.ts +72 -5
  69. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  70. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  71. package/src/runtime/model/model-fallback.ts +21 -1
  72. package/src/runtime/model/pi-args.ts +8 -10
  73. package/src/runtime/recovery/crash-recovery.ts +25 -1
  74. package/src/runtime/run-worker.ts +12 -1
  75. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  76. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  77. package/src/runtime/scheduling/scheduler.ts +49 -13
  78. package/src/runtime/scheduling/semaphore.ts +148 -20
  79. package/src/runtime/scratchpad/README.md +1 -1
  80. package/src/runtime/scratchpad/protocol.ts +1 -1
  81. package/src/runtime/settings-store.ts +1 -1
  82. package/src/runtime/skill-instructions.ts +22 -0
  83. package/src/runtime/stale-reconciler.ts +85 -13
  84. package/src/runtime/task-runner/pre-execution.ts +26 -2
  85. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  86. package/src/runtime/task-runner.ts +21 -1
  87. package/src/runtime/team-runner.ts +38 -1
  88. package/src/runtime/workspace-lock.ts +4 -1
  89. package/src/schema/config-schema.ts +14 -0
  90. package/src/schema/team-tool-schema.ts +17 -0
  91. package/src/state/atomic-write.ts +53 -0
  92. package/src/state/contracts.ts +109 -0
  93. package/src/state/coordination/locks.ts +191 -33
  94. package/src/state/coordination/mailbox.ts +140 -15
  95. package/src/state/crew-init.ts +87 -12
  96. package/src/state/event-log/cursor.ts +37 -1
  97. package/src/state/event-log/event-log-rotation.ts +72 -7
  98. package/src/state/stores/active-run-registry.ts +13 -1
  99. package/src/state/stores/state-store.ts +112 -22
  100. package/src/state/types.ts +4 -0
  101. package/src/ui/dashboard-panes/agents-pane.ts +11 -2
  102. package/src/ui/heartbeat-aggregator.ts +34 -0
  103. package/src/ui/keybinding-map.ts +22 -4
  104. package/src/ui/live-conversation-overlay.ts +6 -3
  105. package/src/ui/run-dashboard.ts +98 -5
  106. package/src/ui/run-snapshot-cache.ts +18 -1
  107. package/src/ui/spinner.ts +26 -2
  108. package/src/ui/tool-progress-formatter.ts +2 -1
  109. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  110. package/src/ui/tool-renderers/index.ts +3 -3
  111. package/src/utils/incremental-reader.ts +11 -3
  112. package/src/utils/paths.ts +94 -12
  113. package/src/utils/project-markers.ts +40 -0
  114. package/src/worktree/worktree-manager.ts +206 -26
  115. package/workflows/distill.workflow.md +3 -3
  116. package/workflows/fast-fix.workflow.md +1 -1
  117. package/workflows/plan-execute.workflow.md +1 -1
  118. package/workflows/review.workflow.md +1 -1
  119. package/workflows/strict-fast-fix.workflow.md +1 -1
  120. package/docs/migration-v0.4-v0.5.md +0 -208
  121. package/docs/runtime-flow.md +0 -148
@@ -80,7 +80,11 @@ function isLockHolderAlive(filePath: string): boolean {
80
80
  *
81
81
  * Returns `{ canSteal: true }` if the lock is stale OR the holder is dead
82
82
  * (safe to forcibly remove); `{ canSteal: false }` if it is fresh AND held by
83
- * a live process (must keep waiting).
83
+ * a live process (must keep waiting). RR-011 additionally reports
84
+ * `heldByLiveInProcess: true` when the stored token belongs to a run-lock
85
+ * acquisition of THIS process that is still inside its critical section — the
86
+ * async acquire loop then WAITS (timer-based retry) instead of throwing or
87
+ * stealing.
84
88
  *
85
89
  * ## EPERM Handling (Accepted Risk)
86
90
  *
@@ -105,7 +109,11 @@ function isLockHolderAlive(filePath: string): boolean {
105
109
  *
106
110
  * See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
107
111
  */
108
- function readLockSnapshot(filePath: string, staleMs: number, options?: { treatOwnPidAsStealable?: boolean }): { canSteal: boolean } {
112
+ function readLockSnapshot(
113
+ filePath: string,
114
+ staleMs: number,
115
+ options?: { treatOwnPidAsStealable?: boolean; activeHolderTokens?: ReadonlySet<string> },
116
+ ): { canSteal: boolean; heldByLiveInProcess: boolean } {
109
117
  const treatOwnPidAsStealable = options?.treatOwnPidAsStealable === true;
110
118
  let stat: fs.Stats | undefined;
111
119
  let raw: string | undefined;
@@ -119,21 +127,23 @@ function readLockSnapshot(filePath: string, staleMs: number, options?: { treatOw
119
127
  // "locked" error.
120
128
  const code = (error as NodeJS.ErrnoException).code;
121
129
  if (code === "ENOENT") {
122
- return { canSteal: true };
130
+ return { canSteal: true, heldByLiveInProcess: false };
123
131
  }
124
132
  // Transient I/O error — be conservative (don't steal, retry the read on
125
133
  // next attempt).
126
- return { canSteal: false };
134
+ return { canSteal: false, heldByLiveInProcess: false };
127
135
  }
128
136
  // Staleness from a single snapshot.
129
137
  let createdAt = parseCreatedAtFromLock(raw);
130
138
  if (createdAt === undefined) createdAt = stat.mtimeMs;
131
139
  const isStale = Date.now() - createdAt > staleMs;
132
140
  let holderPid: number | undefined;
141
+ let holderToken: string | undefined;
133
142
  let isAlive = true;
134
143
  try {
135
- const parsed = JSON.parse(raw) as { pid?: unknown };
144
+ const parsed = JSON.parse(raw) as { pid?: unknown; token?: unknown };
136
145
  holderPid = typeof parsed.pid === "number" ? parsed.pid : undefined;
146
+ holderToken = typeof parsed.token === "string" ? parsed.token : undefined;
137
147
  } catch {
138
148
  /* malformed payload — keep isAlive=true */
139
149
  }
@@ -151,9 +161,28 @@ function readLockSnapshot(filePath: string, staleMs: number, options?: { treatOw
151
161
  // (used by withRunLock* which is single-process — a fresh lock with our pid
152
162
  // between acquisitions is just a leftover from a previous call that
153
163
  // releaseOwnLock didn't get to delete yet; safe to steal).
164
+ //
165
+ // RR-011 (F02): "our own pid" alone can no longer authorize a steal for the
166
+ // async run-lock path — a lock CURRENTLY HELD by another async context of
167
+ // this process also carries our pid while its holder is merely awaiting
168
+ // inside the critical section. The on-disk token disambiguates: a token in
169
+ // `activeHolderTokens` (runLockHeldTokens — every live acquisition of this
170
+ // process registers its token) means the holder is ALIVE in-process → the
171
+ // own-pid steal branch is suppressed (see acquireLockWithRetryAsync: such a
172
+ // holder WAITS instead of stealing/throwing). A token-less payload (legacy
173
+ // lock file) or a token not in the set is a leftover corpse → still stealable,
174
+ // preserving the anti-CI-flake behaviour the flag was added for.
175
+ // NOTE (trap, design.md §4.2): comparing the stored token to the CURRENT
176
+ // acquisition's token would be WRONG — each acquisition mints a fresh
177
+ // randomUUID, so a live holder's token always differs from ours and the
178
+ // predicate would steal anyway. Only a lookup into the LIVE-token set is
179
+ // correct.
154
180
  const isOurOwnHolder = holderPid === process.pid;
181
+ const holderIsLiveInProcess = holderToken !== undefined && (options?.activeHolderTokens?.has(holderToken) ?? false);
182
+ const ownPidStealable = treatOwnPidAsStealable && isOurOwnHolder && !holderIsLiveInProcess;
155
183
  return {
156
- canSteal: isStale || !isAlive || (treatOwnPidAsStealable && isOurOwnHolder),
184
+ canSteal: isStale || !isAlive || ownPidStealable,
185
+ heldByLiveInProcess: holderIsLiveInProcess,
157
186
  };
158
187
  }
159
188
 
@@ -239,25 +268,82 @@ function timingSafeTokenMatch(a: string, b: string): boolean {
239
268
  }
240
269
 
241
270
  /**
242
- * Release the lock we (this process) just acquired. Unlike releaseLock, this is
243
- * used in the `finally` blocks of withRunLock and withRunLockSync so the file was
244
- * created earlier in the SAME call. Within the same process the new acquire uses
245
- * a fresh randomUUID token, so token matching would falsely fail and leak the
246
- * file across releases.
271
+ * Release the lock we (this process) just acquired. Used in the `finally` blocks
272
+ * of withRunLock and withRunLockSync, so the file was created earlier in the SAME
273
+ * call and carries OUR token.
247
274
  *
248
275
  * LOCK-1 (Round 2): PID-guarded release. Previously this deleted
249
276
  * UNCONDITIONALLY — if our critical section exceeded staleMs, another process
250
277
  * could steal our lock (overwrite the file with its own pid); our finally would
251
- * then DELETE THE STEALER's lock, breaking mutual exclusion. We now verify the
252
- * lock file still records OUR pid before removing; if it records a different pid
253
- * the lock was stolen and the current holder owns it. Same-process re-acquire is
254
- * preserved (pid matches → delete). Mirrors the proven pattern in event-log.ts
255
- * (Round 26, BUG 5).
278
+ * then DELETE THE STEALER's lock, breaking mutual exclusion. We therefore verify
279
+ * the lock file still records OUR pid before removing; if it records a different
280
+ * pid the lock was stolen and the current holder owns it.
281
+ *
282
+ * RR-011 (F02): within one process, PID equality no longer identifies "our" lock
283
+ * — two same-process async holders are distinguished ONLY by token. A finishing
284
+ * context must not delete another acquisition's lock (verification probe 2: the
285
+ * lock file was ENOENT while the second holder was still inside its critical
286
+ * section). So after the pid check, the stored token must also match ours. A
287
+ * token-less payload (legacy lock file from an older release) keeps the
288
+ * PID-only behaviour so old files are still cleaned up.
256
289
  *
257
290
  * Symlink guard is preserved: if a symlink appeared since our writeLockFile, we
258
291
  * don't rm it (defense against attacker-planted symlinks).
259
292
  */
260
- export function releaseOwnLock(filePath: string, _token: string): void {
293
+ /**
294
+ * US-002 (2026-09-22): structured, safe, idempotent sweep of stale run locks.
295
+ *
296
+ * Locks whose holder died without releasing (kill -9, crash) previously lingered
297
+ * until some future acquirer hit the stale-steal path. This gives the
298
+ * stale-reconciler an explicit pass.
299
+ *
300
+ * IMPORTANT — the sweep predicate is STRICTER than acquire-time `canSteal`:
301
+ * acquire steals on `stale OR holder-dead` (deliberate: a stale lock must not
302
+ * block a live run forever), but a SWEEP must only remove a lock whose holder is
303
+ * provably DEAD. Removing a stale-but-live holder's lock would let two processes
304
+ * enter the critical section. So: stale AND (no pid OR pid dead). A live pid is
305
+ * never swept, no matter how old.
306
+ *
307
+ * Returns the lock files it removed. Idempotent: a second sweep finds nothing.
308
+ */
309
+ export function sweepStaleLocks(lockFiles: readonly string[], options: RunLockOptions = {}): { removed: string[] } {
310
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
311
+ const removed: string[] = [];
312
+ for (const filePath of lockFiles) {
313
+ try {
314
+ if (!fs.existsSync(filePath)) continue;
315
+ if (!isLockStale(filePath, staleMs)) continue; // fresh → leave alone
316
+ // Stale. Remove ONLY when no live holder owns it.
317
+ if (isLockHolderAlive(filePath)) continue;
318
+ fs.rmSync(filePath, { force: true });
319
+ removed.push(filePath);
320
+ } catch (error) {
321
+ logInternalError("locks.sweep-stale", error, `path=${filePath}`);
322
+ }
323
+ }
324
+ return { removed };
325
+ }
326
+
327
+ /**
328
+ * US-002: discover every `run.lock` under a runs root
329
+ * (`<runsRoot>/<runId>/run.lock`), bounded, for sweepStaleLocks. Missing → empty.
330
+ */
331
+ export function discoverRunLockFiles(runsRoot: string, maxDirs = 500): string[] {
332
+ try {
333
+ if (!fs.existsSync(runsRoot)) return [];
334
+ return fs
335
+ .readdirSync(runsRoot, { withFileTypes: true })
336
+ .filter((e) => e.isDirectory())
337
+ .slice(0, maxDirs)
338
+ .map((e) => path.join(runsRoot, e.name, "run.lock"))
339
+ .filter((p) => fs.existsSync(p));
340
+ } catch (error) {
341
+ logInternalError("locks.discover-run-locks", error, `runsRoot=${runsRoot}`);
342
+ return [];
343
+ }
344
+ }
345
+
346
+ export function releaseOwnLock(filePath: string, token: string): void {
261
347
  try {
262
348
  const stat = fs.lstatSync(filePath);
263
349
  if (stat.isSymbolicLink()) return;
@@ -266,11 +352,20 @@ export function releaseOwnLock(filePath: string, _token: string): void {
266
352
  }
267
353
  try {
268
354
  const raw = fs.readFileSync(filePath, "utf-8");
269
- const holderPid = (JSON.parse(raw) as { pid?: unknown })?.pid;
270
- if (holderPid === process.pid) {
271
- fs.rmSync(filePath, { force: true });
355
+ const parsed = JSON.parse(raw) as { pid?: unknown; token?: unknown };
356
+ const holderPid = parsed.pid;
357
+ const storedToken = typeof parsed.token === "string" ? parsed.token : undefined;
358
+ if (holderPid !== process.pid) {
359
+ // holderPid !== process.pid → lock stolen by another process; do NOT touch.
360
+ return;
272
361
  }
273
- // holderPid !== process.pid → lock stolen by another process; do NOT touch.
362
+ if (storedToken !== undefined && storedToken !== token) {
363
+ // RR-011 (F02): same process, different token → the lock now belongs to
364
+ // another acquisition of THIS process (steal window / superseded holder).
365
+ // Do not delete it — the current holder owns it.
366
+ return;
367
+ }
368
+ fs.rmSync(filePath, { force: true });
274
369
  } catch (error) {
275
370
  const code = (error as NodeJS.ErrnoException).code;
276
371
  if (code !== "ENOENT") {
@@ -322,6 +417,25 @@ function isLockContention(code: string | undefined): boolean {
322
417
  return code === "EEXIST" || code === "EPERM" || code === "EBUSY";
323
418
  }
324
419
 
420
+ // RR-011 (F02): tokens of run-lock acquisitions that are CURRENTLY HELD in this
421
+ // process. It answers exactly one question — "is this holder still alive
422
+ // in-process?" — and NOTHING else:
423
+ // - Re-entrance (bypass) decisions stay in `lockCtx` (per async context). This
424
+ // set must NEVER be consulted for bypass — that is the H-1 bug class.
425
+ // - The async steal predicate consults it (via readLockSnapshot's
426
+ // activeHolderTokens) so a SECOND independent async context can no longer
427
+ // steal a lock whose holder is merely awaiting inside its critical section.
428
+ //
429
+ // INVARIANT: a token is added synchronously right after a successful acquire
430
+ // (no await between acquire returning and the add) and deleted in the
431
+ // acquisition's `finally` BEFORE releaseOwnLock runs — so a token in this set
432
+ // always corresponds to a live critical section, and a leftover lock file from
433
+ // a finished acquisition (token no longer in the set, or a legacy token-less
434
+ // payload) remains stealable, preserving the CI-flake fix that
435
+ // treatOwnPidAsStealable was added for. If a `finally` were ever skipped, the
436
+ // leaked entry is inert: future lock files always mint a fresh randomUUID.
437
+ const runLockHeldTokens = new Set<string>();
438
+
325
439
  function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind = "file"): string {
326
440
  let attempt = 0;
327
441
  const deadline = Date.now() + staleMs * 2;
@@ -381,10 +495,48 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
381
495
  // file still exists with our own pid. Stealing it avoids spurious 'locked'
382
496
  // errors. The sync file-lock path (withFileLockSync) above uses false to
383
497
  // preserve the multi-process safety guarantee.
384
- const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: true });
385
- if (!canSteal) {
498
+ // RR-011 (F02): the steal now consults runLockHeldTokens — a lock whose
499
+ // stored token belongs to a LIVE acquisition of this process is NOT a
500
+ // leftover corpse and must not be stolen (that broke async↔async mutual
501
+ // exclusion: two independent async contexts entered the critical section
502
+ // together). Token-less legacy files are still stolen (CI-flake fix).
503
+ const verdict = readLockSnapshot(filePath, staleMs, {
504
+ treatOwnPidAsStealable: true,
505
+ activeHolderTokens: runLockHeldTokens,
506
+ });
507
+ if (!verdict.canSteal && verdict.heldByLiveInProcess) {
508
+ // RR-011 (F02): the holder is a LIVE acquisition in THIS process (another
509
+ // async context awaiting inside its critical section). It releases in its
510
+ // `finally` on this same event loop — WAIT via a timer (never sleepSync:
511
+ // blocking the event loop would starve the very holder we are waiting
512
+ // for, the v0.9.26 deadlock class) and retry the create. The loop deadline
513
+ // above plus the staleMs check in readLockSnapshot bound the wait, so a
514
+ // hung holder eventually becomes stale-stealable and this can never
515
+ // block indefinitely.
516
+ const delay = Math.min(250, 25 * 2 ** attempt);
517
+ await sleep(delay);
518
+ attempt++;
519
+ continue;
520
+ }
521
+ if (!verdict.canSteal) {
386
522
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
387
523
  }
524
+ if (verdict.heldByLiveInProcess) {
525
+ // Review MAJOR 2: the staleMs backstop is stealing a lock whose holder
526
+ // is STILL a live acquisition of this process (its critical section
527
+ // legitimately exceeded staleMs — fsync stalls, loaded machine). The
528
+ // steal is the documented design (ADR 2026-09-17-run-lock-async-
529
+ // ownership) but mutual exclusion is about to be violated knowingly,
530
+ // so it must be observable, never silent — production holders are
531
+ // ms-scale; seeing this warn means the invariant "critical section <
532
+ // staleMs" was broken and the budget should be revisited.
533
+ logInternalError(
534
+ "locks.steal-live-holder",
535
+ new Error(`staleMs steal of a lock held by a LIVE in-process acquisition (staleMs=${staleMs})`),
536
+ filePath,
537
+ "warn",
538
+ );
539
+ }
388
540
  // Stale or dead holder — forcibly remove the lock.
389
541
  try {
390
542
  fs.rmSync(filePath, { force: true });
@@ -622,6 +774,10 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
622
774
  }
623
775
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
624
776
  const token = acquireLockWithRetry(filePath, staleMs, "run");
777
+ // RR-011 (F02): register the hold so async contenders of this process see a
778
+ // LIVE holder (wait) instead of a stealable corpse. Sync here too, keeping the
779
+ // invariant uniform for every run-lock acquisition of this process.
780
+ runLockHeldTokens.add(token);
625
781
  const prevHeld = lockCtx.getStore() ?? new Set<string>();
626
782
  const newHeld = new Set(prevHeld);
627
783
  newHeld.add(filePath);
@@ -632,14 +788,11 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
632
788
  try {
633
789
  return fn();
634
790
  } finally {
635
- // FIX (CI flake): releaseLock uses token matching to prevent the
636
- // "losing contender wipes winner's lock" race in multi-process scenarios.
637
- // But within withRunLockSync/withRunLock (same process), the new acquire
638
- // uses a FRESH random token — the previous stored token doesn't match the
639
- // current token — and the lock file is never removed. Repeat acquisitions
640
- // then keep failing with EEXIST because the file lingers.
641
- // Since withRunLock* is always single-process, unconditionally delete
642
- // (symlink guard is still applied for safety).
791
+ // RR-011 (F02): unregister BEFORE releasing (see runLockHeldTokens
792
+ // invariant) — the token must not linger as "live" once the hold ends.
793
+ // releaseOwnLock is token-guarded: if our lock was superseded in the
794
+ // meantime, the current holder's file is left untouched.
795
+ runLockHeldTokens.delete(token);
643
796
  releaseOwnLock(filePath, token);
644
797
  }
645
798
  });
@@ -654,6 +807,10 @@ export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promis
654
807
  }
655
808
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
656
809
  const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
810
+ // RR-011 (F02): register the hold IMMEDIATELY (no await between the acquire
811
+ // returning and this add — otherwise a concurrent contender could observe the
812
+ // fresh on-disk token as a stealable corpse). Deleted in the finally below.
813
+ runLockHeldTokens.add(token);
657
814
  const prevHeld = lockCtx.getStore() ?? new Set<string>();
658
815
  const newHeld = new Set(prevHeld);
659
816
  newHeld.add(filePath);
@@ -664,8 +821,9 @@ export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promis
664
821
  try {
665
822
  return await fn();
666
823
  } finally {
667
- // FIX (CI flake): see withRunLockSync above — use releaseOwnLock for
668
- // unconditional deletion of the lock file we created (same process).
824
+ // RR-011 (F02): see withRunLockSync above — unregister the live token
825
+ // BEFORE the token-guarded releaseOwnLock (runLockHeldTokens invariant).
826
+ runLockHeldTokens.delete(token);
669
827
  releaseOwnLock(filePath, token);
670
828
  }
671
829
  });
@@ -467,19 +467,20 @@ function readAllInboxMessages(manifest: TeamRunManifest): MailboxMessage[] {
467
467
  // FIND-01: in-process delivery cache to avoid O(N²) re-reads on every append.
468
468
  // Keyed by delivery file path; invalidated by mtime check on read + updated on
469
469
  // write. Team-runner is single-process so in-process caching is sufficient.
470
- const deliveryCache = new Map<string, { mtimeMs: number; state: MailboxDeliveryState }>();
470
+ const deliveryCache = new Map<string, { mtimeMs: number; size: number; state: MailboxDeliveryState }>();
471
471
  const MAX_DELIVERY_CACHE_ENTRIES = 256;
472
472
  // R1 review fix: setDeliveryCacheEntry stores an immutable snapshot (deep
473
473
  // copy of `messages`) so callers mutating the returned state cannot corrupt
474
474
  // the cache (TOCTOU race), and bounds the map size with FIFO eviction to
475
475
  // prevent unbounded growth across runs.
476
- function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; state: MailboxDeliveryState }): void {
476
+ function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; size: number; state: MailboxDeliveryState }): void {
477
477
  if (deliveryCache.size >= MAX_DELIVERY_CACHE_ENTRIES) {
478
478
  const oldest = deliveryCache.keys().next().value;
479
479
  if (oldest !== undefined) deliveryCache.delete(oldest);
480
480
  }
481
481
  deliveryCache.set(filePath, {
482
482
  mtimeMs: entry.mtimeMs,
483
+ size: entry.size,
483
484
  state: { ...entry.state, messages: { ...entry.state.messages } },
484
485
  });
485
486
  }
@@ -497,7 +498,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
497
498
  return { messages: {}, updatedAt: new Date().toISOString() };
498
499
  }
499
500
  const cached = deliveryCache.get(filePath);
500
- if (cached && cached.mtimeMs === stat.mtimeMs) {
501
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
501
502
  // R2 review fix: return a copy so callers mutating the result cannot
502
503
  // leak into the cached snapshot (residual TOCTOU: the cache holds the
503
504
  // snapshot until the next write replaces it; without this copy, a
@@ -517,7 +518,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
517
518
  messages,
518
519
  updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString(),
519
520
  };
520
- setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, state });
521
+ setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, state });
521
522
  return state;
522
523
  } catch (error) {
523
524
  // NEW-R4: a corrupt delivery.json was previously swallowed silently, returning
@@ -543,22 +544,146 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
543
544
  }
544
545
  }
545
546
 
547
+ const MAX_DELIVERY_MESSAGES = 10000;
548
+
549
+ /**
550
+ * F09 (RR-016): an `acknowledged` delivery entry is the ONLY durable record
551
+ * that a message was handled — the inbox line keeps `status: "queued"` forever
552
+ * (nothing writes the ack back to the message), so `replayPendingMailboxMessages`
553
+ * keys on the delivery map. The old prune sorted `queued(0) < delivered(1) <
554
+ * acknowledged(2)` and kept `slice(0, MAX)`, i.e. it evicted ACKNOWLEDGED entries
555
+ * FIRST — the moment the cap was crossed, an acked message became replayable
556
+ * again, and it never self-healed (replay only writes "delivered"), so it was
557
+ * re-delivered on EVERY resume.
558
+ *
559
+ * Fix: the prune may only drop an acknowledged entry when the message it refers
560
+ * to is provably no longer replayable (absent from the whole replayable history:
561
+ * live inbox files + retained archives — the exact set `replayPendingMailboxMessages`
562
+ * reads). Non-acknowledged entries keep the previous eviction semantics
563
+ * (queued → delivered → acknowledged, oldest-inserted first within a tier).
564
+ *
565
+ * Acknowledged entries are tiny (`"<id>":"acknowledged"`), but they must not
566
+ * grow without bound either: a sweep that drops the PROVABLY-dead acks is run
567
+ * once the ack set is large enough to matter, and at most once per
568
+ * ACK_SWEEP_MIN_INTERVAL_MS (the sweep reads the mailbox history, so it is
569
+ * throttled rather than run on every append).
570
+ */
571
+ const ACK_SWEEP_MIN_ACKS = 1000;
572
+ const ACK_SWEEP_FORCE_ACKS = 5000;
573
+ const ACK_SWEEP_MIN_INTERVAL_MS = 30_000;
574
+ // Bounded FIFO (the asyncAgentReaderCache pattern) so a long-running process
575
+ // cannot accumulate one timestamp per run ever seen.
576
+ const ACK_SWEEP_TIMESTAMP_MAX_ENTRIES = 256;
577
+ const lastAckSweepAt = new Map<string, number>();
578
+
579
+ function recordAckSweep(filePath: string, at: number): void {
580
+ if (lastAckSweepAt.has(filePath)) lastAckSweepAt.delete(filePath);
581
+ lastAckSweepAt.set(filePath, at);
582
+ while (lastAckSweepAt.size > ACK_SWEEP_TIMESTAMP_MAX_ENTRIES) {
583
+ const oldest = lastAckSweepAt.keys().next().value;
584
+ if (oldest === undefined) break;
585
+ lastAckSweepAt.delete(oldest);
586
+ }
587
+ }
588
+
589
+ /** Ids of every message `replayPendingMailboxMessages` could return (live inbox
590
+ * files + retained archives, run-level and per-task). Returns `undefined` when
591
+ * the history could not be READ (fail-closed, review MAJOR 1): an unreadable
592
+ * history must never be conflated with an empty one — the sweep treats an
593
+ * empty set as "every ack is dead" and would delete all acknowledged entries,
594
+ * replaying already-processed messages (the F09 bug class). */
595
+ function collectReplayableInboxIds(manifest: TeamRunManifest): Set<string> | undefined {
596
+ const ids = new Set<string>();
597
+ try {
598
+ for (const message of readAllInboxMessages(manifest)) ids.add(message.id);
599
+ } catch (error) {
600
+ logInternalError("mailbox.collect-replayable-ids", error, `runId=${manifest.runId}`);
601
+ return undefined;
602
+ }
603
+ return ids;
604
+ }
605
+
606
+ /** Drop acknowledged entries whose message can no longer be replayed. Returns
607
+ * the number of entries dropped. Never drops an entry whose message is still
608
+ * in the replayable history. FAIL-CLOSED (review MAJOR 1): if the replayable
609
+ * history cannot be read, the sweep aborts dropping NOTHING and does not
610
+ * consume the throttle window (`recordAckSweep` is only recorded after a
611
+ * successful read), so the next prune retries once the FS is readable. */
612
+ function sweepDeadAcknowledgements(manifest: TeamRunManifest, state: MailboxDeliveryState): number {
613
+ const ackedIds = Object.entries(state.messages)
614
+ .filter(([, status]) => status === "acknowledged")
615
+ .map(([id]) => id);
616
+ if (ackedIds.length === 0) return 0;
617
+ const filePath = deliveryFile(manifest, true);
618
+ const now = Date.now();
619
+ const last = lastAckSweepAt.get(filePath) ?? 0;
620
+ // The sweep reads the whole replayable history, so it is time-throttled:
621
+ // at most once per ACK_SWEEP_MIN_INTERVAL_MS below ACK_SWEEP_FORCE_ACKS,
622
+ // and immediately above it so the ack set cannot grow unbounded.
623
+ const shouldSweep =
624
+ ackedIds.length >= ACK_SWEEP_FORCE_ACKS || (ackedIds.length >= ACK_SWEEP_MIN_ACKS && now - last >= ACK_SWEEP_MIN_INTERVAL_MS);
625
+ if (!shouldSweep) return 0;
626
+ const replayable = collectReplayableInboxIds(manifest);
627
+ if (replayable === undefined) return 0; // unreadable history → abort, keep every ack
628
+ recordAckSweep(filePath, now);
629
+ let dropped = 0;
630
+ for (const id of ackedIds) {
631
+ if (replayable.has(id)) continue;
632
+ delete state.messages[id];
633
+ dropped++;
634
+ }
635
+ return dropped;
636
+ }
637
+
638
+ function pruneDeliveryMessages(manifest: TeamRunManifest, state: MailboxDeliveryState): void {
639
+ const entries = Object.entries(state.messages);
640
+ const ackedCount = entries.reduce((count, [, status]) => (status === "acknowledged" ? count + 1 : count), 0);
641
+ if (ackedCount > ACK_SWEEP_MIN_ACKS) sweepDeadAcknowledgements(manifest, state);
642
+ const remaining = Object.entries(state.messages);
643
+ if (remaining.length <= MAX_DELIVERY_MESSAGES) return;
644
+ // Stable sort: within a status tier the previous insertion order (which is
645
+ // the order entries were first written) is preserved, so the eviction choice
646
+ // for non-acknowledged entries is unchanged from before this fix.
647
+ const sorted = [...remaining].sort(([, a], [, b]) => {
648
+ const order = { queued: 0, delivered: 1, acknowledged: 2 };
649
+ return (order[a] ?? 3) - (order[b] ?? 3);
650
+ });
651
+ const ackedAfterSweep = sorted.filter(([, status]) => status === "acknowledged").length;
652
+ if (ackedAfterSweep > MAX_DELIVERY_MESSAGES) {
653
+ // Pathological: more acks than the cap and every one of them still refers
654
+ // to a replayable message. Correctness (an acked message must never
655
+ // replay) wins over the memory bound — keep them and make it visible.
656
+ logInternalError(
657
+ "mailbox.delivery-ack-over-cap",
658
+ new Error(`delivery.json holds ${ackedAfterSweep} acknowledged entries for replayable messages (cap ${MAX_DELIVERY_MESSAGES})`),
659
+ `runId=${manifest.runId}`,
660
+ "warn",
661
+ );
662
+ }
663
+ const keptIds = new Set<string>();
664
+ let evictableBudget = Math.max(0, MAX_DELIVERY_MESSAGES - ackedAfterSweep);
665
+ for (const [id, status] of sorted) {
666
+ if (status === "acknowledged") {
667
+ keptIds.add(id);
668
+ continue;
669
+ }
670
+ if (evictableBudget > 0) {
671
+ keptIds.add(id);
672
+ evictableBudget--;
673
+ }
674
+ }
675
+ // Filter the ORIGINAL entry order so surviving entries keep their positions.
676
+ state.messages = Object.fromEntries(remaining.filter(([id]) => keptIds.has(id)));
677
+ }
678
+
546
679
  function writeDeliveryState(
547
680
  manifest: TeamRunManifest,
548
681
  state: MailboxDeliveryState,
549
682
  options?: { durability?: "full" | "best-effort" },
550
683
  ): void {
551
684
  ensureRunMailbox(manifest);
552
- // Prune oldest entries if capped
553
- const MAX_DELIVERY_MESSAGES = 10000;
554
- if (Object.keys(state.messages).length > MAX_DELIVERY_MESSAGES) {
555
- const sorted = Object.entries(state.messages).sort(([, a], [, b]) => {
556
- const order = { queued: 0, delivered: 1, acknowledged: 2 };
557
- return (order[a] ?? 3) - (order[b] ?? 3);
558
- });
559
- const trimmed = sorted.slice(0, MAX_DELIVERY_MESSAGES);
560
- state.messages = Object.fromEntries(trimmed);
561
- }
685
+ // Prune oldest entries if capped (F09: acknowledged entries are protected).
686
+ pruneDeliveryMessages(manifest, state);
562
687
  // F4: mailbox delivery is informational — accept losing the very last write on
563
688
  // a hard crash (the next message will overwrite it on disk). Cheaper fsync on
564
689
  // the hot path; terminal/reply paths still pass full durability below.
@@ -572,7 +697,7 @@ function writeDeliveryState(
572
697
  // setDeliveryCacheEntry stores an immutable snapshot (deep copy of
573
698
  // messages) so subsequent read-modify-write callers mutating the
574
699
  // returned state cannot corrupt the cache.
575
- setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, state });
700
+ setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, size: postStat.size, state });
576
701
  } catch {
577
702
  deliveryCache.delete(filePath);
578
703
  }
@@ -13,10 +13,20 @@
13
13
  * The `node:path` import is retained as a *fallback* (only used when the
14
14
  * binding is healthy). Don't add new dependencies on other pi-crew modules.
15
15
  *
16
+ * TWO sanctioned exceptions (RR-020):
17
+ * - `node:os` — a builtin like fs/path, used ONLY inside try/catch so a
18
+ * jiti namespace race degrades to "no home/tmp boundary" (the pre-RR-020
19
+ * walk) instead of crashing ensureCrewDirectory.
20
+ * - `../utils/project-markers.ts` — a zero-import module of plain string
21
+ * literals, so it cannot itself suffer the namespace race and it keeps
22
+ * this resolver from drifting away from `src/utils/paths.ts`.
23
+ *
16
24
  * See: https://github.com/baphuongna/pi-crew/issues/28
17
25
  */
18
26
  import * as fs from "node:fs";
27
+ import * as os from "node:os";
19
28
  import * as path from "node:path";
29
+ import { PROJECT_DIR_MARKERS, PROJECT_FILE_MARKERS } from "../utils/project-markers.ts";
20
30
  import { atomicWriteFile } from "./atomic-write.ts";
21
31
  import { updateGitignore } from "./gitignore-manager.ts";
22
32
 
@@ -164,29 +174,94 @@ function safeResolve(p: string, pathDep?: typeof path): string {
164
174
  }
165
175
 
166
176
  function findProjectRoot(start: string, pathDep?: typeof path): string | undefined {
167
- const dirMarkers = [".git", ".hg", ".svn"];
168
- const fileMarkers = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
169
- // Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
170
- // termination root — fixes the jiti namespace race in issue #28.
171
- const root = parseRoot(start);
177
+ // Marker lists come from src/utils/project-markers.ts (RR-020 Fix 1) so this
178
+ // resolver can never drift from src/utils/paths.ts:computeRepoRoot again —
179
+ // the drift resolved `parent/subproject` (.pi) to `parent/.crew` here while
180
+ // paths.ts resolved the same cwd to `subproject/.pi/teams` (two roots).
181
+ // The module has NO imports of its own, so it is safe under the jiti
182
+ // namespace race documented at the top of this file.
183
+ // RR-020 hardening (cold-verify round 2): the marker arrays are the ONE
184
+ // critical-path dependency on a static import binding. Under the jiti
185
+ // namespace race (issue #28) a binding can arrive undefined — degrade to the
186
+ // narrow `.git`-only probe (findProjectRoot then returns undefined more
187
+ // often, so computeCrewRoot anchors the crew root at the cwd — the same
188
+ // fallback paths.ts uses) instead of throwing inside ensureCrewDirectory,
189
+ // the very function hardened for #28. Mirrors the defensive style of
190
+ // safeJoin/safeDirname/parseRoot and the lazy updateGitignore import.
191
+ function markerLists(): { dirs: string[]; files: string[] } {
192
+ try {
193
+ if (Array.isArray(PROJECT_DIR_MARKERS) && Array.isArray(PROJECT_FILE_MARKERS)) {
194
+ return { dirs: PROJECT_DIR_MARKERS as string[], files: PROJECT_FILE_MARKERS as string[] };
195
+ }
196
+ } catch {
197
+ // namespace unavailable — fall through to the narrow probe
198
+ }
199
+ return { dirs: [".git"], files: [] };
200
+ }
201
+ const markers = markerLists();
202
+ const hasMarker = (dir: string): boolean =>
203
+ markers.dirs.some((marker) => fs.existsSync(safeJoin(dir, marker))) ||
204
+ markers.files.some((marker) => fs.existsSync(safeJoin(dir, marker)));
172
205
  let current = safeResolve(start, pathDep);
206
+ // RR-020 cold-verify follow-up: match findRepoRoot (paths.ts) which
207
+ // realpaths the start BEFORE walking, so the boundary comparisons below
208
+ // compare canonical-to-canonical (macOS /var -> /private/var). Best-effort:
209
+ // ENOENT keeps the lexical path, exactly like findRepoRoot's fallback.
210
+ try {
211
+ current = fs.realpathSync(current);
212
+ } catch {
213
+ // keep the lexical resolution
214
+ }
215
+ // Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
216
+ // termination root — fixes the jiti namespace race in issue #28. Computed
217
+ // from the RESOLVED `current` (post-realpath) so a root-prefix change
218
+ // through a symlink cannot desync `current !== root` (paths.ts computes
219
+ // path.parse on the realpath'd start for the same reason).
220
+ const root = parseRoot(current);
221
+ // RR-020 cold-verify follow-up (bug-029 parity): home/tmp boundary STOP.
222
+ // computeRepoRoot (paths.ts) refuses to check markers at $HOME or the temp
223
+ // root; this walk did not, so once the marker lists were unified onto the
224
+ // wide set, `$HOME/.pi` (created by userPiRoot()) became a marker HERE — a
225
+ // MARKERLESS cwd under $HOME resolved crew-init to $HOME (⇒
226
+ // $HOME/.pi/teams) while projectCrewRoot resolved to <cwd>/.crew: the
227
+ // two-roots bug Fix 1 was meant to kill, in a new shape. `os` access is
228
+ // defensive: under the jiti namespace race (issue #28) the binding can be
229
+ // undefined — degrade to "no boundary" (the pre-RR-020 walk), never crash.
230
+ let home: string | undefined;
231
+ let tempRoot: string | undefined;
232
+ try {
233
+ home = canonicalBoundary(os.homedir());
234
+ tempRoot = canonicalBoundary(os.tmpdir());
235
+ } catch {
236
+ home = undefined;
237
+ tempRoot = undefined;
238
+ }
239
+ const atBoundary = (dir: string): boolean => (home !== undefined && dir === home) || (tempRoot !== undefined && dir === tempRoot);
173
240
  // Walk up to find project root
174
241
  while (current !== root) {
175
- for (const marker of dirMarkers) {
176
- if (fs.existsSync(safeJoin(current, marker))) return current;
177
- }
178
- for (const marker of fileMarkers) {
179
- if (fs.existsSync(safeJoin(current, marker))) return current;
180
- }
242
+ // Stop walking before checking markers at home or temp root.
243
+ if (atBoundary(current)) return undefined;
244
+ if (hasMarker(current)) return current;
181
245
  const parent = safeDirname(current);
182
246
  if (parent === current) break;
183
247
  current = parent;
184
248
  }
185
249
  // Check root as fallback
186
- if (dirMarkers.some((m) => fs.existsSync(safeJoin(root, m)))) return root;
250
+ if (atBoundary(root)) return undefined;
251
+ if (hasMarker(root)) return root;
187
252
  return undefined;
188
253
  }
189
254
 
255
+ /** Canonicalize a boundary dir for comparison with the (realpath'd) walk.
256
+ * Best-effort: an unresolvable path stays lexical. */
257
+ function canonicalBoundary(p: string): string {
258
+ try {
259
+ return fs.realpathSync(p);
260
+ } catch {
261
+ return p;
262
+ }
263
+ }
264
+
190
265
  /**
191
266
  * Compute the crew root directory for a given working directory.
192
267
  * Matches src/utils/paths.ts:projectCrewRoot() logic.