pi-crew 0.11.21 → 0.11.22

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 (82) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.mjs +537 -526
  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.ts +24 -2
  16. package/src/prompt/inbox-poll.ts +77 -25
  17. package/src/prompt/message-tool.ts +62 -4
  18. package/src/prompt/prompt-runtime.ts +82 -32
  19. package/src/runtime/broker/crew-broker-client.ts +74 -16
  20. package/src/runtime/broker/crew-broker.ts +111 -77
  21. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  22. package/src/runtime/broker/wait-status-cache.ts +14 -5
  23. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  24. package/src/runtime/child-pi/child-pi.ts +20 -2
  25. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  26. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  27. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  28. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  29. package/src/runtime/crew-agent-records.ts +7 -1
  30. package/src/runtime/delivery-coordinator.ts +30 -0
  31. package/src/runtime/dwf-state-store.ts +41 -7
  32. package/src/runtime/finalize-run.ts +13 -2
  33. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  34. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  35. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  36. package/src/runtime/live-session/embedded-tier.ts +3 -1
  37. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  38. package/src/runtime/model/model-fallback.ts +23 -1
  39. package/src/runtime/output/pi-json-output.ts +26 -1
  40. package/src/runtime/policy-engine.ts +18 -0
  41. package/src/runtime/process/zombie-scanner.ts +78 -14
  42. package/src/runtime/process-status.ts +6 -1
  43. package/src/runtime/recovery/crash-recovery.ts +170 -28
  44. package/src/runtime/rpc/rpc-worker.ts +44 -1
  45. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
  46. package/src/runtime/scheduling/scheduler.ts +74 -11
  47. package/src/runtime/scratchpad/engine.ts +26 -3
  48. package/src/runtime/settings-store.ts +22 -2
  49. package/src/runtime/surface/tmux-provider.ts +24 -3
  50. package/src/runtime/task-health.ts +16 -1
  51. package/src/runtime/task-output-context.ts +143 -71
  52. package/src/runtime/task-packet.ts +70 -17
  53. package/src/runtime/task-runner/child-executor.ts +27 -0
  54. package/src/runtime/task-runner/output-splitter.ts +79 -16
  55. package/src/runtime/task-runner/post-execution.ts +75 -15
  56. package/src/runtime/task-runner/state-helpers.ts +92 -2
  57. package/src/runtime/usage-tracker.ts +11 -0
  58. package/src/runtime/verification/green-contract.ts +17 -0
  59. package/src/runtime/verification/verification-gates.ts +24 -6
  60. package/src/runtime/workspace-lock.ts +53 -0
  61. package/src/schema/team-tool-schema.ts +10 -4
  62. package/src/state/coordination/mailbox.ts +133 -38
  63. package/src/state/decision-ledger.ts +56 -8
  64. package/src/state/event-log/event-log-rotation.ts +83 -5
  65. package/src/state/event-log/event-log.ts +181 -12
  66. package/src/state/stores/active-run-registry.ts +10 -3
  67. package/src/state/types.ts +9 -1
  68. package/src/ui/agents-jobs-browser.ts +22 -8
  69. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  70. package/src/ui/events-state-source.ts +67 -28
  71. package/src/ui/inline-panel/agent-pane.ts +13 -6
  72. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  73. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  74. package/src/ui/inline-panel/index.ts +21 -1
  75. package/src/ui/live-run-sidebar.ts +14 -3
  76. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  77. package/src/ui/powerbar-publisher.ts +6 -3
  78. package/src/ui/rail.ts +7 -1
  79. package/src/ui/render-scheduler.ts +53 -1
  80. package/src/ui/run-action-dispatcher.ts +68 -20
  81. package/src/ui/status-colors.ts +9 -0
  82. package/src/utils/env-filter.ts +8 -0
@@ -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
  }
@@ -120,6 +120,19 @@ interface SourceInternals {
120
120
  forceDispose(): void;
121
121
  }
122
122
 
123
+ /** O-7 (0.11.22): a scan result of "empty" only counts as a committed compaction
124
+ * when the path is a genuine regular file. A directory (or any non-regular
125
+ * object) that some readers tolerate into an empty cursor must be treated as a
126
+ * FAILED scan so the guard behaves identically on POSIX and Windows. Returns
127
+ * false when statSync throws (missing file shares the same transient policy). */
128
+ function isRegularFile(eventsPath: string): boolean {
129
+ try {
130
+ return fs.statSync(eventsPath).isFile();
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
123
136
  function createEventsStateSource(
124
137
  runId: string,
125
138
  eventsPath: string,
@@ -153,8 +166,11 @@ function createEventsStateSource(
153
166
  }
154
167
 
155
168
  /** STORAGE SCAN (spec U7): one parse of the (tail-capped) log to the
156
- * current seq. No event replay — the root is rebuilt from storage. */
157
- function rootBuild(): void {
169
+ * current seq. No event replay — the root is rebuilt from storage.
170
+ * O-7 (DR-04 R7): returns whether the scan SUCCEEDED — on failure the
171
+ * committed state, the inode/size stamps AND `resynced`/`version` are all
172
+ * left untouched so the next poll re-detects the rewrite and retries. */
173
+ function rootBuild(): boolean {
158
174
  let scanned: TeamEvent[] | undefined;
159
175
  let scannedSeq = 0;
160
176
  try {
@@ -171,14 +187,23 @@ function createEventsStateSource(
171
187
  scanned.length === 0 &&
172
188
  scannedSeq === 0 &&
173
189
  (seq > 0 || events.length > 0) &&
174
- !fs.existsSync(eventsPath)
190
+ !isRegularFile(eventsPath)
175
191
  ) {
176
192
  // readEventsCursor reports a MISSING log as an empty cursor — but a
177
- // log that carried committed events a beat ago and now does not exist
178
- // is a transient disappearance (run dir moving / the rename→wx-create
179
- // window of rotation), NOT a compaction to empty. Keep the committed
180
- // state — same policy as poll()'s miss-file branch — and keep the inode
181
- // stamp so the next poll re-detects the rewrite and retries the resync.
193
+ // log that carried committed events a beat ago and now is not a
194
+ // readable regular file is a transient disappearance (run dir moving /
195
+ // the rename→wx-create window of rotation) OR an unreadable anomaly
196
+ // (a directory/torn state some readers tolerate into an empty result),
197
+ // NOT a compaction to empty. Keep the committed state — same policy as
198
+ // poll()'s miss-file branch — and keep the inode stamp so the next poll
199
+ // re-detects the rewrite and retries the resync.
200
+ //
201
+ // O-7 platform parity (0.11.22 CI): this guard used `!fs.existsSync`,
202
+ // which is TRUE for a directory — Windows' reader tolerates an EISDIR
203
+ // open into an empty cursor while POSIX throws, so the same replacement
204
+ // committed an empty root on Windows and returned false on Linux.
205
+ // Requiring a regular file makes the failed-scan verdict OS-uniform
206
+ // while a genuine compaction-to-empty (regular empty file) still commits.
182
207
  scanned = undefined;
183
208
  }
184
209
  // Stamp the inode/size the root was built from so the FIRST poll after
@@ -186,30 +211,43 @@ function createEventsStateSource(
186
211
  // instead of learning the inode one poll late. Stamp ONLY on a successful
187
212
  // scan — on a failed scan keep the previous stamp so the next poll
188
213
  // re-detects the rewrite and RETRIES the resync.
189
- if (scanned !== undefined) {
190
- try {
191
- const stat = fs.statSync(eventsPath);
192
- lastIno = stat.ino;
193
- lastSize = stat.size;
194
- } catch {
195
- lastIno = -1;
196
- lastSize = -1;
197
- }
198
- events = scanned.slice(-windowSize);
199
- seq = scannedSeq;
214
+ if (scanned === undefined) {
215
+ // O-7: failed scan — do NOT claim a fresh root: `resynced`/`version`
216
+ // stay untouched and no resync frame is emitted (see resyncAttempt).
217
+ return false;
218
+ }
219
+ try {
220
+ const stat = fs.statSync(eventsPath);
221
+ lastIno = stat.ino;
222
+ lastSize = stat.size;
223
+ } catch {
224
+ lastIno = -1;
225
+ lastSize = -1;
200
226
  }
227
+ events = scanned.slice(-windowSize);
228
+ seq = scannedSeq;
201
229
  resynced = true;
202
230
  version += 1;
231
+ return true;
203
232
  }
204
233
 
205
- function resync(): EventsStateSnapshot {
206
- const fromSeq = seq;
207
- rootBuild();
234
+ /** Root-replace and emit ONE resync frame. Returns false (state untouched,
235
+ * no frame) when the storage scan failed — O-7: a failed resync must not
236
+ * count as a committed frame. `stats.resyncs` counts ATTEMPTS either way
237
+ * (MINOR-1 contract: "the resync attempt still counts"). */
238
+ function resyncAttempt(): boolean {
208
239
  stats.resyncs += 1;
240
+ const fromSeq = seq;
241
+ if (!rootBuild()) return false;
209
242
  stats.frames += 1;
210
243
  const frame: EventsStateFrame = { fromSeq, toSeq: seq, events: [...events], version, resync: true };
211
244
  stats.eventsDelivered += frame.events.length;
212
245
  emit(frame);
246
+ return true;
247
+ }
248
+
249
+ function resync(): EventsStateSnapshot {
250
+ resyncAttempt();
213
251
  return snapshot();
214
252
  }
215
253
 
@@ -224,10 +262,11 @@ function createEventsStateSource(
224
262
  try {
225
263
  const stat = fs.statSync(eventsPath);
226
264
  if (lastIno !== -1 && (stat.ino !== lastIno || stat.size < lastSize)) {
227
- lastIno = stat.ino;
228
- lastSize = stat.size;
229
- resync();
230
- return 1;
265
+ // O-7: do NOT stamp the NEW inode/size before the resync — if the
266
+ // scan inside fails, the OLD stamps are exactly what makes the
267
+ // next poll re-detect the rewrite and retry it. rootBuild()
268
+ // stamps after a successful scan.
269
+ return resyncAttempt() ? 1 : 0;
231
270
  }
232
271
  lastIno = stat.ino;
233
272
  lastSize = stat.size;
@@ -262,8 +301,8 @@ function createEventsStateSource(
262
301
  backlog = false;
263
302
  }
264
303
  if (backlog) {
265
- resync();
266
- return committed + 1;
304
+ // O-7: count the resync frame only when the root scan succeeded.
305
+ return committed + (resyncAttempt() ? 1 : 0);
267
306
  }
268
307
  break;
269
308
  }
@@ -86,6 +86,9 @@ export class CrewAgentPane {
86
86
  private readonly paneOptions: CrewAgentPaneOptions | undefined;
87
87
  /** Current agent target, read fresh from the panel store each render. */
88
88
  private currentTaskId: string | undefined;
89
+ /** Run of {@link currentTaskId} — F3: the pane's switch guard is run-scoped
90
+ * so the same-named task in another run resets the transcript state. */
91
+ private currentRunId: string | undefined;
89
92
  /** Cached manifest for the current run, to avoid re-reading on every tick. */
90
93
  private cachedRunId: string | undefined;
91
94
  private cachedManifest: TeamRunManifest | undefined;
@@ -294,11 +297,15 @@ export class CrewAgentPane {
294
297
  const viewed = getViewedAgent();
295
298
  if (!viewed) return [];
296
299
 
297
- // Detect agent switch: reset the transcript cursor so the pane shows
298
- // the new agent's full history instead of continuing from the old cursor.
299
- if (this.currentTaskId !== viewed.taskId) {
300
- resetAgentTranscriptCursor(viewed.taskId);
300
+ // Detect agent switch (F3: run OR task — task ids are positional per
301
+ // run, so a taskId-only guard let a same-named task in another run
302
+ // reuse the stale cursor/buffer): reset the transcript cursor so the
303
+ // pane shows the new agent's full history instead of continuing from
304
+ // the old cursor.
305
+ if (this.currentTaskId !== viewed.taskId || this.currentRunId !== viewed.runId) {
306
+ resetAgentTranscriptCursor(viewed.runId, viewed.taskId);
301
307
  this.currentTaskId = viewed.taskId;
308
+ this.currentRunId = viewed.runId;
302
309
  this.scrollBack = 0;
303
310
  this.lastItems = [];
304
311
  this.lastTranscriptReadAt = 0;
@@ -371,8 +378,8 @@ export class CrewAgentPane {
371
378
  this.unsubscribePanel();
372
379
  this.cachedManifest = undefined;
373
380
  this.cachedRunId = undefined;
374
- // Free the per-task transcript ring buffer (≤500 items with full
381
+ // Free the per-run::task transcript ring buffer (≤500 items with full
375
382
  // message bodies) instead of retaining it for process lifetime.
376
- if (this.currentTaskId) resetAgentTranscriptCursor(this.currentTaskId);
383
+ if (this.currentRunId && this.currentTaskId) resetAgentTranscriptCursor(this.currentRunId, this.currentTaskId);
377
384
  }
378
385
  }