pi-crew 0.11.39 → 0.11.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.39",
3
+ "version": "0.11.40",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -120,6 +120,7 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
120
120
  | `src/config/` (kể cả migration-validator) | T1 → T11c |
121
121
  | `src/runtime/child-pi/` (spawn/kill/steer) | T7 → T9g |
122
122
  | `src/runtime/process/**` (reap-group.ts / run-descendant-sweep.ts) + `child-pi-kill.ts` exited-root branch + `orphan-reaper.ts` identity admission + `crash-recovery.ts` terminal-sweep lane + state fields `childPgid/childStartTime/childUid` + heartbeat `pgid/starttime/uid` (wave-11 class: runtime spawn/kill/teardown + startup reconcile + state schema ADDITIVE) | T1 → pinned unit `test/unit/runtime/process/{reap-group,run-descendant-sweep}.test.ts` + `test/unit/runtime/recovery/reconcile-terminal-run-descendant-sweep.test.ts` + `test/unit/runtime/stale-reconciler-run-descendant-sweep.test.ts` + `test/unit/runtime/stale-reconciler-reap-escalation.test.ts` (5 file / 78 tests @ `f578c223`) → **integration live repro** `node scripts/test-runner.mjs test/integration/run-descendant-sweep-live.test.ts` (A1–A5: spawn leak THẬT → sweep reap ~100 ms; **negative control bắt buộc SỐNG**: no-marker / interactive-tty / parent-còn-sống) → off-path proof `PI_CREW_REAP_GROUP=0` + `PI_CREW_REAP_RUN_DESCENDANTS=0` (unit xanh trên CẢ hai trạng thái switch; KHÔNG để ambient `=0` sót lại làm run thật câm) → T9a `team action='doctor' focus='zombies'` (mảng `runDescendants` report-only + dòng cảnh báo no descent proof ⇒ NOT reaped) |
123
+ | `test/**` — bất kỳ fixture nào spawn một process THẬT (đặc biệt `detached: true`), hoặc test fixture rò rỉ process (orphan còn sống sau khi assert throw) | T1 file vừa sửa (`node scripts/test-runner.mjs <file>` ×2 — teardown là timing-sensitive) + `pgrep -af "setInterva[l]"` phải RỖNG sau mỗi lần chạy + (khi đụng helper) `node scripts/test-runner.mjs test/unit/helpers/fixture-process.test.ts`. **Rule (wave 12c, `test/helpers/README.md`)**: fixture DETACHED ⇒ `spawnFixture`+`reapFixture` trong `finally` (pid-only `child.kill()` KHÔNG phải teardown: không chạm tree, vô hiệu trước fixture nuốt SIGTERM, và không chạy nếu throw TRƯỚC `finally`/trong setup hook) + snapshot identity ngay sau spawn (chống pgid reuse) + `registerFixtureTrap()` cho cửa sổ abort sớm; fixture NON-DETACHED ⇒ pid kill + comment lý do (group reap giết runner). Cảnh báo: `spawnFixture` THROW khi `detached:false` — đúng theo thiết kế |
123
124
  | `src/runtime/mcp-proxy.ts` / `src/runtime/model/pi-args.ts` (worker argv build + MCP role-gate — G2/G3 surface, SDD-2) | T7 (`argv-flags.smoke.ts` với `PI_CREW_SMOKE=1`) → T9b-W loadout theo role (post-G2: read-only KHÔNG có MCP trên live-session path là ĐÚNG — xem mục full-loadout sanity 9b) + pinned suites `mcp-proxy.test.ts` / `phase6-runtime-hardening.test.ts` / `child-pi-timeout`+`child-pi-surface` (argv/spill) |
124
125
  | `src/runtime/async-runner.ts` / `src/runtime/background-runner.ts` (spawn, allowlist, entry-guard, `executeBackgroundRun` core) | T1 → T9b async (event `async.spawned` KHÔNG được có `data.inline:true` — battery phải chứng minh runner DETACHED thật) → T7; đụng `BACKGROUND_RUNNER_ENV_ALLOWLIST` → thêm T10b/T10c (quirk F4-2026-08-30: mux env bị strip ⇒ async headless âm thầm) |
125
126
  | `src/runtime/surface/` | T10a → T10b → T10c (dò herdr bằng `HERDR_ENV=1`, KHÔNG phải `$TMUX`) |
@@ -194,6 +195,8 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
194
195
 
195
196
  > **2026-10-24 đính chính (wave 11c — battery findings F1+F2, mục 4b/7b của `docs/real-test/reports/real-test-2026-10-24-wave11-battery.md`)**: (1) **F2 sync-run miss ĐÃ FIX** — run sync/foreground không có `manifest.async.pid` nên leak mang `PI_CREW_PARENT_PID=<session pi pid>` không bao giờ qua descent proof (đúng rule 11 — an toàn nhưng bỏ sót vĩnh viễn). Từ 11c mọi manifest mới record `sessionPid` (= `process.pid` tại `createRunManifest`); `sweepTerminalRunDescendants` + doctor dry-run đưa nó vào `proofParents` với guard dead-only như mọi parent khác — session còn sống ⇒ KHÔNG là proof (user có thể đang dùng). Run async vẫn dùng `async.pid` primary. Battery addendum sẽ chứng minh bằng E2E sync-leak thật. (2) **F1 test-isolation ĐÃ FIX** — suite `run-descendant-sweep.test.ts` giờ pin `PI_CREW_REAP_RUN_DESCENDANTS=1` trong beforeEach (restore verbatim afterEach, pattern của `stale-reconciler-run-descendant-sweep`): 17/78 test không đỏ nữa khi ambient `=0`; pinned 78→82 (a20 + g8–g10); decision-table yêu cầu "unit xanh trên CẢ HAI trạng thái switch" giữ nguyên.
196
197
 
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
+
197
200
  ## Core principle: disk ≠ live Pi
198
201
 
199
202
  Two locations hold pi-crew state:
@@ -1264,6 +1267,7 @@ When a tier fails, the recovery is usually quick. Match the symptom to the cause
1264
1267
  | `team action='schedule' interval=<ms>` always says `Invalid schedule "<N>ms". Use "5m", …` | handle-schedule builds `${interval}ms`; both interval parsers lacked an `ms` unit — every numeric interval died. Fixed 2026-09-21 | The suggested "5m" formats only work via `cron`/`once` params, not `interval` (interval is schema-typed as a plain ms number) |
1265
1268
  | Runs keep appearing under one scheduled job; children of YOUR pi process; machine thrashing | **Interval ≥ 2^31-1 ms (≥24.8 days) overflowed the Node timer to a 1ms hot loop** (fixed 2026-09-22 — clamped hops). Check `.crew/audit/prune.jsonl` for interleaved auto-prunes. Contain: remove job, then `pkill -f '[b]ackground-runner.ts --cwd <root>'` | Any new timer scheduling must clamp below MAX_TIMER_DELAY_MS — grep for `setInterval(`/`setTimeout(` with computed delays |
1266
1269
  | Live sweep/reap probe chạy "xanh" nhưng KHÔNG process nào bị reap (doctor `runDescendants` liệt kê mà không có gì chết) | **Ambient `PI_CREW_REAP_GROUP=0` / `PI_CREW_REAP_RUN_DESCENDANTS=0` còn trong shell** (sót từ probe off-path trước) — cả 2 switch default ON và được FORWARD sang background runner, ambient là đủ để câm sạch cả sweep lẫn group-reap; ngược lại quên set khi test off-path thì nhánh degrade không được chứng minh | Trước MỌI live repro: `env \| grep PI_CREW_REAP` phải RỖNG; chỉ set `=0` CHỦ ĐÍCH khi chạy nhánh off-path (unit phải xanh trên CẢ hai trạng thái switch), unset ngay sau (wave 11 lesson — 2 kill switch KHÔNG được set ngoài ý muốn theo cả 2 hướng) |
1270
+ | Process MỒ CÔI còn sống mang marker `PI_CREW_KIND=subagent` / `PI_CREW_TASK_ID` (hoặc đúng shape `node -e "setInterval(...)"`) trong khi run đã `completed` từ lâu, parent đã chết và process đã re-parent sang systemd | **Test-fixture teardown, KHÔNG phải runtime bug** (wave 12 incident: 2 fixture sống >6 h): fixture spawn DETACHED (group riêng ⇒ group kill của test runner không tới) + teardown pid-only `child.kill()` (vô hiệu trước fixture nuốt SIGTERM) + không ai giữ handle. Wave-11 sweep TỪ CHỐI reap là ĐÚNG — không chứng minh được descent của một run đã terminal | Kiểm `test/helpers/README.md`: detached ⇒ `spawnFixture`+`reapFixture` trong `finally`; non-detached ⇒ pid kill + comment. Grep suite vừa chạy tìm `detached: true` + `finally { … child.kill(` = thủ phạm; sửa teardown rồi `pgrep -af "setInterva[l]"` phải RỖNG. Đừng "fix" bằng cách nới wave-11 sweep cho process không proof descent |
1267
1271
 
1268
1272
  ## Release gate — battery → push → GitHub Actions GREEN → publish (canonical, 2026-10-14)
1269
1273
 
@@ -1442,6 +1446,7 @@ Use this to answer "đủ full tính năng chưa?" without re-deriving. Every us
1442
1446
  | Steer delivery incl. boot window (pre-bind retry) | `src/prompt/prompt-runtime.ts` (poll + `createSeenSteerIdSet.unmark`), `src/runtime/child-pi/child-pi-spawn.ts:283` (env), `src/runtime/task-runner/child-executor.ts:562` (filename) | `prompt-runtime-steer-boot-window.test.ts` (4) + **Tier 9g** live (watcher at +0.5s; lab variant with `PI_CREW_STEERING_FILE` + `pi --extension <repo>/src/prompt/prompt-runtime.ts`) |
1443
1447
  | Worker process-GROUP reaping (wave 11a F5 — đường group-signal DUY NHẤT) | `src/runtime/process/reap-group.ts` (`reapGroup` / `assertSafeGroup` guard-ladder 8 rung + escalation SIGTERM→poll→SIGKILL + `flushReapEscalationsSync`), wired `child-pi-kill.ts` exited-root (snapshot pgid/starttime trước khi root chết) + `orphan-reaper.ts` identity-proven admission | unit `node scripts/test-runner.mjs test/unit/runtime/process/reap-group.test.ts`; kill switch `PI_CREW_REAP_GROUP=0` (default ON — degrade về pid-only pre-wave; được FORWARD sang background runner); live: spawn worker có group con → kill root → group bị reap; **negative control bắt buộc: process cùng uid nhưng ngoài ladder (không leader-proof / không snapshot khớp) phải SỐNG** |
1444
1448
  | Run-descendant sweep (wave 11b F7 — reap leak sống sót SAU run terminal) | `src/runtime/process/run-descendant-sweep.ts` (`collectRunDescendants`/`reapRunDescendants`, 1 snapshot `/proc`/pass) + `crash-recovery.ts` `selectUnsweptTerminalRunIds` lane (terminal ≥ 60 s, oldest-first, cap 25/pass, window 200, retry `descendantSweepPending` 10 phút) | unit sweep suites (xem decision-table row wave 11) + **integration live repro** `node scripts/test-runner.mjs test/integration/run-descendant-sweep-live.test.ts` (A1–A5) + report `docs/real-test/reports/real-test-2026-10-24-wave11-descendant-reaping.md`; kill switch `PI_CREW_REAP_RUN_DESCENDANTS=0` (no-op hoàn toàn); marker ladder `PI_CREW_BROKER_RUN_ID` (nhánh run-id cần recorded parent ĐÃ CHẾT) → `PI_CREW_KIND=subagent`+`PI_CREW_TASK_ID` → `PI_CREW_PARENT_PID`; **negative control bắt buộc: no-marker / interactive-tty / parent-sống phải SỐNG** |
1449
+ | Fixture test spawn/reap (wave 12 — `test/helpers/`): đường DUY NHẤT một test được spawn + reap một process tree | `test/helpers/fixture-process.ts` (`spawnFixture` — từ chối `detached:false`, snapshot identity ngay sau spawn; `reapFixture` — group reap qua `reapGroup` + escalation SIGTERM→SIGKILL, pid-fallback cho non-Linux, KHÔNG BAO GIỜ throw; `registerFixtureTrap` — `after()` + `process.on("exit")` flush) + rule viết 1 lần ở `test/helpers/README.md` | unit `test/unit/helpers/fixture-process.test.ts` (h1–h5: refuse non-detached, reap tree nuốt SIGTERM < 2 s, idempotent, mismatch ⇒ không signal, trap 1 lần); regression theo file: `pgrep -af "setInterva[l]"` RỖNG sau mỗi lần chạy; GAP-4 pin `reap-group.test.ts` (teardown `reapTree` = leader-snapshot + group reap TRƯỚC, kể cả đường `spawnTree()` fail giữa đường) |
1445
1450
 
1446
1451
  ---
1447
1452
 
@@ -112,6 +112,18 @@ export interface ReapGroupOptions {
112
112
  expected?: ProcIdentity;
113
113
  /** Diagnostic label used in log messages. */
114
114
  name?: string;
115
+ /**
116
+ * uid source for the same-uid rungs (5 and the dead-path uid proof). Default
117
+ * `() => process.getuid?.()` — production behaviour is unchanged. The seam
118
+ * exists because `process.getuid` is undefined on win32, which made the uid
119
+ * rungs untestable there without a process-wide monkeypatch.
120
+ *
121
+ * Read PER CALL and never cached: unlike `selfIdentity()` (which is cached
122
+ * because a process cannot change its group), this is an injected value with
123
+ * no invariant of its own, and caching it would make the first caller win for
124
+ * the lifetime of the module.
125
+ */
126
+ getuid?: () => number | undefined;
115
127
  }
116
128
 
117
129
  /** Sync verdict + the authoritative async verdict. */
@@ -226,6 +238,11 @@ export function __setSelfIdentityReaderForTest(reader: ((pid: number) => ProcIde
226
238
  selfIdentityReader = reader ?? readProcIdentity;
227
239
  }
228
240
 
241
+ /** Default uid source for the same-uid rungs: the real process uid, when the
242
+ * runtime has one (POSIX). win32 has none ⇒ `undefined` ⇒ the rungs degrade
243
+ * exactly as they did before the seam existed. */
244
+ const defaultGetuid = (): number | undefined => process.getuid?.();
245
+
229
246
  /**
230
247
  * Run the refusal ladder. Returns the FIRST failing rung, or undefined when the
231
248
  * group is safe to signal. Pure with respect to injected `readIdentity` /
@@ -251,7 +268,7 @@ export function assertSafeGroup(pgid: number, candidatePid: number, opts: ReapGr
251
268
  // state in which `kill(-pgid)` could take out the host session.
252
269
  if (self.pgid === undefined) return "identity-missing";
253
270
  if (pgid === process.pid || pgid === process.ppid || pgid === self.pgid) return "pgid-init-or-self";
254
- const selfUid = typeof process.getuid === "function" ? process.getuid() : undefined;
271
+ const selfUid = (opts.getuid ?? defaultGetuid)();
255
272
  // Rung 4: a LIVE candidate identity, or (dead candidate) leader-proof.
256
273
  const live = readIdentity(candidatePid);
257
274
  if (!live) {
@@ -232,6 +232,18 @@ export interface SweepOptions {
232
232
  * and the doctor path are unchanged.
233
233
  */
234
234
  snapshot?: ProcSnapshot;
235
+ /**
236
+ * uid source for the same-uid rung (safety rung 3). Default
237
+ * `() => process.getuid?.()` — production behaviour is unchanged. The seam
238
+ * exists because `process.getuid` is undefined on win32, which made the rung
239
+ * untestable there without a process-wide monkeypatch.
240
+ *
241
+ * It is honoured by BOTH halves of a sweep — discovery (`buildProcSnapshot`,
242
+ * `collectRunDescendants`) and the group reap — so an injected uid is never
243
+ * compared here and then ignored one call deeper (see the propagation note at
244
+ * the `reapGroup` call site in `reapRunDescendants`).
245
+ */
246
+ getuid?: () => number | undefined;
235
247
  }
236
248
 
237
249
  /** Sweep verdict: what was signalled, what was refused, what is still in flight. */
@@ -248,6 +260,10 @@ export interface RunSweepResult {
248
260
  descendants: RunDescendant[];
249
261
  }
250
262
 
263
+ /** Default uid source: the real process uid when the runtime has one (POSIX),
264
+ * `undefined` on win32 — the pre-seam behaviour, unchanged. */
265
+ const defaultGetuid = (): number | undefined => process.getuid?.();
266
+
251
267
  // ── default /proc readers (all best-effort, none throw) ──────────────────────
252
268
 
253
269
  /** `/proc/<pid>/environ` as a key=value record; undefined when unreadable. */
@@ -399,7 +415,7 @@ export function buildProcSnapshot(opts: SweepOptions = {}): ProcSnapshot {
399
415
  const readIdentity = opts.readIdentity ?? readProcIdentity;
400
416
  const readEnviron = opts.readEnviron ?? readProcEnviron;
401
417
  const readTtyNr = opts.readTtyNr ?? readProcTtyNr;
402
- const selfUid = typeof process.getuid === "function" ? process.getuid() : undefined;
418
+ const selfUid = (opts.getuid ?? defaultGetuid)();
403
419
  const pids: number[] = [];
404
420
  const entries = new Map<number, ProcSnapshotEntry>();
405
421
  for (const pid of (opts.listPids ?? listProcPids)()) {
@@ -482,7 +498,7 @@ export function collectRunDescendants(markers: RunMarkers, opts: SweepOptions =
482
498
  if ((opts.platform ?? process.platform) !== "linux") return [];
483
499
  const readIdentity = opts.readIdentity ?? readProcIdentity;
484
500
  const candidateStartMs = opts.candidateStartMs ?? defaultCandidateStartMs;
485
- const selfUid = typeof process.getuid === "function" ? process.getuid() : undefined;
501
+ const selfUid = (opts.getuid ?? defaultGetuid)();
486
502
  // Our own group: never a candidate's target group (belt to reap-group's rung).
487
503
  const selfPgrp = readIdentity(process.pid)?.pgid;
488
504
  // 11b.4 (LOW-4): ONE /proc view per call — supplied by the caller (a whole
@@ -715,6 +731,12 @@ export function reapRunDescendants(markers: RunMarkers, opts: SweepOptions = {})
715
731
  ...(opts.gracefulMs !== undefined ? { gracefulMs: opts.gracefulMs } : {}),
716
732
  ...(opts.capMs !== undefined ? { capMs: opts.capMs } : {}),
717
733
  ...(opts.platform !== undefined ? { platform: opts.platform } : {}),
734
+ // F5 propagation (wave 12b): the uid source MUST reach the ladder too.
735
+ // Discovery already used `opts.getuid`; forwarding only at that half would
736
+ // let a caller assert one uid policy and execute another (the sweep would
737
+ // prove a group safe against the injected uid while the ladder compared
738
+ // against the real one) — a silent split-brain, worse than no seam at all.
739
+ ...(opts.getuid !== undefined ? { getuid: opts.getuid } : {}),
718
740
  });
719
741
  if (reap.refusal !== undefined) {
720
742
  // Nothing was signalled. Leave the key unset so a later tick can retry