pi-crew 0.10.2 → 0.10.3
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 +249 -0
- package/dist/index.mjs +98 -307
- package/package.json +2 -1
- package/schema.json +11 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/types.ts +36 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/team-tool.ts +53 -5
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +101 -0
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/state-store.ts +98 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- 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
|
+
}
|
|
@@ -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}` : ""}`;
|
|
@@ -289,6 +290,9 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
|
|
|
289
290
|
* @returns The final `{ manifest, tasks }` result for the run.
|
|
290
291
|
*/
|
|
291
292
|
export async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
293
|
+
// MuxSurface A1 (spec §5.2): dọn launch script mồ côi đúng lúc run kết thúc
|
|
294
|
+
// — best-effort, không ảnh hưởng kết quả run.
|
|
295
|
+
sweepLaunchScriptsAtRunEnd();
|
|
292
296
|
const input = ctx.input;
|
|
293
297
|
let tasks = ctx.tasks;
|
|
294
298
|
let manifest = ctx.manifest;
|
|
@@ -119,15 +119,18 @@ export function registerLiveAgent(
|
|
|
119
119
|
const completed = [...liveAgents.entries()].find(([, h]) => h.activity.completedAtMs > 0);
|
|
120
120
|
if (completed) {
|
|
121
121
|
liveAgents.delete(completed[0]);
|
|
122
|
+
invalidateSortedLiveAgents();
|
|
122
123
|
} else {
|
|
123
124
|
const oldestKey = liveAgents.keys().next().value;
|
|
124
125
|
if (oldestKey !== undefined) {
|
|
125
126
|
logInternalError("live-agent-manager.cap", new Error(`liveAgents at cap ${MAX_LIVE_AGENTS}; evicting oldest ${oldestKey}`));
|
|
126
127
|
liveAgents.delete(oldestKey);
|
|
128
|
+
invalidateSortedLiveAgents();
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
liveAgents.set(input.agentId, handle);
|
|
133
|
+
invalidateSortedLiveAgents();
|
|
131
134
|
try {
|
|
132
135
|
if (eventLogFn && eventsPath)
|
|
133
136
|
eventLogFn(eventsPath, {
|
|
@@ -172,6 +175,7 @@ export function updateLiveAgentStatus(agentId: string, status: CrewAgentRecord["
|
|
|
172
175
|
if (!handle) return;
|
|
173
176
|
handle.status = status;
|
|
174
177
|
handle.updatedAt = new Date().toISOString();
|
|
178
|
+
invalidateSortedLiveAgents();
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
function safeDisposeLiveSession(handle: LiveAgentHandle): void {
|
|
@@ -198,6 +202,7 @@ export async function terminateLiveAgent(
|
|
|
198
202
|
if (!handle) return undefined;
|
|
199
203
|
handle.status = status;
|
|
200
204
|
handle.updatedAt = new Date().toISOString();
|
|
205
|
+
invalidateSortedLiveAgents();
|
|
201
206
|
try {
|
|
202
207
|
if (eventLogFn && eventsPath)
|
|
203
208
|
eventLogFn(eventsPath, {
|
|
@@ -220,6 +225,7 @@ export async function terminateLiveAgent(
|
|
|
220
225
|
} finally {
|
|
221
226
|
safeDisposeLiveSession(handle);
|
|
222
227
|
liveAgents.delete(handle.agentId); // Move AFTER abort completes to prevent race
|
|
228
|
+
invalidateSortedLiveAgents();
|
|
223
229
|
}
|
|
224
230
|
return handle;
|
|
225
231
|
}
|
|
@@ -260,6 +266,7 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
|
|
|
260
266
|
// Terminal handle — evict after grace period
|
|
261
267
|
if (age > STALE_HANDLE_MS) {
|
|
262
268
|
liveAgents.delete(agentId);
|
|
269
|
+
invalidateSortedLiveAgents();
|
|
263
270
|
safeDisposeLiveSession(handle);
|
|
264
271
|
evicted++;
|
|
265
272
|
}
|
|
@@ -289,6 +296,7 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
|
|
|
289
296
|
const liveness = checkProcessLiveness(sessionPid);
|
|
290
297
|
if (!liveness.alive) {
|
|
291
298
|
liveAgents.delete(agentId);
|
|
299
|
+
invalidateSortedLiveAgents();
|
|
292
300
|
safeDisposeLiveSession(handle);
|
|
293
301
|
evicted++;
|
|
294
302
|
}
|
|
@@ -297,8 +305,24 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
|
|
|
297
305
|
return evicted;
|
|
298
306
|
}
|
|
299
307
|
|
|
308
|
+
// PERF (2026-08-24): listLiveAgents is called 4-6x per render frame and used to
|
|
309
|
+
// copy + sort with ICU localeCompare every call. ISO-8601 strings are fixed-width
|
|
310
|
+
// and sort correctly with plain operators; the sorted array is memoized and
|
|
311
|
+
// invalidated at every map mutation. NOTE: callers receive the SHARED memoized
|
|
312
|
+
// array — never sort or mutate it in place; copy (slice/spread) first.
|
|
313
|
+
let sortedLiveAgents: LiveAgentHandle[] | undefined;
|
|
314
|
+
function invalidateSortedLiveAgents(): void {
|
|
315
|
+
sortedLiveAgents = undefined;
|
|
316
|
+
}
|
|
300
317
|
export function listLiveAgents(): LiveAgentHandle[] {
|
|
301
|
-
|
|
318
|
+
if (!sortedLiveAgents) {
|
|
319
|
+
sortedLiveAgents = [...liveAgents.values()].sort((a, b) => {
|
|
320
|
+
const au = a.updatedAt ?? "";
|
|
321
|
+
const bu = b.updatedAt ?? "";
|
|
322
|
+
return au < bu ? 1 : au > bu ? -1 : 0;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return sortedLiveAgents;
|
|
302
326
|
}
|
|
303
327
|
|
|
304
328
|
export function listActiveLiveAgents(): LiveAgentHandle[] {
|
|
@@ -324,6 +348,7 @@ export async function steerLiveAgent(agentIdOrTaskId: string, message: string):
|
|
|
324
348
|
}
|
|
325
349
|
await handle.session.steer(message);
|
|
326
350
|
handle.updatedAt = new Date().toISOString();
|
|
351
|
+
invalidateSortedLiveAgents();
|
|
327
352
|
return handle;
|
|
328
353
|
}
|
|
329
354
|
|
|
@@ -339,6 +364,7 @@ export async function followUpLiveAgent(agentIdOrTaskId: string, prompt: string)
|
|
|
339
364
|
expandPromptTemplates: false,
|
|
340
365
|
});
|
|
341
366
|
handle.updatedAt = new Date().toISOString();
|
|
367
|
+
invalidateSortedLiveAgents();
|
|
342
368
|
return handle;
|
|
343
369
|
}
|
|
344
370
|
|
|
@@ -359,6 +385,7 @@ export async function resumeLiveAgent(agentIdOrTaskId: string, prompt: string):
|
|
|
359
385
|
});
|
|
360
386
|
handle.status = "completed";
|
|
361
387
|
handle.updatedAt = new Date().toISOString();
|
|
388
|
+
invalidateSortedLiveAgents();
|
|
362
389
|
return handle;
|
|
363
390
|
}
|
|
364
391
|
|
|
@@ -377,6 +404,7 @@ export function trackLiveAgentToolStart(agentIdOrTaskId: string, toolName: strin
|
|
|
377
404
|
handle.activity.activeTools.set(toolName, toolName);
|
|
378
405
|
handle.activity.toolUses++;
|
|
379
406
|
handle.updatedAt = new Date().toISOString();
|
|
407
|
+
invalidateSortedLiveAgents();
|
|
380
408
|
}
|
|
381
409
|
|
|
382
410
|
/** G2: Track tool end for a live agent. */
|
|
@@ -394,6 +422,7 @@ export function trackLiveAgentTurnEnd(agentIdOrTaskId: string, compaction = fals
|
|
|
394
422
|
if (compaction) handle.activity.compactionCount++;
|
|
395
423
|
handle.activity.activeTools.clear();
|
|
396
424
|
handle.updatedAt = new Date().toISOString();
|
|
425
|
+
invalidateSortedLiveAgents();
|
|
397
426
|
}
|
|
398
427
|
|
|
399
428
|
/** G2: Track assistant response text. */
|
|
@@ -413,6 +442,7 @@ export function markLiveAgentCompleted(agentIdOrTaskId: string): void {
|
|
|
413
442
|
|
|
414
443
|
export function clearLiveAgentsForTest(): void {
|
|
415
444
|
liveAgents.clear();
|
|
445
|
+
invalidateSortedLiveAgents();
|
|
416
446
|
}
|
|
417
447
|
|
|
418
448
|
/** Phase 7/G4: Send an IRC message to a specific live agent (DM).
|
|
@@ -427,6 +457,7 @@ export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void
|
|
|
427
457
|
}
|
|
428
458
|
handle.pendingMessages.push(message);
|
|
429
459
|
handle.updatedAt = new Date().toISOString();
|
|
460
|
+
invalidateSortedLiveAgents();
|
|
430
461
|
// G4: Try non-blocking delivery via sendCustomMessage
|
|
431
462
|
const session = handle.session as Record<string, unknown>;
|
|
432
463
|
if (typeof session.sendCustomMessage === "function") {
|
|
@@ -467,6 +498,7 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
|
|
|
467
498
|
}
|
|
468
499
|
handle.pendingMessages.push(message);
|
|
469
500
|
handle.updatedAt = new Date().toISOString();
|
|
501
|
+
invalidateSortedLiveAgents();
|
|
470
502
|
// G4: Try non-blocking delivery
|
|
471
503
|
const session = handle.session as Record<string, unknown>;
|
|
472
504
|
if (typeof session.sendCustomMessage === "function") {
|
|
@@ -600,6 +632,7 @@ export async function respondAsBackground(
|
|
|
600
632
|
error: `Target '${targetAgentId}' has no message channel.`,
|
|
601
633
|
};
|
|
602
634
|
handle.updatedAt = new Date().toISOString();
|
|
635
|
+
invalidateSortedLiveAgents();
|
|
603
636
|
|
|
604
637
|
if (!awaitReply) return { ok: true, corrId };
|
|
605
638
|
|
|
@@ -19,6 +19,16 @@ export function subscribeLiveControlRealtime(listener: Listener): () => void {
|
|
|
19
19
|
return () => listeners.delete(listener);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Task 26 (2026-08-24): poll-interval gate for live-session control polling.
|
|
24
|
+
* True while at least one realtime listener is registered — in that mode
|
|
25
|
+
* in-process control requests are delivered immediately and the file poll is
|
|
26
|
+
* only a cross-process fallback, so the poller can run slower.
|
|
27
|
+
*/
|
|
28
|
+
export function hasLiveControlRealtimeListeners(): boolean {
|
|
29
|
+
return listeners.size > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
export function liveControlRealtimeMessage(request: LiveAgentControlRequest): LiveControlRealtimeMessage {
|
|
23
33
|
return { type: "live-control", version: 1, request };
|
|
24
34
|
}
|