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,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface-spawn.ts — nhánh spawn pane (spec §13.1) của child-pi pipeline.
|
|
3
|
+
*
|
|
4
|
+
* `prepareSurfaceSpawn` là hàm thuần-KIỂM-ĐƯỢC nối T2/T5 vào flow spawn thật:
|
|
5
|
+
*
|
|
6
|
+
* sweep TTL → resolveSurface (T2, fail-closed §3)
|
|
7
|
+
* → provider.createSurface({cwd}) [KHÔNG command — trả pane id]
|
|
8
|
+
* → stripHeadlessModeArgs(piArgs) [bỏ `--mode json -p`, KHÔNG đổi gì khác]
|
|
9
|
+
* → buildLaunchScript(env có PI_CREW_SURFACE_PANE = id THẬT, callerEnv=host)
|
|
10
|
+
* → sendCommand(`bash <script>; exit`)
|
|
11
|
+
*
|
|
12
|
+
* Thứ tự đó là bắt buộc: env map của script phải mang pane id thật nên
|
|
13
|
+
* createSurface phải chạy TRƯỚC build script; sendCommand chỉ có thể gọi sau
|
|
14
|
+
* khi script nằm trên đĩa. Không placeholder mechanism.
|
|
15
|
+
*
|
|
16
|
+
* Fail-closed (spec §3): MỌI lỗi sau khi đã resolve được provider (split fail,
|
|
17
|
+
* builder throw, provider không có sendCommand) → đóng pane mồ côi + trả
|
|
18
|
+
* `{mode:"headless"}` với lý do nội bộ. Hàm KHÔNG BAO GIỜ throw ra ngoài —
|
|
19
|
+
* caller luôn rơi về đường spawn headless hiện có.
|
|
20
|
+
*
|
|
21
|
+
* `; exit` cuối command: script chạy trong shell tương tác của pane, nên khi
|
|
22
|
+
* worker kết thúc pane phải tự đóng (spec §13.2 — onExit là tín hiệu hoàn
|
|
23
|
+
* thành/degrade của host). Shell exit sau script → pane chết → onExit bắn.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
|
|
29
|
+
import type { PiTeamsConfig } from "../../config/types.ts";
|
|
30
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
31
|
+
import { agentEventsPathForStateRoot } from "../crew-agent-records.ts";
|
|
32
|
+
import { currentCrewDepth, getPiTempBase } from "../model/pi-args.ts";
|
|
33
|
+
import { getPiSpawnCommand } from "../pi-spawn.ts";
|
|
34
|
+
import { procStartTimeTicks } from "../process/proc-stat.ts";
|
|
35
|
+
import { buildLaunchScript, launchScriptRegistry, shellEscape, sweepLaunchScripts } from "./launch-script.ts";
|
|
36
|
+
import type { ResolveSurfaceOpts, SurfaceGateRejection, SurfaceResolution } from "./resolve-surface.ts";
|
|
37
|
+
import { resolveSurfaceDetailed, surfaceGateEnvSnapshot } from "./resolve-surface.ts";
|
|
38
|
+
import type { SurfaceExitReason, SurfaceHandle, SurfaceProvider } from "./surface-provider.ts";
|
|
39
|
+
|
|
40
|
+
/** Dependencies injectable — test/thăm dò không cần mux thật. */
|
|
41
|
+
export interface SurfaceSpawnDeps {
|
|
42
|
+
/**
|
|
43
|
+
* Provider ĐÃ resolve sẵn (T11 dispatch sở hữu quyền quyết định, hoặc test).
|
|
44
|
+
* Khi có mặt: bỏ qua resolveSurface hoàn toàn. `null` = ép headless.
|
|
45
|
+
*/
|
|
46
|
+
provider?: SurfaceProvider | null;
|
|
47
|
+
/** Forward thẳng cho resolveSurface (tmuxBin/herdrBin/pingSocket/providers). */
|
|
48
|
+
resolve?: ResolveSurfaceOpts;
|
|
49
|
+
/** Đồng hồ (ms epoch) cho TTL sweep — default Date.now. */
|
|
50
|
+
now?: () => number;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve dòng lệnh pi từ argv TUI (default getPiSpawnCommand). Test inject
|
|
53
|
+
* để chạy script thật mà không cần binary pi trên máy.
|
|
54
|
+
*/
|
|
55
|
+
resolveCommand?: typeof getPiSpawnCommand;
|
|
56
|
+
/** Đọc /proc/<pid>/stat (default readFileSync) — parent starttime. */
|
|
57
|
+
readParentStat?: (pid: number) => string | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PrepareSurfaceSpawnInput {
|
|
61
|
+
/**
|
|
62
|
+
* Env DETECTION của host (depth 0/tier-1, TMUX/HERDR_*, PI_CREW_ASYNC_RUN).
|
|
63
|
+
* PHẢI là base env của host — KHÔNG phải built.env của worker (built.env
|
|
64
|
+
* mang depth con = cha+1 và sẽ làm guard lớp 1 chặn nhầm).
|
|
65
|
+
*/
|
|
66
|
+
env: NodeJS.ProcessEnv;
|
|
67
|
+
/** Env đầy đủ worker phải nhận (đã qua allowlist filter + control spread). */
|
|
68
|
+
workerEnv: Record<string, string>;
|
|
69
|
+
config: PiTeamsConfig;
|
|
70
|
+
role: string;
|
|
71
|
+
livePaneCount: number;
|
|
72
|
+
taskId: string;
|
|
73
|
+
cwd: string;
|
|
74
|
+
/** argv CHƯA strip từ buildPiWorkerArgs (`--mode json -p` đứng đầu). */
|
|
75
|
+
piArgs: string[];
|
|
76
|
+
/** State root của run — nguồn PI_CREW_AGENT_EVENTS_PATH. */
|
|
77
|
+
stateRoot: string;
|
|
78
|
+
/** Thư mục chứa launch script (default getPiTempBase()). */
|
|
79
|
+
baseDir?: string;
|
|
80
|
+
deps?: SurfaceSpawnDeps;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Kết quả prepareSurfaceSpawn — hoặc boot được trong pane, hoặc headless. */
|
|
84
|
+
export type SurfaceSpawnOutcome =
|
|
85
|
+
| {
|
|
86
|
+
mode: "surface";
|
|
87
|
+
kind: SurfaceProvider["kind"];
|
|
88
|
+
paneId: string;
|
|
89
|
+
handle: SurfaceHandle;
|
|
90
|
+
/** Provider đằng sau handle — degrade/close (T11) cần gọi nó. */
|
|
91
|
+
provider: SurfaceProvider;
|
|
92
|
+
scriptPath: string;
|
|
93
|
+
/**
|
|
94
|
+
* Per-agent event log của worker này (`agents/{taskId}/events.jsonl`) —
|
|
95
|
+
* host tail đúng file đó (T9 EventLogTailSource). Null khi spawn ngoài
|
|
96
|
+
* run (không stateRoot → không set PI_CREW_AGENT_EVENTS_PATH cho worker).
|
|
97
|
+
*/
|
|
98
|
+
eventsPath: string | null;
|
|
99
|
+
/**
|
|
100
|
+
* Task 5 (tab-layout §5): tab của pane này — tabKey = runId, tabId từ
|
|
101
|
+
* handle.tabId của provider. Caller (child-pi → controller) ghi manifest
|
|
102
|
+
* `surface.tabs[tabKey]` để run end biết đóng tab nào. Vắng mặt = spawn
|
|
103
|
+
* ngoài run hoặc provider chưa hỗ trợ tab.
|
|
104
|
+
*/
|
|
105
|
+
tabKey?: string;
|
|
106
|
+
tabId?: string;
|
|
107
|
+
}
|
|
108
|
+
| {
|
|
109
|
+
mode: "headless";
|
|
110
|
+
reason?: string;
|
|
111
|
+
/**
|
|
112
|
+
* True khi một surface spawn ĐÃ THỰC SỰ được thử (createSurface /
|
|
113
|
+
* sendCommand) và fail — phân biệt với các lối headless không hề đụng
|
|
114
|
+
* mux (gate/resolve null). T11 dùng flag này cho counter spawn-fail
|
|
115
|
+
* riêng của spec §7 ("Spawn-fail ≠ flap, nhưng có lockout riêng").
|
|
116
|
+
*/
|
|
117
|
+
attempted?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Gate nào đã chặn TRƯỚC khi đụng mux (FINDING-3, report 10tier
|
|
120
|
+
* 2026-08-27): mode-off/depth/pane-cap/role-not-visible/
|
|
121
|
+
* no-mux. KHÔNG đặt khi `attempted` (mux probe fail thật là chuyện
|
|
122
|
+
* của counter spawn-fail, không phải gate). Child-pi đọc field này
|
|
123
|
+
* để phát lifecycle `surface_gate_blocked` vào run events.
|
|
124
|
+
*/
|
|
125
|
+
gateRejected?: SurfaceGateRejection;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Per-agent event log của worker surface — DELEGATE sang công thức canonical
|
|
130
|
+
* `agentEventsPathForStateRoot` (crew-agent-records.ts): cùng safeAgentTaskId
|
|
131
|
+
* sanitize (strip phần trước ":" + assertSafePathId) như mọi consumer host
|
|
132
|
+
* (agent-view, status/output writers). Fix round 1/T9: tự path.join với taskId
|
|
133
|
+
* NGUYÊN làm taskId chứa ":" ghi file khác file agent-view đọc. Null khi không
|
|
134
|
+
* có stateRoot (spawn ngoài run) — bỏ key thay vì đường dẫn sai layout. Throw
|
|
135
|
+
* khi taskId không sanitize được → prepareSurfaceSpawn fail-closed headless.
|
|
136
|
+
*/
|
|
137
|
+
export function surfaceAgentEventsPath(stateRoot: string, taskId: string): string | null {
|
|
138
|
+
return stateRoot ? agentEventsPathForStateRoot(stateRoot, taskId) : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Gỡ đúng cụm `--mode json -p` khỏi argv worker. Idempotent-safe: không thấy
|
|
143
|
+
* cụm thì trả nguyên vẹn (giúp gọi hai lần vô hại). Spec §5.2 — surface variant
|
|
144
|
+
* khác headless DUY NHẤT ở chế độ chạy.
|
|
145
|
+
*/
|
|
146
|
+
export function stripHeadlessModeArgs(args: string[]): string[] {
|
|
147
|
+
const idx = args.indexOf("--mode");
|
|
148
|
+
if (idx === -1 || args[idx + 1] !== "json") return [...args];
|
|
149
|
+
if (args[idx + 2] !== "-p") return [...args];
|
|
150
|
+
return [...args.slice(0, idx), ...args.slice(idx + 3)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Đọc field 22 (/proc/<pid>/stat) — clock ticks kể từ boot, bất biến qua pid
|
|
155
|
+
* reuse. Parse DELEGATE cho runtime/process/proc-stat (dùng chung với
|
|
156
|
+
* worker-side parent-guard T8 và zombie-scanner — hai bên PHẢI index giống
|
|
157
|
+
* nhau, nếu không guard sẽ giết nhầm worker khoẻ). Return RAW ticks dạng chuỗi
|
|
158
|
+
* để worker so khớp từng byte với cùng nguồn dữ liệu. Non-Linux / đọc lỗi → "" .
|
|
159
|
+
*/
|
|
160
|
+
export function readParentStartTime(pid: number, readStat?: (pid: number) => string | undefined): string {
|
|
161
|
+
const reader =
|
|
162
|
+
readStat ??
|
|
163
|
+
((p: number) => {
|
|
164
|
+
try {
|
|
165
|
+
return fs.readFileSync(`/proc/${p}/stat`, "utf8");
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const stat = reader(pid);
|
|
171
|
+
if (!stat) return "";
|
|
172
|
+
return procStartTimeTicks(stat) ?? "";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Nối command + args thành một dòng bash an toàn (mỗi token escaped riêng). */
|
|
176
|
+
function joinCommandLine(spec: { command: string; args: string[] }): string {
|
|
177
|
+
return [spec.command, ...spec.args].map(shellEscape).join(" ");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Chuẩn bị nhánh spawn surface. Trả outcome — KHÔNG throw (fail-closed §3).
|
|
182
|
+
*/
|
|
183
|
+
export async function prepareSurfaceSpawn(input: PrepareSurfaceSpawnInput): Promise<SurfaceSpawnOutcome> {
|
|
184
|
+
const now = input.deps?.now ?? Date.now;
|
|
185
|
+
|
|
186
|
+
// TTL sweep trước mỗi spawn (spec §5.2): dọn script mồ côi trước khi tạo mới.
|
|
187
|
+
try {
|
|
188
|
+
sweepLaunchScripts(launchScriptRegistry, now());
|
|
189
|
+
} catch (error) {
|
|
190
|
+
logInternalError("surface-spawn.sweep", error instanceof Error ? error : new Error(String(error)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Lớp guard 1: provider hay null? deps.provider (pre-resolved) thắng —
|
|
194
|
+
// dispatch T11 sở hữu quyết định surface; không có thì đi qua matrix T2.
|
|
195
|
+
// Gate CỨNG duy nhất áp cho cả đường pre-resolved là DEPTH (không pane-in-
|
|
196
|
+
// pane) — spec §3 không thể bị bỏ qua. Async KHÔNG còn là gate: kể từ
|
|
197
|
+
// 2026-08-27, surface không hard-code "async → headless" nữa; pane có hay
|
|
198
|
+
// không do môi trường quét được + `runtime.surface.*` config quyết.
|
|
199
|
+
const hardGated = currentCrewDepth(input.env) > 0 ? `host depth ${currentCrewDepth(input.env)} > 0` : null;
|
|
200
|
+
let resolution: SurfaceResolution;
|
|
201
|
+
try {
|
|
202
|
+
if (hardGated && input.deps?.provider !== undefined) {
|
|
203
|
+
logInternalError(
|
|
204
|
+
"surface-spawn.hard-gate",
|
|
205
|
+
new Error(`pre-resolved surface provider ignored: ${hardGated}`),
|
|
206
|
+
"fail-closed §3",
|
|
207
|
+
"warn",
|
|
208
|
+
);
|
|
209
|
+
return {
|
|
210
|
+
mode: "headless",
|
|
211
|
+
reason: `surface gated by ${hardGated}`,
|
|
212
|
+
gateRejected: {
|
|
213
|
+
gate: "depth",
|
|
214
|
+
reason: `surface gated by ${hardGated}`,
|
|
215
|
+
env: surfaceGateEnvSnapshot(input.env),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
resolution =
|
|
220
|
+
input.deps?.provider !== undefined
|
|
221
|
+
? { provider: input.deps.provider }
|
|
222
|
+
: resolveSurfaceDetailed(input.env, input.config, input.role, input.livePaneCount, input.deps?.resolve);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
logInternalError("surface-spawn.resolve", error instanceof Error ? error : new Error(String(error)), "resolveSurface threw");
|
|
225
|
+
return { mode: "headless", reason: `surface resolution threw: ${error instanceof Error ? error.message : String(error)}` };
|
|
226
|
+
}
|
|
227
|
+
if (!resolution.provider) {
|
|
228
|
+
// Lý do cụ thể (depth/cap/async/mode/mux thiếu) đến từ matrix T2 —
|
|
229
|
+
// rejection đi theo outcome để child-pi phát surface_gate_blocked.
|
|
230
|
+
const rejection = resolution.rejection;
|
|
231
|
+
return {
|
|
232
|
+
mode: "headless",
|
|
233
|
+
reason: rejection
|
|
234
|
+
? `surface gate "${rejection.gate}": ${rejection.reason}`
|
|
235
|
+
: "surface resolution returned null (mode/depth/async/cap/role gate or no mux)",
|
|
236
|
+
gateRejected: rejection,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const provider = resolution.provider;
|
|
240
|
+
|
|
241
|
+
// Pane TRƯỚC — cần id thật cho env map của script. Title = taskId (F4, review
|
|
242
|
+
// T7): pane rename là cosmetic nên KHÔNG được quyết định fail-closed.
|
|
243
|
+
// Tab-layout (spec 2026-08-27-surface-tab-layout): tabKey = runId (basename
|
|
244
|
+
// của stateRoot) — mọi worker của run chia tab; splitIndex = số pane worker
|
|
245
|
+
// run đã có (provider tự luân phiên down/right + mở tab mới khi đầy 8).
|
|
246
|
+
const runId = input.stateRoot ? path.basename(input.stateRoot) : undefined;
|
|
247
|
+
let handle: SurfaceHandle;
|
|
248
|
+
try {
|
|
249
|
+
handle = await provider.createSurface(input.taskId, {
|
|
250
|
+
cwd: input.cwd,
|
|
251
|
+
title: input.taskId,
|
|
252
|
+
...(runId ? { tabKey: runId, splitIndex: input.livePaneCount } : {}),
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logInternalError(
|
|
256
|
+
"surface-spawn.create-surface",
|
|
257
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
258
|
+
`taskId=${input.taskId}`,
|
|
259
|
+
);
|
|
260
|
+
return {
|
|
261
|
+
mode: "headless",
|
|
262
|
+
reason: `createSurface failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
263
|
+
attempted: true,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Từ đây trở đi mọi lỗi phải đóng pane mồ côi rồi fallback headless.
|
|
268
|
+
try {
|
|
269
|
+
if (typeof provider.sendCommand !== "function") {
|
|
270
|
+
throw new Error("provider does not implement sendCommand — cannot boot a commandless pane");
|
|
271
|
+
}
|
|
272
|
+
const tuiArgs = stripHeadlessModeArgs(input.piArgs);
|
|
273
|
+
const spawnSpec = (input.deps?.resolveCommand ?? getPiSpawnCommand)(tuiArgs);
|
|
274
|
+
// Env export = worker env đầy đủ (parity headless) + các biến surface.
|
|
275
|
+
// Không stateRoot (spawn ngoài run) → không có event log để ghi — bỏ key
|
|
276
|
+
// thay vì đường dẫn tương đối sai layout agents/{taskId}/events.jsonl.
|
|
277
|
+
const eventsPath = surfaceAgentEventsPath(input.stateRoot, input.taskId);
|
|
278
|
+
const scriptEnv: Record<string, string> = {
|
|
279
|
+
...input.workerEnv,
|
|
280
|
+
PI_CREW_SURFACE: provider.kind,
|
|
281
|
+
PI_CREW_SURFACE_PANE: handle.id,
|
|
282
|
+
PI_CREW_AUTO_EXIT: "1",
|
|
283
|
+
...(eventsPath ? { PI_CREW_AGENT_EVENTS_PATH: eventsPath } : {}),
|
|
284
|
+
// Host đã set PI_CREW_PARENT_PID trong worker env (child-pi-spawn) —
|
|
285
|
+
// chỉ tự điền khi worker env chưa có để tránh ghi đè ý của caller.
|
|
286
|
+
...(input.workerEnv.PI_CREW_PARENT_PID ? {} : { PI_CREW_PARENT_PID: String(process.pid) }),
|
|
287
|
+
};
|
|
288
|
+
if (!scriptEnv.PI_CREW_PARENT_START_TIME) {
|
|
289
|
+
scriptEnv.PI_CREW_PARENT_START_TIME = readParentStartTime(process.pid, input.deps?.readParentStat);
|
|
290
|
+
}
|
|
291
|
+
// callerEnv = env host → guard lớp 2 đọc độ sâu NGƯỜI GỌI (env export được
|
|
292
|
+
// phép mang PI_CREW_DEPTH=<caller+1> vì đó là depth hợp lệ của worker).
|
|
293
|
+
const scriptPath = buildLaunchScript({
|
|
294
|
+
taskId: input.taskId,
|
|
295
|
+
env: scriptEnv,
|
|
296
|
+
command: joinCommandLine(spawnSpec),
|
|
297
|
+
cwd: input.cwd,
|
|
298
|
+
baseDir: input.baseDir ?? getPiTempBase(),
|
|
299
|
+
callerEnv: input.env,
|
|
300
|
+
});
|
|
301
|
+
// Boot worker + thoát shell khi worker kết thúc (pane đóng → onExit, §13.2).
|
|
302
|
+
await provider.sendCommand(handle, `bash ${shellEscape(scriptPath)}; exit`);
|
|
303
|
+
return {
|
|
304
|
+
mode: "surface",
|
|
305
|
+
kind: provider.kind,
|
|
306
|
+
paneId: handle.id,
|
|
307
|
+
handle,
|
|
308
|
+
provider,
|
|
309
|
+
scriptPath,
|
|
310
|
+
eventsPath,
|
|
311
|
+
// Task 5 (tab-layout): đưa tab identity lên cho caller ghi manifest —
|
|
312
|
+
// provider set handle.tabId trong tab-flow (tabKey có mặt).
|
|
313
|
+
...(runId && handle.tabId ? { tabKey: runId, tabId: handle.tabId } : {}),
|
|
314
|
+
};
|
|
315
|
+
} catch (error) {
|
|
316
|
+
logInternalError(
|
|
317
|
+
"surface-spawn.boot",
|
|
318
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
319
|
+
`taskId=${input.taskId} pane=${handle.id} — falling back to headless`,
|
|
320
|
+
);
|
|
321
|
+
try {
|
|
322
|
+
await provider.closeSurface(handle, { force: true });
|
|
323
|
+
} catch (closeError) {
|
|
324
|
+
logInternalError(
|
|
325
|
+
"surface-spawn.orphan-close",
|
|
326
|
+
closeError instanceof Error ? closeError : new Error(String(closeError)),
|
|
327
|
+
`pane=${handle.id}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
mode: "headless",
|
|
332
|
+
reason: `surface boot failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
333
|
+
attempted: true,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Nhánh outcome đã boot thành công (dùng ở chữ ký waitForSurfaceExit). */
|
|
339
|
+
export type SurfaceSpawned = Extract<SurfaceSpawnOutcome, { mode: "surface" }>;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* TTL sweep cho điểm KẾT THÚC run (spec §5.2) — finalizeRun gọi hàm này để dọn
|
|
343
|
+
* script mồ côi (worker chưa kịp chạy đã chết) cùng lúc với state còn lại của
|
|
344
|
+
* run. Trả số entry đã dọn (best-effort — không throw).
|
|
345
|
+
*/
|
|
346
|
+
export function sweepLaunchScriptsAtRunEnd(now: () => number = Date.now): number {
|
|
347
|
+
try {
|
|
348
|
+
return sweepLaunchScripts(launchScriptRegistry, now());
|
|
349
|
+
} catch (error) {
|
|
350
|
+
logInternalError("surface-spawn.run-end-sweep", error instanceof Error ? error : new Error(String(error)));
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Grace window sau khi host đã INITIATE force-close mà handle.onExit vẫn chưa
|
|
357
|
+
* bắn (final fix I-1): herdr subscribe là edge-event nên pane chết giữa khoảng
|
|
358
|
+
* split → lần đăng ký onExit ĐẦU sẽ bỏ lỡ event; `pane.close` trả
|
|
359
|
+
* `pane_not_found` và closeSurface nuốt thành "thành công" — nếu chỉ dựa vào
|
|
360
|
+
* onExit, promise treo tới host Ctrl-C. tmux miễn nhiễm vì poll TRẠNG THÁI.
|
|
361
|
+
* Hết grace → resolve exit TỔNG HỢP (`synthetic: true`, reason "detached").
|
|
362
|
+
* Timeout test inject được qua {@link WaitForSurfaceExitHooks.graceAfterForceCloseMs}.
|
|
363
|
+
*/
|
|
364
|
+
export const FORCE_CLOSE_EXIT_GRACE_MS = 2_000;
|
|
365
|
+
|
|
366
|
+
export interface SurfaceExitInfo {
|
|
367
|
+
reason: SurfaceExitReason;
|
|
368
|
+
/** True khi parent AbortSignal đã force-close pane (cancel/host-shutdown). */
|
|
369
|
+
cancelledByAbort: boolean;
|
|
370
|
+
/**
|
|
371
|
+
* True khi response deadline đạt TRƯỚC khi pane tự chết — worker bị coi là
|
|
372
|
+
* treo, pane bị force-close (fix round 1/F2: parity timeout tối thiểu với
|
|
373
|
+
* đường headless để slot worker không bị giữ vô hạn).
|
|
374
|
+
*/
|
|
375
|
+
timedOut: boolean;
|
|
376
|
+
/**
|
|
377
|
+
* True khi đây là exit TỔNG HỢP do grace window hết sau force-close mà
|
|
378
|
+
* onExit vẫn im lặng (final fix I-1) — không có event thật nào từ mux;
|
|
379
|
+
* caller phải coi như worker đã chết (exitCode null), không phải hoàn thành.
|
|
380
|
+
*/
|
|
381
|
+
synthetic?: boolean;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface WaitForSurfaceExitHooks {
|
|
385
|
+
signal?: AbortSignal;
|
|
386
|
+
/** Hard deadline (ms) tính từ lúc bắt đầu chờ — undefined = không deadline. */
|
|
387
|
+
deadlineMs?: number;
|
|
388
|
+
/** Ghi đè grace window (mặc định FORCE_CLOSE_EXIT_GRACE_MS) — test ngắn. */
|
|
389
|
+
graceAfterForceCloseMs?: number;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Chờ worker trong pane kết thúc — nguồn chờ chính là handle.onExit (pane chết
|
|
394
|
+
* / mux chết / host chủ động detach). Ba cơ chế giải thoát promise để slot
|
|
395
|
+
* worker KHÔNG BAO GIỜ bị giữ vô hạn:
|
|
396
|
+
* 1. Parent AbortSignal → closeSurface({force:true}) → onExit bắn "detached".
|
|
397
|
+
* 2. Response deadline (fix round 1/F2) → cùng force-close; A1 không có tín
|
|
398
|
+
* hiệu activity từ stream nên deadline là một mốc hard duy nhất kể từ lúc
|
|
399
|
+
* boot (nghiêm hơn nghĩa "no new output" của headless — T11 refine).
|
|
400
|
+
* 3. Synthetic-exit grace (final fix I-1) → force-close xong mà onExit vẫn
|
|
401
|
+
* câm (herdr edge-event gap / socket request treo — T4 A2 note) thì tự
|
|
402
|
+
* kết thúc bằng exit tổng hợp.
|
|
403
|
+
* Spec §13.2.
|
|
404
|
+
*/
|
|
405
|
+
export async function waitForSurfaceExit(outcome: SurfaceSpawned, hooks: WaitForSurfaceExitHooks = {}): Promise<SurfaceExitInfo> {
|
|
406
|
+
const forceClose = async (): Promise<void> => {
|
|
407
|
+
try {
|
|
408
|
+
await outcome.provider.closeSurface(outcome.handle, { force: true });
|
|
409
|
+
} catch (error) {
|
|
410
|
+
logInternalError(
|
|
411
|
+
"surface-spawn.force-close",
|
|
412
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
413
|
+
`pane=${outcome.paneId}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
return await new Promise<SurfaceExitInfo>((resolve) => {
|
|
418
|
+
let cancelledByAbort = hooks.signal?.aborted === true;
|
|
419
|
+
let timedOut = false;
|
|
420
|
+
let settled = false;
|
|
421
|
+
let synthTimer: ReturnType<typeof setTimeout> | null = null;
|
|
422
|
+
const finish = (info: SurfaceExitInfo): void => {
|
|
423
|
+
if (settled) return;
|
|
424
|
+
settled = true;
|
|
425
|
+
if (synthTimer) clearTimeout(synthTimer);
|
|
426
|
+
resolve(info);
|
|
427
|
+
};
|
|
428
|
+
const armSyntheticFallback = (): void => {
|
|
429
|
+
if (settled || synthTimer) return;
|
|
430
|
+
const graceMs = Math.max(1, hooks.graceAfterForceCloseMs ?? FORCE_CLOSE_EXIT_GRACE_MS);
|
|
431
|
+
synthTimer = setTimeout(() => {
|
|
432
|
+
logInternalError(
|
|
433
|
+
"surface-spawn.synthetic-exit",
|
|
434
|
+
new Error(`no onExit within ${graceMs}ms of force-close`),
|
|
435
|
+
`pane=${outcome.paneId} — resolving synthetic exit (pane may have died before its first onExit subscription)`,
|
|
436
|
+
);
|
|
437
|
+
finish({ reason: "detached", cancelledByAbort, timedOut, synthetic: true });
|
|
438
|
+
}, graceMs);
|
|
439
|
+
synthTimer.unref();
|
|
440
|
+
};
|
|
441
|
+
if (cancelledByAbort) {
|
|
442
|
+
// Cancel thắng mọi thứ — initiate ngay cả khi signal đã aborted trước.
|
|
443
|
+
cancelledByAbort = true;
|
|
444
|
+
void forceClose();
|
|
445
|
+
armSyntheticFallback();
|
|
446
|
+
}
|
|
447
|
+
const onAbort = (): void => {
|
|
448
|
+
if (cancelledByAbort || timedOut || settled) return;
|
|
449
|
+
cancelledByAbort = true;
|
|
450
|
+
void forceClose();
|
|
451
|
+
armSyntheticFallback();
|
|
452
|
+
};
|
|
453
|
+
hooks.signal?.addEventListener("abort", onAbort, { once: true });
|
|
454
|
+
const timer =
|
|
455
|
+
hooks.deadlineMs !== undefined
|
|
456
|
+
? setTimeout(
|
|
457
|
+
() => {
|
|
458
|
+
if (cancelledByAbort || timedOut || settled) return; // cancel thắng — đã force-close
|
|
459
|
+
timedOut = true;
|
|
460
|
+
void forceClose();
|
|
461
|
+
armSyntheticFallback();
|
|
462
|
+
},
|
|
463
|
+
Math.max(1, hooks.deadlineMs),
|
|
464
|
+
)
|
|
465
|
+
: null;
|
|
466
|
+
timer?.unref(); // deadline không được giữ event loop sống vô ích
|
|
467
|
+
outcome.handle.onExit((reason) => finish({ reason, cancelledByAbort, timedOut }));
|
|
468
|
+
if (!hooks.signal && !timer && !synthTimer && !settled) {
|
|
469
|
+
// Không đường giải thoát nào được cấu hình (không signal, không
|
|
470
|
+
// deadline): vẫn phải có hồi kết — dùng grace từ lúc bắt đầu chờ thay
|
|
471
|
+
// vì treo khi pane chết trước lần đăng ký onExit đầu.
|
|
472
|
+
armSyntheticFallback();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|