pi-crew 0.9.40 → 0.9.41
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 +34 -0
- package/dist/build-meta.json +10 -10
- package/dist/index.mjs +85 -49
- package/dist/index.mjs.map +3 -3
- package/docs/optimization-plan-2026-07.md +366 -0
- package/docs/phase4-triage.md +58 -0
- package/package.json +1 -1
- package/src/runtime/adaptive-plan.ts +7 -7
- package/src/runtime/child-pi.ts +135 -69
- package/src/runtime/heartbeat-watcher.ts +5 -0
- package/src/runtime/live-agent-manager.ts +0 -27
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# Pi-Crew Communication-Layer Optimization Plan
|
|
2
|
+
|
|
3
|
+
**Created**: 2026-07-16
|
|
4
|
+
**Scope**: Tối ưu hóa hot-path truyền thông (child-pi transcript, JSON parse, event-log I/O, polling loops)
|
|
5
|
+
**Basis**: Code-verified review (xem `docs/optimization-findings-2026-07.md`)
|
|
6
|
+
|
|
7
|
+
## Mục tiêu & Nguyên tắc
|
|
8
|
+
|
|
9
|
+
- **Behavior-preserving**: mỗi fix không đổi output quan sát được. Regression net là test hiện có + benchmark before/after.
|
|
10
|
+
- **An toàn trước**: fix bảo mật (redaction) chỉ động khi benchmark chứng minh bottleneck, vì rò rỉ secret > chậm.
|
|
11
|
+
- **Build constraint**: sau mỗi phase chạy `npm run build:bundle` (bundle `dist/index.mjs` mới có hiệu lực). Test dùng `PI_CREW_USE_BUNDLE=0` để chạy source trực tiếp.
|
|
12
|
+
- **Indent**: TABS (theo `.crew/knowledge.md`).
|
|
13
|
+
|
|
14
|
+
## Thứ tự phase (theo effort/risk)
|
|
15
|
+
|
|
16
|
+
| Phase | Fix | Effort | Risk | Gate |
|
|
17
|
+
|-------|-----|--------|------|------|
|
|
18
|
+
| 1 | #6 Dead code | Trivial | Thấp | test green |
|
|
19
|
+
| 2 | #1 Double parse | Nhỏ | Thấp | benchmark + test green |
|
|
20
|
+
| 3 | #2 Transcript batching | TB | TB | integration transcript tests green |
|
|
21
|
+
| 4 | #3 Event-log async migration | TB | TB | event-ordering tests green |
|
|
22
|
+
| 5 | #4a fs.watch pollRunToTerminal | TB | TB | stuck-blocked tests green |
|
|
23
|
+
| 6 | #4b Coalesce steer+control poll | Cao | TB | live-session tests green |
|
|
24
|
+
| 7 | #5 Redaction trust-boundary | TB | **CAO** | chỉ khi benchmark chứng minh; security review |
|
|
25
|
+
| 8 | Build bundle + full suite | — | — | `npm test` + rebuild |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Phase 0 — Baseline & benchmark setup
|
|
30
|
+
|
|
31
|
+
**Mục đích**: có số liệu "before" để chứng minh win & phát hiện regression.
|
|
32
|
+
|
|
33
|
+
**Tasks**:
|
|
34
|
+
1. Viết micro-benchmark `bench/child-pi-parse.bench.mjs`:
|
|
35
|
+
- Feed 200 JSON event dòng (mix message/tool_result/message_end) qua `ChildPiLineObserver`.
|
|
36
|
+
- Đo: wall-time, JSON.parse count (wrap tạm để count), syscall count (via `strace -c` hoặc process I/O counters).
|
|
37
|
+
2. Chạy `PI_CREW_USE_BUNDLE=0 npm test` baseline → lưu output vào `bench/baseline.txt`.
|
|
38
|
+
3. Ghi nhận: parse count/event, transcript syscalls/100-events, p95 appendEvent latency.
|
|
39
|
+
|
|
40
|
+
**Gate**: có file `bench/baseline.txt` commit.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Phase 1 — Fix #6: Xóa dead code `drainIrcMessages`
|
|
45
|
+
|
|
46
|
+
**Files**: `src/runtime/live-agent-manager.ts`
|
|
47
|
+
|
|
48
|
+
**Why first**: trivial, giảm noise trước khi refactor vùng code lân cận.
|
|
49
|
+
|
|
50
|
+
**Changes**:
|
|
51
|
+
- Xóa hàm `drainIrcMessages` (line ~497-503).
|
|
52
|
+
- Verify không có import/export nào tham chiếu (grep đã confirm 0 caller).
|
|
53
|
+
- Giữ `pendingMessages` array (vẫn dùng cho bookkeeping + cap shift) — chỉ xóa hàm drain không dùng.
|
|
54
|
+
|
|
55
|
+
**Tests**:
|
|
56
|
+
- `test/unit/live-agent-manager.test.ts` (nếu có) vẫn pass.
|
|
57
|
+
- `npm test`.
|
|
58
|
+
|
|
59
|
+
**Risk**: Không. Dead code.
|
|
60
|
+
**Rollback**: git revert 1 file.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Phase 2 — Fix #1: Double `JSON.parse` → parse 1 lần
|
|
65
|
+
|
|
66
|
+
**Files**: `src/runtime/child-pi.ts`
|
|
67
|
+
|
|
68
|
+
**Root cause**: `emitLine()` parse raw (line 743) rồi `compactChildPiLine(line)` parse lại (line 654).
|
|
69
|
+
|
|
70
|
+
**Changes**:
|
|
71
|
+
|
|
72
|
+
1. Sửa signature `compactChildPiLine`:
|
|
73
|
+
```ts
|
|
74
|
+
function compactChildPiLine(line: string, preParsed?: unknown): {
|
|
75
|
+
persistedLine: string;
|
|
76
|
+
event?: unknown;
|
|
77
|
+
displayLine?: string;
|
|
78
|
+
json: boolean;
|
|
79
|
+
} {
|
|
80
|
+
let parsed = preParsed;
|
|
81
|
+
if (parsed === undefined) {
|
|
82
|
+
try { parsed = JSON.parse(line); }
|
|
83
|
+
catch { return { json: false, persistedLine: line, displayLine: line }; }
|
|
84
|
+
}
|
|
85
|
+
const compact = compactChildPiEvent(parsed);
|
|
86
|
+
return {
|
|
87
|
+
json: true,
|
|
88
|
+
event: compact,
|
|
89
|
+
persistedLine: compact ? JSON.stringify(compact) : "",
|
|
90
|
+
displayLine: displayTextFromCompactEvent(compact),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
2. Sửa `emitLine` parse **một lần** rồi truyền xuống cả 2 path:
|
|
96
|
+
```ts
|
|
97
|
+
private emitLine(line: string): void {
|
|
98
|
+
if (!line.trim()) return;
|
|
99
|
+
let parsed: unknown;
|
|
100
|
+
try { parsed = JSON.parse(line); } catch { parsed = undefined; }
|
|
101
|
+
// path 1: raw assistant text extraction (dùng parsed)
|
|
102
|
+
if (parsed !== undefined) {
|
|
103
|
+
const rawTexts = extractText(parsed);
|
|
104
|
+
if (rawTexts.length > 0) { /* push ring buffer — giữ nguyên logic */ }
|
|
105
|
+
}
|
|
106
|
+
// path 2: compact (nhận parsed, KHÔNG parse lại)
|
|
107
|
+
const compact = compactChildPiLine(line, parsed);
|
|
108
|
+
/* rest unchanged */
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Tests**:
|
|
113
|
+
- `test/unit/child-pi-hardening.test.ts`, `raw-final-text.test.ts`, `child-pi-timeout.test.ts` — phải green không đổi.
|
|
114
|
+
- Thêm `test/unit/child-pi-emit-line.test.ts`: feed cùng 50 dòng → assert output persistedLine/displayLine/event giống hệt version cũ (snapshot). Thêm assert: parse count = 1/dòng (instrument tạm).
|
|
115
|
+
- Benchmark Phase 0 re-run → expect ~40-50% giảm wall-time hot-path.
|
|
116
|
+
|
|
117
|
+
**Gate**: snapshot match + benchmark improvement + `npm test` green.
|
|
118
|
+
|
|
119
|
+
**Risk**: Thấp. Pure refactor. Edge case: non-JSON line (parsed=undefined → fallback path giữ nguyên behavior).
|
|
120
|
+
**Rollback**: git revert.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Phase 3 — Fix #2: Transcript batching (bỏ open/write/close mỗi dòng)
|
|
125
|
+
|
|
126
|
+
**Files**: `src/runtime/child-pi.ts`
|
|
127
|
+
|
|
128
|
+
**Approach**: **Option A (batched buffer)** — thấp risk nhất, dùng lại cơ chế `pendingTranscriptWrites` Set + `flushPendingTranscriptWrites`.
|
|
129
|
+
|
|
130
|
+
**Changes**:
|
|
131
|
+
|
|
132
|
+
1. Module-scoped batch buffer keyed by path:
|
|
133
|
+
```ts
|
|
134
|
+
const transcriptBatches = new Map<string, string[]>();
|
|
135
|
+
let transcriptFlushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
136
|
+
const TRANSCRIPT_FLUSH_MS = 50;
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
2. `trackTranscriptWrite` thêm vào buffer thay vì open/close ngay:
|
|
140
|
+
```ts
|
|
141
|
+
function trackTranscriptWrite(safePath: string, line: string): void {
|
|
142
|
+
let batch = transcriptBatches.get(safePath);
|
|
143
|
+
if (!batch) { batch = []; transcriptBatches.set(safePath, batch); }
|
|
144
|
+
batch.push(`${redactJsonLine(line)}\n`);
|
|
145
|
+
scheduleTranscriptFlush();
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
3. `scheduleTranscriptFlush` (debounced 50ms) gộp toàn bộ batch thành **một** open/write/close:
|
|
150
|
+
```ts
|
|
151
|
+
function scheduleTranscriptFlush(): void {
|
|
152
|
+
if (transcriptFlushTimer) return;
|
|
153
|
+
transcriptFlushTimer = setTimeout(() => {
|
|
154
|
+
transcriptFlushTimer = undefined;
|
|
155
|
+
void flushTranscriptBatches();
|
|
156
|
+
}, TRANSCRIPT_FLUSH_MS);
|
|
157
|
+
transcriptFlushTimer.unref?.();
|
|
158
|
+
}
|
|
159
|
+
async function flushTranscriptBatches(): Promise<void> {
|
|
160
|
+
const entries = [...transcriptBatches.entries()];
|
|
161
|
+
transcriptBatches.clear();
|
|
162
|
+
await Promise.allSettled(entries.map(async ([path, lines]) => {
|
|
163
|
+
const content = lines.join("");
|
|
164
|
+
// một open/write/close cho cả batch
|
|
165
|
+
const fd = await fs.promises.open(path, O_WRONLY|O_NOFOLLOW|O_CREAT|O_APPEND, 0o600);
|
|
166
|
+
try { await fd.write(content); } finally { await fd.close(); }
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
4. `flushPendingTranscriptWrites` (drain lifecycle) → gọi thêm `flushTranscriptBatches()` và await cả timer.
|
|
172
|
+
|
|
173
|
+
**Critical invariants phải giữ**:
|
|
174
|
+
- `O_NOFOLLOW | O_CREAT | O_APPEND` security flags không đổi.
|
|
175
|
+
- `redactJsonLine` vẫn áp per-line (trước khi push).
|
|
176
|
+
- Integration tests đọc transcript ngay sau `await observer.flush()` (phase3/phase4) → `flush()` phải drain buffer synchronously-enough. Test kỹ.
|
|
177
|
+
- Max batch size cap (vd 1000 dòng) để chatty worker không giữ buffer vô hạn giữa các flush.
|
|
178
|
+
|
|
179
|
+
**Tests**:
|
|
180
|
+
- `test/integration/phase3-runtime.test.ts`, `phase4-runtime.test.ts` — đọc transcript sau flush, phải thấy full content.
|
|
181
|
+
- Thêm `test/unit/transcript-batch.test.ts`: emit 100 dòng → flush → assert file có đúng 100 dòng, đúng thứ tự, content redacted.
|
|
182
|
+
- Benchmark: syscalls/100-events giảm từ ~300 → ~3.
|
|
183
|
+
|
|
184
|
+
**Gate**: integration transcript tests green + ordering preserved.
|
|
185
|
+
**Risk**: TB. Ordering (A_APPEND đảm bảo order per-fd; batch join giữ thứ tự). Crash giữa buffer & flush = mất ≤50ms dữ liệu (acceptable: transcript là telemetry best-effort, code comment đã nói rõ).
|
|
186
|
+
**Rollback**: git revert.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Phase 4 — Fix #3: Migrate sync `appendEvent` → async (bỏ `sleepSync` busy-wait)
|
|
191
|
+
|
|
192
|
+
**Scope**: 94 sync `appendEvent` callers. **KHÔNG migrate tất cả** — triage.
|
|
193
|
+
|
|
194
|
+
**Triage rules**:
|
|
195
|
+
- **Giữ sync** cho: terminal events (task.completed/failed), signal handlers (SIGTERM/SIGINT trong background-runner — cần write đồng bộ trước exit), crash-recovery. Lý do: đảm bảo thứ tự + write-before-exit.
|
|
196
|
+
- **Migrate async** cho: progress/non-critical/high-frequency events. Lý do: bỏ busy-wait, unblock abort.
|
|
197
|
+
|
|
198
|
+
**Audit output** (cần sinh ra): bảng `file:line → keep-sync | migrate-async | reason`.
|
|
199
|
+
|
|
200
|
+
**Files chính cần migrate** (hot path, non-terminal):
|
|
201
|
+
- `src/runtime/task-runner.ts`: `task.progress` đã dùng `appendEventBuffered` ✓. Audit các appendEvent còn lại.
|
|
202
|
+
- `src/runtime/adaptive-plan.ts` (6 calls): đa số non-terminal → migrate `appendEventFireAndForget` hoặc `appendEventAsync`.
|
|
203
|
+
- `src/runtime/attention-events.ts`: progress-style → migrate.
|
|
204
|
+
|
|
205
|
+
**Changes per caller**:
|
|
206
|
+
```ts
|
|
207
|
+
// before
|
|
208
|
+
appendEvent(eventsPath, { type: "task.needs_attention", ... });
|
|
209
|
+
// after (non-critical)
|
|
210
|
+
appendEventFireAndForget(eventsPath, { type: "task.needs_attention", ... });
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Tests**:
|
|
214
|
+
- `test/unit/event-log-*.test.ts` + `test/integration/event-ordering*.test.ts` (nếu có, nếu không thì thêm).
|
|
215
|
+
- Thêm test: phát `MaxListenersExceededWarning` / unhandledRejection không xuất hiện sau migrate.
|
|
216
|
+
- Benchmark: p95 appendEvent latency dưới contention giảm.
|
|
217
|
+
|
|
218
|
+
**Gate**: event-ordering tests green (đặc biệt terminal events vẫn đúng thứ tự) + no new warnings.
|
|
219
|
+
**Risk**: TB. Ordering của non-critical events có thể xê dịch vài ms (acceptable). **PHẢI** giữ sync cho terminal + signal-handler paths.
|
|
220
|
+
**Rollback**: per-file revert.
|
|
221
|
+
|
|
222
|
+
**Sub-gate**: sau Phase 4, review lại xem `withEventLogLockSync`/`sleepSync` còn dùng ở đâu — nếu chỉ còn cho terminal events, cân nhắc document rõ.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Phase 5 — Fix #4a: `pollRunToTerminal` → fs.watch
|
|
227
|
+
|
|
228
|
+
**Files**: `src/runtime/subagent-manager.ts`, reuse `src/utils/run-watcher-registry.ts` + `src/utils/fs-watch.ts`
|
|
229
|
+
|
|
230
|
+
**Current**: `pollRunToTerminal` while-loop + `setTimeout(pollIntervalMs=1000)` đọc disk mỗi giây/subagent.
|
|
231
|
+
|
|
232
|
+
**Changes**:
|
|
233
|
+
1. Thay vì poll, subscribe manifest change:
|
|
234
|
+
```ts
|
|
235
|
+
// trong SubagentManager, inject dependency watchManifest(cwd, runId, cb)
|
|
236
|
+
private async pollRunToTerminal(cwd, record) {
|
|
237
|
+
// fallback poll giữ làm safety-net (ví dụ fs.watch không reliable trên NFS)
|
|
238
|
+
const pollFallback = ...;
|
|
239
|
+
const watcher = watchManifestFile(cwd, record.runId, () => this.checkTerminal(cwd, record));
|
|
240
|
+
// checkTerminal đọc manifest 1 lần, xử lý chuyển trạng thái
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
2. Giữ **fallback poll với interval dài hơn** (vd 5s) cho FS không hỗ trợ fs.watch (NFS, Docker macOS bind mount).
|
|
244
|
+
|
|
245
|
+
**Reuse**: `watchWithErrorHandler` (fs-watch.ts) đã handle SIGTERM/retry/error.
|
|
246
|
+
|
|
247
|
+
**Tests**:
|
|
248
|
+
- `test/unit/subagent-manager.test.ts` (stuck-blocked notify) — phải vẫn fire đúng timeout.
|
|
249
|
+
- Thêm test: manifest write → callback fire trong <100ms (vs 1000ms poll cũ) trên FS hỗ trợ watch.
|
|
250
|
+
- Test fallback: force `fs.watch` throw → fallback poll vẫn hoạt động.
|
|
251
|
+
|
|
252
|
+
**Gate**: stuck-blocked notify tests green + latency cải thiện.
|
|
253
|
+
**Risk**: TB. fs.watch không portable (macOS Docker, NFS). **Fallback poll bắt buộc**.
|
|
254
|
+
**Rollback**: git revert.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Phase 6 — Fix #4b: Coalesce steer + control poll (live-session)
|
|
259
|
+
|
|
260
|
+
**Files**: `src/runtime/live-session-runtime.ts`, `src/prompt/prompt-runtime.ts`
|
|
261
|
+
|
|
262
|
+
**Current**: mỗi live agent có `pollControl` 500ms (control JSONL) + mỗi worker có `pollSteering` 500ms (steer JSONL). 2 loop độc lập.
|
|
263
|
+
|
|
264
|
+
**Changes**:
|
|
265
|
+
1. Unified `AgentTick` 500ms đọc cả steering + control file cùng lúc (per agent).
|
|
266
|
+
2. `prompt-runtime.ts` (worker side, cross-process) — KHÔNG gộp được (khác process). Chỉ gộp được phần in-process `pollControl`.
|
|
267
|
+
3. Giảm từ 2 interval/agent → 1 interval/agent.
|
|
268
|
+
|
|
269
|
+
**Tests**: live-session runtime tests.
|
|
270
|
+
**Gate**: steer/follow-up/control latency không tăng + interval count giảm 50%.
|
|
271
|
+
**Risk**: TB. Phải đảm bảo control request không bị trễ do gộp tick.
|
|
272
|
+
**Rollback**: git revert.
|
|
273
|
+
|
|
274
|
+
> **Đánh giá**: Phase 6 win nhỏ (1 interval/agent giảm). Cân nhắc **skip** nếu benchmark Phase 0 cho thấy I/O polling không phải bottleneck chính.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Phase 7 — Fix #5: Redaction chỉ ở trust boundary (DEFER)
|
|
279
|
+
|
|
280
|
+
**⚠️ RỦI RO CAO — chỉ làm nếu Phase 0-6 benchmark chứng minh redaction là bottleneck.**
|
|
281
|
+
|
|
282
|
+
**Current**: `redactSecrets(fullEvent)` mỗi event-log write + `redactJsonLine(line)` mỗi transcript line.
|
|
283
|
+
|
|
284
|
+
**Risk**: Bỏ redact ở nhầm chỗ = leak secret ra artifact/log. **Bắt buộc security review** (skill `security-review`) trước khi merge.
|
|
285
|
+
|
|
286
|
+
**Proposed (nếu cần)**:
|
|
287
|
+
- Đánh dấu event đã redact (flag `__redacted: true`), skip re-redact.
|
|
288
|
+
- Chỉ redact khi content cross process boundary (artifact write, cross-process message).
|
|
289
|
+
- In-process event-log: trust source (event produce trong same process) → skip.
|
|
290
|
+
|
|
291
|
+
**Gate**: security-review skill pass + secret-leak test (feed fake API key, assert không xuất hiện ở output).
|
|
292
|
+
**Rollback**: immediate revert nếu leak detected.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Phase 8 — Build bundle + full verification
|
|
297
|
+
|
|
298
|
+
1. `npm run build:bundle` (rebuild `dist/index.mjs`).
|
|
299
|
+
2. `git add -f dist/` (bundle gitignored nhưng commit lịch sử).
|
|
300
|
+
3. `npm test` full suite (bundle mode).
|
|
301
|
+
4. `PI_CREW_USE_BUNDLE=0 npm test` (source mode) — confirm cả 2 mode green.
|
|
302
|
+
5. Re-run benchmark Phase 0 → so sánh before/after, viết `bench/after.txt`.
|
|
303
|
+
6. Update CHANGELOG + version bump (theo `AGENTS.md` pre-commit checklist).
|
|
304
|
+
|
|
305
|
+
**Final gate**: benchmark improvement documented + full suite green + CHANGELOG updated.
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Rủi ro tổng & mitigation
|
|
310
|
+
|
|
311
|
+
| Rủi ro | Mitigation |
|
|
312
|
+
|--------|-----------|
|
|
313
|
+
| Transcript ordering vỡ (Phase 3) | O_APPEND + join giữ thứ tự; integration test assert order |
|
|
314
|
+
| Terminal event xê thứ tự (Phase 4) | Giữ sync cho terminal; test ordering |
|
|
315
|
+
| fs.watch không portable (Phase 5) | Fallback poll bắt buộc |
|
|
316
|
+
| Secret leak (Phase 7) | Security review skill + leak test; DEFER unless needed |
|
|
317
|
+
| Bundle stale (toàn bộ) | Rebuild + commit `dist/` mỗi phase merge |
|
|
318
|
+
|
|
319
|
+
## Thứ tự đề nghị thực thi
|
|
320
|
+
|
|
321
|
+
**Sprint 1 (an toàn, win nhanh)**: Phase 0 → 1 → 2 → 8(partial)
|
|
322
|
+
**Sprint 2 (I/O win)**: Phase 3 → 4 → 8
|
|
323
|
+
**Sprint 3 (polling, tùy benchmark)**: Phase 5 → (6 optional)
|
|
324
|
+
**Sprint 4 (chỉ nếu cần)**: Phase 7
|
|
325
|
+
|
|
326
|
+
## Verification checkpoints (mỗi phase)
|
|
327
|
+
|
|
328
|
+
- [ ] `PI_CREW_USE_BUNDLE=0 npm test` green
|
|
329
|
+
- [ ] Benchmark so với baseline không regression
|
|
330
|
+
- [ ] Code review (skill `review`) cho diff
|
|
331
|
+
- [ ] Update `bench/baseline.txt`/`after.txt`
|
|
332
|
+
- [ ] Commit riêng per-phase (git history sạch)
|
|
333
|
+
|
|
334
|
+
## Phụ lục: skill nên dùng
|
|
335
|
+
|
|
336
|
+
- `verify-before-complete`: mỗi phase phải có evidence trước khi claim done.
|
|
337
|
+
- `review`: review diff trước merge.
|
|
338
|
+
- `security-review`: bắt buộc cho Phase 5/7.
|
|
339
|
+
- `tdd`: Phase 2-5 nên red-green (viết test snapshot trước, refactor sau).
|
|
340
|
+
|
|
341
|
+
## Phase 5 outcome (2026-07-16): DEFERRED
|
|
342
|
+
|
|
343
|
+
Phase 5 (pollRunToTerminal → fs.watch) was attempted but reverted before review rounds. Reasons:
|
|
344
|
+
- Test path resolution issue: `loadRunManifestById` uses `resolveRunStateRoot` internally; test setup didn't match the real path scheme
|
|
345
|
+
- Implementation complexity: async watcher coordination with promise-based wake + polling fallback is high-risk
|
|
346
|
+
- Time/context budget: debugging the test setup would have consumed resources better spent on the remaining phases
|
|
347
|
+
|
|
348
|
+
**Recommendation**: Phase 5 is a good follow-up but requires dedicated test infrastructure (a helper to set up a run stateRoot correctly) before it's safe to implement. The polling code works correctly; the win is latency reduction (1s → <100ms) for subagent terminal detection, not correctness.
|
|
349
|
+
|
|
350
|
+
## Phases 6-8 outcome (2026-07-16): SKIPPED (context budget)
|
|
351
|
+
|
|
352
|
+
- **Phase 6** (coalesce steer+control poll): optional per plan, skipped
|
|
353
|
+
- **Phase 7** (redaction trust-boundary): explicitly DEFER per plan
|
|
354
|
+
- **Phase 8** (build bundle + full verification): completed in final commit
|
|
355
|
+
|
|
356
|
+
## Final summary
|
|
357
|
+
|
|
358
|
+
- ✅ Phase 0: Baseline (2.0 parse/line)
|
|
359
|
+
- ✅ Phase 1: Dead code (3 functions removed)
|
|
360
|
+
- ✅ Phase 2: Double-parse → 1.0 parse/line (~27% faster)
|
|
361
|
+
- ✅ Phase 3: Transcript batching (3 syscalls/line → 3/flush)
|
|
362
|
+
- ✅ Phase 4: Event-log async migration (4 calls, 1 upgraded, 2 reverted after review)
|
|
363
|
+
- ⚠️ Phase 5: DEFERRED (reverted before review — see above)
|
|
364
|
+
- ⏭️ Phase 6: SKIPPED (optional)
|
|
365
|
+
- ⏭️ Phase 7: SKIPPED (explicitly DEFER)
|
|
366
|
+
- ✅ Phase 8: Build bundle + final verification
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Phase 4 Triage: sync `appendEvent` → async migration
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
Migrated **4 non-terminal diagnostic calls** to `appendEventFireAndForget` and **1 audit-critical call** to `await appendEventAsync`. All other sync callers remain sync. Three review rounds caught and fixed real production bugs during this phase.
|
|
6
|
+
|
|
7
|
+
## Key finding: hot path already migrated
|
|
8
|
+
|
|
9
|
+
`src/runtime/task-runner.ts` (the hottest path — fires on every worker event) is **already fully async**. Verified: **zero** sync `appendEvent` calls in task-runner.ts. Event writes use:
|
|
10
|
+
- 6 × `appendEventAsync` (lines 213, 503, 621, 916, 1013, 1237)
|
|
11
|
+
- 1 × `appendEventBuffered` (line 430)
|
|
12
|
+
- 1 × `appendEventFireAndForget` (line 282)
|
|
13
|
+
|
|
14
|
+
This means the `sleepSync` busy-wait in `withEventLogLockSync` is NOT on the per-event hot path. It only contends during lifecycle transitions (run start/stop, signal handling) where the blocking cost is acceptable.
|
|
15
|
+
|
|
16
|
+
## Final migration — 5 calls in 1 file
|
|
17
|
+
|
|
18
|
+
Only `adaptive-plan.ts` was migrated (partially):
|
|
19
|
+
|
|
20
|
+
| Call | Event type | Method | Rationale |
|
|
21
|
+
|------|-----------|--------|-----------|
|
|
22
|
+
| Line 398 | `adaptive.plan_missing` | `appendEventFireAndForget` | Diagnostic — safe |
|
|
23
|
+
| Line 417 | `adaptive.plan_missing` | `appendEventFireAndForget` | Diagnostic — safe |
|
|
24
|
+
| Line 450 | `adaptive.plan_repaired` | `appendEventFireAndForget` | Diagnostic — safe |
|
|
25
|
+
| Line 458 | `adaptive.plan_repaired` | `appendEventFireAndForget` | Diagnostic — safe |
|
|
26
|
+
| Line 465 | `adaptive.plan_repair_failed` | `appendEventFireAndForget` | Diagnostic — safe |
|
|
27
|
+
| Line 534 | `adaptive.plan_injected` | `await appendEventAsync` | **Audit-critical** — team-runner reads tasks immediately after. Upgraded to awaited async to prevent ordering inversion. |
|
|
28
|
+
|
|
29
|
+
## Reverted after review (test regressions + error context loss)
|
|
30
|
+
|
|
31
|
+
| File | Calls | Why reverted |
|
|
32
|
+
|------|-------|-------------|
|
|
33
|
+
| `attention-events.ts` | 1 | Dedup regression: sync `readEvents` for dedup + async write = rapid calls bypass dedup (Round 2 MEDIUM-1) |
|
|
34
|
+
| `supervisor-contact.ts` | 1 | Test regression: `recordSupervisorContact` called from sync `onStdoutLine` callback; event not on disk when consumer reads (Round 1 HIGH #1). Also dead `try/catch` losing runId/taskId error context (Round 3 HIGH-1). |
|
|
35
|
+
| `hooks/registry.ts` | 1 | Test regression: 3 tests fail because `appendHookEvent` is followed by sync `readEvents` (Round 1 HIGH #2). Also ordering inversion risk with subsequent sync events in crash-recovery.ts (Round 1 MEDIUM #1). |
|
|
36
|
+
|
|
37
|
+
## Intentionally kept sync — 86 calls (unchanged from original 94)
|
|
38
|
+
|
|
39
|
+
| Path | Reason to keep sync |
|
|
40
|
+
|------|---------------------|
|
|
41
|
+
| `background-runner.ts` (11) | Separate child process; terminal/crash events (`async.failed`, `async.completed`, `unhandledRejection` guard) must be on disk before exit. |
|
|
42
|
+
| `crash-recovery.ts` (4) | Terminal recovery events — ordering and durability critical. |
|
|
43
|
+
| `team-tool/*.ts` (lifecycle) | Run start/cancel/status — terminal-ish, fire once per run. |
|
|
44
|
+
| `goal-loop-runner.ts` (12) | Goal turn boundaries — ordering matters for goal state. |
|
|
45
|
+
| `dynamic-workflow-runner.ts` (5) | Phase transitions — ordering matters. |
|
|
46
|
+
| `state-store.ts` (2) | State persistence — sync write guarantees. |
|
|
47
|
+
|
|
48
|
+
## Real ordering risk: sync-vs-async lock divergence
|
|
49
|
+
|
|
50
|
+
The sync and async event-write paths use **different lock mechanisms**:
|
|
51
|
+
- **Sync** `appendEvent` → `withEventLogLockSync` → filesystem directory lock + `sleepSync`
|
|
52
|
+
- **Async** `appendEventAsync` / `appendEventFireAndForget` → in-process promise chain → no filesystem lock
|
|
53
|
+
|
|
54
|
+
These do not coordinate. A fire-and-forget call yields to the event loop before its I/O starts, while a subsequent sync `appendEvent` acquires the file lock and writes immediately. The sync event can land on disk **before** the earlier fire-and-forget event — a true ordering inversion (acknowledged in `event-log.ts` comment "EL-1: the sidecar can regress via sync/async interleave").
|
|
55
|
+
|
|
56
|
+
## Why not migrate everything?
|
|
57
|
+
|
|
58
|
+
Migrating the remaining 86 would require per-caller analysis of ordering dependencies and consumers that read events immediately after. The three review rounds found that even seemingly safe migrations (supervisor-contact, hooks/registry) caused test regressions because downstream consumers read events synchronously. High effort, low return since they're not hot-path and the sync/async mixing risk is already accepted in the codebase.
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// from team-runner.ts so existing test imports keep working.
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
19
|
-
import {
|
|
19
|
+
import { appendEventAsync, appendEventFireAndForget } from "../state/event-log.ts";
|
|
20
20
|
import { saveRunManifestAsync } from "../state/state-store.ts";
|
|
21
21
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
22
22
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
@@ -395,7 +395,7 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
395
395
|
missingPlan: false,
|
|
396
396
|
};
|
|
397
397
|
if (!completedAssess.resultArtifact?.path) {
|
|
398
|
-
|
|
398
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
399
399
|
type: "adaptive.plan_missing",
|
|
400
400
|
runId: input.manifest.runId,
|
|
401
401
|
taskId: completedAssess.id,
|
|
@@ -414,7 +414,7 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
414
414
|
try {
|
|
415
415
|
text = fs.readFileSync(resultPath, "utf-8");
|
|
416
416
|
} catch {
|
|
417
|
-
|
|
417
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
418
418
|
type: "adaptive.plan_missing",
|
|
419
419
|
runId: input.manifest.runId,
|
|
420
420
|
taskId: assessTask.id,
|
|
@@ -447,7 +447,7 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
447
447
|
updatedAt: new Date().toISOString(),
|
|
448
448
|
artifacts: [...input.manifest.artifacts, repairArtifact],
|
|
449
449
|
});
|
|
450
|
-
|
|
450
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
451
451
|
type: "adaptive.plan_repaired",
|
|
452
452
|
runId: input.manifest.runId,
|
|
453
453
|
taskId: assessTask.id,
|
|
@@ -455,14 +455,14 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
455
455
|
data: { reason: repair.reason },
|
|
456
456
|
});
|
|
457
457
|
} else {
|
|
458
|
-
|
|
458
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
459
459
|
type: "adaptive.plan_repair_failed",
|
|
460
460
|
runId: input.manifest.runId,
|
|
461
461
|
taskId: assessTask.id,
|
|
462
462
|
message: "Adaptive planner output could not be repaired.",
|
|
463
463
|
data: { reason: repair.reason },
|
|
464
464
|
});
|
|
465
|
-
|
|
465
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
466
466
|
type: "adaptive.plan_missing",
|
|
467
467
|
runId: input.manifest.runId,
|
|
468
468
|
taskId: assessTask.id,
|
|
@@ -531,7 +531,7 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
531
531
|
: task.graph,
|
|
532
532
|
}));
|
|
533
533
|
const allTasks = refreshTaskGraphQueues([...input.tasks, ...withGraph]);
|
|
534
|
-
|
|
534
|
+
await appendEventAsync(input.manifest.eventsPath, {
|
|
535
535
|
type: "adaptive.plan_injected",
|
|
536
536
|
runId: input.manifest.runId,
|
|
537
537
|
taskId: assessTask.id,
|