pi-crew 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -0,0 +1,374 @@
1
+ /**
2
+ * event-log-tail-source.ts — WorkerEventSource cho surface workers (spec §5.3).
3
+ *
4
+ * Headless workers phát events qua stdout JSON stream; surface workers chạy
5
+ * TUI trong pane mux — không có stream nào cả — nên worker-side recorder
6
+ * (S2-T8, prompt/surface-worker.ts) tự ghi per-agent
7
+ * `agents/{taskId}/events.jsonl` theo đúng dòng `{seq,time,event}` host vẫn
8
+ * viết (crew-agent-records.ts appendCrewAgentEvent). `EventLogTailSource` là
9
+ * nửa host của hợp đồng đó: tail file này và phát lại `line.event` — payload
10
+ * compacted pi event, tức đúng shape `bridgeEventFromJsonEvent` tiêu thụ (và
11
+ * đúng shape `ChildPiLineObserver` phát cho `onJsonEvent` ở đường headless).
12
+ *
13
+ * Đọc incremental: watcher callback (fs.watch qua watchWithErrorHandler) chỉ
14
+ * báo "có đổi" — SOURCE tự giữ byte offset (spec §5.3: caller quản position).
15
+ * stat size > offset → readSync từ offset; size shrink (truncate) → reset về
16
+ * 0; nửa dòng chưa có `\n` được giữ lại tới khi đủ. File chưa tồn tại lúc
17
+ * start là ĐƯỜNG BÌNH THƯỜNG của surface (recorder tạo file ở event đầu, thậm
18
+ * chí mkdir lười) — source bootstrap bằng poll nhẹ cho tới khi gắn được
19
+ * watcher, rồi tắt poll.
20
+ *
21
+ * FEEDBACK-LOOP GUARD (đọc trước khi nối consumer): host KHÔNG ĐƯỢC đưa events
22
+ * này vào chuỗi `onJsonEvent` của task-runner — callback đó gọi
23
+ * `appendCrewAgentEventBuffered` ghi vào CHÍNH file này (worker đã ghi rồi,
24
+ * §5.3 "transcript per-agent: worker-side recorder") → mỗi dòng bị ghi hai
25
+ * lần và watcher tự kích hoạt chính nó (watch → append → watch …) không dừng.
26
+ * Call site (child-pi.ts) chỉ bridge qua `bridgeEventFromJsonEvent` +
27
+ * `runEventBus` cho dashboard/sidebar — không đụng disk.
28
+ *
29
+ * StdoutJsonEventSource: adapter mỏng của đường headless — bọc child.stdout,
30
+ * tách dòng, compact qua `compactChildPiEvent` (cùng compaction recorder
31
+ * worker dùng) và phát event. Đường headless CHÍNH vẫn giữ nguyên wiring
32
+ * ChildPiLineObserver của nó (transcript/raw-text/steering không đổi); class
33
+ * này tồn tại để cả hai nguồn cùng implement một interface cho consumer chung.
34
+ */
35
+
36
+ import * as fs from "node:fs";
37
+ import * as path from "node:path";
38
+ import type { Readable } from "node:stream";
39
+
40
+ import { closeWatcher, watchWithErrorHandler } from "../utils/fs-watch.ts";
41
+ import { logInternalError } from "../utils/internal-error.ts";
42
+ import { compactChildPiEvent } from "./child-pi/child-pi-streams.ts";
43
+
44
+ /**
45
+ * Payload cả hai source phát: compacted pi event (`{type, ...}`) — INPUT shape
46
+ * của `bridgeEventFromJsonEvent`, OUTPUT shape của `ChildPiLineObserver` /
47
+ * worker recorder. Spec §5.3 phác callback là `StreamBridgeEvent`, nhưng không
48
+ * source constructor nào mang runId/taskId để build shape đó; bridging thuộc
49
+ * call site (child-pi.ts) nơi đủ ngữ cảnh.
50
+ */
51
+ export type WorkerEventPayload = unknown;
52
+
53
+ export interface WorkerEventSource {
54
+ /** Phân biệt nguồn — consumer chặn re-append cho "event-log" (xem header). */
55
+ readonly sourceType: "stdout" | "event-log";
56
+ /** Đăng ký callback nhận event (một source một callback — gọi lại là thay). */
57
+ onEvent(cb: (event: WorkerEventPayload) => void): void;
58
+ /** Dừng watcher/timer, detach stream. Idempotent, sync. */
59
+ close(): void;
60
+ }
61
+
62
+ /** Nhịp poll bootstrap khi chưa watch được file (mặc định 250ms). */
63
+ export const TAIL_BOOTSTRAP_POLL_MS = 250;
64
+ /**
65
+ * Backoff tối đa cho bootstrap poll khi file chưa xuất hiện (surface worker
66
+ * ghi chậm; ENOENT là đường bình thường). Không poll 250ms + log vô hạn khiến
67
+ * UI repaint liên tục — tăng dần lên tới mức này.
68
+ */
69
+ export const TAIL_BOOTSTRAP_POLL_MAX_MS = 4 * TAIL_BOOTSTRAP_POLL_MS;
70
+ /**
71
+ * Nhịp poll dự phòng KHI watcher đã gắn (spec §5.3 tail -f model). Watcher
72
+ * cho latency thấp, nhưng FSEvents (macOS) gộp/im lặng với appends liên tiếp
73
+ * vào cùng file (CI 33463597499: append thứ 2 trong cùng process không bao
74
+ * giờ tới) — poll nhẹ này là lưới an toàn cho MỌI backend watcher. statSync
75
+ * một file mỗi nhịp là no-op rẻ khi không đổi (size === offset → return).
76
+ */
77
+ export const TAIL_STEADY_POLL_MS = 250;
78
+
79
+ interface TailDeps {
80
+ /** Timer override cho test. */
81
+ setTimeoutFn?: (fn: () => void, ms: number) => unknown;
82
+ clearTimeoutFn?: (timer: unknown) => void;
83
+ }
84
+
85
+ export class EventLogTailSource implements WorkerEventSource {
86
+ readonly sourceType = "event-log" as const;
87
+
88
+ private readonly eventsPath: string;
89
+ private readonly deps: TailDeps;
90
+ private callback: ((event: WorkerEventPayload) => void) | undefined;
91
+ private watcher: fs.FSWatcher | null = null;
92
+ private bootstrapTimer: unknown;
93
+ /** Poll dự phòng steady-state (song song watcher) — xem TAIL_STEADY_POLL_MS. */
94
+ private steadyTimer: unknown;
95
+ /** Số lần bootstrap poll fail liên tiếp — cho backoff + log-once. */
96
+ private bootstrapAttempts = 0;
97
+ private closed = false;
98
+ /** Cờ chống drain-before-close đệ quy (consumer close() trong callback). */
99
+ private draining = false;
100
+ /** Byte offset đã đọc tới — tự giữ qua các lần watcher báo đổi. */
101
+ private offset = 0;
102
+ /** Nửa dòng chưa kết thúc `\n` — giữ tới lần đọc kế tiếp. */
103
+ private partial = "";
104
+
105
+ constructor(input: { eventsPath: string }, deps: TailDeps = {}) {
106
+ this.eventsPath = input.eventsPath;
107
+ this.deps = deps;
108
+ }
109
+
110
+ onEvent(cb: (event: WorkerEventPayload) => void): void {
111
+ this.callback = cb;
112
+ if (this.closed) return;
113
+ // Bắt kịp nội dung có sẵn (event landed trước khi source được dựng),
114
+ // rồi gắn watcher. File chưa có → bootstrap poll.
115
+ this.readFromOffset();
116
+ this.attachWatcher();
117
+ }
118
+
119
+ close(): void {
120
+ if (this.closed) return;
121
+ // Final catch-up (bắt được từ E2E herdr thật, 2026-08-27): pane surface
122
+ // có thể đóng rất nhanh sau worker.completed — host close ngay khi thấy
123
+ // result, TRƯỚC cả nhịp bootstrap poll 250ms kế tiếp kịp attach file
124
+ // agent-log (herdr socket nhanh; tmux chậm hơn nên thắng race). Đọc lần
125
+ // cuối đồng bộ để mọi dòng worker đã ghi vẫn tới consumer (dashboard,
126
+ // T11 controller pid) trước khi source chết.
127
+ this.drainBeforeClose();
128
+ this.closed = true;
129
+ closeWatcher(this.watcher);
130
+ this.watcher = null;
131
+ if (this.bootstrapTimer !== undefined) {
132
+ const clear = this.deps.clearTimeoutFn ?? ((timer: unknown) => clearTimeout(timer as ReturnType<typeof setTimeout>));
133
+ clear(this.bootstrapTimer);
134
+ this.bootstrapTimer = undefined;
135
+ }
136
+ if (this.steadyTimer !== undefined) {
137
+ const clear = this.deps.clearTimeoutFn ?? ((timer: unknown) => clearTimeout(timer as ReturnType<typeof setTimeout>));
138
+ clear(this.steadyTimer);
139
+ this.steadyTimer = undefined;
140
+ }
141
+ }
142
+
143
+ /** Drain một lần, chống đệ quy khi consumer gọi close() ngay trong callback. */
144
+ private drainBeforeClose(): void {
145
+ if (this.draining) return;
146
+ this.draining = true;
147
+ try {
148
+ this.readFromOffset();
149
+ } finally {
150
+ this.draining = false;
151
+ }
152
+ }
153
+
154
+ private attachWatcher(): void {
155
+ if (this.closed || this.watcher) return;
156
+ // `created` phải là let-null (không phải const gán kết quả) — onError có
157
+ // thể chạy ĐỒNG BỘ bên trong watchWithErrorHandler khi fs.watch throw,
158
+ // trước khi kết quả gán xong (TDZ).
159
+ let created: fs.FSWatcher | null = null;
160
+ const onError = (error?: unknown): void => {
161
+ if (created && this.watcher !== created) return; // watcher cũ lỗi muộn
162
+ this.watcher = null;
163
+ if (created) closeWatcher(created);
164
+ // Log ENOENT ĐÚNG MỘT LẦN (không spam per-poll): file agent-log chưa
165
+ // xuất hiện là đường bình thường của surface (recorder tạo event đầu);
166
+ // log lặp mỗi 250ms là nguồn UI repaint liên tục. Lần đầu vẫn warn để
167
+ // kỹ sư thấy được, sau đó chỉ backoff-im lặng.
168
+ const err = error instanceof Error ? error : new Error(String(error));
169
+ const isEnoent = (err as NodeJS.ErrnoException)?.code === "ENOENT" || /ENOENT/.test(err.message);
170
+ if (this.bootstrapAttempts === 0 || !isEnoent) {
171
+ logInternalError("event-log-tail.watch", err, `eventsPath=${this.eventsPath}`, "warn");
172
+ }
173
+ // Watcher chết giữa chừng (file bị unlink…) — bootstrap lại với backoff;
174
+ // file missing tăng dần nhịp + không log lặp.
175
+ this.scheduleBootstrap();
176
+ };
177
+ // macOS (CI 33462332100): fs.watch trên CHÍNH file miss appends — FSEvents
178
+ // gộp/im lặng với write cùng-từ-process-khác, test "2 event qua 2 append"
179
+ // timeout 4s trên macOS dù xanh Linux. Watch DIRECTORY chứa file thay thế:
180
+ // dir change events fire đáng tin trên cả FSEvents (macOS), inotify
181
+ // (Linux), ReadDirectoryChangesW (Windows). Lọc theo filename rồi đọc
182
+ // incremental — size === offset → no-op nên event khác file vô hại.
183
+ // Dir chưa tồn tại (surface mkdir lười) vẫn ENOENT → bootstrap poll giữ
184
+ // nguyên đường cũ.
185
+ const dir = path.dirname(this.eventsPath);
186
+ const base = path.basename(this.eventsPath);
187
+ created = watchWithErrorHandler(
188
+ dir,
189
+ (_eventType, filename) => {
190
+ if (typeof filename === "string" && path.basename(filename) !== base) return;
191
+ this.readFromOffset();
192
+ },
193
+ onError,
194
+ );
195
+ if (created) {
196
+ this.watcher = created;
197
+ this.bootstrapAttempts = 0; // gắn được → reset backoff/log-once
198
+ this.scheduleSteadyPoll();
199
+ } else {
200
+ this.scheduleBootstrap(); // fs.watch unsupported / file chưa tồn tại
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Lưới an toàn steady-state: watcher có thể gộp/im lặng (FSEvents macOS gộp
206
+ * appends liên tiếp — CI 33463597499). Poll nhẹ chạy song song, tự reschedule
207
+ * tới khi close(); unref để không giữ event loop sống.
208
+ */
209
+ private scheduleSteadyPoll(): void {
210
+ if (this.closed || this.steadyTimer !== undefined) return;
211
+ const set = this.deps.setTimeoutFn ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
212
+ const timer = set(() => {
213
+ this.steadyTimer = undefined;
214
+ if (this.closed) return;
215
+ this.readFromOffset();
216
+ // Watcher chết ngầm giữa chừng (không error event) → bootstrap lại.
217
+ if (!this.watcher) this.scheduleBootstrap();
218
+ else this.scheduleSteadyPoll();
219
+ }, TAIL_STEADY_POLL_MS);
220
+ this.steadyTimer = timer;
221
+ (timer as { unref?: () => void } | null)?.unref?.();
222
+ }
223
+
224
+ private scheduleBootstrap(): void {
225
+ if (this.closed || this.bootstrapTimer !== undefined || this.watcher) return;
226
+ const set = this.deps.setTimeoutFn ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
227
+ // Backoff nhịp poll khi file chưa xuất hiện (bootstrapAttempts tăng dần):
228
+ // tránh poll 250ms + repaint vô hạn khi worker surface chậm/không ghi file.
229
+ this.bootstrapAttempts += 1;
230
+ const delay = Math.min(TAIL_BOOTSTRAP_POLL_MAX_MS, TAIL_BOOTSTRAP_POLL_MS * 2 ** (this.bootstrapAttempts - 1));
231
+ const timer = set(() => {
232
+ this.bootstrapTimer = undefined;
233
+ if (this.closed) return;
234
+ this.readFromOffset();
235
+ this.attachWatcher();
236
+ if (!this.watcher) this.scheduleBootstrap();
237
+ }, delay);
238
+ this.bootstrapTimer = timer;
239
+ // Poll bootstrap không được giữ event loop sống — surface branch đã có
240
+ // waitForSurfaceExit lo việc chờ.
241
+ (timer as { unref?: () => void } | null)?.unref?.();
242
+ }
243
+
244
+ /** Đọc từ offset tới cuối file; truncate → reset offset; phát từng dòng JSON. */
245
+ private readFromOffset(): void {
246
+ if (this.closed) return;
247
+ let size: number;
248
+ try {
249
+ size = fs.statSync(this.eventsPath).size;
250
+ } catch {
251
+ // File biến mất (chưa từng có, hoặc bị dọn giữa attempt) — đọc lại từ
252
+ // 0 khi nó (tái)xuất hiện, đúng semantics truncate.
253
+ this.offset = 0;
254
+ this.partial = "";
255
+ return;
256
+ }
257
+ if (size < this.offset) {
258
+ // Shrink = truncate/replace → bỏ vị trí cũ, đọc lại toàn bộ.
259
+ this.offset = 0;
260
+ this.partial = "";
261
+ }
262
+ if (size === this.offset) return;
263
+ let chunk: string;
264
+ let bytesRead: number;
265
+ try {
266
+ const fd = fs.openSync(this.eventsPath, "r");
267
+ try {
268
+ const length = size - this.offset;
269
+ const buffer = Buffer.alloc(length);
270
+ // File có thể đổi giữa statSync và readSync (worker đang append /
271
+ // truncate): bytesRead là sự thật, stat size chỉ là dự đoán.
272
+ bytesRead = fs.readSync(fd, buffer, 0, length, this.offset);
273
+ chunk = buffer.toString("utf-8", 0, bytesRead);
274
+ } finally {
275
+ fs.closeSync(fd);
276
+ }
277
+ } catch (error) {
278
+ logInternalError(
279
+ "event-log-tail.read",
280
+ error instanceof Error ? error : new Error(String(error)),
281
+ `eventsPath=${this.eventsPath}`,
282
+ "warn",
283
+ );
284
+ return;
285
+ }
286
+ this.offset += bytesRead;
287
+
288
+ const lines = (this.partial + chunk).split("\n");
289
+ this.partial = lines.pop() ?? ""; // giữ nửa dòng cuối (chưa có \n)
290
+ for (const text of lines) {
291
+ if (!text.trim()) continue;
292
+ let parsed: { event?: unknown };
293
+ try {
294
+ parsed = JSON.parse(text) as { event?: unknown };
295
+ } catch {
296
+ // Dòng hỏng/dở — bỏ qua, không làm kẹt cả stream.
297
+ logInternalError("event-log-tail.parse", new Error("unparseable JSONL line"), `eventsPath=${this.eventsPath}`, "debug");
298
+ continue;
299
+ }
300
+ if (parsed.event === null || typeof parsed.event !== "object") continue;
301
+ try {
302
+ this.callback?.(parsed.event);
303
+ } catch (error) {
304
+ // Consumer bug không được giết watcher — event kế tiếp vẫn phải tới.
305
+ logInternalError("event-log-tail.callback", error instanceof Error ? error : new Error(String(error)), undefined, "warn");
306
+ }
307
+ }
308
+ }
309
+ }
310
+
311
+ /** Adapter mỏng của đường headless: child.stdout JSON lines → compacted events. */
312
+ export class StdoutJsonEventSource implements WorkerEventSource {
313
+ readonly sourceType = "stdout" as const;
314
+
315
+ private readonly stdout: Readable;
316
+ private callback: ((event: WorkerEventPayload) => void) | undefined;
317
+ private buffer = "";
318
+ private attached = false;
319
+
320
+ constructor(child: { stdout: Readable }) {
321
+ this.stdout = child.stdout;
322
+ }
323
+
324
+ onEvent(cb: (event: WorkerEventPayload) => void): void {
325
+ this.callback = cb;
326
+ if (this.attached) return;
327
+ this.attached = true;
328
+ this.stdout.on("data", this.onData);
329
+ // Stream kết thúc → flush nửa dòng còn đọng (nếu là JSON trọn vẹn).
330
+ this.stdout.once("end", this.onEnd);
331
+ }
332
+
333
+ close(): void {
334
+ this.attached = false;
335
+ this.callback = undefined;
336
+ this.stdout.removeListener("data", this.onData);
337
+ this.stdout.removeListener("end", this.onEnd);
338
+ }
339
+
340
+ private readonly onData = (chunk: Buffer | string): void => {
341
+ this.observe(typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
342
+ };
343
+
344
+ private readonly onEnd = (): void => {
345
+ this.observe("\n");
346
+ };
347
+
348
+ private observe(text: string): void {
349
+ this.buffer += text;
350
+ const lines = this.buffer.split(/\r?\n/);
351
+ this.buffer = lines.pop() ?? "";
352
+ for (const line of lines) {
353
+ if (!line.trim()) continue;
354
+ let parsed: unknown;
355
+ try {
356
+ parsed = JSON.parse(line);
357
+ } catch {
358
+ continue; // dòng không phải JSON — đường headless cũng bỏ qua
359
+ }
360
+ const compacted = compactChildPiEvent(parsed);
361
+ if (compacted === undefined) continue;
362
+ try {
363
+ this.callback?.(compacted);
364
+ } catch (error) {
365
+ logInternalError(
366
+ "stdout-json-event.callback",
367
+ error instanceof Error ? error : new Error(String(error)),
368
+ undefined,
369
+ "warn",
370
+ );
371
+ }
372
+ }
373
+ }
374
+ }
@@ -19,7 +19,7 @@ import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
19
19
  import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
20
20
  import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
21
21
  import { withRunLock } from "../state/coordination/locks.ts";
22
- import { appendEvent, appendEventAsync, appendEventFireAndForget, readEvents } from "../state/event-log/event-log.ts";
22
+ import { appendEventAsync, appendEventBuffered, appendEventFireAndForget, readEvents } from "../state/event-log/event-log.ts";
23
23
  import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
24
24
  import { HealthStore } from "../state/stores/health-store.ts";
25
25
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
@@ -33,6 +33,7 @@ import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts
33
33
  import { buildRecoveryLedger } from "./recovery/recovery-recipes.ts";
34
34
  import type { SchedulerContext } from "./scheduler-context.ts";
35
35
  import { taskGraphSnapshot } from "./scheduling/task-graph-scheduler.ts";
36
+ import { sweepLaunchScriptsAtRunEnd } from "./surface/surface-spawn.ts";
36
37
 
37
38
  function formatTaskProgress(task: TeamTaskState): string {
38
39
  return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.error ? ` - ${task.error}` : ""}`;
@@ -210,12 +211,12 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
210
211
  createdAt: new Date().toISOString(),
211
212
  };
212
213
  decisions = [...decisions, branchDecision];
213
- appendEvent(manifest.eventsPath, {
214
+ appendEventBuffered(manifest.eventsPath, {
214
215
  type: "branch.stale",
215
216
  runId: manifest.runId,
216
217
  message: branchFreshness.message,
217
218
  data: { branchFreshness },
218
- });
219
+ }).catch((e) => logInternalError("finalize-run.buffered", e, "type=branch.stale"));
219
220
  }
220
221
  const policyArtifact = writeArtifact(manifest.artifactsRoot, {
221
222
  kind: "metadata",
@@ -231,15 +232,17 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
231
232
  content: `${JSON.stringify(recoveryLedger, null, 2)}\n`,
232
233
  });
233
234
  for (const item of decisions)
234
- appendEvent(manifest.eventsPath, {
235
+ appendEventBuffered(manifest.eventsPath, {
235
236
  type: item.action === "escalate" ? "policy.escalated" : "policy.action",
236
237
  runId: manifest.runId,
237
238
  taskId: item.taskId,
238
239
  message: item.message,
239
240
  data: { action: item.action, reason: item.reason },
240
- });
241
+ }).catch((e) =>
242
+ logInternalError("finalize-run.buffered", e, `type=${item.action === "escalate" ? "policy.escalated" : "policy.action"}`),
243
+ );
241
244
  for (const item of recoveryLedger.entries)
242
- appendEvent(manifest.eventsPath, {
245
+ appendEventBuffered(manifest.eventsPath, {
243
246
  type: item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted",
244
247
  runId: manifest.runId,
245
248
  taskId: item.taskId,
@@ -250,7 +253,13 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
250
253
  attempt: item.attempt,
251
254
  state: item.state,
252
255
  },
253
- });
256
+ }).catch((e) =>
257
+ logInternalError(
258
+ "finalize-run.buffered",
259
+ e,
260
+ `type=${item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted"}`,
261
+ ),
262
+ );
254
263
  return {
255
264
  ...manifest,
256
265
  updatedAt: new Date().toISOString(),
@@ -289,6 +298,9 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
289
298
  * @returns The final `{ manifest, tasks }` result for the run.
290
299
  */
291
300
  export async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
301
+ // MuxSurface A1 (spec §5.2): dọn launch script mồ côi đúng lúc run kết thúc
302
+ // — best-effort, không ảnh hưởng kết quả run.
303
+ sweepLaunchScriptsAtRunEnd();
292
304
  const input = ctx.input;
293
305
  let tasks = ctx.tasks;
294
306
  let manifest = ctx.manifest;
@@ -149,6 +149,11 @@ export function writeForegroundInterruptRequest(
149
149
  /* no pid file — fall through to sleep */
150
150
  }
151
151
  // Brief sleep to avoid CPU spinning
152
+ // WI-7.3 (M7 spec §5): kept sync. Making this async would force
153
+ // requestForegroundControl() + its caller chain async (10+
154
+ // callers in message-tool/lifecycle paths). 10ms spin per
155
+ // iteration is bounded by the 5s timeout above (max ~500 iters
156
+ // = 5s total worst-case block, matching the lock-timeout).
152
157
  sleepSync(10);
153
158
  }
154
159
  }
@@ -180,12 +185,20 @@ export function writeForegroundInterruptRequest(
180
185
  };
181
186
  fs.mkdirSync(path.dirname(controlPath), { recursive: true });
182
187
  atomicWriteFile(controlPath, `${JSON.stringify({ requests: [...requests, request] }, null, 2)}\n`);
183
- appendEvent(manifest.eventsPath, {
184
- type: "foreground.interrupt_requested",
185
- runId: manifest.runId,
186
- message: reason,
187
- data: { requestId: request.id, controlPath },
188
- });
188
+ // Read-your-writes (CI 2026-09-11, phase6 integration): for a fresh
189
+ // manifest this may be the FIRST event — buffered append leaves
190
+ // events.jsonl nonexistent at the sync read right after (ENOENT).
191
+ // Sync appendEvent (base behavior, b6eba80f pattern).
192
+ try {
193
+ appendEvent(manifest.eventsPath, {
194
+ type: "foreground.interrupt_requested",
195
+ runId: manifest.runId,
196
+ message: reason,
197
+ data: { requestId: request.id, controlPath },
198
+ });
199
+ } catch (e) {
200
+ logInternalError("foreground_control.append", e, "type=foreground.interrupt_requested");
201
+ }
189
202
  return request;
190
203
  } finally {
191
204
  releaseLock();
@@ -687,6 +687,10 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
687
687
  // Idempotency: same phase title → no event, no state change.
688
688
  if (title === phaseState.currentPhase) return;
689
689
  // Close out the previous open phase BEFORE the new one opens.
690
+ // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — phase
691
+ // transitions are low-frequency AND read back synchronously (tests,
692
+ // checkpoint resume; dwf-setresult rounds 12/14/18 assert the events
693
+ // file immediately after the run).
690
694
  if (phaseState.currentPhase !== undefined) {
691
695
  appendEvent(manifest.eventsPath, {
692
696
  type: "dwf.phase_completed",
@@ -710,6 +714,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
710
714
  );
711
715
  }
712
716
  }
717
+ // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — see phase_completed.
713
718
  appendEvent(manifest.eventsPath, {
714
719
  type: "dwf.phase_started",
715
720
  runId: manifest.runId,
@@ -724,6 +729,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
724
729
  if (wfState.logs.length < 1000) {
725
730
  wfState.logs.push(text);
726
731
  }
732
+ // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — see phase().
727
733
  appendEvent(manifest.eventsPath, {
728
734
  type: "dwf.log",
729
735
  runId: manifest.runId,
@@ -154,6 +154,9 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
154
154
  // code execution. Default-deny project workflows unless the user explicitly opts in via
155
155
  // PI_CREW_TRUST_PROJECT_DWF=1. Builtin and user workflows proceed without restriction.
156
156
  if (workflow.source === "project" && getCrewEnv("PI_CREW_TRUST_PROJECT_DWF") !== "1") {
157
+ // REVIEW FIX (2026-09-10): DWF lifecycle events reverted from M2a
158
+ // buffered conversion — runner sites are read back synchronously
159
+ // (dwf-setresult tests, resume flow, status display after run end).
157
160
  appendEvent(eventsPath, {
158
161
  type: "dwf.trust_denied",
159
162
  runId: manifest.runId,