pi-crew 0.11.21 → 0.11.23

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 (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/index.mjs +542 -531
  3. package/package.json +2 -1
  4. package/src/errors.ts +14 -1
  5. package/src/extension/async-notifier.ts +42 -17
  6. package/src/extension/cross-extension-rpc.ts +42 -5
  7. package/src/extension/notification-router.ts +15 -2
  8. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  9. package/src/extension/registration/runtime-cleanup.ts +17 -0
  10. package/src/extension/registration/wire-cross-extension.ts +26 -0
  11. package/src/extension/rpc-hmac.ts +53 -2
  12. package/src/extension/team-tool/cancel.ts +34 -21
  13. package/src/extension/team-tool/dispatch/index.ts +6 -5
  14. package/src/extension/team-tool/dispatch/status.ts +42 -2
  15. package/src/extension/team-tool/respond.ts +8 -6
  16. package/src/extension/team-tool/run.ts +4 -4
  17. package/src/extension/team-tool.ts +24 -2
  18. package/src/prompt/inbox-poll.ts +77 -25
  19. package/src/prompt/message-tool.ts +62 -4
  20. package/src/prompt/prompt-runtime.ts +82 -32
  21. package/src/prompt/surface-worker.ts +13 -1
  22. package/src/runtime/broker/crew-broker-client.ts +74 -16
  23. package/src/runtime/broker/crew-broker.ts +120 -97
  24. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  25. package/src/runtime/broker/protocol/request-parsers.ts +68 -0
  26. package/src/runtime/broker/wait-status-cache.ts +14 -5
  27. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  28. package/src/runtime/child-pi/child-pi.ts +20 -2
  29. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  30. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  31. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  32. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  33. package/src/runtime/crew-agent-records.ts +32 -14
  34. package/src/runtime/delivery-coordinator.ts +30 -0
  35. package/src/runtime/dispatch-batch.ts +9 -6
  36. package/src/runtime/dwf-state-store.ts +41 -7
  37. package/src/runtime/finalize-run.ts +13 -2
  38. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  39. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  40. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  41. package/src/runtime/live-session/embedded-tier.ts +3 -1
  42. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  43. package/src/runtime/model/model-fallback.ts +23 -1
  44. package/src/runtime/output/pi-json-output.ts +26 -1
  45. package/src/runtime/policy-engine.ts +18 -0
  46. package/src/runtime/process/zombie-scanner.ts +84 -16
  47. package/src/runtime/process-status.ts +6 -1
  48. package/src/runtime/recovery/crash-recovery.ts +170 -28
  49. package/src/runtime/rpc/rpc-worker.ts +44 -1
  50. package/src/runtime/scheduling/run-coalesced-task-group.ts +73 -6
  51. package/src/runtime/scheduling/scheduler.ts +74 -11
  52. package/src/runtime/scratchpad/engine.ts +26 -3
  53. package/src/runtime/settings-store.ts +22 -2
  54. package/src/runtime/stale-reconciler.ts +4 -1
  55. package/src/runtime/surface/tmux-provider.ts +24 -3
  56. package/src/runtime/task-health.ts +16 -1
  57. package/src/runtime/task-output-context.ts +143 -71
  58. package/src/runtime/task-packet.ts +70 -17
  59. package/src/runtime/task-runner/child-executor.ts +27 -0
  60. package/src/runtime/task-runner/output-splitter.ts +79 -16
  61. package/src/runtime/task-runner/post-execution.ts +75 -15
  62. package/src/runtime/task-runner/state-helpers.ts +92 -2
  63. package/src/runtime/usage-tracker.ts +11 -0
  64. package/src/runtime/verification/green-contract.ts +17 -0
  65. package/src/runtime/verification/verification-gates.ts +24 -6
  66. package/src/runtime/workspace-lock.ts +53 -0
  67. package/src/schema/team-tool-schema.ts +10 -4
  68. package/src/state/coordination/mailbox.ts +133 -38
  69. package/src/state/decision-ledger.ts +56 -8
  70. package/src/state/event-log/cursor.ts +14 -2
  71. package/src/state/event-log/event-log-rotation.ts +83 -5
  72. package/src/state/event-log/event-log.ts +181 -12
  73. package/src/state/stores/active-run-registry.ts +10 -3
  74. package/src/state/types.ts +9 -1
  75. package/src/ui/agents-jobs-browser.ts +22 -8
  76. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  77. package/src/ui/events-state-source.ts +67 -28
  78. package/src/ui/inline-panel/agent-pane.ts +13 -6
  79. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  80. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  81. package/src/ui/inline-panel/index.ts +21 -1
  82. package/src/ui/live-run-sidebar.ts +14 -3
  83. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  84. package/src/ui/powerbar-publisher.ts +6 -3
  85. package/src/ui/rail.ts +7 -1
  86. package/src/ui/render-scheduler.ts +53 -1
  87. package/src/ui/run-action-dispatcher.ts +68 -20
  88. package/src/ui/status-colors.ts +9 -0
  89. package/src/utils/env-filter.ts +8 -0
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { logInternalError } from "../../utils/internal-error.ts";
4
4
  import { atomicWriteFile } from "../atomic-write.ts";
5
- import { type TeamEvent, withEventLogLockSync } from "./event-log.ts";
5
+ import { type TeamEvent, withEventLogAppendLockSync, withEventLogLockSync } from "./event-log.ts";
6
6
  import { poisonMarkerPath } from "./poison-marker.ts";
7
7
 
8
8
  /**
@@ -169,7 +169,11 @@ export interface CompactionResult {
169
169
  * 5. If events were lost, append them
170
170
  * 6. Return compaction stats
171
171
  */
172
- export function compactEventLog(eventsPath: string, config?: Partial<RotationConfig>): CompactionResult | undefined {
172
+ export function compactEventLog(
173
+ eventsPath: string,
174
+ config?: Partial<RotationConfig>,
175
+ options?: CompactionLockOptions,
176
+ ): CompactionResult | undefined {
173
177
  // US-001 (2026-09-22): lock-scope reduction. The expensive part is the READ +
174
178
  // parse of the whole log (prepareCompaction). It is read-only, so it does NOT
175
179
  // need the append lock: hold the lock ONLY for the write+recover phase
@@ -196,7 +200,15 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
196
200
  // log grew unbounded until events were silently dropped past 50MB).
197
201
  const prepared = prepareCompaction(eventsPath, config);
198
202
  if (!prepared) return undefined;
199
- return compactPreparedEventLog(eventsPath, prepared);
203
+ return compactPreparedEventLog(eventsPath, prepared, options);
204
+ }
205
+
206
+ /** Options for the locked write phase of compaction. `lock` is forwarded to
207
+ * `withEventLogAppendLockSync` (and transitively `withEventLogLockSync`) so
208
+ * callers/tests can bound the wait — the defaults (5s timeout / 10s stale)
209
+ * mirror both existing lock families. */
210
+ export interface CompactionLockOptions {
211
+ lock?: { timeoutMs?: number; staleMs?: number };
200
212
  }
201
213
 
202
214
  /**
@@ -212,15 +224,81 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
212
224
  * ones it never saw. So under the lock we read the bytes appended after the
213
225
  * snapshot offset and append them to the write. Appenders hold the same lock,
214
226
  * so this read is a complete tail snapshot.
227
+ *
228
+ * R4-2 (DR-02 Wave 2, 2026-10-15): the write phase now ADDITIONALLY acquires the
229
+ * async family's `.alock` (sync-mode, via `withEventLogAppendLockSync`) BEFORE
230
+ * `readTailSince`/the `atomicWriteFile` rename, releasing it only after the C2
231
+ * recovery check completes. Rationale: async-family appends hold `.alock`
232
+ * ACROSS await gaps (`open(eventsPath, "a")` → `appendFile`); running the rename
233
+ * under `.mkdirlock` alone let it interleave with that gap, orphaning the
234
+ * appender's fd — its event landed on the old inode and was silently lost (C2
235
+ * only re-splices the `kept` window). Holding `.alock` across the whole write
236
+ * phase excludes in-flight async appends cross-process (same on-disk lock dir
237
+ * + pid-file protocol). Lock order stays strictly `.mkdirlock` → `.alock` (the
238
+ * async family never takes `.mkdirlock`), so no deadlock. NOT archive-style by
239
+ * lead decision: rotation's rename-to-archive already preserves in-flight fds
240
+ * (ST-8), so only compaction's inode-replacing write needed the guard.
215
241
  */
216
242
  export function compactPreparedEventLog(
217
243
  eventsPath: string,
218
244
  prepared: { lines: string; originalSize: number; originalCount: number; kept: TeamEvent[] },
245
+ options?: CompactionLockOptions,
219
246
  ): CompactionResult | undefined {
220
- return withEventLogLockSync(eventsPath, () => {
247
+ // R7-2 (DR-04, 2026-10-16): the write phase is now `applyCompactionAppendGuarded`
248
+ // (below) — `.alock` nested inside the `.mkdirlock` held here, plus the
249
+ // mandatory `readTailSince` splice. This wrapper keeps the historical
250
+ // locked entry point (public API + tests) with identical semantics.
251
+ return withEventLogLockSync(eventsPath, () => applyCompactionAppendGuarded(eventsPath, prepared, options), options?.lock);
252
+ }
253
+
254
+ /** R7-2 (DR-04, 2026-10-16): options for `applyCompactionAppendGuarded`.
255
+ * `lock` is forwarded to `withEventLogAppendLockSync` so callers/tests can
256
+ * bound the wait; `holdsAppendLock` declares the caller's lock state (see the
257
+ * helper docblock). */
258
+ export interface ApplyCompactionGuardOptions extends CompactionLockOptions {
259
+ /** Pass `true` ONLY when the caller ALREADY holds the async family's
260
+ * `.alock` (i.e. the guarded apply runs inside `withEventLogLockAsync`).
261
+ * The on-disk lock is NOT re-entrant — re-acquiring our own `.alock` in
262
+ * sync mode can only end in the bounded timeout (sleepSync starves the
263
+ * very in-process holder that must release it). With `true` the helper
264
+ * only tail-merges; with `false`/omitted it acquires `.alock` itself. */
265
+ holdsAppendLock?: boolean;
266
+ }
267
+
268
+ /**
269
+ * R7-2 (DR-04, 2026-10-16): the guarded write phase for INLINE compaction
270
+ * call sites (the overflow/rotation-tick paths inside event-log.ts) that
271
+ * previously called `prepareCompaction` + `applyCompactionUnlocked` bare —
272
+ * no `.alock` and no tail-merge, so the inode-replacing write could interleave
273
+ * with an in-flight async-family append parked between `open()` and
274
+ * `appendFile()` (its fd kept pointing at the renamed-away inode → silent
275
+ * loss; C2 recovery only re-splices the `kept` window), and sync-family bytes
276
+ * appended between the prepare-read and the write were clobbered.
277
+ *
278
+ * Contract:
279
+ * - ALWAYS merges `readTailSince(eventsPath, prepared.originalSize)` into
280
+ * `prepared.lines` before `applyCompactionUnlocked` — sync-family appenders
281
+ * never hold `.alock`, so they can land in the prepare→apply window even
282
+ * while an async-family caller holds `.alock`; the splice rescues them.
283
+ * - `opts.holdsAppendLock === true` (caller is inside `withEventLogLockAsync`):
284
+ * tail-merge ONLY — never re-acquire `.alock` (non-reentrant → self-timeout).
285
+ * - `opts.holdsAppendLock` false/omitted (caller holds `.mkdirlock` — sync
286
+ * family — or no lock): acquire `.alock` in sync mode nested inside the
287
+ * held `.mkdirlock`, per the R4-2 lock order (`.mkdirlock` → `.alock`; the
288
+ * async family never takes `.mkdirlock`, so no inversion). NEVER call this
289
+ * with `false` while `.alock` is already held by this process.
290
+ */
291
+ export function applyCompactionAppendGuarded(
292
+ eventsPath: string,
293
+ prepared: { lines: string; originalSize: number; originalCount: number; kept: TeamEvent[] },
294
+ options?: ApplyCompactionGuardOptions,
295
+ ): CompactionResult | undefined {
296
+ const applyWithTailMerge = (): CompactionResult | undefined => {
221
297
  const merged = { ...prepared, lines: prepared.lines + readTailSince(eventsPath, prepared.originalSize) };
222
298
  return applyCompactionUnlocked(eventsPath, merged);
223
- });
299
+ };
300
+ if (options?.holdsAppendLock) return applyWithTailMerge();
301
+ return withEventLogAppendLockSync(eventsPath, applyWithTailMerge, options?.lock);
224
302
  }
225
303
 
226
304
  /**
@@ -7,7 +7,7 @@ import { emitFromTeamEvent } from "../../ui/run-event-bus.ts";
7
7
  import { logInternalError } from "../../utils/internal-error.ts";
8
8
  import { redactSecrets } from "../../utils/redaction.ts";
9
9
  import { sleep, sleepSync } from "../../utils/sleep.ts";
10
- import { applyCompactionUnlocked, needsRotation, prepareCompaction, rotateEventLogUnlocked } from "./event-log-rotation.ts";
10
+ import { applyCompactionAppendGuarded, needsRotation, prepareCompaction, rotateEventLogUnlocked } from "./event-log-rotation.ts";
11
11
  import {
12
12
  advanceSequenceCounter,
13
13
  persistSequenceMonotonic,
@@ -219,6 +219,125 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
219
219
  }
220
220
  }
221
221
 
222
+ /**
223
+ * R4-2 (DR-02 Wave 2, 2026-10-15): SYNC-mode acquisition of the ASYNC family's
224
+ * `.alock` lock dir (`${eventsPath}.alock`). Mirrors the `withEventLogLockSync`
225
+ * protocol exactly — `fs.mkdirSync` (O_EXCL semantics on POSIX), pid file via
226
+ * `openSync(pidFile, "wx")` + plain write, mtime-stale reclaim checked FIRST for
227
+ * every holder (Round 26 BUG 4 pattern), dead-pid secondary fast-path that only
228
+ * WAITS (never steals a fresh lock), `sleepSync(50)` bounded retry loop, and a
229
+ * PID-guarded release that never deletes a stealer's dir (Round 26 BUG 5).
230
+ *
231
+ * WHY this exists: the async append family (`withEventLogLockAsync` →
232
+ * `appendEventAsync`) holds `.alock` ACROSS await gaps — notably between
233
+ * `fs.promises.open(eventsPath, "a")` and `fd.appendFile(...)`. Compaction's
234
+ * `applyCompactionUnlocked` replaces the events file via `atomicWriteFile`
235
+ * (temp+rename → new inode). When compaction ran under `.mkdirlock` only, an
236
+ * fd opened by a mid-gap async append kept pointing at the OLD inode, which the
237
+ * rename orphaned → the append was silently lost (C2 recovery only re-splices
238
+ * events in the `kept` window). Acquiring `.alock` around the compaction write
239
+ * phase closes that window: the rename can no longer interleave with an
240
+ * in-flight async-family append, cross-process included (same on-disk dir +
241
+ * pid-file protocol as the async acquirer).
242
+ *
243
+ * LOCK ORDER: strictly `.mkdirlock` → `.alock`. The async family NEVER takes
244
+ * `.mkdirlock` (its bodies call the unlocked compaction/rotation variants and
245
+ * the short `.seqlock` only), so a sync caller holding `.mkdirlock` may safely
246
+ * acquire this — but NEVER the reverse, and never call this from INSIDE
247
+ * `withEventLogLockAsync` (the dir would be our own — the retry loop would
248
+ * sleepSync-starve the very holder that must release it → guaranteed timeout).
249
+ *
250
+ * IN-PROCESS HAZARD (same rationale as the two-family split): if a SAME-PROCESS
251
+ * async-family holder is mid-await-gap on this `.alock`, this sync acquirer's
252
+ * `sleepSync` retry blocks the event loop the async holder needs to finish —
253
+ * the acquire can only end in the bounded timeout (throw), never in a steal.
254
+ * That is why the only in-tree caller is `compactPreparedEventLog`
255
+ * (event-log-rotation.ts), whose callers are cross-process / maintenance / test
256
+ * entry points, NOT the in-process append paths (those already hold `.alock`
257
+ * or call the unlocked variants directly).
258
+ */
259
+ export function withEventLogAppendLockSync<T>(eventsPath: string, fn: () => T, options?: { timeoutMs?: number; staleMs?: number }): T {
260
+ // Ensure parent directory exists before attempting lock (mirrors the sync family).
261
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
262
+ const lockDir = `${eventsPath}.alock`;
263
+ const pidFile = path.join(lockDir, "pid");
264
+ const start = Date.now();
265
+ const timeout = options?.timeoutMs ?? 5000;
266
+ const staleMs = options?.staleMs ?? 10000;
267
+ let acquired = false;
268
+ while (true) {
269
+ try {
270
+ fs.mkdirSync(lockDir);
271
+ try {
272
+ // Same disposable pid file as both existing families: "wx" open +
273
+ // plain write — the lock dir itself is the mutex; the pid file only
274
+ // feeds the stale/dead detection and the guarded release.
275
+ const pidFd = fs.openSync(pidFile, "wx");
276
+ try {
277
+ fs.writeSync(pidFd, String(process.pid));
278
+ } finally {
279
+ fs.closeSync(pidFd);
280
+ }
281
+ } catch {
282
+ /* best-effort — e.g. EEXIST under a re-taken dir; stale pid is mtime-detected */
283
+ }
284
+ acquired = true;
285
+ break;
286
+ } catch {
287
+ if (Date.now() - start > timeout) {
288
+ throw errors.eventLogLockTimeout(eventsPath, timeout);
289
+ }
290
+ // Mtime-stale reclaim FIRST, independent of the pid file (Round 26 BUG 3
291
+ // pattern): a holder that crashed between mkdir and the pid write has no
292
+ // pid file — only an aged mtime can reclaim its dir.
293
+ try {
294
+ const dirStat = fs.statSync(lockDir);
295
+ if (Date.now() - dirStat.mtimeMs > staleMs) {
296
+ fs.rmSync(lockDir, { recursive: true, force: true });
297
+ continue;
298
+ }
299
+ } catch {
300
+ /* dir vanished — let loop retry */
301
+ }
302
+ // Dead-pid secondary fast-path (Round 26 BUG 4 pattern): mtime already
303
+ // handled above for ALL holders; a provably-dead pid that is not stale
304
+ // yet only makes us keep waiting — never steal a fresh lock on a pid
305
+ // lookup race.
306
+ try {
307
+ const raw = fs.readFileSync(pidFile, "utf-8").trim();
308
+ const ownerPid = Number.parseInt(raw, 10);
309
+ if (!Number.isNaN(ownerPid) && ownerPid !== process.pid) {
310
+ try {
311
+ process.kill(ownerPid, 0);
312
+ } catch {
313
+ /* dead — but mtime not stale yet, keep waiting */
314
+ }
315
+ }
316
+ } catch {
317
+ /* no pid file — mtime check above already handles it */
318
+ }
319
+ sleepSync(50);
320
+ }
321
+ }
322
+ try {
323
+ return fn();
324
+ } finally {
325
+ if (acquired) {
326
+ // PID-guarded release (Round 26 BUG 5 pattern): only remove the dir if
327
+ // the pid file still records OUR pid — if our fn outlived staleMs and a
328
+ // stealer re-took the dir, the stealer owns it now.
329
+ try {
330
+ const currentPid = fs.readFileSync(pidFile, "utf-8").trim();
331
+ if (currentPid === String(process.pid)) {
332
+ fs.rmSync(lockDir, { recursive: true, force: true });
333
+ }
334
+ } catch {
335
+ /* lock stolen or already gone — do not touch */
336
+ }
337
+ }
338
+ }
339
+ }
340
+
222
341
  export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
223
342
  return createHash("sha256")
224
343
  .update(
@@ -306,8 +425,12 @@ async function drainAsyncQueues(): Promise<void> {
306
425
  *
307
426
  * NOT re-entrant: callers inside this lock must use unlocked compaction
308
427
  * variants (prepareCompaction + applyCompactionUnlocked, rotateEventLogUnlocked)
309
- * to avoid self-deadlock. */
310
- async function withEventLogLockAsync<T>(
428
+ * to avoid self-deadlock.
429
+ *
430
+ * R4-2 note (2026-10-15): exported so tests (and future async callers) can pin
431
+ * the `.alock` family's mutual exclusion against the sync-mode acquirer
432
+ * `withEventLogAppendLockSync` used by the compaction path. */
433
+ export async function withEventLogLockAsync<T>(
311
434
  eventsPath: string,
312
435
  fn: () => Promise<T>,
313
436
  options?: { timeoutMs?: number; staleMs?: number },
@@ -520,7 +643,11 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
520
643
  overflowHandled = true;
521
644
  try {
522
645
  const prepared = prepareCompaction(eventsPath);
523
- if (prepared) applyCompactionUnlocked(eventsPath, prepared);
646
+ // R7-2 (DR-04): LOCK STATE — `.alock` HELD. We are inside
647
+ // doAppendUnderLock, which runs under withEventLogLockAsync
648
+ // (appendEventAsync). The lock is NOT re-entrant, so the guarded
649
+ // apply must NOT re-acquire it — tail-merge only.
650
+ if (prepared) applyCompactionAppendGuarded(eventsPath, prepared, { holdsAppendLock: true });
524
651
  } catch (error) {
525
652
  logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
526
653
  }
@@ -624,7 +751,11 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
624
751
  if (tickAppendCounter(eventsPath) && needsRotation(eventsPath)) {
625
752
  try {
626
753
  const prepared = prepareCompaction(eventsPath);
627
- if (prepared) applyCompactionUnlocked(eventsPath, prepared);
754
+ // R7-2 (DR-04): LOCK STATE — `.alock` HELD. Same scope as the
755
+ // immediate-compact site above: doAppendUnderLock runs under
756
+ // withEventLogLockAsync (appendEventAsync). Tail-merge only; the
757
+ // sync-family bytes appended since the prepare-read are spliced.
758
+ if (prepared) applyCompactionAppendGuarded(eventsPath, prepared, { holdsAppendLock: true });
628
759
  } catch (error) {
629
760
  logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
630
761
  }
@@ -706,8 +837,17 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
706
837
  * buffered write path, which is the entire point of buffering — the previous
707
838
  * per-event fsync made buffer coalescing useless and added ~30ms/event on
708
839
  * tmpfs.
840
+ *
841
+ * R7-2 (DR-04, 2026-10-16): `locks.holdsAppendLock` declares the CALLING
842
+ * family's lock state so the inline overflow compaction below can pick the
843
+ * right guarded-apply mode — see the site comment and
844
+ * `applyCompactionAppendGuarded` (event-log-rotation.ts).
709
845
  */
710
- async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
846
+ async function appendEventBatchInsideLock(
847
+ eventsPath: string,
848
+ queue: BufferedAppend[],
849
+ locks?: { holdsAppendLock?: boolean },
850
+ ): Promise<void> {
711
851
  if (queue.length === 0) return;
712
852
  // P0-2: keep the per-path counter honest for cross-path rotation sampling.
713
853
  tickAppendCounter(eventsPath, queue.length);
@@ -732,7 +872,14 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
732
872
  if (preStat.size > MAX_EVENTS_BYTES) {
733
873
  try {
734
874
  const prepared = prepareCompaction(eventsPath);
735
- if (prepared) applyCompactionUnlocked(eventsPath, prepared);
875
+ // R7-2 (DR-04): LOCK STATE — MIXED, declared by the CALLER. This
876
+ // function is entered from BOTH families: the async flush
877
+ // (flushOneEventLogBuffer, inside withEventLogLockAsync → `.alock`
878
+ // HELD → holdsAppendLock: true, re-acquiring would self-timeout)
879
+ // and the EL-2 sync flush (flushBufferedQueuesSync, inside
880
+ // withEventLogLockSync → only `.mkdirlock` held → the helper
881
+ // must acquire `.alock` itself, nested in the R4-2 order).
882
+ if (prepared) applyCompactionAppendGuarded(eventsPath, prepared, locks);
736
883
  } catch (error) {
737
884
  logInternalError("event-log.batch-immediate-compact", error, `eventsPath=${eventsPath}`);
738
885
  }
@@ -926,7 +1073,14 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
926
1073
  // unbounded log growth → events silently dropped past 50MB).
927
1074
  try {
928
1075
  const prepared = prepareCompaction(eventsPath);
929
- if (prepared) applyCompactionUnlocked(eventsPath, prepared);
1076
+ // R7-2 (DR-04): LOCK STATE — `.mkdirlock` HELD, `.alock` NOT held.
1077
+ // appendEventInsideLock runs under withEventLogLockSync (appendEvent,
1078
+ // :366). The guarded apply acquires `.alock` nested under our
1079
+ // `.mkdirlock` (R4-2 lock order) so the inode-replacing write cannot
1080
+ // interleave with an in-flight async appender parked between open()
1081
+ // and appendFile(); the tail-merge splices sync-family bytes that
1082
+ // landed after the prepare-read.
1083
+ if (prepared) applyCompactionAppendGuarded(eventsPath, prepared);
930
1084
  } catch (error) {
931
1085
  logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
932
1086
  }
@@ -1017,9 +1171,12 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
1017
1171
  // locked compactEventLog would deadlock → 5s timeout → compaction never
1018
1172
  // ran → unbounded log growth → events silently dropped past 50MB. Use the
1019
1173
  // unlocked apply path instead (lock already held).
1174
+ // R7-2 (DR-04): LOCK STATE — `.mkdirlock` HELD, `.alock` NOT held (same
1175
+ // scope as the immediate-compact site above). applyCompactionAppendGuarded
1176
+ // acquires `.alock` itself (nested R4-2 order) + tail-merges.
1020
1177
  try {
1021
1178
  const prepared = prepareCompaction(eventsPath);
1022
- if (prepared) applyCompactionUnlocked(eventsPath, prepared);
1179
+ if (prepared) applyCompactionAppendGuarded(eventsPath, prepared);
1023
1180
  } catch (error) {
1024
1181
  logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
1025
1182
  }
@@ -1136,7 +1293,9 @@ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
1136
1293
  // turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
1137
1294
  // 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
1138
1295
  await withEventLogLockAsync(eventsPath, async () => {
1139
- await appendEventBatchInsideLock(eventsPath, queue);
1296
+ // R7-2 (DR-04): `.alock` HELD here (async family) — the batch's inline
1297
+ // compaction must not re-acquire it.
1298
+ await appendEventBatchInsideLock(eventsPath, queue, { holdsAppendLock: true });
1140
1299
  });
1141
1300
  } catch (error) {
1142
1301
  // Lock acquire failed — fail every queued item so callers can fall back.
@@ -1176,6 +1335,9 @@ export function flushBufferedQueuesSync(): void {
1176
1335
  // synchronous (fs.appendFileSync + fs.fsyncSync + persistSequence,
1177
1336
  // no awaits). Invoking without await runs the body synchronously and
1178
1337
  // returns a resolved Promise that we discard.
1338
+ // R7-2 (DR-04): only `.mkdirlock` is held here (sync family) — the
1339
+ // batch's inline compaction acquires `.alock` itself via the helper
1340
+ // (nested in the R4-2 lock order).
1179
1341
  void appendEventBatchInsideLock(eventsPath, queue);
1180
1342
  });
1181
1343
  } catch (error) {
@@ -1193,8 +1355,15 @@ export function flushBufferedQueuesSync(): void {
1193
1355
  // this hook lets T2 assert the fsync gate directly. Both production call paths
1194
1356
  // (async flush via withEventLogLockAsync and the EL-2 sync flush via
1195
1357
  // withEventLogLockSync) route through appendEventBatchInsideLock.
1196
- export function __test__appendBatchForUnitTest(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
1197
- return appendEventBatchInsideLock(eventsPath, queue);
1358
+ export function __test__appendBatchForUnitTest(
1359
+ eventsPath: string,
1360
+ queue: BufferedAppend[],
1361
+ locks?: { holdsAppendLock?: boolean },
1362
+ ): Promise<void> {
1363
+ // R7-2 (DR-04): `locks` mirrors the production flush paths' declarations for
1364
+ // tests that reproduce a specific family's lock context (default = no lock
1365
+ // held, matching a bare direct call — the helper then acquires `.alock`).
1366
+ return appendEventBatchInsideLock(eventsPath, queue, locks);
1198
1367
  }
1199
1368
 
1200
1369
  /**
@@ -336,8 +336,11 @@ function filterAliveEntries(entries: ActiveRunRegistryEntry[]): {
336
336
  } catch (err) {
337
337
  // Only treat "process does not exist" as dead (ESRCH/ENOENT).
338
338
  // EPERM means process is alive but protected; treat as alive.
339
+ // R7-15 (2026-10-16): both branches previously `return false`d,
340
+ // contradicting the comment and silently dropping live EPERM
341
+ // entries from the registry.
339
342
  const code = (err as NodeJS.ErrnoException).code;
340
- if (code !== "ESRCH" && code !== "ENOENT") return false;
343
+ if (code !== "ESRCH" && code !== "ENOENT") return true;
341
344
  return false;
342
345
  }
343
346
  // FIX Issue 2: Async runs older than the staleness horizon are stale even if
@@ -437,8 +440,12 @@ export function activeRunEntries(): ActiveRunRegistryEntry[] {
437
440
  if (manifest.async?.pid) {
438
441
  try {
439
442
  process.kill(manifest.async.pid, 0);
440
- } catch {
441
- continue;
443
+ } catch (err) {
444
+ // R7-15 (2026-10-16): only ESRCH/ENOENT mean the process is gone.
445
+ // EPERM (or any other code) means it exists but we cannot signal
446
+ // it — keep the entry, mirroring filterAliveEntries.
447
+ const code = (err as NodeJS.ErrnoException).code;
448
+ if (code === "ESRCH" || code === "ENOENT") continue;
442
449
  }
443
450
  }
444
451
  // Stale non-async run: live-session/scaffold runs older than 30 min
@@ -43,6 +43,11 @@ export interface VerificationEvidence {
43
43
  satisfied: boolean;
44
44
  commands: VerificationCommandResult[];
45
45
  notes?: string;
46
+ /** R4-5 (DR-02 wave 2): true when the contract REQUIRES a green level but
47
+ * configures zero verification commands — satisfaction can then only come
48
+ * from self-attestation (allowManualEvidence). Surfacing flag only: the
49
+ * satisfied logic and defaultVerificationContract are unchanged. */
50
+ decorative?: boolean;
46
51
  }
47
52
 
48
53
  export interface TaskOutputSchema {
@@ -162,7 +167,10 @@ export type PolicyDecisionReason =
162
167
  | "review_rejected"
163
168
  | "branch_stale"
164
169
  | "scope_mismatch"
165
- | "ineffective_worker";
170
+ | "ineffective_worker"
171
+ /** R4-4 (DR-02 wave 2): task terminalised needs_attention without ever
172
+ * submitting a result (surface-lost / no-yield paths). Notify-level only. */
173
+ | "task_needs_attention";
166
174
 
167
175
  export interface PolicyDecision {
168
176
  action: PolicyDecisionAction;
@@ -266,7 +266,9 @@ export class AgentsJobsBrowser {
266
266
  /** Transient header notice (e.g. why `p` did nothing) — auto-expires. */
267
267
  private notice: { text: string; until: number } | undefined;
268
268
  private pollTimer: ReturnType<typeof setInterval> | undefined;
269
- private readonly manifests = new Map<string, TeamRunManifest | null>();
269
+ /** Manifest reads, cached until the next force refresh (R5-3: misses are
270
+ * never stored — see manifestFor). */
271
+ private readonly manifests = new Map<string, TeamRunManifest>();
270
272
  /** U7: committed-state caches (default data path only). */
271
273
  private readonly manifestCache?: ManifestCache;
272
274
  private readonly snapshotCache?: RunSnapshotCache;
@@ -361,6 +363,10 @@ export class AgentsJobsBrowser {
361
363
 
362
364
  /** Reload the merged list when the TTL expired (or `force`). */
363
365
  refreshData(force = false): void {
366
+ // R5-3 (DR-02 wave 2): a force refresh invalidates the manifest cache too
367
+ // — manifests used to live for the browser's whole lifetime, so a manifest
368
+ // created/renamed while the overlay was open never became visible.
369
+ if (force) this.manifests.clear();
364
370
  const ttl = this.options.refreshTtlMs ?? REFRESH_TTL_MS_DEFAULT;
365
371
  const at = this.nowMs();
366
372
  if (!force && ttl > 0 && at - this.cachedAt < ttl) return;
@@ -519,16 +525,21 @@ export class AgentsJobsBrowser {
519
525
  }
520
526
 
521
527
  private manifestFor(runId: string): TeamRunManifest | null {
522
- if (this.manifests.has(runId)) return this.manifests.get(runId) ?? null;
523
- let manifest: TeamRunManifest | null = null;
528
+ const cached = this.manifests.get(runId);
529
+ if (cached) return cached;
530
+ // R5-3 (DR-02 wave 2): a miss (manifest not found / transient read
531
+ // failure — loadRunManifestById takes no run lock, so a read can race a
532
+ // write) is NEVER cached: returning null without touching the map makes
533
+ // the next call retry instead of pinning the miss for the overlay's
534
+ // lifetime. Successful loads still cache until the next force refresh.
524
535
  try {
525
536
  const loaded = loadRunManifestById(this.options.cwd, runId); // NOTE: no run lock — best-effort read, mirrors viewers.ts
526
- manifest = loaded ? loaded.manifest : null;
537
+ if (!loaded) return null;
538
+ this.manifests.set(runId, loaded.manifest);
539
+ return loaded.manifest;
527
540
  } catch {
528
- manifest = null;
541
+ return null;
529
542
  }
530
- this.manifests.set(runId, manifest);
531
- return manifest;
532
543
  }
533
544
 
534
545
  private recordFor(runId: string, taskId: string): CrewAgentRecord | undefined {
@@ -559,7 +570,10 @@ export class AgentsJobsBrowser {
559
570
  return lines;
560
571
  }
561
572
  const record = entry.record ?? this.recordFor(entry.runId, entry.taskId);
562
- const manifest = this.manifestFor(entry.runId);
573
+ // R5-3: prefer the FRESH entry.manifest the list pipeline already
574
+ // carries (re-read on every refresh tick) — manifestFor is the fallback
575
+ // for provider-based entries without one.
576
+ const manifest = entry.manifest ?? this.manifestFor(entry.runId);
563
577
  if (!record || !manifest) {
564
578
  return [
565
579
  `▸ ${entry.taskId ?? "?"} (${entry.role ?? "?"})`,
@@ -2,7 +2,7 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
2
2
  import { type LiveAgentHandle, listLiveAgents, listLiveAgentsByWorkspace } from "../../runtime/live-session/live-agent-manager.ts";
3
3
  import { formatCost } from "../../state/usage.ts";
4
4
  import { visibleWidth } from "../../utils/visual.ts";
5
- import { formatCount, formatDuration } from "../format-helpers.ts";
5
+ import { formatCount, formatDuration, truncLine } from "../format-helpers.ts";
6
6
  import { computeLiveDurationMs } from "../live-duration.ts";
7
7
  import { overflowHint, statusIcon } from "../rail.ts";
8
8
  import type { RunDashboardOptions } from "../run-dashboard.ts";
@@ -99,7 +99,10 @@ function describeActivity(handle: LiveAgentHandle): string {
99
99
  .split("\n")
100
100
  .find((l) => l.trim())
101
101
  ?.trim() ?? "";
102
- return line.length > 40 ? line.slice(0, 40) + "…" : line;
102
+ // O-5 (DR-04): grapheme-aware truncation at the original budget (≤40
103
+ // content columns + 1 ellipsis column) — the ad-hoc slice(0, 40) split
104
+ // surrogate pairs (emoji → stray U+FFFD).
105
+ return truncLine(line, 41);
103
106
  }
104
107
  return "thinking…";
105
108
  }