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,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux SurfaceProvider (spec §4)
|
|
3
|
+
*
|
|
4
|
+
* Port surface logic nhánh tmux từ pi-interactive-subagents, viết lại theo
|
|
5
|
+
* interface pi-crew. Trong team run (tabKey): một run = một window (tab),
|
|
6
|
+
* worker split dọc/ngang xen kẽ tối đa MAX_PANES_PER_TAB pane. Ngoài run:
|
|
7
|
+
* một worker = một pane split ngang từ pane cha ($TMUX_PANE) để pane đi theo
|
|
8
|
+
* agent thay vì theo focus của user.
|
|
9
|
+
* Launch command đã build sẵn ("bash <script-path>") — gửi vào pane qua
|
|
10
|
+
* send-keys; script tự lo cwd (spec §5.2, Task 5).
|
|
11
|
+
*
|
|
12
|
+
* onExit: MỘT interval 2s cho cả provider — mỗi tick gọi list-panes đúng
|
|
13
|
+
* một lần rồi đối chiếu mọi pane đang được theo dõi (không spawn N subprocess
|
|
14
|
+
* cho N pane). Pane biến khỏi list hoặc pane_dead=1 → "pane-closed" bắn đúng
|
|
15
|
+
* một lần. tmux call throw (binary mất đột ngột, server chết) → "mux-dead"
|
|
16
|
+
* cho mọi handle còn sống.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
import type { SurfaceDetection, SurfaceExitReason, SurfaceHandle, SurfaceProvider, SurfaceSpawnOpts } from "./surface-provider.ts";
|
|
22
|
+
import { MAX_PANES_PER_TAB, splitDirectionFor } from "./surface-provider.ts";
|
|
23
|
+
|
|
24
|
+
/** Chờ process trong pane chết sau SIGTERM trước khi force kill-pane (spec §4). */
|
|
25
|
+
const GRACEFUL_TERM_WAIT_MS = 3000;
|
|
26
|
+
|
|
27
|
+
/** Chu kỳ poll list-panes cho onExit (spec §4). */
|
|
28
|
+
const EXIT_POLL_INTERVAL_MS = 2000;
|
|
29
|
+
|
|
30
|
+
/** Timer handle trả về từ deps.schedule. */
|
|
31
|
+
export interface SurfaceTimer {
|
|
32
|
+
clear(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Dependencies injectable — mọi I/O đều thay được để unit test không chạm tmux thật. */
|
|
36
|
+
export interface TmuxProviderDeps {
|
|
37
|
+
/** Chạy `tmux <args>`, trả stdout. Throw khi exit != 0 hoặc binary mất. */
|
|
38
|
+
tmux?: (args: string[]) => string;
|
|
39
|
+
/** Env nguồn TMUX / TMUX_PANE (default process.env). */
|
|
40
|
+
env?: NodeJS.ProcessEnv;
|
|
41
|
+
/** Ngủ chờ graceful close (default setTimeout). */
|
|
42
|
+
sleep?: (ms: number) => Promise<void>;
|
|
43
|
+
/** SIGTERM process trong pane theo pid (default process.kill SIGTERM). */
|
|
44
|
+
killTree?: (pid: number) => void;
|
|
45
|
+
/** Lập lịch poll onExit (default setInterval + unref). Test gọi tick thủ công. */
|
|
46
|
+
schedule?: (fn: () => void, ms: number) => SurfaceTimer;
|
|
47
|
+
/** Probe binary cho detect (default `command -v tmux`, có cache). */
|
|
48
|
+
hasCommand?: (bin: string) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Binary availability cache — `command -v` là subprocess, memoize như resolve-surface. */
|
|
52
|
+
const binaryAvailability = new Map<string, boolean>();
|
|
53
|
+
|
|
54
|
+
function defaultHasCommand(bin: string): boolean {
|
|
55
|
+
const cached = binaryAvailability.get(bin);
|
|
56
|
+
if (cached !== undefined) return cached;
|
|
57
|
+
let available = false;
|
|
58
|
+
try {
|
|
59
|
+
execFileSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" });
|
|
60
|
+
available = true;
|
|
61
|
+
} catch {
|
|
62
|
+
available = false;
|
|
63
|
+
}
|
|
64
|
+
binaryAvailability.set(bin, available);
|
|
65
|
+
return available;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultSchedule(fn: () => void, ms: number): SurfaceTimer {
|
|
69
|
+
const timer = setInterval(fn, ms);
|
|
70
|
+
timer.unref();
|
|
71
|
+
return { clear: () => clearInterval(timer) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Một pane đang được theo dõi onExit. */
|
|
75
|
+
interface PaneWatcher {
|
|
76
|
+
callbacks: Array<(reason: SurfaceExitReason) => void>;
|
|
77
|
+
exited: boolean;
|
|
78
|
+
reason?: SurfaceExitReason;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parse `list-panes -F '#{pane_dead} #{pane_id}'` → Map id → dead. */
|
|
82
|
+
function parsePaneStatus(stdout: string): Map<string, boolean> {
|
|
83
|
+
const status = new Map<string, boolean>();
|
|
84
|
+
for (const line of stdout.split("\n")) {
|
|
85
|
+
const match = line.trim().match(/^(\d+)\s+(\S+)$/);
|
|
86
|
+
if (match) status.set(match[2], match[1] === "1");
|
|
87
|
+
}
|
|
88
|
+
return status;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Tìm pid của pane trong `list-panes -F '#{pane_pid} #{pane_id}'`. */
|
|
92
|
+
function findPanePid(stdout: string, paneId: string): number | null {
|
|
93
|
+
for (const line of stdout.split("\n")) {
|
|
94
|
+
const match = line.trim().match(/^(\d+)\s+(\S+)$/);
|
|
95
|
+
if (match && match[2] === paneId) {
|
|
96
|
+
const pid = Number(match[1]);
|
|
97
|
+
return Number.isFinite(pid) ? pid : null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createTmuxProvider(deps: TmuxProviderDeps = {}): SurfaceProvider {
|
|
104
|
+
const tmux = deps.tmux ?? ((args: string[]) => execFileSync("tmux", args, { encoding: "utf8" }));
|
|
105
|
+
const env = deps.env ?? process.env;
|
|
106
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
107
|
+
const killTree = deps.killTree ?? ((pid: number) => process.kill(pid, "SIGTERM"));
|
|
108
|
+
const hasCommand = deps.hasCommand ?? defaultHasCommand;
|
|
109
|
+
const schedule = deps.schedule ?? defaultSchedule;
|
|
110
|
+
|
|
111
|
+
// Watcher theo pane id — MỘT timer chung cho mọi pane đang theo dõi.
|
|
112
|
+
const watchers = new Map<string, PaneWatcher>();
|
|
113
|
+
let timer: SurfaceTimer | null = null;
|
|
114
|
+
|
|
115
|
+
// Tab-layout (spec 2026-08-27-surface-tab-layout): tabKey(run) → MỌI window
|
|
116
|
+
// của run + số pane đã spawn trong window đang nhận. Tab mở khi run spawn
|
|
117
|
+
// worker đầu, KHÔNG đóng khi từng worker xong — chỉ đóng khi run end
|
|
118
|
+
// (closeTab, Task 5). Giữ array vì run dài có thể vượt MAX_PANES_PER_TAB
|
|
119
|
+
// và mở window kế — closeTab phải dọn cả window cũ, không chỉ window cuối.
|
|
120
|
+
const tabWindows = new Map<string, { windows: string[]; paneCount: number }>();
|
|
121
|
+
|
|
122
|
+
function activeWatchers(): number {
|
|
123
|
+
let active = 0;
|
|
124
|
+
for (const watcher of watchers.values()) if (!watcher.exited) active += 1;
|
|
125
|
+
return active;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ensureTimer(): void {
|
|
129
|
+
if (timer || activeWatchers() === 0) return;
|
|
130
|
+
timer = schedule(pollExits, EXIT_POLL_INTERVAL_MS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function maybeStopTimer(): void {
|
|
134
|
+
if (!timer || activeWatchers() > 0) return;
|
|
135
|
+
timer.clear();
|
|
136
|
+
timer = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Bắn reason MỘT lần cho mọi callback của pane (copy list — cb có thể dispose). */
|
|
140
|
+
function fire(paneId: string, reason: SurfaceExitReason): void {
|
|
141
|
+
const watcher = watchers.get(paneId);
|
|
142
|
+
if (!watcher || watcher.exited) return;
|
|
143
|
+
watcher.exited = true;
|
|
144
|
+
watcher.reason = reason;
|
|
145
|
+
for (const cb of [...watcher.callbacks]) cb(reason);
|
|
146
|
+
maybeStopTimer();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function pollExits(): void {
|
|
150
|
+
let stdout: string;
|
|
151
|
+
try {
|
|
152
|
+
// -a: liệt kê panes toàn server — pane id là duy nhất toàn server,
|
|
153
|
+
// còn không -a thì chỉ thấy current window (pane-closed giả khi user
|
|
154
|
+
// switch window giữa chừng).
|
|
155
|
+
stdout = tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"]);
|
|
156
|
+
} catch {
|
|
157
|
+
// tmux binary mất đột ngột / server chết — mọi handle còn sống thành mux-dead.
|
|
158
|
+
for (const paneId of [...watchers.keys()]) fire(paneId, "mux-dead");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const status = parsePaneStatus(stdout);
|
|
162
|
+
for (const [paneId, watcher] of [...watchers]) {
|
|
163
|
+
// Không có trong list (đã dọn) hoặc pane_dead=1 → đóng.
|
|
164
|
+
if (!watcher.exited && status.get(paneId) !== false) fire(paneId, "pane-closed");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function makeHandle(paneId: string, tabId?: string): SurfaceHandle {
|
|
169
|
+
return {
|
|
170
|
+
id: paneId,
|
|
171
|
+
kind: "tmux",
|
|
172
|
+
...(tabId ? { tabId } : {}),
|
|
173
|
+
onExit(cb) {
|
|
174
|
+
let watcher = watchers.get(paneId);
|
|
175
|
+
if (!watcher) {
|
|
176
|
+
watcher = { callbacks: [], exited: false };
|
|
177
|
+
watchers.set(paneId, watcher);
|
|
178
|
+
}
|
|
179
|
+
watcher.callbacks.push(cb);
|
|
180
|
+
// Đăng ký sau exit → replay reason ngay để không mất event.
|
|
181
|
+
if (watcher.exited) {
|
|
182
|
+
cb(watcher.reason as SurfaceExitReason);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
ensureTimer();
|
|
186
|
+
},
|
|
187
|
+
dispose() {
|
|
188
|
+
const watcher = watchers.get(paneId);
|
|
189
|
+
if (!watcher) return;
|
|
190
|
+
// Host chủ động dispose khi pane còn sống → "detached" cho listener.
|
|
191
|
+
if (!watcher.exited) fire(paneId, "detached");
|
|
192
|
+
watchers.delete(paneId);
|
|
193
|
+
maybeStopTimer();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function assertTmuxHandle(handle: SurfaceHandle): void {
|
|
199
|
+
if (handle.kind !== "tmux") {
|
|
200
|
+
throw new Error(`Expected a tmux handle, got kind "${handle.kind}" (id ${handle.id})`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Pane còn trong list (toàn server) và chưa dead. tmux throw (mux chết) → false. */
|
|
205
|
+
function isPaneAlive(paneId: string): boolean {
|
|
206
|
+
try {
|
|
207
|
+
return parsePaneStatus(tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"])).get(paneId) === false;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** kill-pane best-effort — pane có thể tự chết trong lúc chờ SIGTERM. */
|
|
214
|
+
function killPaneBestEffort(paneId: string): void {
|
|
215
|
+
try {
|
|
216
|
+
tmux(["kill-pane", "-t", paneId]);
|
|
217
|
+
} catch {
|
|
218
|
+
// Pane đã mất — coi như đã đóng.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
kind: "tmux",
|
|
224
|
+
|
|
225
|
+
detect(): SurfaceDetection {
|
|
226
|
+
if (!env.TMUX) {
|
|
227
|
+
return { ok: false, reason: "TMUX env not set — pi is not running inside a tmux session" };
|
|
228
|
+
}
|
|
229
|
+
if (!hasCommand("tmux")) {
|
|
230
|
+
return { ok: false, reason: "tmux binary not found on PATH" };
|
|
231
|
+
}
|
|
232
|
+
return { ok: true, kind: "tmux" };
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async createSurface(_name: string, opts: SurfaceSpawnOpts): Promise<SurfaceHandle> {
|
|
236
|
+
let targetWindow: string;
|
|
237
|
+
let directionFlag: string;
|
|
238
|
+
// Task 5: window (tab) chứa pane này — None khi đường legacy.
|
|
239
|
+
let tabId: string | undefined;
|
|
240
|
+
// Tab-map chỉ ghi sau khi split-window THÀNH CÔNG — nếu split fail,
|
|
241
|
+
// paneCount không đếm pane không tồn tại (luân phiên không lệch bước
|
|
242
|
+
// ở lần retry kế tiếp).
|
|
243
|
+
let commitTabPane: (() => void) | null = null;
|
|
244
|
+
if (opts.tabKey) {
|
|
245
|
+
// Tab-layout: mọi worker của cùng run chia 1 window (tab), split
|
|
246
|
+
// dọc/ngang xen kẽ theo pane index trong tab (spec tab-layout §4).
|
|
247
|
+
const tabKey = opts.tabKey;
|
|
248
|
+
const existing = tabWindows.get(tabKey);
|
|
249
|
+
if (existing && existing.paneCount < MAX_PANES_PER_TAB) {
|
|
250
|
+
const paneIndexInTab = existing.paneCount;
|
|
251
|
+
const currentWindowId = existing.windows[existing.windows.length - 1] as string;
|
|
252
|
+
targetWindow = currentWindowId;
|
|
253
|
+
tabId = currentWindowId;
|
|
254
|
+
directionFlag = splitDirectionFor(paneIndexInTab) === "down" ? "-v" : "-h";
|
|
255
|
+
commitTabPane = () => {
|
|
256
|
+
existing.paneCount = paneIndexInTab + 1;
|
|
257
|
+
};
|
|
258
|
+
} else {
|
|
259
|
+
// Tab mới cho run (hoặc tab cũ đã đầy MAX_PANES_PER_TAB) — window riêng.
|
|
260
|
+
// -d: window mới không thành current window — không steal focus client.
|
|
261
|
+
const windowId = tmux(["new-window", "-d", "-P", "-F", "#{window_id}"]).trim();
|
|
262
|
+
if (!/^@\d+$/.test(windowId)) {
|
|
263
|
+
throw new Error(`Unexpected tmux new-window output: ${JSON.stringify(windowId)}`);
|
|
264
|
+
}
|
|
265
|
+
const label = opts.title ?? tabKey;
|
|
266
|
+
try {
|
|
267
|
+
tmux(["rename-window", "-t", windowId, label]);
|
|
268
|
+
} catch {
|
|
269
|
+
// rename là cosmetic — pane vẫn dùng được.
|
|
270
|
+
}
|
|
271
|
+
targetWindow = windowId;
|
|
272
|
+
tabId = windowId;
|
|
273
|
+
directionFlag = splitDirectionFor(0) === "down" ? "-v" : "-h";
|
|
274
|
+
const priorWindows = existing?.windows ?? [];
|
|
275
|
+
commitTabPane = () => {
|
|
276
|
+
tabWindows.set(tabKey, { windows: [...priorWindows, windowId], paneCount: 1 });
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
// Đường legacy (spawn ngoài run): split từ pane cha để pane đi theo
|
|
281
|
+
// agent, không theo focus của user — giữ nguyên "-h" như trước.
|
|
282
|
+
const parentPane = env.TMUX_PANE;
|
|
283
|
+
if (!parentPane) {
|
|
284
|
+
throw new Error("TMUX_PANE not set — tmux provider chỉ chạy bên trong tmux session");
|
|
285
|
+
}
|
|
286
|
+
targetWindow = parentPane;
|
|
287
|
+
directionFlag = "-h";
|
|
288
|
+
}
|
|
289
|
+
const raw = tmux(["split-window", "-d", directionFlag, "-P", "-F", "#{pane_id}", "-t", targetWindow]);
|
|
290
|
+
const paneId = raw.trim();
|
|
291
|
+
if (!/^%\d+$/.test(paneId)) {
|
|
292
|
+
throw new Error(`Unexpected tmux split-window output: ${JSON.stringify(raw)}`);
|
|
293
|
+
}
|
|
294
|
+
commitTabPane?.();
|
|
295
|
+
if (opts.title) {
|
|
296
|
+
try {
|
|
297
|
+
tmux(["select-pane", "-t", paneId, "-T", opts.title]);
|
|
298
|
+
} catch {
|
|
299
|
+
// Title là cosmetic — pane vẫn dùng được.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Command đã build sẵn ("bash <script-path>") — gửi literal rồi Enter.
|
|
303
|
+
// Commandless tạo pane là hợp lệ (spec §13.1: host cần pane id trước,
|
|
304
|
+
// build script xong mới boot worker qua sendCommand) — không gửi gì cả.
|
|
305
|
+
if (opts.command !== undefined) {
|
|
306
|
+
tmux(["send-keys", "-t", paneId, "-l", opts.command]);
|
|
307
|
+
tmux(["send-keys", "-t", paneId, "Enter"]);
|
|
308
|
+
}
|
|
309
|
+
return makeHandle(paneId, tabId);
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async sendCommand(handle, text) {
|
|
313
|
+
assertTmuxHandle(handle);
|
|
314
|
+
// Gửi literal text (không tmux-interpreted) rồi Enter — giống cách
|
|
315
|
+
// createSurface gửi command người dùng gõ được.
|
|
316
|
+
tmux(["send-keys", "-t", handle.id, "-l", text]);
|
|
317
|
+
tmux(["send-keys", "-t", handle.id, "Enter"]);
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
attach(id: string): SurfaceHandle | null {
|
|
321
|
+
let status: Map<string, boolean>;
|
|
322
|
+
try {
|
|
323
|
+
status = parsePaneStatus(tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"]));
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
if (status.get(id) !== false) return null;
|
|
328
|
+
return makeHandle(id);
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
async readScreen(handle: SurfaceHandle, lines = 50): Promise<string> {
|
|
332
|
+
assertTmuxHandle(handle);
|
|
333
|
+
// Pane mất → tmux exit != 0 → throw (spec: throw khi handle không hợp lệ).
|
|
334
|
+
return tmux(["capture-pane", "-p", "-t", handle.id, "-S", `-${Math.max(1, lines)}`]);
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
async closeSurface(handle: SurfaceHandle, opts?: { force?: boolean }): Promise<void> {
|
|
338
|
+
assertTmuxHandle(handle);
|
|
339
|
+
if (opts?.force) {
|
|
340
|
+
killPaneBestEffort(handle.id);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Graceful (spec §4): SIGTERM pid trong pane → đợi 3s → force kill-pane.
|
|
344
|
+
if (!isPaneAlive(handle.id)) return; // đã đóng — idempotent
|
|
345
|
+
const pid = findPanePid(tmux(["list-panes", "-a", "-F", "#{pane_pid} #{pane_id}"]), handle.id);
|
|
346
|
+
// pid > 1: pid 0/1 không bao giờ là process của pane — không signal.
|
|
347
|
+
// Không tìm được pid thì SIGTERM không thể → force luôn, không đợi vô ích.
|
|
348
|
+
if (pid !== null && pid > 1) {
|
|
349
|
+
killTree(pid);
|
|
350
|
+
await sleep(GRACEFUL_TERM_WAIT_MS);
|
|
351
|
+
}
|
|
352
|
+
if (isPaneAlive(handle.id)) killPaneBestEffort(handle.id);
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Task 5 (spec tab-layout §5): run end → kill MỌI window của run theo
|
|
357
|
+
* map nội bộ (run dài >8 pane mở window kế — cả hai đều phải chết).
|
|
358
|
+
* Window đã tự đóng từ trước (pane cuối tự exit làm window biến mất)
|
|
359
|
+
* → kill-window throw → nuốt: idempotent.
|
|
360
|
+
*/
|
|
361
|
+
async closeTab(tabKey: string): Promise<void> {
|
|
362
|
+
const entry = tabWindows.get(tabKey);
|
|
363
|
+
if (!entry) return;
|
|
364
|
+
tabWindows.delete(tabKey);
|
|
365
|
+
for (const windowId of entry.windows) {
|
|
366
|
+
try {
|
|
367
|
+
tmux(["kill-window", "-t", windowId]);
|
|
368
|
+
} catch {
|
|
369
|
+
// window đã mất — coi như đã đóng.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Task 6 (doctor cleanup-by-id): đóng MỘT window theo id đọc từ
|
|
376
|
+
* manifest.surface.tabs — doctor chạy ở process khác host đã spawn nên
|
|
377
|
+
* tabWindows ở đó trống (closeTab no-op). Liveness check TRƯỚC khi
|
|
378
|
+
* đóng (pattern pane-orphan của doctor): list-windows toàn server, dễ
|
|
379
|
+
* thấy window → gone, không kill-window mù; window mất giữa lúc check
|
|
380
|
+
* và kill → throw được nuốt thành gone (idempotent).
|
|
381
|
+
*/
|
|
382
|
+
async closeTabById(tabId: string): Promise<"closed" | "gone"> {
|
|
383
|
+
let windows: string[];
|
|
384
|
+
try {
|
|
385
|
+
windows = tmux(["list-windows", "-a", "-F", "#{window_id}"]).trim().split("\n");
|
|
386
|
+
} catch (error) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`tmux list-windows failed before closing tab ${tabId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (!windows.includes(tabId)) return "gone";
|
|
392
|
+
try {
|
|
393
|
+
tmux(["kill-window", "-t", tabId]);
|
|
394
|
+
} catch {
|
|
395
|
+
return "gone"; // window mất giữa chừng — mục tiêu đạt rồi
|
|
396
|
+
}
|
|
397
|
+
return "closed";
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -741,6 +741,53 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
741
741
|
input.signal.removeEventListener("abort", externalAbortListener);
|
|
742
742
|
}
|
|
743
743
|
}
|
|
744
|
+
// ── MuxSurface A1 (spec §7 D3): degrade short-circuit ────────────────
|
|
745
|
+
// classifyOnExit found NO worker.completed after the pane died, so the
|
|
746
|
+
// worker never finished. This is NOT a model failure and NOT an empty
|
|
747
|
+
// result — deriving an error here would route into autoRetry (no-op
|
|
748
|
+
// retries + deadletter noise) AND persist a "failed" task that
|
|
749
|
+
// handleFailedTask could abort the whole run on. Instead: hand the loss
|
|
750
|
+
// to finalize as `surfaceLost` (needs_attention), leave the task
|
|
751
|
+
// non-terminal in the meantime, and let the team-runner drain re-queue
|
|
752
|
+
// it headless.
|
|
753
|
+
if (childResult.surface?.degraded) {
|
|
754
|
+
const degraded = childResult.surface.degraded;
|
|
755
|
+
logs.push(
|
|
756
|
+
`SURFACE DEGRADE: worker in ${childResult.surface.kind} pane ${childResult.surface.paneId} lost (${degraded.cause}) ` +
|
|
757
|
+
`with no completion within the classify window; re-dispatching headless`,
|
|
758
|
+
"",
|
|
759
|
+
);
|
|
760
|
+
return {
|
|
761
|
+
resultArtifact: undefined,
|
|
762
|
+
logArtifact: undefined,
|
|
763
|
+
transcriptArtifact: undefined,
|
|
764
|
+
exitCode: null,
|
|
765
|
+
error: undefined,
|
|
766
|
+
modelAttempts,
|
|
767
|
+
parsedOutput: undefined,
|
|
768
|
+
finalStdout: "",
|
|
769
|
+
rawFinalText: undefined,
|
|
770
|
+
transcriptPath,
|
|
771
|
+
terminalEvidence,
|
|
772
|
+
startupEvidence: createStartupEvidence({
|
|
773
|
+
command: "pi",
|
|
774
|
+
startedAt: attemptStartedAt,
|
|
775
|
+
finishedAt: new Date(),
|
|
776
|
+
promptSentAt: attemptStartedAt,
|
|
777
|
+
promptAccepted: false,
|
|
778
|
+
stderr: childResult.stderr,
|
|
779
|
+
error: undefined,
|
|
780
|
+
exitCode: null,
|
|
781
|
+
}),
|
|
782
|
+
surfaceLost: {
|
|
783
|
+
taskId: task.id,
|
|
784
|
+
paneId: childResult.surface.paneId,
|
|
785
|
+
cause: degraded.cause,
|
|
786
|
+
exitReason: degraded.exitReason,
|
|
787
|
+
ts: new Date().toISOString(),
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|
|
744
791
|
const evidenceStatus = evidenceStatusFor(childResult);
|
|
745
792
|
terminalEvidence = [
|
|
746
793
|
...terminalEvidence,
|
|
@@ -56,7 +56,22 @@ import { persistSingleTaskUpdate, updateTask } from "./state-helpers.ts";
|
|
|
56
56
|
* mutation guard (fail mode) inside finalizeTaskResult.
|
|
57
57
|
*/
|
|
58
58
|
export interface TaskExecutionResult {
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* MuxSurface A1 (spec §7 D3): the worker's pane died with NO worker.completed
|
|
61
|
+
* inside the classify window — the worker never finished. When set,
|
|
62
|
+
* {@link finalizeTaskResult} short-circuits into a dedicated degrade
|
|
63
|
+
* terminalisation (needs_attention, no fabricated result) INSTEAD of the
|
|
64
|
+
* yield/mutation/output guards, which would misread the loss as an
|
|
65
|
+
* empty-result failure and trigger autoRetry double-dispatch.
|
|
66
|
+
*/
|
|
67
|
+
surfaceLost?: {
|
|
68
|
+
taskId: string;
|
|
69
|
+
paneId: string;
|
|
70
|
+
cause: "pane-closed" | "mux-dead";
|
|
71
|
+
exitReason: string;
|
|
72
|
+
ts: string;
|
|
73
|
+
};
|
|
74
|
+
resultArtifact?: ArtifactDescriptor;
|
|
60
75
|
logArtifact: ArtifactDescriptor | undefined;
|
|
61
76
|
transcriptArtifact: ArtifactDescriptor | undefined;
|
|
62
77
|
exitCode: number | null;
|
|
@@ -123,6 +138,46 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
|
|
|
123
138
|
const terminalEvidence = execResult.terminalEvidence;
|
|
124
139
|
const startupEvidence = execResult.startupEvidence;
|
|
125
140
|
|
|
141
|
+
// ── MuxSurface A1 (spec §7 D3): degrade terminalisation ───────────────
|
|
142
|
+
// The pane died with NO worker.completed in the classify window: the worker
|
|
143
|
+
// never finished, so the yield/mutation/output guards would MISREAD this as
|
|
144
|
+
// an empty-result failure (autoRetry would then double-dispatch and a failed
|
|
145
|
+
// task could abort the whole run at handleFailedTask). Terminalise as
|
|
146
|
+
// needs_attention WITHOUT fabricating a result; team-runner's surface-degrade
|
|
147
|
+
// drain re-queues the unit headless (scratchpad restore + pendingSteers
|
|
148
|
+
// replay + resume note) WITHOUT consuming retry budget (spec §7 step 5).
|
|
149
|
+
if (execResult.surfaceLost) {
|
|
150
|
+
const lost = execResult.surfaceLost;
|
|
151
|
+
task = {
|
|
152
|
+
...task,
|
|
153
|
+
status: "needs_attention",
|
|
154
|
+
finishedAt: new Date().toISOString(),
|
|
155
|
+
exitCode: null,
|
|
156
|
+
error: undefined,
|
|
157
|
+
claim: undefined,
|
|
158
|
+
heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), { alive: false }),
|
|
159
|
+
workerExitStatus: terminalEvidence.at(-1)?.exitStatus,
|
|
160
|
+
terminalEvidence: terminalEvidence.length ? [...(task.terminalEvidence ?? []), ...terminalEvidence] : task.terminalEvidence,
|
|
161
|
+
diagnostics: {
|
|
162
|
+
...(task.diagnostics ?? {}),
|
|
163
|
+
surfaceLost: { ts: lost.ts, cause: lost.cause, paneId: lost.paneId, exitReason: lost.exitReason },
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
tasks = updateTask(tasks, task);
|
|
167
|
+
// ST-7: durable terminal write under the run lock — crash recovery must
|
|
168
|
+
// see needs_attention, not a phantom "running" worker.
|
|
169
|
+
tasks = await withRunLock(manifest, async () => persistSingleTaskUpdate(manifest, tasks, task, undefined, true));
|
|
170
|
+
upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
|
|
171
|
+
await appendEventAsync(manifest.eventsPath, {
|
|
172
|
+
type: "task.surface_lost",
|
|
173
|
+
runId: manifest.runId,
|
|
174
|
+
taskId: task.id,
|
|
175
|
+
message: `Surface worker lost (${lost.cause}) before completing — re-dispatching headless`,
|
|
176
|
+
data: { paneId: lost.paneId, cause: lost.cause, exitReason: lost.exitReason, ts: lost.ts },
|
|
177
|
+
});
|
|
178
|
+
return { manifest, tasks };
|
|
179
|
+
}
|
|
180
|
+
|
|
126
181
|
// --- Yield-based completion contract ---
|
|
127
182
|
// _yieldResult: preserved for future use — yield completion contract not yet wired to task.result
|
|
128
183
|
let _yieldResult: YieldResult | undefined;
|
|
@@ -538,7 +593,7 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
|
|
|
538
593
|
artifacts: [
|
|
539
594
|
...manifest.artifacts,
|
|
540
595
|
promptArtifact,
|
|
541
|
-
resultArtifact,
|
|
596
|
+
...(resultArtifact ? [resultArtifact] : []),
|
|
542
597
|
inputsArtifact,
|
|
543
598
|
coordinationArtifact,
|
|
544
599
|
...(skillArtifact ? [skillArtifact] : []),
|
|
@@ -41,6 +41,7 @@ export function coordinationBridgeInstructions(task: TeamTaskState): string {
|
|
|
41
41
|
`Mailbox target for this task: ${task.id}`,
|
|
42
42
|
"Use the run mailbox contract for coordination with the leader/orchestrator:",
|
|
43
43
|
"- If blocked or uncertain, report the blocker in your final result and, when mailbox tools/API are available, send an inbox/outbox message addressed to the leader.",
|
|
44
|
+
"- Never guess implementation details that materially affect decisions. If the `ask` tool is available and you need a clarification, a decision, or a missing requirement before you can proceed safely, call `ask` and wait — a parked question is cheaper than a wrong build.",
|
|
44
45
|
"- Ask the leader before editing when scope is ambiguous, requirements conflict, destructive action is needed, or you discover likely overlap with another task.",
|
|
45
46
|
"- Before making non-trivial edits, state intended changed files in your notes/result; if another worker may touch the same file/symbol, pause and request sequencing/ownership guidance.",
|
|
46
47
|
"- Do not resolve cross-worker conflicts silently. Escalate via mailbox/result with: file/symbol, conflicting task if known, proposed owner, and safest next step.",
|