pi-crew 0.11.47 → 0.11.49
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/.githooks/pre-push +15 -0
- package/CHANGELOG.md +92 -0
- package/dist/index.mjs +440 -440
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +5 -0
- package/src/config/env-allowlist-registry.ts +2 -2
- package/src/config/env-vars.ts +1 -1
- package/src/extension/autonomous-policy.ts +8 -1
- package/src/extension/knowledge-injection.ts +5 -1
- package/src/prompt/prompt-runtime.ts +11 -3
- package/src/prompt/surface-worker.ts +1 -1
- package/src/runtime/broker/crew-broker-child.ts +63 -28
- package/src/runtime/broker/crew-broker-delegate-request.ts +5 -5
- package/src/runtime/broker/stdin-handshake.ts +113 -17
- package/src/runtime/child-pi/child-pi-spawn.ts +88 -32
- package/src/runtime/child-pi/child-pi.ts +32 -13
- package/src/state/stores/state-store.ts +34 -0
package/package.json
CHANGED
|
@@ -197,6 +197,11 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
|
|
|
197
197
|
|
|
198
198
|
> **2026-10-24 update (wave 12 — test-fixture teardown, commits `75542295`→`a97714d2` + wave-12c F4/F6; spec `fixwave12-fixture-teardown-2026-10-24/SPEC.md`)**: lớp leak THỨ HAI, khác wave 11: 2 process `node -e "process.on('SIGTERM',()=>{}); setInterval(...)"` sống >6 h sau khi run `team_20261024024905_4cf882558c7c2b6d` completed — **test fixture**, không phải worker descendant: spawn `detached:true` (group riêng ⇒ group kill của runner không tới được) + teardown pid-only `child.kill()` (vô hiệu trước fixture nuốt SIGTERM) + không handle ⇒ không ai reap. Wave-11 sweep từ chối reap là **hành vi ĐÚNG** (không proof descent). (1) **Surface mới `test/helpers/fixture-process.ts`** — `spawnFixture` (THROW khi `detached:false`: child non-detached chia sẻ group của RUNNER, group kill sẽ giết runner + mọi file sibling; snapshot `readProcIdentity` NGAY sau spawn, normalise pgid=pid), `reapFixture` (group reap + escalation SIGTERM→SIGKILL, refusal chứng minh pid lạ ⇒ KHÔNG signal gì, pid-fallback cho non-Linux, không bao giờ throw), `registerFixtureTrap` (`after()` + `process.on("exit")` sync flush cho cửa sổ abort sớm). Rule viết 1 lần: `test/helpers/README.md`. (2) **GAP-1 (HIGH, wave 12c-F3)** — `background-runner-sigint-cleanup`: grandchild detached của harness giờ được reap theo group trong `finally` (harness giữ pid-only ĐÚNG — non-detached). (3) **GAP-2/GAP-3 (wave 12c-F3)** — fake `rg` tự ghi pid + reap group; `inference-broker` giữ `ChildProcess`, spawn detached + `reapFixture`/trap. (4) **GAP-4 (wave 12c-F4)** — `reap-group.test.ts`: teardown qua `reapTree` (leader-snapshot + group reap **TRƯỚC**, rồi pid kills idempotent) và `spawnTree()` tự reap group trên đường FAIL (timeout 15 s / spawn error / JSON hỏng) — trước đó leader biết, kids không ⇒ mồ côi toàn bộ. GAP-5 sweep: `guest-zombie` (detached thật) chuyển sang helper; 4 file còn lại là NON-detached ⇒ giữ pid kill + comment `// non-detached: pid-only by design (shares runner's group)`. (5) **Wave-12a bug tự gây** — thêm `test/unit/helpers/` mà không đăng ký shard weight ⇒ 4 test shard-partition fail (`ebe0138c`). (6) Battery cho wave này: T1 từng file đã sửa ×2 + `pgrep -af "setInterva[l]"` RỖNG + `test:unit` không tăng fail so baseline. (7) **Wave 12d lesson** — teardown test có async writer (`appendEventAsync` → `fs.promises.mkdir`) phải dùng `removeDirWithRetry` (`test/helpers/rm-retry.ts`: settle 250 ms + retry EBUSY/EPERM/ENOTEMPTY); `rmSync` trần ⇒ `ENOTEMPTY` dưới load vì writer tái tạo `runs/<runId>/` sau khi dir đã bị xoá.
|
|
199
199
|
|
|
200
|
+
> **2026-10-28 update (incident 0.11.46/0.11.47 silent-worker + v0.11.48 hotfix + battery HARD-GATE, commits `eed005b3`(46)/`68788df4`(47)/`d55632d0`+`840900b3`(48)/`85de1893`(gate))**: (1) **INCIDENT (live-caught bởi chính tier này)** — LOW-1 stdin handshake (0.11.46) ship default-ON: reader worker-side `startChildBrokerClientAsync` → `readStdinFirstLine` (env broker identity + KHÔNG env token) làm toàn bộ `pi --mode json -p` worker **exit 0 với 0 byte stdout trong ~1.2s**; mọi team run/subagent trên 0.11.46–0.11.47 chết. **Fingerprint regression trong events: `empty-or-stderr-only-result`** — T7 giờ PHẢI grep chuỗi này trong events của run smoke (judge từ DISK, không tin prose report của pi -p). CI mù hoàn toàn: unit dùng seam `readStdinLine` (không chạy process.stdin thật trong pi thật), integration chạy fake-pi. (2) **v0.11.48 flip** — `PI_CREW_BROKER_STDIN` giờ **OPT-IN** (`=1` bật stdin route; unset/`0` = legacy env route bit-for-bit pre-0.11.46, cả 2 phía — `resolveBrokerStdinHandshakeEnabled` child-pi-spawn.ts + `resolveBrokerStdinFallbackEnabled` crew-broker-child.ts). LOW-1 code giữ nguyên chờ root-cause reader (strace: DNS → HTTPS :443 Cloudflare → rt_sigaction SIGTERM → exit_group(0) — ĐÃ giải thích + fix: xem update 2026-10-28-b bên dưới). Regression live test mới: `test/integration/broker-stdin-reader-liveworker.test.ts` (spawn pi thật + env shape chết, assert stdout>0, tự-skip khi không có `pi`). (3) **BATTERY HARD-GATE** — `scripts/pre-publish-battery.mjs` cắm vào `.githooks/pre-push` (sau unit, push main) + `prepublish-ci.mjs` (trước `npm run ci`): T1/T3/T11j luôn + T7 path-triggered từ diff tag-trước→HEAD; skip-không-pi CHỈ qua `PI_CREW_BATTERY_SKIP=1` (ghi audit `.crew/audit/battery.jsonl`; skip im lặng = ĐỎ); quy tắc **gate KHÔNG CHẠY = KHÔNG PUBLISH**. Script là lớp AUTO — battery thủ công theo decision table (tier còn lại: T2/T4/T5/T6/T8/T9/T10/T11c/T12/T13) VẪN bắt buộc theo changed paths và KHÔNG được cherry-pick tier rẻ. (4) **META-RULE (bài học 2 bản sót liền)** — đổi code ảnh hưởng behavior mà skill này mô tả ⇒ **update SKILL.md TRONG CộNG WAVE, TRƯỚC khi chạy battery**; battery chạy FULL decision-table cho mọi changed path. Gate prose bị bỏ 2/2 lần dưới áp lực; chỉ gate script (exit-code) sống sót — đó là lý do (3) tồn tại.
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
> **2026-10-28-b update (ROOT-CAUSE CLOSED + fd3 transport + review-wave fixes, commits `38d5ed58`..`5190e66b`, cold-verified review + 3-agent verification)**: (1) **ROOT-CAUSE incident 0.11.46/47 (đóng)** — pi's `readPipedStdin` (pi@1.0.4 `dist/main.js:52-65`) đọc TOÀN BỘ piped stdin làm PROMPT CONTENT trong mọi mode trừ `--mode=rpc` ⇒ handshake fd0 race 3 cách: token JSON thành literal prompt text (SECRET LEAK — chứng minh trên transcript thật), stream-ended creds-less câm lặng, reader `pause()` trước EOF drain → pi boot await deadlock → **TỰ exit 0** (KHÔNG phải parent SIGTERM — strace cũ misread) + takeOverStdout buffer ⇒ ~0 bytes. **Fix: handshake dời sang fd3** (stdio entry 4 `['ignore','pipe','pipe','pipe']`, `readFirstLineFromFd(BROKER_HANDSHAKE_FD=3)`; fd0 về `"ignore"` — shape pre-0.11.46); detached runner GIỮ fd0 (node process, không race). Gate `PI_CREW_BROKER_STDIN` vẫn OPT-IN. Evidence: pre-fix pi@1.0.4 gate-ON 5/5 chết (~1s, 0B) → post-fix 2/2 sống (106-110KB, ~3.2s, 0 leak); integration live-worker giờ assert CẢ gate-ON liveness (bỏ comment "DO NOT assert liveness") + leak-check `v":2` không xuất hiện trong output. **Contract nền: fd0 KHÔNG BAO GIỜ là kênh sideband của extension khi spawn pi binary — dùng stdio entry 4+ (fd3).** (2) **T7 judge mechanics ĐỔI** — dispatch goal giờ mang nonce `[t7-nonce:<uuid>]`, judge CHỈ dirs có nonce trong events.jsonl (hết race newest-mtime với run đồng thời); non-terminal run = FAIL (check TRƯỚC killed-branch); stdout/stderr của dispatch được capture (cap 64KB) vào failure reason + audit. (3) **T7 triggers mở rộng** — thêm `src/(state|utils|agents|teams|workflows|hooks|observability)/` + `scripts/` (gate changes live-test the gate). (4) diff-base tag filter `^v\d+\.\d+\.\d+$` + porcelain rename both-sides; `PI_CREW_BATTERY_SKIP` giờ sống qua prepublishOnly path. (5) maxTasksPerRun admission cap tại createRunManifest (pre-dispatch); seam fix gate đọc `options.env ?? process.env` (test inject được — hết mutate process.env); `buildBrokerStdinLine` warn >60KiB (reader abort ~131KiB thực tế Linux — pipe chunks). (6) **CI 'Checks' step có gates NGOÀI battery list** (`check:env-vars`, `check:decision-drift`) — thêm vào leader local gate list sau mọi src edit.
|
|
204
|
+
|
|
200
205
|
## Core principle: disk ≠ live Pi
|
|
201
206
|
|
|
202
207
|
Two locations hold pi-crew state:
|
|
@@ -199,8 +199,8 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
|
|
|
199
199
|
},
|
|
200
200
|
PI_CREW_BROKER_TASK_ID: { kind: "child-write", reason: "child-pi-spawn broker handshake" },
|
|
201
201
|
PI_CREW_BROKER_STDIN: {
|
|
202
|
-
kind: "
|
|
203
|
-
reason: "LOW-1
|
|
202
|
+
kind: "child-write",
|
|
203
|
+
reason: "LOW-1 broker-credential transport gate — read by the HOST spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route, then RE-WRITTEN by that spawner into every brokered child's built.env ('1') so the worker-side mirror gate (crew-broker-child .resolveBrokerStdinFallbackEnabled, which reads its OWN process.env) actually sees it — ambient forwarding never reaches the child, so child-write is the only channel that wires the route through (FIX-1 2026-10-28). Never ambient-allowlisted: route selection is per-spawn",
|
|
204
204
|
},
|
|
205
205
|
PI_CREW_INFERENCE_BROKER: {
|
|
206
206
|
kind: "gap",
|
package/src/config/env-vars.ts
CHANGED
|
@@ -668,7 +668,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
668
668
|
},
|
|
669
669
|
PI_CREW_BROKER_STDIN: {
|
|
670
670
|
name: "PI_CREW_BROKER_STDIN",
|
|
671
|
-
doc: "LOW-1 closure (2026-10-27) broker-credential transport gate
|
|
671
|
+
doc: "LOW-1 closure (2026-10-27) broker-credential transport gate, OPT-IN since v0.11.48 (2026-10-28): '1' sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over a DEDICATED fd3 spawn pipe (one buildBrokerStdinLine v2 payload on stdio entry 4; heap → fd3 → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader; fd0 stays 'ignore') so the token never appears in the child env; unset/any-other-value (default) keeps the LEGACY ENV ROUTE (token in built.env) on BOTH sides — the original fd0 variant was caught by the live battery (real-test-pi-crew T7) killing real headless workers with a silent exit 0 (root cause: pi@1.0.4 readPipedStdin consumes all piped fd0 as prompt content); the fd3 transport removes that race. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side fd3 read skips --mode rpc argv by design)",
|
|
672
672
|
},
|
|
673
673
|
PI_CREW_GUEST: {
|
|
674
674
|
name: "PI_CREW_GUEST",
|
|
@@ -202,8 +202,15 @@ export function registerAutonomousPolicy(pi: ExtensionAPI): void {
|
|
|
202
202
|
const autonomous = effectiveAutonomousConfig(loaded.config.autonomous);
|
|
203
203
|
if (!autonomous.enabled) return undefined;
|
|
204
204
|
if (!autonomous.injectPolicy) return undefined;
|
|
205
|
+
const prompt = typeof event.prompt === "string" ? event.prompt : "";
|
|
206
|
+
// `prompt` is absent on turns with no user text (bg-task notifications, goal
|
|
207
|
+
// continuations, timer turns); unguarded it reaches prompt.toLowerCase() and throws.
|
|
208
|
+
// FIX-8 (GH #64 wave, defense-in-depth): `systemPrompt` is typed `string`
|
|
209
|
+
// and the runner always sets it, but coerce anyway — the output is built
|
|
210
|
+
// FROM it, so an undefined would stringify as a literal "undefined" prefix.
|
|
211
|
+
const systemPrompt = typeof event.systemPrompt === "string" ? event.systemPrompt : "";
|
|
205
212
|
return {
|
|
206
|
-
systemPrompt: appendAutonomousPolicy(
|
|
213
|
+
systemPrompt: appendAutonomousPolicy(systemPrompt, prompt, autonomous, cwd),
|
|
207
214
|
};
|
|
208
215
|
});
|
|
209
216
|
}
|
|
@@ -474,6 +474,10 @@ export function registerKnowledgeInjection(pi: ExtensionAPI): void {
|
|
|
474
474
|
const cwd = typeof options.cwd === "string" ? options.cwd : process.cwd();
|
|
475
475
|
const fragment = buildKnowledgeFragment(cwd);
|
|
476
476
|
if (!fragment) return undefined;
|
|
477
|
-
|
|
477
|
+
// FIX-8 (GH #64 wave, defense-in-depth): `systemPrompt` is typed `string`
|
|
478
|
+
// and the runner always sets it, but coerce anyway — the output is built
|
|
479
|
+
// FROM it, so an undefined would stringify as a literal "undefined" prefix.
|
|
480
|
+
const systemPrompt = typeof event.systemPrompt === "string" ? event.systemPrompt : "";
|
|
481
|
+
return { systemPrompt: `${systemPrompt}${fragment}` };
|
|
478
482
|
});
|
|
479
483
|
}
|
|
@@ -1191,10 +1191,11 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
1191
1191
|
// deliver pushed steers with the SAME sanitize + pi.sendMessage path as the
|
|
1192
1192
|
// file poll above. The file poll remains the durable fallback; a broker
|
|
1193
1193
|
// connect failure is invisible to the worker.
|
|
1194
|
-
// LOW-1 closure: the registration hook is SYNC, but the credential
|
|
1195
|
-
// may now be the spawn
|
|
1194
|
+
// LOW-1 closure → FD-3: the registration hook is SYNC, but the credential
|
|
1195
|
+
// source may now be the DEDICATED fd3 spawn pipe (one buildBrokerStdinLine
|
|
1196
|
+
// line — fd0 stays "ignore"; pi@1.0.4 readPipedStdin owns fd0) — resolve
|
|
1196
1197
|
// the async bootstrap into a placeholder handle and swap it in when ready.
|
|
1197
|
-
// A session_shutdown racing the (bounded ≤2s)
|
|
1198
|
+
// A session_shutdown racing the (bounded ≤2s) fd3 read closes whichever
|
|
1198
1199
|
// handle is live at that moment, and the late arrival closes itself.
|
|
1199
1200
|
let brokerHandle: ChildBrokerClientHandle = { active: false, close: async () => undefined };
|
|
1200
1201
|
let brokerShutdownSeen = false;
|
|
@@ -1356,6 +1357,13 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
1356
1357
|
|
|
1357
1358
|
// ── Prompt rewriting (existing) ────────────────────────────────────────
|
|
1358
1359
|
pi.on("before_agent_start", (event) => {
|
|
1360
|
+
// FIX-8 (GH #64 wave, defense-in-depth): `systemPrompt` is typed `string`
|
|
1361
|
+
// and the runner always sets it. If a host ever hands a non-string through,
|
|
1362
|
+
// SKIP the rewrite entirely — coercing "" here would REPLACE the real
|
|
1363
|
+
// system prompt with a blank one (this handler strips sections), unlike
|
|
1364
|
+
// the append-only hooks (autonomous-policy / knowledge-injection) which
|
|
1365
|
+
// safely coerce "".
|
|
1366
|
+
if (typeof event.systemPrompt !== "string") return;
|
|
1359
1367
|
const inheritProjectContext = readBooleanEnvAny(PI_CREW_INHERIT_PROJECT_CONTEXT_ENV, PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV);
|
|
1360
1368
|
const inheritSkills = readBooleanEnvAny(PI_CREW_INHERIT_SKILLS_ENV, PI_TEAMS_INHERIT_SKILLS_ENV);
|
|
1361
1369
|
if (inheritProjectContext === undefined && inheritSkills === undefined) return;
|
|
@@ -182,7 +182,7 @@ export function parentAlive(pid: number, expectedStartTime: string | undefined,
|
|
|
182
182
|
// workers at the first 5s tick.
|
|
183
183
|
readerThrew = true;
|
|
184
184
|
}
|
|
185
|
-
if (readerThrew) return expectedStartTime
|
|
185
|
+
if (readerThrew) return !expectedStartTime;
|
|
186
186
|
// Reader returned "no entry" (default /proc reader ENOENT = dead on
|
|
187
187
|
// Linux) — dead-parent detection stays intact.
|
|
188
188
|
if (stat === undefined) return false;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type * as net from "node:net";
|
|
18
18
|
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
19
19
|
import { type BrokerEventFrame, CrewBrokerClient, type CrewBrokerClientOptions } from "./crew-broker-client.ts";
|
|
20
|
-
import { parseStdinBrokerPayload,
|
|
20
|
+
import { BROKER_HANDSHAKE_FD, parseStdinBrokerPayload, readFirstLineFromFd } from "./stdin-handshake.ts";
|
|
21
21
|
|
|
22
22
|
export interface ChildBrokerClientHandle {
|
|
23
23
|
/** True once the client has completed the handshake. */
|
|
@@ -39,9 +39,10 @@ export interface StartChildBrokerClientOptions {
|
|
|
39
39
|
netModule?: typeof net;
|
|
40
40
|
/** Test seam: override client construction. */
|
|
41
41
|
clientFactory?: (opts: CrewBrokerClientOptions) => CrewBrokerClient;
|
|
42
|
-
/** Test seam: override the
|
|
43
|
-
* (defaults to stdin-handshake.
|
|
44
|
-
|
|
42
|
+
/** Test seam: override the handshake first-line read for the LOW-1 fd3
|
|
43
|
+
* fallback (defaults to stdin-handshake.readFirstLineFromFd(3) on the
|
|
44
|
+
* dedicated spawn stdio pipe — NOT fd0, which pi owns for its prompt). */
|
|
45
|
+
readHandshakeLine?: () => Promise<string | undefined>;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const NOOP_HANDLE: ChildBrokerClientHandle = {
|
|
@@ -51,10 +52,10 @@ const NOOP_HANDLE: ChildBrokerClientHandle = {
|
|
|
51
52
|
|
|
52
53
|
export function startChildBrokerClient(options: StartChildBrokerClientOptions = {}): ChildBrokerClientHandle {
|
|
53
54
|
const env = options.env ?? process.env;
|
|
54
|
-
const socketPath = env
|
|
55
|
-
const token = env
|
|
56
|
-
const runId = env
|
|
57
|
-
const taskId = env
|
|
55
|
+
const socketPath = getCrewEnv("PI_CREW_BROKER_SOCKET", env);
|
|
56
|
+
const token = getCrewEnv("PI_CREW_BROKER_TOKEN", env);
|
|
57
|
+
const runId = getCrewEnv("PI_CREW_BROKER_RUN_ID", env);
|
|
58
|
+
const taskId = getCrewEnv("PI_CREW_BROKER_TASK_ID", env);
|
|
58
59
|
if (!socketPath || !token || !runId || !taskId) {
|
|
59
60
|
return NOOP_HANDLE;
|
|
60
61
|
}
|
|
@@ -96,33 +97,56 @@ export function startChildBrokerClient(options: StartChildBrokerClientOptions =
|
|
|
96
97
|
* its stdin IS the JSONL protocol channel and must never be consumed by the
|
|
97
98
|
* credential handshake. Those spawns keep the token in env (the RPC branch of
|
|
98
99
|
* child-pi.ts never strips it), so the fallback below is unreachable there
|
|
99
|
-
* anyway — this guard is defense-in-depth against hybrid/old-parent skew.
|
|
100
|
+
* anyway — this guard is defense-in-depth against hybrid/old-parent skew.
|
|
101
|
+
* FIX-2 (2026-10-28): match BOTH argv spellings — the pi-args builder emits
|
|
102
|
+
* the two-argv form ("--mode rpc"), but wrappers/users can pass the equals
|
|
103
|
+
* form ("--mode=rpc"); a spelling miss here would let the fallback eat a
|
|
104
|
+
* protocol line off the RPC JSONL channel. */
|
|
100
105
|
function argvIndicatesRpcMode(): boolean {
|
|
101
|
-
return process.argv.includes("--mode") && process.argv.includes("rpc");
|
|
106
|
+
return process.argv.includes("--mode=rpc") || (process.argv.includes("--mode") && process.argv.includes("rpc"));
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/** LOW-1 residual closure (2026-10-27): async wrapper used by the worker
|
|
105
110
|
* bootstrap (prompt-runtime Feature 2b). Identical contract to
|
|
106
111
|
* {@link startChildBrokerClient}, plus: when the env token is ABSENT but the
|
|
107
|
-
* socket/run/task identity env IS present (
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
112
|
+
* socket/run/task identity env IS present (opt-in parent on the fd3 route —
|
|
113
|
+
* FIX-1 re-writes the flag into the child's built.env, without which this
|
|
114
|
+
* fallback was dead-wired), read the single buildBrokerStdinLine payload off
|
|
115
|
+
* the DEDICATED fd3 handshake pipe (bounded, never throws) and merge the
|
|
116
|
+
* per-task token into a COPY of the env before connecting. FD-3
|
|
117
|
+
* (2026-10-28, root-cause fix): pi@1.0.4 readPipedStdin consumes ALL piped
|
|
118
|
+
* fd0 bytes as PROMPT CONTENT in every mode except --mode rpc — the old
|
|
119
|
+
* fd0 handshake raced it three ways (token JSON leaked into the worker
|
|
120
|
+
* prompt; creds-less silence; pause() deadlock → SIGTERM exit 0 with ~0
|
|
121
|
+
* stdout — the 0.11.46/47 live incident), so the spawner now writes the
|
|
122
|
+
* payload to a dedicated stdio[3] pipe and this reader reads fd 3, never
|
|
123
|
+
* fd0. Ordering matrix (post FIX-1, 2026-10-28 — the gate is OPT-IN and the
|
|
124
|
+
* spawner writes flag + token into built.env for every brokered spawn; only
|
|
125
|
+
* the headless JSON compose strips the token):
|
|
126
|
+
* - opt-in parent (PI_CREW_BROKER_STDIN=1) + headless JSON worker → flag
|
|
127
|
+
* set, token ABSENT from env → token via fd3 (env stays secret-free);
|
|
128
|
+
* - opt-in parent + RPC / surface worker → flag set, token PRESENT in env
|
|
129
|
+
* → env route, fd3 untouched (their stdin is the protocol channel/TTY);
|
|
130
|
+
* - legacy parent (gate off/unset) → env token present → env route;
|
|
131
|
+
* - payload missing/invalid/wrong-run/socket-mismatch/unreadable fd3 →
|
|
132
|
+
* creds-less no-op handle (same as today), never a throw.
|
|
116
133
|
* The sync {@link startChildBrokerClient} stays untouched (env-only) so every
|
|
117
134
|
* existing caller/test keeps its exact behavior. */
|
|
118
135
|
export async function startChildBrokerClientAsync(options: StartChildBrokerClientOptions = {}): Promise<ChildBrokerClientHandle> {
|
|
119
136
|
let env = options.env ?? process.env;
|
|
120
|
-
const socketPath = env
|
|
121
|
-
const runId = env
|
|
122
|
-
const taskId = env
|
|
123
|
-
if (
|
|
137
|
+
const socketPath = getCrewEnv("PI_CREW_BROKER_SOCKET", env);
|
|
138
|
+
const runId = getCrewEnv("PI_CREW_BROKER_RUN_ID", env);
|
|
139
|
+
const taskId = getCrewEnv("PI_CREW_BROKER_TASK_ID", env);
|
|
140
|
+
if (
|
|
141
|
+
socketPath &&
|
|
142
|
+
runId &&
|
|
143
|
+
taskId &&
|
|
144
|
+
!getCrewEnv("PI_CREW_BROKER_TOKEN", env) &&
|
|
145
|
+
resolveBrokerStdinFallbackEnabled(env) &&
|
|
146
|
+
!argvIndicatesRpcMode()
|
|
147
|
+
) {
|
|
124
148
|
try {
|
|
125
|
-
const raw = await (options.
|
|
149
|
+
const raw = await (options.readHandshakeLine ?? (() => readFirstLineFromFd(BROKER_HANDSHAKE_FD)))();
|
|
126
150
|
const payload = raw ? parseStdinBrokerPayload(raw, runId) : undefined;
|
|
127
151
|
// socketPath cross-check: the payload must describe the SAME socket the
|
|
128
152
|
// parent announced via env (both are parent-controlled, but a mismatch
|
|
@@ -139,8 +163,19 @@ export async function startChildBrokerClientAsync(options: StartChildBrokerClien
|
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
/** Worker-side mirror of the parent gate (child-pi-spawn
|
|
142
|
-
* .resolveBrokerStdinHandshakeEnabled)
|
|
143
|
-
*
|
|
144
|
-
|
|
145
|
-
|
|
166
|
+
* .resolveBrokerStdinHandshakeEnabled). **OPT-IN since v0.11.48** (live
|
|
167
|
+
* battery caught the reader path killing real headless workers — see the
|
|
168
|
+
* parent gate's doc block): the default is OFF, so a legacy parent that set
|
|
169
|
+
* the env token travels the legacy env route. PI_CREW_BROKER_STDIN=1 opts
|
|
170
|
+
* BOTH sides into the stdin route — FIX-1 (2026-10-28): the spawner now
|
|
171
|
+
* re-writes the flag into the child's built.env ("child-write" registry
|
|
172
|
+
* kind), so an opt-in parent actually reaches this mirror gate (previously
|
|
173
|
+
* the flag died with the ambient-env strip and this fallback was dead-wired).
|
|
174
|
+
* Seam (2026-10-28): the gate reads the RESOLVED env — the same source as
|
|
175
|
+
* the identity vars above (`options.env ?? process.env`, threaded through
|
|
176
|
+
* getCrewEnv's env param) — so spawn-env delivery (FIX-1 writes the flag
|
|
177
|
+
* into the child's built.env) is pinnable in tests via options.env without
|
|
178
|
+
* mutating process.env. */
|
|
179
|
+
function resolveBrokerStdinFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
180
|
+
return getCrewEnv("PI_CREW_BROKER_STDIN", env) === "1";
|
|
146
181
|
}
|
|
@@ -43,14 +43,14 @@ import { loadRunManifestById, saveRunTasks } from "../../state/stores/state-stor
|
|
|
43
43
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
44
44
|
import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
|
|
45
45
|
import { resolveCrewMaxDepth } from "../model/pi-args.ts";
|
|
46
|
-
import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
|
|
46
|
+
import type { NestedSlotBudget } from "../scheduling/nested-slots.ts";
|
|
47
47
|
import { evaluateDelegateAdmission } from "../spawn-policy.ts";
|
|
48
|
-
import { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
|
|
49
|
-
import {
|
|
48
|
+
import type { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
|
|
49
|
+
import type { DelegateEventTarget, DelegateEventType } from "./delegate/delegate-event.ts";
|
|
50
50
|
import { delegateTerminalEvent, finalizeDelegateResult } from "./delegate/delegate-finalize.ts";
|
|
51
51
|
import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
52
|
+
import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
|
|
53
|
+
import type { LockBusyOutcome } from "./protocol/lock-busy.ts";
|
|
54
54
|
import { parseDelegateRequestParams } from "./protocol/request-parsers.ts";
|
|
55
55
|
|
|
56
56
|
/** Options slice the delegate handler reads off the broker's RESOLVED
|
|
@@ -12,9 +12,29 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Pure protocol pieces live here (not in background-runner.ts, which runs
|
|
14
14
|
* `await main()` at module scope) so tests can import them without booting
|
|
15
|
-
* the runner.
|
|
15
|
+
* the runner. buildBrokerStdinLine additionally carries ONE LOUD oversize
|
|
16
|
+
* diagnostic (FIX-3, 2026-10-28) — stderr-only, never throws, never blocks
|
|
17
|
+
* the write.
|
|
18
|
+
*
|
|
19
|
+
* FD-3 TRANSPORT (2026-10-28, LOW-1 root-cause fix): brokered child-pi
|
|
20
|
+
* workers (real `pi` processes) CANNOT use fd0 for this handshake — pi@1.0.4
|
|
21
|
+
* `readPipedStdin()` (dist/main.js:52-65) consumes ALL piped stdin as PROMPT
|
|
22
|
+
* CONTENT in every mode except --mode rpc, so an fd0 handshake raced it
|
|
23
|
+
* three ways (token JSON merged into the prompt = secret leak; stream-ended
|
|
24
|
+
* creds-less silence; reader pause() before EOF → pi's await readPipedStdin()
|
|
25
|
+
* deadlock → SIGTERM exit 0 with ~0 stdout — the 0.11.46/47 live incident).
|
|
26
|
+
* The spawn side therefore adds a DEDICATED pipe as stdio entry 4 (fd 3) and
|
|
27
|
+
* writes the payload there (child-pi-spawn.ts/child-pi.ts); readFirstLineFromFd
|
|
28
|
+
* below is the worker-side reader for that pipe. fd0 stays "ignore"
|
|
29
|
+
* (pre-0.11.46 shape). The detached background RUNNER keeps the fd0 route —
|
|
30
|
+
* it is a plain node process whose stdin is dedicated to the handshake
|
|
31
|
+
* (readStdinFirstLine, unchanged); only the child-pi (pi binary) path needed
|
|
32
|
+
* the dedicated fd.
|
|
16
33
|
*/
|
|
17
34
|
|
|
35
|
+
import * as fs from "node:fs";
|
|
36
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
37
|
+
|
|
18
38
|
/** The one-line payload async-runner writes to the runner's stdin carrying
|
|
19
39
|
* PER-TASK compound tokens. v2 (ADR-0 2026-08-17 item 6): wait.* accepts
|
|
20
40
|
* task-scoped (compound) tokens ONLY — the legacy bare-runId token
|
|
@@ -56,27 +76,62 @@ export function parseStdinBrokerPayload(raw: string, expectedRunId: string): Std
|
|
|
56
76
|
* the detached background runner AND the main child-pi spawn path — share the
|
|
57
77
|
* pure protocol piece and cannot drift from the reader parser below (a
|
|
58
78
|
* writer/reader drift silently breaks coordination). Compound (runId+taskId)
|
|
59
|
-
* tokens only: wait.* rejects bare-runId tokens (ADR-0 item 6).
|
|
79
|
+
* tokens only: wait.* rejects bare-runId tokens (ADR-0 item 6).
|
|
80
|
+
*
|
|
81
|
+
* FIX-3 (2026-10-28): LOUD oversize diagnostic at the single construction
|
|
82
|
+
* point shared by BOTH writers. The worker-side reader (readStdinFirstLine)
|
|
83
|
+
* aborts once its buffer passes 64 KiB without a newline — a payload built
|
|
84
|
+
* from ~2.5k tasks (~131 KiB observed live) silently loses EVERY task's
|
|
85
|
+
* broker creds (ask/message/mailbox/steer degrade to file-poll) with zero
|
|
86
|
+
* trace in production. Warn at 60 KiB (below the reader cap, far above any
|
|
87
|
+
* sane task fanout) — NOT gated on PI_CREW_DEBUG: this is a "your creds
|
|
88
|
+
* will be lost" operational alarm, not a debug trace. The line is still
|
|
89
|
+
* returned (and written by the caller) — never blocked. */
|
|
90
|
+
export const BROKER_STDIN_LINE_WARN_BYTES = 60 * 1024;
|
|
91
|
+
|
|
60
92
|
export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record<string, string>): string {
|
|
61
|
-
|
|
93
|
+
const line = `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
|
|
94
|
+
if (line.length > BROKER_STDIN_LINE_WARN_BYTES) {
|
|
95
|
+
logInternalError(
|
|
96
|
+
"stdin-handshake.oversize-payload",
|
|
97
|
+
new Error(
|
|
98
|
+
`broker stdin handshake line is ${line.length} bytes — the worker reader aborts past 64 KiB, so EVERY task loses its broker creds (ask/message/mailbox/steer fall back to file-poll)`,
|
|
99
|
+
),
|
|
100
|
+
`${Object.keys(tasks).length} tasks on run ${runId}; shrink the task fanout or split the run`,
|
|
101
|
+
"warn",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return line;
|
|
62
105
|
}
|
|
63
106
|
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
107
|
+
/** Dedicated handshake fd for brokered child-pi workers: spawn stdio entry
|
|
108
|
+
* 4 (spawnOptions.stdio = ["ignore","pipe","pipe","pipe"]). fd0 stays
|
|
109
|
+
* "ignore" — the pre-0.11.46 shape — so pi@1.0.4 readPipedStdin can never
|
|
110
|
+
* race or merge the credential into the worker prompt (LOW-1 root cause,
|
|
111
|
+
* see the module docblock). */
|
|
112
|
+
export const BROKER_HANDSHAKE_FD = 3;
|
|
113
|
+
|
|
114
|
+
/** Read the FIRST line off a byte stream with a hard cap. Shared core of
|
|
115
|
+
* BOTH transports (fd0 for the detached runner, fd3 for child-pi workers).
|
|
116
|
+
* Resolves undefined for: silence past the timeout / oversize / stream
|
|
117
|
+
* error / empty EOF. Never throws, never destroys the stream (pauses it
|
|
118
|
+
* only after a first line was consumed — probe-verified 2026-10-28:
|
|
119
|
+
* destroy()/closeSync on an fs fd-stream is fragile on the error path
|
|
120
|
+
* (SIGABRT on EINVAL with autoClose) and a plain pause releases the loop on
|
|
121
|
+
* the payload/EOF shapes; the only shape that outlives the timeout is an
|
|
122
|
+
* open silent pipe, which a same-version parent can never produce — the
|
|
123
|
+
* spawner writes payload + end() synchronously in the spawn tick). */
|
|
124
|
+
function readFirstLineFromStream(
|
|
125
|
+
stream: Pick<NodeJS.ReadableStream, "on" | "removeListener" | "pause">,
|
|
126
|
+
timeoutMs: number,
|
|
127
|
+
): Promise<string | undefined> {
|
|
68
128
|
return new Promise((resolve) => {
|
|
69
|
-
const stdin = process.stdin;
|
|
70
|
-
if (!stdin || stdin.isTTY || stdin.destroyed || !stdin.readable) {
|
|
71
|
-
resolve(undefined);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
129
|
let settled = false;
|
|
75
130
|
const finish = (value: string | undefined): void => {
|
|
76
131
|
if (settled) return;
|
|
77
132
|
settled = true;
|
|
78
133
|
clearTimeout(timer);
|
|
79
|
-
|
|
134
|
+
stream.removeListener("data", onData);
|
|
80
135
|
resolve(value);
|
|
81
136
|
};
|
|
82
137
|
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
@@ -85,14 +140,55 @@ export function readStdinFirstLine(timeoutMs = 2000): Promise<string | undefined
|
|
|
85
140
|
buf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
86
141
|
const nl = buf.indexOf("\n");
|
|
87
142
|
if (nl >= 0) {
|
|
88
|
-
|
|
143
|
+
stream.pause();
|
|
89
144
|
finish(buf.slice(0, nl));
|
|
90
145
|
} else if (buf.length > 64 * 1024) {
|
|
91
146
|
finish(undefined);
|
|
92
147
|
}
|
|
93
148
|
};
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
149
|
+
stream.on("data", onData);
|
|
150
|
+
stream.on("end", () => finish(buf.length > 0 ? buf : undefined));
|
|
151
|
+
stream.on("error", () => finish(undefined));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Read the FIRST stdin line with a hard cap. Resolves undefined for: no
|
|
156
|
+
* stdin / TTY / silence past the timeout / oversize / error. Never throws,
|
|
157
|
+
* never destroys stdin (leaves it paused for the rest of boot). Detached
|
|
158
|
+
* background-runner transport ONLY (see module docblock — pi workers read
|
|
159
|
+
* the dedicated fd3 pipe via readFirstLineFromFd). */
|
|
160
|
+
export function readStdinFirstLine(timeoutMs = 2000): Promise<string | undefined> {
|
|
161
|
+
const stdin = process.stdin;
|
|
162
|
+
if (!stdin || stdin.isTTY || stdin.destroyed || !stdin.readable) {
|
|
163
|
+
return Promise.resolve(undefined);
|
|
164
|
+
}
|
|
165
|
+
return readFirstLineFromStream(stdin, timeoutMs);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Read the FIRST line from a raw file descriptor — the worker-side reader
|
|
169
|
+
* of the DEDICATED fd3 handshake pipe (BROKER_HANDSHAKE_FD). Same bounded
|
|
170
|
+
* contract as readStdinFirstLine (2s default timeout, 64 KiB cap, never
|
|
171
|
+
* throws): a missing/closed/unreadable fd (old-parent skew: gate flag set
|
|
172
|
+
* but no 4th stdio entry), an EINVAL read on a non-readable fd, silence
|
|
173
|
+
* past the timeout, oversize, or error all resolve undefined — the caller
|
|
174
|
+
* proceeds creds-less exactly like a broker-less spawn. win32: immediate
|
|
175
|
+
* undefined (extra-fd spawn plumbing differs there and the broker is
|
|
176
|
+
* auto-disabled on Windows anyway). The fd is never closed by this reader
|
|
177
|
+
* (autoClose: false — it belongs to the spawn stdio table, and closing an
|
|
178
|
+
* fd under an active stream poll is what crashes Node's fs streams). */
|
|
179
|
+
export function readFirstLineFromFd(fd: number, timeoutMs = 2000): Promise<string | undefined> {
|
|
180
|
+
if (process.platform === "win32") {
|
|
181
|
+
return Promise.resolve(undefined);
|
|
182
|
+
}
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
let stream: fs.ReadStream;
|
|
185
|
+
try {
|
|
186
|
+
// fd option ⇒ the path argument is only a label, never opened.
|
|
187
|
+
stream = fs.createReadStream("", { fd, autoClose: false });
|
|
188
|
+
} catch {
|
|
189
|
+
resolve(undefined);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
readFirstLineFromStream(stream, timeoutMs).then(resolve, () => resolve(undefined));
|
|
97
193
|
});
|
|
98
194
|
}
|
|
@@ -46,17 +46,42 @@ import {
|
|
|
46
46
|
// NOT set this (their sessions persist and benefit from long-lived cache).
|
|
47
47
|
export const PI_CACHE_RETENTION_ENV = "PI_CACHE_RETENTION";
|
|
48
48
|
|
|
49
|
-
/** LOW-1 residual closure (2026-10-27
|
|
50
|
-
* are present, the per-task compound TOKEN
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
49
|
+
/** LOW-1 residual closure (2026-10-27, DISABLED-DEFAULT 2026-10-28 v0.11.48):
|
|
50
|
+
* when the inter-pi broker credentials are present, the per-task compound TOKEN
|
|
51
|
+
* can travel over a DEDICATED fd3 spawn pipe (one `buildBrokerStdinLine`
|
|
52
|
+
* payload, heap → stdio[3] → heap — fd0 stays "ignore") instead of the child
|
|
53
|
+
* env. **EXPERIMENTAL / OPT-IN since v0.11.48**: the first attempt (v0.11.46/47)
|
|
54
|
+
* rode fd0 and was caught by the live battery (real-test-pi-crew T7) making
|
|
55
|
+
* REAL headless JSON workers exit 0 silently with zero stdout. Root cause
|
|
56
|
+
* (2026-10-28): pi@1.0.4 `readPipedStdin()` (dist/main.js:52-65) consumes ALL
|
|
57
|
+
* piped stdin as PROMPT CONTENT in every mode except --mode rpc — the fd0
|
|
58
|
+
* handshake raced it three ways (token JSON merged into the prompt = secret
|
|
59
|
+
* leak; stream-ended creds-less silence; reader pause() before EOF → pi's
|
|
60
|
+
* await readPipedStdin() deadlock → SIGTERM exit 0 with takeOverStdout
|
|
61
|
+
* buffering stdout to ~0 bytes). The fix moves the handshake to fd3
|
|
62
|
+
* (stdio entry 4); fd0 returns to "ignore" (pre-0.11.46 shape), killing every
|
|
63
|
+
* race ordering. Unit tests missed the original because they inject the
|
|
64
|
+
* `readHandshakeLine` seam instead of running the fd reader in a real pi
|
|
65
|
+
* process. The gate stays OPT-IN (legacy env route bit-for-bit pre-0.11.46);
|
|
66
|
+
* set PI_CREW_BROKER_STDIN=1 to opt the parent into the fd3 route.
|
|
67
|
+
*
|
|
68
|
+
* FIX-1 dead-wire repair (2026-10-28): when this gate is ON and broker
|
|
69
|
+
* credentials exist, prepareSpawnContext (1) WRITES PI_CREW_BROKER_STDIN="1"
|
|
70
|
+
* into built.env — the registry classifies the var "child-write" so ambient
|
|
71
|
+
* forwarding never carries it, and the worker-side mirror gate
|
|
72
|
+
* (crew-broker-child.resolveBrokerStdinFallbackEnabled) reads ITS OWN
|
|
73
|
+
* process.env; before the fix the flag never reached the child, so the
|
|
74
|
+
* fallback NEVER activated and every opt-in spawn stayed creds-less;
|
|
75
|
+
* (2) puts the TOKEN in built.env for BOTH branches — the RPC branch (stdin
|
|
76
|
+
* is the JSONL protocol channel) and surface TUI panes compose WITHOUT the
|
|
77
|
+
* payload, so only that env write hands them the credential (a token in
|
|
78
|
+
* their env also disarms the worker fd3 fallback — safe by design).
|
|
79
|
+
* Only the headless JSON branch composes WITH ctx.brokerStdinPayload, and
|
|
80
|
+
* buildFinalChildPiSpawnOptions strips the token from the FINAL env there
|
|
81
|
+
* (defense-in-depth) — on that branch alone the token rides the fd3 pipe.
|
|
82
|
+
* Gate OFF: no flag, no payload, token via env — bit-for-bit legacy. */
|
|
58
83
|
export function resolveBrokerStdinHandshakeEnabled(): boolean {
|
|
59
|
-
return getCrewEnv("PI_CREW_BROKER_STDIN")
|
|
84
|
+
return getCrewEnv("PI_CREW_BROKER_STDIN") === "1";
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
// ── Env allowlist (base set always passed to children) ──────────────────
|
|
@@ -303,15 +328,23 @@ export function buildFinalChildPiSpawnOptions(
|
|
|
303
328
|
if (workerCacheSplit) {
|
|
304
329
|
spawnOptions.env[PI_CACHE_RETENTION_ENV] = "short";
|
|
305
330
|
}
|
|
306
|
-
// (d) LOW-1 closure: when the caller will hand the
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
// the secret from the env as
|
|
310
|
-
// omits it; the delete
|
|
311
|
-
//
|
|
312
|
-
//
|
|
331
|
+
// (d) LOW-1 closure → FD-3 (2026-10-28): when the caller will hand the
|
|
332
|
+
// broker token to the headless JSON child over the DEDICATED fd3 pipe
|
|
333
|
+
// (it passes the payload it got from prepareSpawnContext), add the 4th
|
|
334
|
+
// stdio entry ("pipe") and strip the secret from the env as
|
|
335
|
+
// defense-in-depth (prepareSpawnContext already omits it; the delete
|
|
336
|
+
// guards direct builtEnv constructions in tests/callers that still
|
|
337
|
+
// carry it). fd0 stays "ignore" — the pre-0.11.46 shape: pi@1.0.4
|
|
338
|
+
// readPipedStdin (dist/main.js:52-65) consumes ALL piped fd0 bytes as
|
|
339
|
+
// PROMPT CONTENT in every mode except --mode rpc, so the earlier fd0
|
|
340
|
+
// handshake raced it three ways (token JSON merged into the prompt =
|
|
341
|
+
// secret leak; creds-less silence; pause() → readPipedStdin deadlock →
|
|
342
|
+
// SIGTERM exit 0 with ~0 stdout — the 0.11.46/47 live incident).
|
|
343
|
+
// fd0=ignore kills every ordering: fd3 is a dedicated pipe pi never
|
|
344
|
+
// touches. RPC branch / surface workerEnv snapshots call WITHOUT this
|
|
345
|
+
// param → env keeps the token, 3-entry stdio, nothing else changes.
|
|
313
346
|
if (stdinBrokerPayload !== undefined) {
|
|
314
|
-
spawnOptions.stdio = ["pipe", "pipe", "pipe"];
|
|
347
|
+
spawnOptions.stdio = ["ignore", "pipe", "pipe", "pipe"];
|
|
315
348
|
delete spawnOptions.env.PI_CREW_BROKER_TOKEN;
|
|
316
349
|
}
|
|
317
350
|
// (e) Return the composed SpawnOptions for spawn(...).
|
|
@@ -338,10 +371,14 @@ export interface SpawnContext {
|
|
|
338
371
|
/** v0.11.31: inference-broker worker key that owns this spawn's token (cleanup/revoke bookkeeping). */
|
|
339
372
|
inferenceWorkerKey?: string;
|
|
340
373
|
/** LOW-1 closure: one-line broker credential payload to write to the
|
|
341
|
-
* child's
|
|
342
|
-
* branch owns stdin for its JSONL protocol and
|
|
343
|
-
*
|
|
344
|
-
*
|
|
374
|
+
* child's DEDICATED fd3 pipe (stdio entry 4) right after spawn — headless
|
|
375
|
+
* JSON branch ONLY (the RPC branch owns stdin for its JSONL protocol and
|
|
376
|
+
* surface panes are TTY; fd0 stays "ignore" for every branch). Present ⇒
|
|
377
|
+
* the headless compose adds the 4th stdio entry and strips
|
|
378
|
+
* PI_CREW_BROKER_TOKEN from the FINAL child env
|
|
379
|
+
* (buildFinalChildPiSpawnOptions); builtEnv still carries the token for
|
|
380
|
+
* the RPC/surface call sites, which compose WITHOUT this payload and keep
|
|
381
|
+
* the env route. Undefined ⇒ env route everywhere, 3-entry stdio. */
|
|
345
382
|
brokerStdinPayload?: string;
|
|
346
383
|
}
|
|
347
384
|
|
|
@@ -452,21 +489,40 @@ export function prepareSpawnContext(
|
|
|
452
489
|
// safe under assertOnlyControlEnvKeys). Only when the parent broker issued
|
|
453
490
|
// credentials for this run — i.e. the broker is enabled AND this run is
|
|
454
491
|
// eligible. NEVER persisted to disk.
|
|
455
|
-
// LOW-1 closure
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
492
|
+
// LOW-1 closure (OPT-IN since v0.11.48; FIX-1 dead-wire repair 2026-10-28;
|
|
493
|
+
// FD-3 transport 2026-10-28): when PI_CREW_BROKER_STDIN=1 the headless
|
|
494
|
+
// JSON spawn branch delivers the token over a DEDICATED fd3 pipe (4th
|
|
495
|
+
// stdio entry; ctx.brokerStdinPayload — fd0 stays "ignore") as a single
|
|
496
|
+
// buildBrokerStdinLine payload, and buildFinalChildPiSpawnOptions strips
|
|
497
|
+
// it from the FINAL env there. The token still lands in built.env for
|
|
498
|
+
// every brokered spawn — the RPC/surface call sites compose without the
|
|
499
|
+
// payload and need the env credential. SOCKET/RUN_ID/TASK_ID stay in env
|
|
500
|
+
// (non-secret, needed by the worker bootstrap to bind and validate the
|
|
501
|
+
// fd3 payload). Default (gate off) keeps the env route bit-for-bit.
|
|
462
502
|
if (input.brokerSpawn?.socketPath && input.brokerSpawn.token) {
|
|
463
503
|
built.env.PI_CREW_BROKER_SOCKET = input.brokerSpawn.socketPath;
|
|
464
|
-
|
|
504
|
+
const brokerStdinRoute = resolveBrokerStdinHandshakeEnabled();
|
|
505
|
+
if (brokerStdinRoute) {
|
|
506
|
+
// FIX-1: the worker-side mirror gate reads PI_CREW_BROKER_STDIN from
|
|
507
|
+
// the CHILD's own process.env; the registry classifies the var
|
|
508
|
+
// "child-write" (ambient forwarding never carries it), so the spawner
|
|
509
|
+
// MUST write it here — before the fix the flag never reached the
|
|
510
|
+
// child and the stdin fallback NEVER activated (creds-less NOOP).
|
|
511
|
+
built.env.PI_CREW_BROKER_STDIN = "1";
|
|
512
|
+
}
|
|
513
|
+
// FIX-1: the token is written into built.env for BOTH branches. The RPC
|
|
514
|
+
// branch (child-pi-rpc.ts) and surface TUI panes (child-pi.ts
|
|
515
|
+
// trySurfaceBranch) compose WITHOUT the payload — their stdin is the
|
|
516
|
+
// JSONL protocol channel / a TTY — so only this env write hands them the
|
|
517
|
+
// credential (a token in their env also disarms the worker fd3 fallback
|
|
518
|
+
// — safe). The headless JSON branch composes WITH the payload below, and
|
|
519
|
+
// buildFinalChildPiSpawnOptions strips the token from the FINAL env
|
|
520
|
+
// there — on that branch alone the token rides the fd3 pipe.
|
|
521
|
+
built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
|
|
522
|
+
if (brokerStdinRoute && input.runId && input.agentId) {
|
|
465
523
|
brokerStdinPayload = buildBrokerStdinLine(input.runId, input.brokerSpawn.socketPath, {
|
|
466
524
|
[input.agentId]: input.brokerSpawn.token,
|
|
467
525
|
});
|
|
468
|
-
} else {
|
|
469
|
-
built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
|
|
470
526
|
}
|
|
471
527
|
// runId/taskId are threaded unconditionally above (I5); nothing to add here.
|
|
472
528
|
}
|