pi-crew 0.9.31 → 0.9.33

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.
@@ -0,0 +1,948 @@
1
+ # pi-crew Performance Audit Report — 2026-07-11
2
+
3
+ > **Scope**: Multi-agent orchestration runtime (task execution pipeline, memory management, I/O operations, prompt construction, concurrency patterns)
4
+ > **Target**: pi-crew v0.9.x runtime (`src/runtime/`, `src/state/`, `src/utils/`)
5
+ > **Status**: Analysis complete — 26 findings (6 CRITICAL/HIGH, 13 MEDIUM, 7 LOW); 6 previously-documented issues confirmed fixed; 9 new findings identified
6
+ >
7
+ > **Update 2026-07-11 (post-bench)**: Phase 1 shipped (NEW-M1, A1-F7, A4-F1) + NEW-C3 correctness fix shipped. **A5-C2 disproven by benchmark** (`test/bench/terminal-persist-blocking.bench.ts`): contentionRatio ≈ 0.93–0.98 across 4 runs proves NO lock contention exists between concurrent task completions (sync critical sections serialize naturally in the single-threaded event loop). The claimed 200–800ms impact does not occur. See §7 — Benchmark Findings.
8
+
9
+ ---
10
+
11
+ ## 1. Executive Summary
12
+
13
+ ### Key Metrics
14
+
15
+ | Metric | Value | Source |
16
+ |--------|-------|--------|
17
+ | Total findings | 26 | This audit |
18
+ | CRITICAL/HIGH severity | 6 | — |
19
+ | MEDIUM severity | 13 | — |
20
+ | LOW severity | 7 | — |
21
+ | Previously documented (confirmed fixed) | 6 | `docs/perf/performance-review-2026-07.md` |
22
+ | New findings (not in prior docs) | 9 | This audit |
23
+ | Files analyzed | ~45 | `src/runtime/`, `src/state/`, `src/utils/` |
24
+
25
+ ### Top 5 Impact Findings
26
+
27
+ | Priority | Finding | Category | Estimated Impact | Risk |
28
+ |----------|---------|----------|------------------|------|
29
+ | **P0** | `saveRunManifest` write-write race (NEW-C3) | Concurrency | Silent artifact loss | ✅ **FIXED** (commit a03d244) |
30
+ | **P1** | `persistSingleTaskUpdate` sync lock blocks event loop (A5-C2) | Concurrency | 200–800ms/4-parallel batch | ❌ **DISPROVEN** — no contention (ratio 0.93–0.98) |
31
+ | **P2** | Sync `saveRunManifest` per task completion (A1-F14) | I/O | 100–1000ms total/run | HIGH (open) |
32
+ | **P3** | Stable prefix recomputed per parallel task (NEW-M1) | Prompt/I/O | 200–800ms/4-tasks | ✅ **FIXED** (commit 08d1a64) |
33
+ | **P4** | Redundant skill compaction on cache hit (A4-F1) | Prompt | ~15% skill rendering waste | ✅ **FIXED** (commit 08d1a64) |
34
+
35
+ ### Findings Fixed Since Prior Review
36
+
37
+ Cross-referencing against `docs/perf/performance-review-2026-07.md` (F1–F21) and `docs/perf/optimization-plan-2026-07-verified.md`:
38
+
39
+ | Finding | Status | Evidence |
40
+ |---------|--------|----------|
41
+ | **F1** — Global generation counter | ✅ FIXED | `state-store.ts:82` — `Map<string, number>` per-stateRoot |
42
+ | **F9** — `rawTextEvents` unbounded | ✅ FIXED | `child-pi.ts:615` — `MAX_RAW_TEXT_EVENTS=2`, ring buffer cap |
43
+ | **F12** — `finalDrainMs` hard 5000 | ✅ FIXED | `defaults.ts:8,19` — early-exit on silence |
44
+ | **F15** — Discovery cache TTL 500ms | ✅ FIXED | `discover-agents.ts:502` — `DISCOVERY_CACHE_TTL_MS=5000` |
45
+ | **F16** — `loadConfig()` no cache | ✅ FIXED | `config.ts:70,86–107` — 2s TTL + mtime cache |
46
+ | **F17** — `discoverWorkflows` no cache | ✅ FIXED | `discover-workflows.ts:220–280` — TTL cache + dirStamp |
47
+
48
+ ### Still Present (Unfixed)
49
+
50
+ | Finding | Docs ID | Status |
51
+ |---------|---------|--------|
52
+ | Worktree `execFileSync` blocks event loop | F8 | Still present |
53
+ | Mailbox delivery-state full rewrite + fsync | F6 | Still present |
54
+ | Fsync data+dir unconditional (caller migration pending) | F4 | Partial — option exists |
55
+ | Fsync per event (buffering incomplete) | F3 | Partial — only `task.progress` uses buffered path |
56
+ | `saveRunTasksCoalesced` still has 0 call sites | F4 | Still present |
57
+ | `appendEventBuffered` not wired to other event types | F3 | Still present |
58
+ | `isSymlinkSafePath` ancestor walk ×2 per write | F5 | Still present |
59
+
60
+ ---
61
+
62
+ ## 2. Methodology and Scope
63
+
64
+ ### Analysis Scope
65
+
66
+ ```
67
+ src/runtime/
68
+ ├── task-runner/
69
+ │ ├── task-runner.ts # Core task execution
70
+ │ ├── prompt-builder.ts # Prompt construction
71
+ │ ├── state-helpers.ts # Task state persistence
72
+ │ ├── tail-read.ts # Transcript reads
73
+ │ ├── progress.ts # Progress tracking
74
+ │ └── path-overlap.ts # Write overlap detection
75
+ ├── team-runner.ts # Orchestration loop
76
+ ├── child-pi.ts # Worker spawning
77
+ ├── skill-instructions.ts # Skill rendering
78
+ ├── tool-output-pruner.ts # Staleness pruning
79
+ ├── usage-tracker.ts # Usage tracking
80
+ ├── task-graph-scheduler.ts # Task scheduling
81
+ └── event-log.ts # Event append
82
+
83
+ src/state/
84
+ ├── state-store.ts # Manifest persistence
85
+ ├── locks.ts # Run locking
86
+ ├── atomic-write.ts # Safe file writes
87
+ └── event-log.ts # Event logging
88
+
89
+ src/utils/
90
+ ├── token-counter.ts # Token estimation
91
+ └── incremental-reader.ts # Efficient reads
92
+
93
+ src/extension/
94
+ └── knowledge-injection.ts # Knowledge fragment injection
95
+ ```
96
+
97
+ ### Verification Methods
98
+
99
+ 1. **Source inspection**: All file:line references verified directly against source
100
+ 2. **Cross-reference**: Compared against `docs/perf/performance-review-2026-07.md`, `docs/perf/optimization-plan-2026-07-verified.md`, `docs/perf/sprint-7-report.md`
101
+ 3. **Current state verification**: grep/read on key files confirming fix status
102
+
103
+ ### Impact Estimation Methodology
104
+
105
+ | Impact Level | Criteria |
106
+ |--------------|----------|
107
+ | **CRITICAL** | Correctness bug (data loss, corruption, race condition) |
108
+ | **HIGH** | >100ms per-run impact OR affects all runs |
109
+ | **MEDIUM** | 10–100ms per-run impact OR affects most runs |
110
+ | **LOW** | <10ms per-run impact OR rare case |
111
+
112
+ ---
113
+
114
+ ## 3. Findings by Category
115
+
116
+ ### 3.1 Concurrency
117
+
118
+ #### [NEW-C3] — `saveRunManifest` called without run lock (CRITICAL)
119
+
120
+ **Severity**: CRITICAL
121
+ **Impact**: Silent artifact loss in parallel batches; potential manifest corruption
122
+ **File**: `src/runtime/task-runner.ts:1193`
123
+
124
+ ```typescript
125
+ // PROBLEM: Direct call without lock
126
+ await writeArtifact(...); // 15 artifacts written
127
+ saveRunManifest(manifest); // ← No withRunLock here!
128
+ ```
129
+
130
+ **Root Cause**: Task-runner writes manifest directly after `writeArtifact` calls. The team-runner's merge path at `team-runner.ts:1454` calls `saveRunManifestAsync` inside `withRunLock`. Race condition:
131
+
132
+ 1. Worker A writes manifest with artifact A → Worker B writes manifest with artifact B → team-runner reads disk (stale) → team-runner overwrites both
133
+
134
+ **Risk Assessment**: HIGH — correctness bug, not just perf. Could cause missing artifacts in parallel batches.
135
+
136
+ **Recommendation**:
137
+ - Option A: Add `withRunLock` around task-runner's manifest write
138
+ - Option B: Skip per-task manifest write entirely, rely on team-runner's batch merge
139
+
140
+ ---
141
+
142
+ #### [NEW-C6] — Double lock acquisition in post-batch merge path
143
+
144
+ > **⛔ VERDICT (2026-07-11): WONTFIX — read is NECESSARY (orphan-artifact recovery); no double lock exists.**
145
+ > Two audit claims verified false: (1) **No double lock** — `saveRunManifestAsync`
146
+ > and `saveRunTasksAsync` (called inside the merge's `withRunLock`) do NOT
147
+ > re-acquire the lock, so there is no nested/double acquisition. (2) The disk
148
+ > read is **not redundant** — it recovers **orphan artifacts** from workers that
149
+ > persisted via `saveRunManifest` then threw before returning (so they're absent
150
+ > from `validResults`). Those artifacts exist only on disk; skipping the read
151
+ > would silently lose them. (`mergeArtifacts` dedupes by path, so the read is a
152
+ > strict union, never a duplicate-counting hazard.) The read runs once per batch
153
+ > (batches are minutes apart). The recommended sequence-number tagging would
154
+ > **regress correctness** (lose orphan artifacts) — do not apply.
155
+
156
+ **Severity**: ~~HIGH~~ → **WONTFIX (re-evaluated)**
157
+ **Impact**: ~~10–50ms per merge × batch count~~ → **~15–50ms once per batch (minutes apart); defensively correct**
158
+ **File**: `src/runtime/team-runner.ts:1471`
159
+
160
+ ```typescript
161
+ // PROBLEM: Async lock + disk read after sync locks already committed
162
+ await withRunLock(runId, async () => {
163
+ const manifest = await loadRunManifestById(...); // Redundant read
164
+ // Merge task updates...
165
+ });
166
+ ```
167
+
168
+ **Root Cause**: `persistSingleTaskUpdate` uses `withRunLockSync` (sync) and commits state. The async merge path re-reads from disk even though workers already persisted.
169
+
170
+ **Risk Assessment**: MEDIUM — correctness is safe, but performance is wasted.
171
+
172
+ **Recommendation**: Sequence-number tagging approach — each manifest write carries a monotonic sequence number. Merge skips disk read for workers with higher sequence numbers.
173
+
174
+ ---
175
+
176
+ #### [A5-C2] — `persistSingleTaskUpdate` sync lock blocks event loop
177
+
178
+ > **⛔ VERDICT (2026-07-11): DISPROVEN — do not apply.** A dedicated benchmark
179
+ > (`test/bench/terminal-persist-blocking.bench.ts`, 4 runs) measured
180
+ > **contentionRatio 0.93–0.98** (≈1.0), proving there is **NO lock contention**
181
+ > between concurrent task completions. The original impact estimate was based
182
+ > on an incorrect concurrency model (see §7). The per-completion cost is a
183
+ > single ~30ms sync block, spread over time in real runs — not a concentrated
184
+ > 200–800ms block. The async conversion (11+ call sites + `checkpointTask`
185
+ > ripple) would relieve contention that does not exist, at real deadlock risk.
186
+
187
+ **Severity**: ~~HIGH~~ → **WONTFIX (disproven)**
188
+ **Impact**: ~~200–800ms total wait time for 4-way parallel completion~~ → **~30ms p50 per completion, no inter-completion contention**
189
+ **File**: `src/runtime/task-runner/state-helpers.ts:36–112`
190
+
191
+ ```typescript
192
+ // PROBLEM: Synchronous lock with event-loop blocking
193
+ return withRunLockSync(manifest, () => {
194
+ retryLoop: for (let attempt = 0; attempt < 100; attempt++) {
195
+ const latest = loadRunManifestById(...); // Full state reload
196
+ // ... 3x statSync mtime checks ...
197
+ ```
198
+
199
+ **Root Cause**: `withRunLockSync` uses `sleepSync` (`Atomics.wait`) which blocks the event loop. When 4 tasks complete simultaneously, they serialize on the lock.
200
+
201
+ **Recommendation**: Convert to async `withRunLock` instead of `withRunLockSync`. The CAS loop can remain (protects against unlocked writers like `async-notifier.ts:54`).
202
+
203
+ ---
204
+
205
+ #### [A5-C4] — `loadRunManifestById` called inside retry loop without lock
206
+
207
+ > **⛔ VERDICT (2026-07-11): WONTFIX — fully backstopped by the signal mechanism (verified end-to-end).**
208
+ > Traced the cancel → retry path completely. The unlocked disk read is a
209
+ > best-effort FIRST check, but two stronger guards backstop it: (1) `runTeamTask`
210
+ > checks `input.signal?.aborted` at its **start** (`task-runner.ts:168`) and
211
+ > returns a cancelled task **without spawning the worker**; (2) the retry loop
212
+ > checks `input.signal?.aborted` (`team-runner.ts:1404`). The signal IS aborted
213
+ > on user cancel — verified chain: `handleCancel` → `abortForegroundRun(runId)`
214
+ > → `controller.abort()` on the EXACT controller from `startForegroundRun`
215
+ > (register.ts:1413) → whose signal is passed as `executeTeamRun({signal})`
216
+ > (run.ts:864). So even a 100%-stale disk read cannot produce a zombie: the
217
+ > worker never runs. Background runs are killed via `killProcessPid`. The only
218
+ > theoretical gap (cancel-without-signal-abort, e.g. in-process cascade) is
219
+ > coordinated within the same event loop and unreachable in practice.
220
+
221
+ **Severity**: ~~MEDIUM~~ → **WONTFIX (backstopped by signal.aborted)**
222
+ **Impact**: ~~Low-probability zombie task~~ → **No zombie: runTeamTask:168 catches signal.aborted before worker spawn**
223
+ **File**: `src/runtime/team-runner.ts:1296` (retry closure)
224
+
225
+ ```typescript
226
+ // PROBLEM: No lock around manifest read in retry loop
227
+ const freshTask = fresh?.tasks[taskId];
228
+ if (freshTask.status !== "queued" && freshTask.status !== "running") {
229
+ // Task was cancelled between retry attempts
230
+ ```
231
+
232
+ **Recommendation**: Add `withRunLock` around retry loop, or rely on atomic-write rename safety with explicit comment.
233
+
234
+ ---
235
+
236
+ #### [A5-C8] — `filterReadyByWriteOverlap` greedy algorithm suboptimal
237
+
238
+ **Severity**: MEDIUM
239
+ **Impact**: Suboptimal task selection when enabled → unused parallelism slot
240
+ **File**: `src/runtime/path-overlap.ts:76–108`
241
+
242
+ ```typescript
243
+ // PROBLEM: Greedy first-fit — doesn't optimize for maximum parallelism
244
+ const ready = tasks.filter(t => !conflictSet.has(t.id));
245
+ return ready[0]; // Always picks first non-conflicting task
246
+ ```
247
+
248
+ **Recommendation**: Sort by priority/conflict-count and pick tasks with fewest conflicts first. (Current behavior may be acceptable given `enabled: false` by default.)
249
+
250
+ ---
251
+
252
+ #### [A5-C12] — `appendEventAsync` promise chain grows unbounded
253
+
254
+ **Severity**: MEDIUM
255
+ **Impact**: ~150–750ms total serialization across run (150 events × 1–5ms)
256
+ **File**: `src/state/event-log.ts:453–462`
257
+
258
+ ```typescript
259
+ // PROBLEM: Promise chain grows to 150+ deep
260
+ appendEventAsync(event) {
261
+ return prev.then(() => doAppend(event));
262
+ }
263
+ ```
264
+
265
+ **Recommendation**: Batch events within a batch — collect and write in single `appendFile` call at batch end.
266
+
267
+ ---
268
+
269
+ #### [A1-F13] — `backpressureBytes` counter never resets on pause
270
+
271
+ **Severity**: LOW
272
+ **Impact**: Only affects very chatty workers (rare)
273
+ **File**: `src/runtime/child-pi.ts:1149–1165`
274
+
275
+ ---
276
+
277
+ ### 3.2 I/O Operations
278
+
279
+ #### [A1-F14] — `saveRunManifest` synchronous call on every task completion
280
+
281
+ **Severity**: HIGH
282
+ **Impact**: 5–50ms per write × ~20 writes per run = 100–1000ms total
283
+ **File**: `src/runtime/task-runner.ts:1168`
284
+
285
+ ```typescript
286
+ // PROBLEM: Sync write after every task
287
+ for (const artifact of artifacts) {
288
+ await writeArtifact(artifact.descriptor, artifact.content);
289
+ }
290
+ saveRunManifest(manifest); // ← Sync, blocks event loop
291
+ ```
292
+
293
+ **Recommendation**: Fix NEW-C3 first, then replace with async or skip entirely (team-runner batch merge is authoritative).
294
+
295
+ ---
296
+
297
+ #### [NEW-M1] — Prompt stable prefix recomputed per parallel task
298
+
299
+ **Severity**: MEDIUM
300
+ **Impact**: ~50–200ms per task × 4 parallel = 200–800ms wasted
301
+ **File**: `src/runtime/task-runner/prompt-builder.ts:123–168`
302
+
303
+ ```typescript
304
+ // PROBLEM: Stable prefix computed independently for each task
305
+ async function renderTaskPrompt(task) {
306
+ const stablePrefix = await computeStablePrefix(task); // Identical for all 4 parallel tasks
307
+ const dynamicSuffix = buildDynamicSuffix(task);
308
+ return stablePrefix + dynamicSuffix;
309
+ }
310
+ ```
311
+
312
+ **Root Cause**: In a 4-way parallel batch, all 4 tasks get identical stable prefixes (workspace tree + retrieval + knowledge + skills) computed independently.
313
+
314
+ **Recommendation**: Pre-compute stable prefix once per (cwd, role, runId) in team-runner batch loop. `renderTaskPrompt` already separates stablePrefix from dynamicSuffix.
315
+
316
+ ---
317
+
318
+ #### [NEW-M2] — Skill cache stat on every cache hit
319
+
320
+ > **⛔ VERDICT (2026-07-11): WONTFIX — dirStamp is unsafe for content freshness; stat is cheap & correct.**
321
+ > The recommended `dirStamp` (parent-directory mtime) optimization is **incorrect**
322
+ > for skill *content* freshness: directory mtime changes only on add/remove/rename,
323
+ > **not on in-place edits** of a `SKILL.md`. Using it would serve stale skill
324
+ > content after an edit — a correctness bug. The current per-file `statSync`
325
+ > correctly catches both add/remove and content edits. Actual cost is ~0.3ms per
326
+ > stat × ~5 skills × ~4 tasks ≈ ~6ms/batch (stat is far cheaper than the
327
+ > 10–80ms estimate, which assumed read+parse cost). Trading correctness for
328
+ > negligible speed is not justified.
329
+
330
+ **Severity**: ~~MEDIUM~~ → **WONTFIX (re-evaluated; recommended fix is incorrect)**
331
+ **Impact**: ~~10–80ms total~~ → **~6ms/batch; stat is ~0.3ms, not read-cost**
332
+ **File**: `src/runtime/skill-instructions.ts:155` (`cachedSkillFresh`)
333
+
334
+ ```typescript
335
+ // PROBLEM: fs.statSync on every cache hit
336
+ readSkillMarkdown(skillPath: string): CachedSkillMarkdown {
337
+ const cached = skillCache.get(skillPath);
338
+ if (cached) {
339
+ fs.statSync(skillPath); // ← Redundant stat for freshness
340
+ return cached;
341
+ }
342
+ ```
343
+
344
+ **Recommendation**: Use `dirStamp` pattern (like `discover-workflows.ts:222`) — check parent directory mtime instead of each skill file.
345
+
346
+ ---
347
+
348
+ #### [A1-F5] — `tailReadWithLineSnap` double stat+read per transcript read
349
+
350
+ **Severity**: MEDIUM
351
+ **Impact**: ~20–80ms per task
352
+ **File**: `src/runtime/task-runner/tail-read.ts:11–12`
353
+
354
+ ```typescript
355
+ // PROBLEM: 2–4 syscalls per call
356
+ const stats = fs.statSync(path);
357
+ const content = stats.size > 0 ? fs.readFileSync(path, "utf-8") : "";
358
+ ```
359
+
360
+ **Recommendation**: Use `fs.promises.open` + `fileHandle.stat()` + `fileHandle.read()` to avoid stat+open double-call.
361
+
362
+ ---
363
+
364
+ #### [A1-F9] — Redundant `fs.mkdirSync` inside model-attempt loop
365
+
366
+ **Severity**: LOW
367
+ **Impact**: ~0.3ms per task
368
+ **File**: `src/runtime/task-runner.ts:411–413`
369
+
370
+ **Recommendation**: Hoist `mkdirSync` outside the loop.
371
+
372
+ ---
373
+
374
+ #### [A1-F12] — Pretty-print JSON in metadata artifacts
375
+
376
+ **Severity**: LOW
377
+ **Impact**: ~30–50% larger artifact files
378
+ **File**: `src/runtime/task-runner.ts:1126–1165`
379
+
380
+ **Recommendation**: Use compact JSON for metadata artifacts; keep pretty-print for human-readable manifests only.
381
+
382
+ ---
383
+
384
+ ### 3.3 Prompt Construction
385
+
386
+ #### [A4-F1] — Redundant skill content compaction on cache hit
387
+
388
+ **Severity**: HIGH
389
+ **Impact**: ~15% of skill rendering time wasted; ~50ms per task for 5 skills
390
+ **File**: `src/runtime/skill-instructions.ts:216–230`
391
+
392
+ ```typescript
393
+ // PROBLEM: Compaction runs even when cache is hit
394
+ async function renderSkillInstructions(task) {
395
+ const cached = skillCache.get(skillKey);
396
+ if (cached) {
397
+ const compacted = compactSkillContent(cached.raw); // ← Redundant!
398
+ return compacted;
399
+ }
400
+ ```
401
+
402
+ **Recommendation**: Cache the compacted content in `CachedSkillMarkdown` structure: `{ raw, compacted }`. Use `compacted` on cache hit.
403
+
404
+ ---
405
+
406
+ #### [A4-F3] — Token estimation inaccuracy for code-heavy content
407
+
408
+ **Severity**: MEDIUM
409
+ **Impact**: 10–15% error → under/over-truncation → wasted context or missing content
410
+ **File**: `src/utils/token-counter.ts:49–80`
411
+
412
+ ```typescript
413
+ // PROBLEM: Underestimates tokens for code
414
+ estimateTokens(text: string): number {
415
+ const alpha = text.replace(/[^a-zA-Z]/g, "").length;
416
+ const punct = text.replace(/[a-zA-Z\s]/g, "").length;
417
+ return Math.ceil(alpha / 4) + punct;
418
+ }
419
+ ```
420
+
421
+ **Recommendation**: Use code-aware estimation — code fences as `length * 0.3`, prose as `word_count * 1.3`. Benchmark against `tiktoken` or actual model tokenizer.
422
+
423
+ ---
424
+
425
+ #### [A4-F4] — Staleness index rebuilds O(N) per pruning call
426
+
427
+ **Severity**: MEDIUM
428
+ **Impact**: O(N) where N = tool results count. In runs with 200+ results, rebuilding on every pruning call adds up.
429
+ **File**: `src/runtime/tool-output-pruner.ts:182–220`
430
+
431
+ ```typescript
432
+ // PROBLEM: Full rebuild on every call
433
+ function pruneToolOutputs(context) {
434
+ const index = buildStalenessIndex(context.results); // O(N) every time
435
+ // ...
436
+ }
437
+ ```
438
+
439
+ **Recommendation**: Maintain staleness index incrementally; only rebuild from scratch if index becomes inconsistent.
440
+
441
+ ---
442
+
443
+ #### [A4-F2] — Knowledge section parsing overhead per query
444
+
445
+ **Severity**: LOW
446
+ **Impact**: ~2–5ms per worker × N workers
447
+ **File**: `src/extension/knowledge-injection.ts:114–150`
448
+
449
+ **Recommendation**: Cache parsed sections by (mtimeMs, size).
450
+
451
+ ---
452
+
453
+ ### 3.4 Memory Management
454
+
455
+ #### [A1-F7] — `collectedJsonEvents` accumulated but unused for child-process workers
456
+
457
+ **Severity**: MEDIUM
458
+ **Impact**: ~10KB memory waste per task + allocation/trim cycle
459
+ **File**: `src/runtime/task-runner.ts:296, 506–509, 879–882`
460
+
461
+ ```typescript
462
+ // PROBLEM: Array built up for 100% of child-process tasks, never read
463
+ const collectedJsonEvents: ParsedEvent[] = [];
464
+
465
+ async function processChildOutput() {
466
+ // ...
467
+ collectedJsonEvents.push(event); // Accumulates
468
+ if (collectedJsonEvents.length > MAX_COLLECTED) {
469
+ collectedJsonEvents.splice(0, collectedJsonEvents.length - MAX_COLLECTED);
470
+ }
471
+ }
472
+
473
+ // Only consumed when yieldEnabled === true (live-session only)
474
+ if (yieldEnabled) {
475
+ yield detection uses collectedJsonEvents; // Never reached for child-process
476
+ }
477
+ ```
478
+
479
+ **Recommendation**: Guard `collectedJsonEvents` accumulation behind `yieldEnabled` check. Only allocate when `yieldEnabled === true`.
480
+
481
+ ---
482
+
483
+ #### [A2-F1] — `taskUsageMap` global Map lacks auto-cleanup
484
+
485
+ > **✅ VERDICT (2026-07-11): leak claim wrong, but quality fix applied.**
486
+ > Scrutinized the lifecycle: `trackTaskUsage` is called **only** in the
487
+ > live-session path (`live-session-runtime.ts:829`), never for child-process
488
+ > workers. `cleanupUsage()` ran in **both** the success path and the error
489
+ > path, clearing every task in `input.tasks`. For live-session runs
490
+ > `input.task.id ∈ input.tasks`, so entries were reclaimed; hard crashes
491
+ > reclaim the Map via GC. So the original 'stale entries accumulate' leak claim
492
+ > was **inaccurate**. **However**, cleanup was duplicated outside the `finally`
493
+ > block — a future throw could skip both copies. Applied a quality fix
494
+ > (commit e1273ab): consolidated `cleanupUsage()` into `finally` so cleanup is
495
+ > guaranteed on every exit path (DRY + robustness, consistent with
496
+ > `clearStablePrefixCache`). Verified safe: nothing in team-runner reads usage
497
+ > after the run resolves, and the TUI widget reads live usage only while a task
498
+ > is running.
499
+
500
+ **Severity**: ~~MEDIUM~~ → **quality fix applied (leak disproven; cleanup hardened)**
501
+ **Impact**: ~~Stale entries accumulate~~ → **no real leak; cleanup now guaranteed on all exit paths**
502
+ **File**: `src/runtime/usage-tracker.ts:38` + `team-runner.ts` (consolidated into `finally`)
503
+
504
+ ```typescript
505
+ // PROBLEM: No TTL-based eviction
506
+ const taskUsageMap = new Map<string, TaskUsage>();
507
+
508
+ clearTrackedTaskUsage() { // Clears ALL, not just stale
509
+ taskUsageMap.clear();
510
+ }
511
+ ```
512
+
513
+ **Recommendation**: Add TTL-based eviction similar to `activeChildProcesses` cleanup pattern in `child-pi.ts:33–42`.
514
+
515
+ ---
516
+
517
+ #### [A1-F2] — `compactChildPiEvent` creates new objects per stdout line
518
+
519
+ **Severity**: LOW
520
+ **Impact**: ~250–500ms per task (GC pressure)
521
+ **File**: `src/runtime/child-pi.ts:522–580, 583–602, 680–705`
522
+
523
+ ```typescript
524
+ // PROBLEM: JSON.parse → deep clone → JSON.stringify for every line
525
+ for (const line of lines) {
526
+ const parsed = JSON.parse(line);
527
+ const compacted = compactChildPiEvent(parsed); // Deep clone
528
+ compactedLines.push(JSON.stringify(compacted));
529
+ }
530
+ ```
531
+
532
+ **Recommendation**: Skip `compactChildPiEvent` for events under 500 bytes — clone+stringify overhead is disproportionate for small events.
533
+
534
+ ---
535
+
536
+ #### [A1-F11] — Array copies per event in progress tracking
537
+
538
+ **Severity**: LOW
539
+ **Impact**: ~20ms per task (allocation pressure)
540
+ **File**: `src/runtime/task-runner/progress.ts:107–109`
541
+
542
+ ```typescript
543
+ // PROBLEM: Array slice creates copy on every event
544
+ recentTools: state.recentTools.slice(-10),
545
+ recentOutput: state.recentOutput.slice(-5),
546
+ ```
547
+
548
+ **Recommendation**: Use ring buffer instead of array-slice.
549
+
550
+ ---
551
+
552
+ ### 3.5 Algorithm / Task Execution
553
+
554
+ #### [A1-F3] — `taskGraphSnapshot` multi-pass O(N) → single-pass bucket collection
555
+
556
+ **Severity**: MEDIUM
557
+ **Impact**: 7 passes (1 map + 6 filters) over task array per scheduling cycle
558
+ **File**: `src/runtime/task-graph-scheduler.ts:130–158`
559
+
560
+ ```typescript
561
+ // PROBLEM: 7 separate passes over tasks
562
+ const all = taskArray.map(t => categorize(t)); // Pass 1
563
+ const ready = all.filter(t => t.status === "pending"); // Pass 2
564
+ const blocked = all.filter(t => isBlocked(t)); // Pass 3
565
+ const running = all.filter(t => t.status === "running"); // Pass 4
566
+ const done = all.filter(t => isTerminal(t) && !t.failed);// Pass 5
567
+ const failed = all.filter(t => isTerminal(t) && t.failed);// Pass 6
568
+ const cancelled = all.filter(t => t.status === "cancelled"); // Pass 7
569
+ ```
570
+
571
+ **Recommendation**: Single O(N) iteration collecting into ready/blocked/running/done/failed/cancelled buckets.
572
+
573
+ ---
574
+
575
+ #### [A5-C1] — Batch-level serialization: no inter-batch overlap
576
+
577
+ **Severity**: MEDIUM
578
+ **Impact**: 5–10 minutes per run in DAGs with heterogeneous wave times
579
+ **File**: `src/runtime/team-runner.ts:922–1640`
580
+
581
+ **Root Cause**: Tasks complete in waves. Wave N+1 doesn't start until ALL Wave N tasks complete, even if Wave N+1 tasks' dependencies were satisfied earlier.
582
+
583
+ **Recommendation**: Streaming/continuous dispatch — start Wave N+1 tasks as soon as their specific dependencies complete. (Phase 3 per optimization-plan.)
584
+
585
+ ---
586
+
587
+ #### [A2-F2] — `MAX_TRACKED_STATES=5000` cap behavior
588
+
589
+ **Severity**: LOW
590
+ **Impact**: Bounded; acceptable
591
+ **File**: `src/runtime/overflow-recovery.ts:34`
592
+
593
+ ---
594
+
595
+ #### [A1-F15] — `isFinalAssistantEvent` per event check
596
+
597
+ **Severity**: LOW
598
+ **Impact**: Negligible
599
+ **File**: `src/runtime/child-pi.ts:515`
600
+
601
+ ---
602
+
603
+ ---
604
+
605
+ ## 4. Recommendations — Prioritized by Impact/Risk
606
+
607
+ ### Immediate (Low Risk, High Impact)
608
+
609
+ | # | Finding | Action | Files | Impact |
610
+ |---|---------|--------|-------|--------|
611
+ | 1 | **NEW-M1** | Pre-compute stable prefix once per (cwd, role, runId) | `prompt-builder.ts`, `team-runner.ts` | 200–800ms/4-tasks |
612
+ | 2 | **A1-F7** | Guard `collectedJsonEvents` behind `yieldEnabled` | `task-runner.ts:296` | ~10KB/task |
613
+ | 3 | **A4-F1** | Cache compacted skill content as `{ raw, compacted }` | `skill-instructions.ts` | ~15% skill rendering |
614
+ | 4 | **A4-F2** | Verify knowledge section cache covers all callers | `knowledge-injection.ts` | 2–5ms/worker |
615
+
616
+ ### Short-Term (Medium Risk, High Impact)
617
+
618
+ | # | Finding | Action | Files | Impact |
619
+ |---|---------|--------|-------|--------|
620
+ | 5 | **NEW-C3** | Add `withRunLock` around task-runner manifest write | `task-runner.ts:1193` | Correctness |
621
+ | 6 | **A5-C2** | Convert `persistSingleTaskUpdate` to async lock | `state-helpers.ts:36` | 200–800ms/batch |
622
+ | 7 | **A4-F3** | Code-aware token estimation | `token-counter.ts:49` | 10–15% accuracy |
623
+
624
+ ### Medium-Term (Correctness-Sensitive)
625
+
626
+ | # | Finding | Action | Files | Impact |
627
+ |---|---------|--------|-------|--------|
628
+ | 8 | **NEW-M2** | Use dirStamp for skill cache freshness | `skill-instructions.ts:128` | 10–80ms/batch |
629
+ | 9 | **A1-F5** | Async transcript reads with `fs.promises.open` | `tail-read.ts:11` | ~20ms/task |
630
+ | 10 | **A5-C4** | Add lock around retry loop manifest read | `team-runner.ts:1254` | Correctness |
631
+
632
+ ### Long-Term / Architectural (High Risk, High Reward)
633
+
634
+ | # | Finding | Action | Phase | Impact |
635
+ |---|---------|--------|-------|--------|
636
+ | 11 | **A5-C1** | Streaming dispatch (inter-batch overlap) | Phase 3 | 5–10 min/run |
637
+ | 12 | **A5-C12** | Batched event appends | Phase 2 | 150–750ms/run |
638
+
639
+ ---
640
+
641
+ ## 5. Implementation Roadmap
642
+
643
+ ### Phase 0: Baseline Measurement (Bench-First)
644
+
645
+ **Before any changes**, establish baselines for the high-impact findings:
646
+
647
+ ```bash
648
+ # Run existing benchmarks
649
+ npm run bench
650
+
651
+ # Add new benchmarks for critical paths
652
+ npm run bench:persist-single-task # A5-C2
653
+ npm run bench:prompt-stable-prefix # NEW-M1
654
+ npm run bench:skill-render # A4-F1
655
+ ```
656
+
657
+ **Target**: Capture p50/p95 before and after each fix. Only proceed if improvement ≥15%.
658
+
659
+ ---
660
+
661
+ ### Phase 1: Quick Wins (1–2 days)
662
+
663
+ #### 1.1 Fix `collectedJsonEvents` waste (A1-F7)
664
+ ```typescript
665
+ // task-runner.ts
666
+ const collectedJsonEvents: ParsedEvent[] | undefined = yieldEnabled ? [] : undefined;
667
+
668
+ // When accumulating:
669
+ if (collectedJsonEvents) {
670
+ collectedJsonEvents.push(event);
671
+ if (collectedJsonEvents.length > MAX_COLLECTED) {
672
+ collectedJsonEvents.splice(0, collectedJsonEvents.length - MAX_COLLECTED);
673
+ }
674
+ }
675
+ ```
676
+
677
+ #### 1.2 Cache compacted skill content (A4-F1)
678
+ ```typescript
679
+ // skill-instructions.ts
680
+ interface CachedSkillMarkdown {
681
+ raw: string;
682
+ compacted: string; // Add this
683
+ mtimeMs: number;
684
+ size: number;
685
+ }
686
+ ```
687
+
688
+ #### 1.3 Stable prefix memoization (NEW-M1)
689
+ ```typescript
690
+ // team-runner.ts — batch loop
691
+ const stablePrefixCache = new Map<string, string>();
692
+
693
+ // Before spawning batch
694
+ for (const task of batch) {
695
+ const key = `${task.cwd}|${task.agent.role}|${runId}`;
696
+ if (!stablePrefixCache.has(key)) {
697
+ stablePrefixCache.set(key, await computeStablePrefix(task));
698
+ }
699
+ task.stablePrefix = stablePrefixCache.get(key);
700
+ }
701
+ ```
702
+
703
+ ---
704
+
705
+ ### Phase 2: Correctness + Performance (3–5 days)
706
+
707
+ #### 2.1 Fix `saveRunManifest` lock race (NEW-C3)
708
+ ```typescript
709
+ // task-runner.ts:1193
710
+ await withRunLock(runId, () => saveRunManifestAsync(manifest));
711
+ ```
712
+
713
+ #### 2.2 Convert `persistSingleTaskUpdate` to async (A5-C2)
714
+ ```typescript
715
+ // state-helpers.ts
716
+ export async function persistSingleTaskUpdateAsync(
717
+ manifest: TeamRunManifest,
718
+ taskId: string,
719
+ update: Partial<TeamTaskState>
720
+ ): Promise<void> {
721
+ await withRunLock(manifest.runId, async () => {
722
+ // Async read + write, single stat on hit
723
+ });
724
+ }
725
+ ```
726
+
727
+ #### 2.3 Skill cache dirStamp (NEW-M2)
728
+ ```typescript
729
+ // skill-instructions.ts
730
+ function dirStamp(skillDir: string): string {
731
+ try { return fs.statSync(skillDir).mtimeMs.toString(); }
732
+ catch { return "0"; }
733
+ }
734
+ ```
735
+
736
+ ---
737
+
738
+ ### Phase 3: Architectural (1–2 weeks)
739
+
740
+ #### 3.1 Streaming dispatch (A5-C1)
741
+ Replace batch loop with continuous dispatch that starts Wave N+1 tasks as soon as their specific dependencies complete.
742
+
743
+ #### 3.2 Batched event appends (A5-C12)
744
+ Collect events during batch, write once at batch end using `appendFile` batch.
745
+
746
+ ---
747
+
748
+ ## 6. Validation Strategy
749
+
750
+ ### Unit Tests
751
+
752
+ | Finding | Test Coverage |
753
+ |---------|---------------|
754
+ | NEW-C3 | Test that parallel artifact writes don't lose data |
755
+ | A5-C2 | Test async lock doesn't deadlock with sync paths |
756
+ | A1-F7 | Test `collectedJsonEvents` is `undefined` for child-process |
757
+ | A4-F1 | Test compacted content cached correctly |
758
+ | NEW-M1 | Test stable prefix identical across parallel tasks |
759
+
760
+ ### Integration Tests
761
+
762
+ ```bash
763
+ # Run full test suite
764
+ npm test
765
+
766
+ # Run with concurrency
767
+ npm run test:parallel
768
+
769
+ # Run benchmarks
770
+ npm run bench
771
+ npm run bench:check # Compare against baseline
772
+ ```
773
+
774
+ ### Benchmark Metrics
775
+
776
+ | Metric | Tool | Baseline | Target |
777
+ |--------|------|----------|--------|
778
+ | Task completion latency | `bench:persist-single-task` | TBD | <15% improvement |
779
+ | Skill rendering time | `bench:skill-render` | TBD | <15% improvement |
780
+ | Stable prefix computation | `bench:prompt-stable-prefix` | TBD | <15% improvement |
781
+ | Memory per task | `bench:memory` | TBD | <10KB reduction |
782
+ | I/O per task | `bench:io` | TBD | <20% reduction |
783
+
784
+ ### Performance Regression Guards
785
+
786
+ ```bash
787
+ # CI: Fail if benchmarks regress >15%
788
+ npm run bench:check
789
+
790
+ # Pre-commit: Run fast unit tests
791
+ npm test
792
+ ```
793
+
794
+ ---
795
+
796
+ ## Appendix A: Findings Summary Table
797
+
798
+ | ID | Severity | Category | Finding | Impact | Status | File:Line |
799
+ |----|----------|----------|---------|--------|--------|-----------|
800
+ | NEW-C3 | **CRITICAL** | Concurrency | `saveRunManifest` no lock — write-write race | Silent artifact loss | **NEW** | task-runner.ts:1193 |
801
+ | NEW-C6 | **HIGH** | Concurrency | Double lock + redundant disk read in merge | 10–50ms × batches | **NEW** | team-runner.ts:1450 |
802
+ | A5-C2 | **HIGH** | Concurrency | `persistSingleTaskUpdate` sync lock blocks event loop | 200–800ms/4-parallel | **NEW** | state-helpers.ts:36 |
803
+ | A4-F1 | **HIGH** | Prompt | Redundant skill compaction on cache hit | ~15% waste | **NEW** | skill-instructions.ts:216 |
804
+ | A1-F14 | **HIGH** | I/O | Sync `saveRunManifest` on every task completion | 100–1000ms/run | **NEW** | task-runner.ts:1168 |
805
+ | NEW-M1 | MEDIUM | I/O/Prompt | Stable prefix recomputed per parallel task | 200–800ms/4-tasks | **NEW** | prompt-builder.ts:123 |
806
+ | NEW-M2 | MEDIUM | I/O | Skill cache stat on every cache hit | 10–80ms/4-tasks | **NEW** | skill-instructions.ts:128 |
807
+ | A4-F3 | MEDIUM | Prompt | Token estimation 10–15% error for code | Context waste | **NEW** | token-counter.ts:49 |
808
+ | A4-F4 | MEDIUM | Algorithm | Staleness index rebuilds O(N) per pruning | O(N) per call | **NEW** | tool-output-pruner.ts:182 |
809
+ | A1-F3 | MEDIUM | Algorithm | taskGraphSnapshot 7 passes → 1 pass | 0.1–0.5ms/cycle | **NEW** | task-graph-scheduler.ts:130 |
810
+ | A1-F5 | MEDIUM | I/O | tailReadWithLineSnap double stat+read | ~20ms/task | **NEW** | tail-read.ts:11 |
811
+ | A1-F7 | MEDIUM | Memory | collectedJsonEvents unused for child-process | 10KB/task waste | **NEW** | task-runner.ts:296 |
812
+ | A5-C4 | MEDIUM | Concurrency | loadRunManifestById retry without lock | Low-prob zombie | **NEW** | team-runner.ts:1254 |
813
+ | A5-C8 | MEDIUM | Algorithm | Greedy write-overlap filter suboptimal | Unused slot | **NEW** | path-overlap.ts:76 |
814
+ | A5-C12 | MEDIUM | I/O | appendEventAsync promise chain unbounded | 150–750ms/run | **NEW** | event-log.ts:453 |
815
+ | A2-F1 | MEDIUM | Memory | taskUsageMap no auto-cleanup | Unbounded growth | **NEW** | usage-tracker.ts:38 |
816
+ | A5-C1 | MEDIUM | Architecture | Batch serialization — no inter-batch overlap | 5–10 min/run | **NEW** | team-runner.ts:922 |
817
+ | A1-F2 | LOW | GC | compactChildPiEvent object churn per line | 250–500ms/task | **NEW** | child-pi.ts:522 |
818
+ | A4-F2 | LOW | Prompt | Knowledge section parsing per query | 2–5ms/worker | **NEW** | knowledge-injection.ts:114 |
819
+ | A1-F9 | LOW | I/O | Redundant mkdirSync in attempt loop | 0.3ms/task | **NEW** | task-runner.ts:411 |
820
+ | A1-F11 | LOW | Memory | Array copies per event in progress | 20ms/task | **NEW** | progress.ts:107 |
821
+ | A1-F12 | LOW | I/O | Pretty-print JSON in metadata artifacts | 30–50% size | **NEW** | task-runner.ts:1126 |
822
+ | A1-F13 | LOW | Concurrency | backpressureBytes counter never resets | Minor | **NEW** | child-pi.ts:1149 |
823
+ | A1-F15 | LOW | CPU | isFinalAssistantEvent per event | Negligible | **NEW** | child-pi.ts:515 |
824
+ | A2-F2 | LOW | Memory | MAX_TRACKED_STATES=5000 cap | Bounded | **NEW** | overflow-recovery.ts:34 |
825
+
826
+ ---
827
+
828
+ ## Appendix B: Cross-Reference with Prior Documentation
829
+
830
+ ### vs. `performance-review-2026-07.md`
831
+
832
+ | Docs Finding | Audit Status | Notes |
833
+ |--------------|--------------|-------|
834
+ | F1 — Global generation counter | ✅ FIXED | `state-store.ts:82` |
835
+ | F2 — persistSingleTaskUpdate sync | ⚠️ PARTIAL | Phase 2 plan exists |
836
+ | F3 — fsync per event | ⚠️ PARTIAL | `task.progress` only |
837
+ | F4 — tasks.json full rewrite | ⚠️ PARTIAL | Option exists, not migrated |
838
+ | F5 — symlink check no cache | ⚠️ PRESENT | No caching yet |
839
+ | F6 — mailbox full rewrite | ⚠️ PRESENT | High-risk refactor |
840
+ | F8 — execFileSync blocking | ⚠️ PRESENT | All sync worktree ops |
841
+ | F9 — rawTextEvents unbounded | ✅ FIXED | Ring buffer cap |
842
+ | F12 — finalDrainMs hard | ✅ FIXED | Early-exit on silence |
843
+ | F15 — discovery cache TTL | ✅ FIXED | 5000ms |
844
+ | F16 — config no cache | ✅ FIXED | 2s TTL + mtime |
845
+ | F17 — discoverWorkflows no cache | ✅ FIXED | TTL + dirStamp |
846
+
847
+ ### vs. `optimization-plan-2026-07-verified.md`
848
+
849
+ | Plan Phase | Item | Audit Status |
850
+ |------------|------|--------------|
851
+ | Phase 1 | F17 cache discoverWorkflows | ✅ DONE |
852
+ | Phase 1 | F15 TTL discovery | ✅ DONE |
853
+ | Phase 1 | F4 durability best-effort | ⚠️ PARTIAL |
854
+ | Phase 1 | F9 ring buffer | ✅ DONE |
855
+ | Phase 2 | F2 stat consolidation | ⚠️ PENDING |
856
+ | Phase 2 | F3a fsync terminal only | ⚠️ PENDING |
857
+ | Phase 2 | F5 symlink cache | ⚠️ PENDING |
858
+ | Phase 2 | F1 generation per-stateRoot | ✅ DONE |
859
+ | Phase 3 | F12 early-exit-on-silence | ✅ DONE |
860
+ | Phase 3 | F3b buffering deadlock | ⚠️ REVERTED |
861
+ | Phase 3 | F6 mailbox append-only | ⚠️ PENDING |
862
+ | Phase 3 | In-memory task state | ⚠️ PENDING |
863
+
864
+ ---
865
+
866
+ *Report generated: 2026-07-11*
867
+ *Source data: adaptive-01 through adaptive-05 exploration artifacts + existing `docs/perf/` documentation*
868
+
869
+ ---
870
+
871
+ ## 7. Benchmark Findings (2026-07-11, post-bench)
872
+
873
+ A new benchmark `test/bench/terminal-persist-blocking.bench.ts` was added to
874
+ empirically answer the A5-C2 question: *does `persistSingleTaskUpdate`'s sync
875
+ run lock block the event loop 200–800ms during 4-way parallel completion?*
876
+
877
+ ### Method
878
+
879
+ Measures the **real persist path** (not just the atomic-write primitive) in
880
+ four scenarios, with `monitorEventLoopDelay` capturing true event-loop lag:
881
+
882
+ 1. **serialPersist** — full `persistSingleTaskUpdate` (lock + load + CAS + write), isolated.
883
+ 2. **saveManifestLarge** — `saveRunManifest` with 60 artifact descriptors (terminal size).
884
+ 3. **singleTerminalBlock** — ONE full terminal block (saveManifest + persist) = the actual
885
+ per-completion cost. In real runs completions are spread over minutes, so each runs
886
+ exactly one such block.
887
+ 4. **spacedBurst** — 4 completions spaced by 15ms setTimeout gaps (so the event loop turns
888
+ over between blocks), under `monitorEventLoopDelay`.
889
+
890
+ ### Results (captured in `test/bench/results.json`)
891
+
892
+ | Metric | p50 | p95 | max |
893
+ |--------|-----|-----|-----|
894
+ | serialPersist (ms) | 15.17 | 19.28 | 42.96 |
895
+ | saveManifestLarge (ms) | 13.99 | 22.18 | 38.94 |
896
+ | **singleTerminalBlock (ms)** | **30.28** | **32.83** | **38.92** |
897
+ | spacedBurstPerCall (ms) | 28.20 | 34.72 | 68.81 |
898
+ | eventLoopDelay (ms) | 1.09 | 29.69 | 210.11 |
899
+ | **contentionRatio** | **0.93** | — | — |
900
+
901
+ **contentionRatio was 0.93 / 0.98 / 0.96 / 0.98 across four independent runs** —
902
+ consistently ≈ 1.0.
903
+
904
+ ### Verdict: A5-C2 disproven
905
+
906
+ - **contentionRatio ≈ 1.0** means a completion's per-call latency under a 4-way
907
+ burst is **statistically identical** to a single isolated block. There is
908
+ **no lock contention** to relieve. The audit's premise ("4 tasks serialize
909
+ on the lock → `sleepSync` blocks 200–800ms") does not hold.
910
+
911
+ - **Why**: JavaScript is single-threaded. `withRunLockSync`'s critical section
912
+ is fully synchronous, so concurrent `mapConcurrent` completions **cannot
913
+ overlap** — worker A's persist runs atomically to completion, then worker B's
914
+ runs. By the time B runs, A has released the lock (re-entrance map cleared),
915
+ so B acquires fresh via `O_EXCL` on the first try — `sleepSync`/`Atomics.wait`
916
+ **never triggers** for same-process workers. It only triggers under genuine
917
+ *cross-process* contention (a separate OS process holding the lock file),
918
+ which doesn't happen for in-process task completion.
919
+
920
+ - **Real per-completion cost**: ~30ms p50 (one sync block). Real runs spread
921
+ completions over minutes, so this is ~30ms of event-loop blocking per
922
+ completion — acceptable for a CLI tool (no sub-16ms interactive frame budget
923
+ during that moment). The `eventLoopDelay` max of ~210ms is an occasional
924
+ fsync/GC outlier, **not** lock contention — and is the same fsync-cost issue
925
+ already tracked as F3/F4 in the prior review.
926
+
927
+ - **Why the async conversion wouldn't help even if applied**: `persistSingleTaskUpdate`'s
928
+ body is fully synchronous I/O (`loadRunManifestById`, `saveRunTasksCoalesced`,
929
+ `statSync`). Converting only the lock to async leaves the body I/O synchronous,
930
+ so event-loop blocking from the I/O persists regardless. Removing it would
931
+ require converting the entire persist path to async I/O — a much larger,
932
+ higher-risk refactor — for ~30ms of infrequent blocking that doesn't matter
933
+ in practice.
934
+
935
+ ### Recommendation
936
+
937
+ **Do not implement A5-C2 as specified.** The 11+ call-site + `checkpointTask`
938
+ ripple (with deadlock risk) buys nothing measurable. If event-loop blocking
939
+ from the persist path ever becomes a real problem (e.g. a highly concurrent
940
+ interactive TUI), the correct target is **async I/O throughout
941
+ `persistSingleTaskUpdate`** + a benchmark proving the win — not the lock
942
+ conversion.
943
+
944
+ ### What this benchmark is now good for
945
+
946
+ `terminal-persist-blocking.bench.ts` is a durable regression guard: any future
947
+ change to the persist/lock path can be re-benched to confirm `contentionRatio`
948
+ stays ≈1.0 and `singleTerminalBlock` doesn't regress.