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
@@ -0,0 +1,92 @@
1
+ /**
2
+ * shadow-lifecycle.ts — broker-owned lifecycle for delegate grandchild SHADOW
3
+ * task records (RR-012, findings F03/F16; ADR-5 §1/§3 S1#1).
4
+ *
5
+ * The `delegate.request` handler (crew-broker.ts) mints a `gc-*` shadow task
6
+ * (agent "delegate", NO stepId) so the subId identity gets a real depth/role
7
+ * entry. The record is NOT a workflow task: it is managed by the EXTERNAL
8
+ * grandchild spawner (the broker), never by the workflow scheduler.
9
+ * isDelegateShadowTask() is the single discriminator the scheduler uses to
10
+ * keep shadow records out of DAG/batch selection — they carry no stepId, and
11
+ * dispatching one would throw ResourceNotFound in findStep()/findAgent()
12
+ * (dispatch-batch.ts; RR-012 §3 reachability proof in
13
+ * test/unit/runtime/broker/shadow-task-dag-readiness.test.ts).
14
+ *
15
+ * BR-09 (discriminator correctness): the marker is STRUCTURAL — the absence of
16
+ * `stepId` — NOT the agent name. Every scheduler-managed task sets
17
+ * `stepId: step.id` (state-store.ts materializeTasks), so "no stepId" means
18
+ * "not a workflow task". The previous test (`agent === "delegate"`) filtered
19
+ * out any LEGITIMATE workflow task whose resolved agent happens to be named
20
+ * `delegate` — such a task never entered a batch, was never dispatched, and
21
+ * finalize-run then marked the whole run `blocked`. (`delegate` is deliberately
22
+ * NOT in PROTECTED_AGENT_NAMES, so the name itself carries no reserved
23
+ * meaning.) Backward compatible both ways: legacy persisted shadows also lack
24
+ * `stepId` (still excluded) and legacy workflow tasks carry one (still
25
+ * dispatched); no schema change.
26
+ *
27
+ * KNOWN overlap (cold-verify): `reconstructTasksFromEventLog`
28
+ * (manifest-io.ts, ST-4 corruption recovery) also emits tasks WITHOUT `stepId`
29
+ * — those are classified as shadows and excluded from dispatch selection.
30
+ * That is fail-closed, and strictly better than the pre-guard behavior
31
+ * (findStep would throw ResourceNotFound and kill the batch): reconstructed
32
+ * tasks carry agent "unknown" and no step, so they cannot be dispatched
33
+ * anyway. Do NOT reuse this predicate to mean "broker shadow" outside
34
+ * scheduler selection.
35
+ *
36
+ * F16 lifecycle contract (broker-owned; every write under the run lock):
37
+ * queued → saved by the admission write (crew-broker.ts handleDelegateRequest)
38
+ * running → promoteShadowToRunning() from the spawner's onSpawn hook the
39
+ * moment the grandchild process exists (pid !== null). Idempotent:
40
+ * only a record still "queued" is promoted — a terminal flip or a
41
+ * stale-reconciler cancel always wins over a late promote.
42
+ * completed | failed → the unconditional terminal flip when the spawner
43
+ * settles (success, failure, error, timeout — every path).
44
+ */
45
+
46
+ import { withRunLockSync } from "../../../state/coordination/locks.ts";
47
+ import { loadRunManifestById, saveRunTasks } from "../../../state/stores/state-store.ts";
48
+ import type { TeamTaskState } from "../../../state/types.ts";
49
+ import { logInternalError } from "../../../utils/internal-error.ts";
50
+
51
+ /** Shadow-task discriminator (RR-012 + BR-09): a task record managed by the
52
+ * external delegate-broker spawner rather than the workflow scheduler.
53
+ *
54
+ * The marker is STRUCTURAL: shadow records are persisted with NO `stepId`
55
+ * (the broker's shadow literal in crew-broker.ts sets none), while every
56
+ * scheduler-materialized task sets `stepId: step.id`. A record without a
57
+ * stepId cannot be dispatched anyway — `findStep()` matches on `stepId` and
58
+ * throws ResourceNotFound — so "no stepId" is exactly the set the scheduler
59
+ * must skip. The agent name is NOT part of the predicate (BR-09: a real
60
+ * workflow task may legitimately resolve to an agent named `delegate`). */
61
+ export function isDelegateShadowTask(task: Pick<TeamTaskState, "id" | "agent" | "stepId">): boolean {
62
+ return task.stepId === undefined;
63
+ }
64
+
65
+ /** F16: queued→running promote, called from the spawner's onSpawn hook when
66
+ * the grandchild process exists. Best-effort by design: a failure logs and
67
+ * leaves the record as-is — the unconditional terminal flip on settle still
68
+ * runs, so a promote failure can never strand a record in "queued" past the
69
+ * settle (and depth-3 admission merely stays blocked until then, which is
70
+ * the fail-closed direction). */
71
+ export function promoteShadowToRunning(brokerCwd: string, runId: string, subId: string): void {
72
+ try {
73
+ const fresh = loadRunManifestById(brokerCwd, runId);
74
+ if (!fresh) return;
75
+ withRunLockSync(fresh.manifest, () => {
76
+ const latest = loadRunManifestById(brokerCwd, runId);
77
+ if (!latest) return;
78
+ const shadow = latest.tasks.find((t) => t.id === subId);
79
+ if (shadow?.status !== "queued") return;
80
+ saveRunTasks(
81
+ latest.manifest,
82
+ latest.tasks.map((t) => (t.id === subId ? { ...t, status: "running" as const } : t)),
83
+ );
84
+ });
85
+ } catch (err) {
86
+ logInternalError(
87
+ "crew-broker.delegate.promote-shadow",
88
+ err instanceof Error ? err : new Error(String(err)),
89
+ `runId=${runId} subId=${subId}`,
90
+ );
91
+ }
92
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * wait-status-cache.ts — R10-3: mtime+size-gated load cache for task.waitStatus.
3
3
  *
4
- * Problem (docs/refactor-plan.review.md §Round 10, R10-3): the broker's
4
+ * Problem (docs/archive/refactor-plan.review.md §Round 10, R10-3): the broker's
5
5
  * task.waitStatus poll loop ticks every 200ms per waiter and each tick calls
6
6
  * loadRunManifestById() — stat manifest + stat tasks + (on the state-store
7
7
  * manifest-cache miss, which every write invalidates via generation bump) a
@@ -20,7 +20,7 @@
20
20
  * `deps` (values) + `state` (getters/setters over the runChildPi-local flags)
21
21
  * so behavior is preserved byte-for-byte.
22
22
  *
23
- * R6-F1 (refactor-plan.review.md §ROUND 6): clearAll() MUST cover all six
23
+ * R6-F1 (docs/archive/refactor-plan.review.md §ROUND 6): clearAll() MUST cover all six
24
24
  * constructs — previously cancelHardKill was a local const inside abort() and
25
25
  * was NOT cleared by clearChildPiTimeouts(), leaking a 200 ms unref'd timer per
26
26
  * cancel on short-lived children.
@@ -131,6 +131,54 @@ export async function runMockChildPi(
131
131
  stderr: "[MOCK] rate limit: mock failure",
132
132
  };
133
133
 
134
+ // RR-013 (F04) test seam: reproduce the MuxSurface A1 degrade result WITHOUT a
135
+ // real multiplexer. child-executor's `childResult.surface?.degraded` branch is
136
+ // the ONLY producer of TaskExecutionResult.surfaceLost, and runTeamTask offers
137
+ // no seam to inject `surface.providers` (child-executor's runWorker() call
138
+ // passes no `surface` field), so no other mock mode can reach that branch.
139
+ //
140
+ // SCOPE NOTE: this mode does NOT simulate `classifyOnExit` (the 2s window) — it
141
+ // starts from the exact point where classifyOnExit already returned
142
+ // `degraded`. That is the correct scope: the F04 defect lives at the
143
+ // child-executor → task-runner → finalizer boundary, not in classify (which is
144
+ // covered by degrade.test.ts + child-pi-surface.test.ts).
145
+ //
146
+ // Guarded by the existing PI_CREW_ALLOW_MOCK parent-only check above, so it
147
+ // cannot be activated from a child worker.
148
+ if (mock === "surface-degraded") {
149
+ return {
150
+ exitCode: 0,
151
+ stdout: "",
152
+ stderr: "",
153
+ rawFinalText: "",
154
+ surface: {
155
+ kind: "tmux",
156
+ paneId: "%9",
157
+ scriptPath: "/tmp/pi-crew-mock-launch.sh",
158
+ degraded: {
159
+ cause: "pane-closed",
160
+ exitReason: "pane-closed",
161
+ classifiedAt: new Date().toISOString(),
162
+ },
163
+ },
164
+ };
165
+ }
166
+
167
+ // RR-013 (AC-2) test seam: the ONLY result channel carrying text is
168
+ // `rawFinalText` (stdout/transcript are empty). This reproduces the compaction
169
+ // scenario the TaskExecutionResult.rawFinalText docstring describes —
170
+ // parsedOutput.finalText and finalStdout are both empty, so the spec-evidence
171
+ // footer union can only see the text if rawFinalText survives the
172
+ // child-executor → task-runner → finalizer boundary.
173
+ if (mock === "raw-final-text-only") {
174
+ return {
175
+ exitCode: 0,
176
+ stdout: "",
177
+ stderr: "",
178
+ rawFinalText: "RR-013 boundary probe result.\n\nSPEC-EVIDENCE:\nacc-1: ran the boundary check\n",
179
+ };
180
+ }
181
+
134
182
  // E2E fallback-chain fixture: invocation #1 returns a SILENT retryable
135
183
  // failure (exit code 0, no real assistant text, message_end carries a
136
184
  // retryable-pattern errorMessage). Invocation #2+ delegates to the
@@ -1,9 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { atomicWriteJson, atomicWriteJsonCoalesced, flushPendingAtomicWrites, readJsonFile } from "../state/atomic-write.ts";
4
+ import {
5
+ atomicWriteJson,
6
+ atomicWriteJsonCoalesced,
7
+ cancelPendingCoalescedWriteForPath,
8
+ flushPendingAtomicWrites,
9
+ peekPendingCoalescedWrite,
10
+ readJsonFile,
11
+ } from "../state/atomic-write.ts";
5
12
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
13
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
14
+ import { readLinesSince } from "../utils/incremental-reader.ts";
7
15
  import { logInternalError } from "../utils/internal-error.ts";
8
16
  import { redactSecretString, redactSecrets } from "../utils/redaction.ts";
9
17
  import { assertSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
@@ -237,7 +245,9 @@ function withAgentsLock<T>(manifest: TeamRunManifest, fn: () => T): T {
237
245
  break;
238
246
  } catch (error) {
239
247
  const code = (error as NodeJS.ErrnoException).code;
240
- if (code !== "EEXIST" && code !== "EISDIR") throw error;
248
+ // EPERM/EACCES/EBUSY: Windows cross-process contention (another handle
249
+ // holds the lock open) — retry like EEXIST, bounded by the deadline.
250
+ if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw error;
241
251
  if (code === "EISDIR") {
242
252
  try {
243
253
  fs.rmSync(filePath, { recursive: true, force: true });
@@ -282,19 +292,22 @@ function setAsyncAgentReaderCache(
282
292
  }
283
293
 
284
294
  export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
285
- // 2.5: ensure intra-process coalesced writes are visible to subsequent
286
- // readers in the same process. Cross-process readers still see the file
287
- // after at most one coalesce window (250 ms).
288
- // R10-2: scoped flush — readCrewAgents only reads agents.json, so it must
289
- // not drain unrelated coalesced writes (tasks.json of live runs, other
290
- // runs' agents.json) process-wide on every read.
291
- flushPendingAtomicWrites(agentsPath(manifest));
295
+ // F06 / RR-017: read-after-write WITHOUT forcing the pending coalesced write
296
+ // to disk. `upsertCrewAgent` always reads first, so the previous scoped
297
+ // `flushPendingAtomicWrites(agentsPath)` (a scoped flush IS a write when an
298
+ // entry exists — atomic-write.ts:1147) turned every non-terminal upsert into
299
+ // a durable agents.json rewrite, destroying the very coalescing window the
300
+ // caller had just created (measured: 20 progress upserts → 19 agents.json
301
+ // renames). The buffered value is byte-for-byte what a flush would have
302
+ // written, so overlaying it preserves the "intra-process readers see the
303
+ // latest data" contract (and cross-process readers still see the file within
304
+ // one coalesce window) with zero I/O.
305
+ const agentsFilePath = agentsPath(manifest);
306
+ const pending = peekPendingCoalescedWrite<CrewAgentRecord[]>(agentsFilePath);
292
307
  try {
293
- const records = readJsonFileCoalesced(
294
- agentsPath(manifest),
295
- AGENT_READER_TTL_MS,
296
- () => readJsonFile<CrewAgentRecord[]>(agentsPath(manifest)) ?? [],
297
- );
308
+ const records =
309
+ pending ??
310
+ readJsonFileCoalesced(agentsFilePath, AGENT_READER_TTL_MS, () => readJsonFile<CrewAgentRecord[]>(agentsFilePath) ?? []);
298
311
  // Validate schema and deduplicate by id to handle concurrent write conflicts
299
312
  const seen = new Set<string>();
300
313
  const deduped = records.filter((r) => {
@@ -368,6 +381,122 @@ export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<Cr
368
381
  return inFlight;
369
382
  }
370
383
 
384
+ // ---------------------------------------------------------------------------
385
+ // F06 / RR-017: dirty tracking for per-task status.json writes.
386
+ //
387
+ // `saveCrewAgents`/`saveCrewAgentsCoalesced` used to loop over EVERY record and
388
+ // write its status.json, with no notion of which records actually changed. The
389
+ // measured cost of updating ONE of four `completed` records was 6 renames / 12
390
+ // fsyncs, and the three unchanged records each paid a full durable rename they
391
+ // had no reason to pay.
392
+ //
393
+ // SCOPE OF THE SKIP — durable (terminal) writes only, deliberately:
394
+ // - a coalesced write is buffered in RAM and collapses per path, so skipping
395
+ // it saves no I/O; and a skip there could not be trusted anyway, since the
396
+ // buffered value may never reach disk (the memo would then claim a write
397
+ // that a crash erased). Coalesced writes therefore stay unconditional.
398
+ // - a DURABLE write is only skipped when the exact same bytes are already
399
+ // durably on disk: the memo is written ONLY by durable writes, and the skip
400
+ // is re-validated against the file actually existing. So a skip can never
401
+ // lose durability — it can only avoid re-writing identical bytes.
402
+ // Durability is otherwise untouched: agents.json keeps full durability on every
403
+ // save, terminal status keeps its deliberate DOUBLE write (H2/F4 — the second
404
+ // write is unconditional and is NOT removed here), and non-terminal status keeps
405
+ // the coalesced best-effort path.
406
+ // ---------------------------------------------------------------------------
407
+
408
+ /**
409
+ * Last record content (redacted, JSON-serialized) written DURABLY per status
410
+ * path.
411
+ *
412
+ * INVARIANT (single-writer, RM-03 / review MINOR 4): this memo is a
413
+ * PROCESS-GLOBAL Map with no locking, and it is only correct because exactly
414
+ * ONE writer owns a given `statusPath` at a time. Two concurrent writers for
415
+ * the same path would each see a stale memo and could skip a durable write
416
+ * that the other expects to have happened. The single-writer property is
417
+ * provided by the run-lock / task-claim protocol (see
418
+ * src/state/coordination/locks.ts): a task is claimed by one agent, and only
419
+ * that agent calls saveCrewAgents for its statusPath.
420
+ *
421
+ * Do NOT add a second writer for the same statusPath without first replacing
422
+ * this memo with a lock-protected (or content-addressed) structure — and do
423
+ * NOT rely on the memo across processes (it is per-process; cross-process
424
+ * readers see the file, which is why needsDurableStatusWrite re-checks
425
+ * fs.existsSync rather than trusting the memo).
426
+ *
427
+ * Bounded by LAST_WRITTEN_STATUS_MAX_ENTRIES with FIFO eviction, so an evicted
428
+ * entry merely causes one redundant durable write (safe), never a skipped one.
429
+ */
430
+ const lastWrittenStatus = new Map<string, string>();
431
+ const LAST_WRITTEN_STATUS_MAX_ENTRIES = 2048;
432
+
433
+ /** @internal Test-only: how many records a save actually persisted status for. */
434
+ let lastSaveStatusWriteCount = 0;
435
+ /** @internal Test-only: read the counter from the most recent save call. */
436
+ export function __test__lastAgentStatusWriteCount(): number {
437
+ return lastSaveStatusWriteCount;
438
+ }
439
+
440
+ /** @internal Test-only: drop the durable-write memo. */
441
+ export function __test__clearWrittenStatusMemo(): void {
442
+ lastWrittenStatus.clear();
443
+ }
444
+
445
+ function statusSnapshotKey(record: CrewAgentRecord): string {
446
+ return JSON.stringify(redactSecrets(record));
447
+ }
448
+
449
+ /**
450
+ * True when `record`'s status.json must be written durably: either its content
451
+ * differs from the last durable snapshot for that path, or the file is gone
452
+ * (someone else deleted it — never trust the memo over the filesystem).
453
+ */
454
+ function needsDurableStatusWrite(manifest: TeamRunManifest, record: CrewAgentRecord): boolean {
455
+ const statusPath = agentStatusPath(manifest, record.taskId);
456
+ const snapshot = statusSnapshotKey(record);
457
+ if (lastWrittenStatus.get(statusPath) !== snapshot) return true;
458
+ return !fs.existsSync(statusPath);
459
+ }
460
+
461
+ function rememberWrittenStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
462
+ const statusPath = agentStatusPath(manifest, record.taskId);
463
+ if (lastWrittenStatus.has(statusPath)) lastWrittenStatus.delete(statusPath);
464
+ lastWrittenStatus.set(statusPath, statusSnapshotKey(record));
465
+ while (lastWrittenStatus.size > LAST_WRITTEN_STATUS_MAX_ENTRIES) {
466
+ const oldest = lastWrittenStatus.keys().next().value;
467
+ if (oldest === undefined) break;
468
+ lastWrittenStatus.delete(oldest);
469
+ }
470
+ }
471
+
472
+ /** Drop the memo for a status path whose file was deleted (cancel/removal). */
473
+ function forgetWrittenStatus(statusPath: string): void {
474
+ lastWrittenStatus.delete(statusPath);
475
+ }
476
+
477
+ function writeStatusForRecords(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
478
+ lastSaveStatusWriteCount = 0;
479
+ for (const record of records) {
480
+ // H2 (2026-08-10): per-task status.json is a DENORMALIZED read
481
+ // optimization for the dashboard/notifier, not an authoritative
482
+ // record — agents.json + events.jsonl cover crash recovery.
483
+ // Previously EVERY record (including running/queued progress
484
+ // snapshots) got a full fsync: N+1 fsyncs per saveCrewAgents call
485
+ // (50-task team ≈ 750ms blocking). Only TERMINAL records keep
486
+ // full durability (F4: notifier/dashboard must see the final
487
+ // state immediately); non-terminal per-task status is best-effort
488
+ // coalesced like the upsertCrewAgent non-terminal path.
489
+ if (TERMINAL_AGENT_STATUSES.has(record.status ?? "")) {
490
+ if (!needsDurableStatusWrite(manifest, record)) continue;
491
+ lastSaveStatusWriteCount++;
492
+ writeCrewAgentStatus(manifest, record);
493
+ } else {
494
+ lastSaveStatusWriteCount++;
495
+ writeCrewAgentStatusCoalesced(manifest, record);
496
+ }
497
+ }
498
+ }
499
+
371
500
  export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
372
501
  // P0-3: flush any pending coalesced (best-effort) write first so a stale
373
502
  // debounced progress snapshot can't clobber this durable write.
@@ -379,22 +508,7 @@ export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentReco
379
508
  // durability.
380
509
  atomicWriteJson(filePath, redactSecrets(records));
381
510
  asyncAgentReaderCache.delete(filePath);
382
- for (const record of records) {
383
- // H2 (2026-08-10): per-task status.json is a DENORMALIZED read
384
- // optimization for the dashboard/notifier, not an authoritative
385
- // record — agents.json + events.jsonl cover crash recovery.
386
- // Previously EVERY record (including running/queued progress
387
- // snapshots) got a full fsync: N+1 fsyncs per saveCrewAgents call
388
- // (50-task team ≈ 750ms blocking). Only TERMINAL records keep
389
- // full durability (F4: notifier/dashboard must see the final
390
- // state immediately); non-terminal per-task status is best-effort
391
- // coalesced like the upsertCrewAgent non-terminal path.
392
- if (TERMINAL_AGENT_STATUSES.has(record.status ?? "")) {
393
- writeCrewAgentStatus(manifest, record);
394
- } else {
395
- writeCrewAgentStatusCoalesced(manifest, record);
396
- }
397
- }
511
+ writeStatusForRecords(manifest, records);
398
512
  });
399
513
  }
400
514
 
@@ -431,6 +545,15 @@ export function removeCrewAgent(manifest: TeamRunManifest, taskId: string): { re
431
545
  // 2. Remove per-task status.json
432
546
  try {
433
547
  const statusPath = agentStatusPath(manifest, taskId);
548
+ // F10 / RR-016: cancel the pending coalesced status write for THIS exact
549
+ // path BEFORE unlinking. The record was just dropped from the index, so
550
+ // `flushPendingAgentWrites` (which only iterates records still in the
551
+ // index) will never drain — or cancel — its buffered write. Without this
552
+ // the 250ms timer fired AFTER the unlink and RE-CREATED the file with the
553
+ // stale pre-cancel snapshot (measured: status.json reappearing as
554
+ // `status:"running"` after the exit drain while agents.json stayed empty).
555
+ cancelPendingCoalescedWriteForPath(statusPath);
556
+ forgetWrittenStatus(statusPath);
434
557
  if (fs.existsSync(statusPath)) {
435
558
  fs.unlinkSync(statusPath);
436
559
  removedStatus = true;
@@ -480,6 +603,10 @@ export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgen
480
603
  // F4: terminal agent status (completed/failed/cancelled/blocked) — keep full
481
604
  // durability so the notifier/dashboard health sees the final state immediately.
482
605
  atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record), { durability: "full" });
606
+ // F06/RR-017: record the DURABLE snapshot so a later save can skip re-writing
607
+ // byte-identical content for this record (see writeStatusForRecords). Only
608
+ // durable writes memoize — a coalesced write may never reach disk.
609
+ rememberWrittenStatus(manifest, record);
483
610
  }
484
611
 
485
612
  // 2.5 — coalesced variants. Buffer per-agent record + aggregate writes for
@@ -496,7 +623,7 @@ export function saveCrewAgentsCoalesced(manifest: TeamRunManifest, records: Crew
496
623
  // events.jsonl independently of these JSON files.
497
624
  atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS, { durability: "best-effort" });
498
625
  asyncAgentReaderCache.delete(filePath);
499
- for (const record of records) writeCrewAgentStatusCoalesced(manifest, record);
626
+ writeStatusForRecords(manifest, records);
500
627
  }
501
628
 
502
629
  export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record: CrewAgentRecord): void {
@@ -640,6 +767,183 @@ function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown
640
767
  return readCrewAgentEventsCursor(manifest, taskId).events;
641
768
  }
642
769
 
770
+ // ---------------------------------------------------------------------------
771
+ // F07 / RR-017: incremental agent-event cursor.
772
+ //
773
+ // The legacy reader did `readFileSync → split → JSON.parse each line → filter →
774
+ // slice`, applying `sinceSeq`/`limit` only AFTER reading and parsing the WHOLE
775
+ // file. Measured on a 10000-event file: 697788 bytes read per IDLE poll, linear
776
+ // in file size, and the inline panel polls it every 700 ms
777
+ // (src/ui/inline-panel/index.ts:46, consumer agent-transcript.ts:291).
778
+ //
779
+ // The fix REUSES the existing incremental primitive
780
+ // (`readLinesSince` in src/utils/incremental-reader.ts — the same module
781
+ // src/state/event-log/cursor.ts uses) instead of adding a new reader. A per-path
782
+ // watermark holds `{byteOffset, lineCount, prefixMaxSeq, size, mtimeMs, ino,
783
+ // dev}` — O(1) memory per path, bounded to 512 paths:
784
+ // - idle poll (unchanged size/mtimeMs/inode): ZERO event bytes read;
785
+ // - append: reads only the delta [byteOffset, size);
786
+ // - shrink (truncation) or inode replacement (rotation/rewrite): the watermark
787
+ // is discarded and the file is re-read from 0 — the same "rewrite-then-
788
+ // regrow" hole cursor.ts closes with inode stamping.
789
+ //
790
+ // WHY A PREFIX IS NEVER RE-READ (soundness): the cached prefix is the set of
791
+ // events already consumed before `byteOffset`. `prefixMaxSeq` is the maximum seq
792
+ // seen in that prefix, so EVERY prefix event satisfies `seq <= prefixMaxSeq`.
793
+ // When `sinceSeq >= prefixMaxSeq`, no prefix event can pass the
794
+ // `seq > sinceSeq` filter, so the answer is exactly "the delta's matches" —
795
+ // provable without assuming file order equals seq order. When
796
+ // `sinceSeq < prefixMaxSeq` the reader falls back to a full read from 0, so
797
+ // correctness never depends on the bound being tight.
798
+ //
799
+ // LEGACY SEQ SEMANTICS ARE PRESERVED EXACTLY: a line without a numeric `seq`
800
+ // gets `seq = lineIndex + 1`, where lineIndex is the index of that line among
801
+ // the file's NON-EMPTY lines. `readLinesSince` only increments `lineCount` for
802
+ // non-empty lines, so a delta's absolute base index is the cached `lineCount`
803
+ // and the parity holds without re-reading from offset 0.
804
+ //
805
+ // A trailing fragment without "\n" (a mid-append partial line) is held back by
806
+ // `readLinesSince` (it does not advance the committed offset past it), so it is
807
+ // neither lost nor parsed as a torn event — the next poll sees the completed
808
+ // line. Every writer of this file appends a trailing "\n", so this only ever
809
+ // defers a mid-write fragment.
810
+ // ---------------------------------------------------------------------------
811
+
812
+ interface AgentEventsCursorState {
813
+ byteOffset: number;
814
+ lineCount: number;
815
+ /** Max seq among events already consumed before `byteOffset`. */
816
+ prefixMaxSeq: number;
817
+ size: number;
818
+ mtimeMs: number;
819
+ ino: number;
820
+ dev: number;
821
+ }
822
+
823
+ const agentEventsCursorCache = new Map<string, AgentEventsCursorState>();
824
+ const AGENT_EVENTS_CURSOR_CACHE_MAX_ENTRIES = 512;
825
+
826
+ // F07 test hook (AC-5/AC-6/AC-11): bytes of event payload the cursor actually
827
+ // read. The tests assert STRUCTURALLY (bytes read, not wall-clock), so the
828
+ // reader exposes this counter instead of the suite monkey-patching `node:fs`
829
+ // (whose ESM named exports are frozen). Mirrors the `__test__agentRecordBufferCount`
830
+ // precedent in this file. Only the incremental read path increments it.
831
+ let agentEventBytesRead = 0;
832
+
833
+ /** @internal Test-only: reset the F07 byte counter. */
834
+ export function __test__resetAgentEventBytesRead(): void {
835
+ agentEventBytesRead = 0;
836
+ }
837
+
838
+ /** @internal Test-only: event-file bytes read since the last reset. */
839
+ export function __test__agentEventBytesRead(): number {
840
+ return agentEventBytesRead;
841
+ }
842
+
843
+ /** @internal Test-only: drop the per-path incremental cursor watermarks. */
844
+ export function __test__clearAgentEventsCursorCache(): void {
845
+ agentEventsCursorCache.clear();
846
+ }
847
+
848
+ /** @internal Test-only: inspect a watermark (undefined when uncached). */
849
+ export function __test__agentEventsCursorState(filePath: string): AgentEventsCursorState | undefined {
850
+ return agentEventsCursorCache.get(filePath);
851
+ }
852
+
853
+ function setAgentEventsCursorState(filePath: string, state: AgentEventsCursorState): void {
854
+ if (agentEventsCursorCache.has(filePath)) agentEventsCursorCache.delete(filePath);
855
+ agentEventsCursorCache.set(filePath, state);
856
+ while (agentEventsCursorCache.size > AGENT_EVENTS_CURSOR_CACHE_MAX_ENTRIES) {
857
+ const oldest = agentEventsCursorCache.keys().next().value;
858
+ if (oldest === undefined) break;
859
+ agentEventsCursorCache.delete(oldest);
860
+ }
861
+ }
862
+
863
+ /**
864
+ * Parse raw JSONL lines into cursor events, assigning the legacy `seq = index+1`
865
+ * fallback for lines that carry no numeric seq. `lineIndexBase` is the index of
866
+ * the FIRST line in this batch among the file's non-empty lines (0 when reading
867
+ * from offset 0).
868
+ *
869
+ * A single trailing "\r" is stripped so CRLF files behave like the legacy
870
+ * `split(/\r?\n/)` reader (JSON.parse tolerates it either way; the difference
871
+ * only shows up in the `raw` payload of an already-corrupt line).
872
+ */
873
+ function parseAgentEventLines(lines: string[], lineIndexBase: number): { events: Array<Record<string, unknown>>; maxSeq: number } {
874
+ const events: Array<Record<string, unknown>> = [];
875
+ let maxSeq = 0;
876
+ for (let index = 0; index < lines.length; index += 1) {
877
+ const raw = lines[index]!;
878
+ const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
879
+ const lineIndex = lineIndexBase + index;
880
+ let event: Record<string, unknown>;
881
+ try {
882
+ const parsed = JSON.parse(text) as unknown;
883
+ event = parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : { seq: lineIndex + 1, raw: text };
884
+ if (typeof event.seq !== "number") event.seq = lineIndex + 1;
885
+ } catch {
886
+ event = { seq: lineIndex + 1, raw: text };
887
+ }
888
+ events.push(event);
889
+ if (typeof event.seq === "number" && event.seq > maxSeq) maxSeq = event.seq;
890
+ }
891
+ return { events, maxSeq };
892
+ }
893
+
894
+ /**
895
+ * Read the agent events file, consuming only the bytes that are new since the
896
+ * last read of this path, and return every event with `seq > sinceSeq` (the
897
+ * legacy `total` is this array's length before `limit` is applied).
898
+ */
899
+ function readAgentEventMatches(filePath: string, sinceSeq: number): Array<Record<string, unknown>> {
900
+ let stat: fs.Stats;
901
+ try {
902
+ stat = fs.statSync(filePath);
903
+ } catch {
904
+ return [];
905
+ }
906
+ const cached = agentEventsCursorCache.get(filePath);
907
+ const sameFile = cached !== undefined && cached.ino === stat.ino && cached.dev === stat.dev;
908
+ // A shrink (truncate) or an inode change (rotation / rewrite) invalidates the
909
+ // byte offset: the new content must be read from 0 or events are lost.
910
+ const usable = sameFile && stat.size > cached.byteOffset;
911
+ const canServeDelta = usable && sinceSeq >= cached.prefixMaxSeq;
912
+ const canServeIdle = sameFile && stat.size === cached.byteOffset && stat.mtimeMs === cached.mtimeMs && sinceSeq >= cached.prefixMaxSeq;
913
+
914
+ if (canServeIdle) {
915
+ // Nothing changed AND no prefix event can pass the filter — no bytes read.
916
+ return [];
917
+ }
918
+
919
+ const baseOffset = canServeDelta ? cached.byteOffset : 0;
920
+ const baseLineCount = canServeDelta ? cached.lineCount : 0;
921
+ const baseMaxSeq = canServeDelta ? cached.prefixMaxSeq : 0;
922
+ const result = readLinesSince(filePath, { byteOffset: baseOffset, lineCount: baseLineCount });
923
+ // `readLinesSince` reads [state.byteOffset, fileSize); account for the bytes
924
+ // it requested (a held-back trailing fragment is still READ, just not parsed).
925
+ agentEventBytesRead += Math.max(0, stat.size - baseOffset);
926
+ const { events, maxSeq } = parseAgentEventLines(result.lines, baseLineCount);
927
+ const nextSeqInFile = Math.max(baseMaxSeq, maxSeq);
928
+ setAgentEventsCursorState(filePath, {
929
+ byteOffset: result.state.byteOffset,
930
+ lineCount: result.state.lineCount,
931
+ prefixMaxSeq: nextSeqInFile,
932
+ // `Math.max` covers a concurrent append that landed between the stat above
933
+ // and the read: storing the SMALLER (stale) size would make the next poll
934
+ // see `size === byteOffset` and needlessly re-read the whole file.
935
+ size: Math.max(stat.size, result.state.byteOffset),
936
+ mtimeMs: stat.mtimeMs,
937
+ ino: stat.ino,
938
+ dev: stat.dev,
939
+ });
940
+
941
+ // `events` is the whole-file parse when we could not serve a delta, and the
942
+ // delta-only parse otherwise — either way it holds EVERY event with
943
+ // seq > sinceSeq, because in the delta case the prefix is provably all <= sinceSeq.
944
+ return events.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
945
+ }
946
+
643
947
  export function readCrewAgentEventsCursor(
644
948
  manifest: TeamRunManifest,
645
949
  taskId: string,
@@ -676,20 +980,8 @@ export function readCrewAgentEventsCursor(
676
980
  const sinceSeq =
677
981
  typeof options.sinceSeq === "number" && Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : 0;
678
982
  const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : undefined;
679
- const parsed = fs
680
- .readFileSync(filePath, "utf-8")
681
- .split(/\r?\n/)
682
- .filter(Boolean)
683
- .map((line, index) => {
684
- try {
685
- const event = JSON.parse(line) as Record<string, unknown>;
686
- if (typeof event.seq !== "number") event.seq = index + 1;
687
- return event;
688
- } catch {
689
- return { seq: index + 1, raw: line };
690
- }
691
- });
692
- const filtered = parsed.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
983
+
984
+ const filtered = readAgentEventMatches(filePath, sinceSeq);
693
985
  const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
694
986
  const returnedMaxSeq = events.reduce((max, event) => (typeof event.seq === "number" ? Math.max(max, event.seq) : max), sinceSeq);
695
987
  return {