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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- 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/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -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/scratchpad-lifecycle.ts +3 -3
- 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 +43 -42
- 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 +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- 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 +368 -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 +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- 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 +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- 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/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- 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 +80 -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 +104 -3
- 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/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- 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/settings-overlay.ts +4 -1
- 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
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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,
|