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.
- package/CHANGELOG.md +88 -0
- package/dist/build-meta.json +187 -153
- package/dist/index.mjs +1346 -1276
- package/dist/index.mjs.map +4 -4
- package/docs/perf/performance-audit-report-2026-07.md +948 -0
- package/package.json +3 -2
- package/src/extension/run-maintenance.ts +29 -1
- package/src/extension/team-tool/lifecycle-actions.ts +22 -0
- package/src/extension/team-tool.ts +22 -1
- package/src/runtime/skill-instructions.ts +7 -2
- package/src/runtime/task-runner/prompt-builder.ts +66 -19
- package/src/runtime/task-runner.ts +20 -7
- package/src/runtime/team-runner.ts +87 -7
- package/src/state/atomic-write.ts +23 -0
- package/src/state/event-log.ts +71 -25
- package/tsconfig.json +3 -1
|
@@ -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.
|