pi-crew 0.11.18 → 0.11.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/NOTICE.md +13 -0
- package/dist/index.mjs +546 -498
- package/package.json +3 -3
- package/skills/real-test-pi-crew/SKILL.md +8 -1
- package/src/config/env-allowlist-registry.ts +8 -0
- package/src/config/env-vars.ts +11 -0
- package/src/extension/registration/subagent-manager-setup.ts +87 -17
- package/src/runtime/child-pi/mock-fixtures.ts +36 -0
- package/src/runtime/goal-workflow/goal-achievement.ts +47 -0
- package/src/runtime/live-session/live-session-runtime.ts +11 -55
- package/src/runtime/live-session/usage-bridge.ts +93 -0
- package/src/runtime/output/pi-json-output.ts +12 -25
- package/src/runtime/recovery/crash-recovery.ts +4 -0
- package/src/runtime/recovery/retry-executor.ts +142 -0
- package/src/runtime/session-usage.ts +48 -5
- package/src/runtime/task-runner/child-executor.ts +7 -2
- package/src/runtime/task-runner/pre-execution.ts +1 -1
- package/src/runtime/task-runner.ts +136 -81
- package/src/runtime/usage-tracker.ts +41 -8
- package/src/runtime/verification/verify-gate.ts +164 -7
- package/src/state/atomic-write.ts +10 -3
- package/src/state/coordination/locks.ts +132 -2
- package/src/state/coordination/process-lock.ts +204 -0
- package/src/state/event-log/cursor.ts +292 -84
- package/src/state/event-log/event-log-rotation.ts +30 -1
- package/src/state/event-log/poison-marker.ts +251 -0
- package/src/state/event-log/worker-atomic-writer.ts +282 -41
- package/src/state/types.ts +14 -3
- package/src/state/usage.ts +15 -21
- package/src/utils/incremental-reader.ts +316 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.20",
|
|
4
4
|
"description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
|
|
5
5
|
"author": "baphuongna",
|
|
6
6
|
"license": "MIT",
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"@earendil-works/pi-agent-core": "*",
|
|
137
137
|
"@earendil-works/pi-ai": "*",
|
|
138
138
|
"@earendil-works/pi-coding-agent": "*",
|
|
139
|
-
"@earendil-works/pi-tui": "
|
|
139
|
+
"@earendil-works/pi-tui": ">=1.0.4 <2.0.0",
|
|
140
140
|
"@sinclair/typebox": "*"
|
|
141
141
|
},
|
|
142
142
|
"dependencies": {
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"@earendil-works/pi-agent-core": "^1.0.4",
|
|
154
154
|
"@earendil-works/pi-ai": "^1.0.4",
|
|
155
155
|
"@earendil-works/pi-coding-agent": "^1.0.4",
|
|
156
|
-
"@earendil-works/pi-tui": "
|
|
156
|
+
"@earendil-works/pi-tui": "1.0.4",
|
|
157
157
|
"@sinclair/typebox": "^0.34.52",
|
|
158
158
|
"@types/node": "^25.9.8",
|
|
159
159
|
"tsx": "^4.23.15",
|
|
@@ -133,7 +133,8 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
|
|
|
133
133
|
| `scripts/wc-gate.mjs` | T11b |
|
|
134
134
|
| `scripts/test-runner.mjs` (the runner itself) | T1 (exercises it) + `test/unit/scripts/test-runner-exit.test.ts` (21 tests: exit codes, cancelled-guard red-green, missing-file pre-check, batch shard empirical) + ONE batch-mode run (`PI_CREW_TEST_BATCH_SIZE=2 node scripts/test-runner.mjs --shard=0/1 <3 small files>`). **Home isolation per-suite từ 2026-10-11** (set CẢ `PI_CREW_HOME` + `PI_TEAMS_HOME` — mirror pair, đặt 1 tên là defeatable; sweep prefix `pi-crew-` cover orphan `pi-crew-test-home-*`; opt-out `PI_CREW_TEST_NO_HOME_ISOLATION=1`) → khi đụng isolation: leak detector = đếm run-dirs `~/.pi/agent/extensions/pi-crew/state/runs/` before/after một suite, KHÔNG được tăng. Dev script — NOT bundled, no T3/T4 needed |
|
|
135
135
|
| `scripts/check-lazy-imports.mjs` | Run the gate on a clean tree (`node scripts/check-lazy-imports.mjs` exit 0) + re-probe the pattern battery vs the KNOWN GAPS comment (round-3 lesson: a skip-rule fix can OPEN a new false-skip hole — probe both directions; fixture suite `test/unit/scripts/check-lazy-imports-gate.test.ts` 4 tests probe cả 2 hướng). **AST-based từ Δ2 (2026-10-11, esbuild strip + acorn)**: per-entry budget `{maxFiles}` là RATCHET — bump chỉ khi chủ ý + justify trong commit; hosted-git-info allowlist ĐÃ xóa (2026-10-12, dead require dropped — gate strict trở lại). NOT bundled |
|
|
136
|
-
| `src/runtime/verification/verify-gate.ts` + task-runner gate mount (U13) | T7 smoke ×2 — (a) goal whose checks PASS → run must reach `completed` (NOT `blocked`) + `task.verifier_pre_gate` event with `skipVerifier:true` + verifier result artifact carries gate summary; (b) goal whose checks FAIL (e.g. `false` command) → LLM verifier spawns WITH gate context. **Guards the U13×effectiveness interplay** (live-caught 2026-10-10: green-gated verifier = no usage/transcript by design → effectiveness guard false-blocked the run → sync calls hung forever; fixed by counting `diagnostics.verifyGate` as observable work — `effectiveness-guard.test.ts` pins it) |
|
|
136
|
+
| `src/runtime/verification/verify-gate.ts` + task-runner gate mount (U13) | T7 smoke ×2 — (a) goal whose checks PASS → run must reach `completed` (NOT `blocked`) + `task.verifier_pre_gate` event with `skipVerifier:true` + verifier result artifact carries gate summary; (b) goal whose checks FAIL (e.g. `false` command) → LLM verifier spawns WITH gate context. **Guards the U13×effectiveness interplay** (live-caught 2026-10-10: green-gated verifier = no usage/transcript by design → effectiveness guard false-blocked the run → sync calls hung forever; fixed by counting `diagnostics.verifyGate` as observable work — `effectiveness-guard.test.ts` pins it). U13-ADJ (2026-10-13): layer-3 determinism `PI_CREW_VERIFY_DETERMINISM` DEFAULT OFF — battery recipe KHÔNG đổi trừ khi flag bật; khi bật: rerun critical-check cuối cùng PASS, flaky/executor-death → INCONCLUSIVE (không FAILED), không rerun khi checks đã FAILED; belt never-fail-task giờ wrap cả writeArtifact skip-path + finalizer NGỒI NGOÀI try (throw lan truyền, không fall-through spawn) |
|
|
137
|
+
| `src/state/coordination/**` (run-lock socket U21, 2026-10-13) | T7 + pinned `test/unit/state/coordination/{process-lock,run-lock-socket}.test.ts` (U21-1..6) — escape hatch `PI_CREW_RUN_LOCK_SOCKET=0` (pre-U21 bit-for-bit). Semantics MỚI khi socket ON (default): stale-steal một LIVE holder giờ WAIT trong deadline `staleMs*2` thay vì dual-entry (test cũ `run-lock-steal-live-holder-warn.test.ts` giờ env-scoped socket=0). **Test-rule cho mọi lock-freeze tương lai**: PHẢI await acquired-signal deterministic từ body (pattern `team-tool-resume-liveness` F1 sau 2026-10-13) — socket-listen là MACROTASK, assumption "acquisition thắng race với await kế tiếp" đã sai 1 lần (RR-011 token từng vô hình với contender same-process trong cửa sổ file→socket) |
|
|
137
138
|
| `PI_CREW_STATE_BACKEND=sqlite` (U8 state backend) | Unit: `sqlite-run-state.test.ts` + `state-store-sqlite-parity.test.ts` (incl. coalesced-mirror pin). LIVE: one fast-fix run with the env set → assert `state.sqlite` exists, **sqlite tasks reach terminal status** (live-caught 2026-10-10: `saveRunTasksCoalesced` hot path skipped the db — manifest+tasks.json completed while sqlite stuck at createRun `queued`), events.jsonl still written (audit spine), JSON mirrors present |
|
|
138
139
|
| `PI_CREW_CHILD_PI_MODE=rpc` / `PI_CREW_CHILD_PI_POOL` (U14 transport) | Unit: `child-pi-rpc-mode.test.ts` (18) + `owned-process-u15.test.ts` (11). LIVE: one async run with env set → workers' status.json carry rpc transport markers, run green, `pgrep -f "mode rpc"` clean after completion (no orphan rpc processes), json default unaffected |
|
|
139
140
|
| `src/runtime/live-session/**` shared-services rework (U4) | Unit: `live-session-u4` suite (10 — init<50ms via `session_created.elapsedMs`, recursion assert, D4 planning-window replay, hung-tool drop). LIVE: T8 md5 + one fast-fix run (live-session path opt-in) — session_created.elapsedMs < 50ms in events |
|
|
@@ -178,6 +179,12 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
|
|
|
178
179
|
> **Steering channel filename is RUN-KIND-DEPENDENT — the #1 way to fake a failure** (cost this session a full false-negative investigation): **team workflow runs** poll `<artifactsRoot>/steering/<taskId>.jsonl` (`child-executor.ts:562` → env `PI_CREW_STEERING_FILE` → `prompt-runtime.ts:855`), e.g. `01_assess.jsonl`, `adaptive-01-executor.jsonl`; **crew_agent / direct subagent runs** use the agent-slot name `<slot>-agent.jsonl` (e.g. `01_01-agent.jsonl`). A watcher writing `01_01-agent.jsonl` into a team run's steering dir writes a file **no component reads** (0-byte `<taskId>.jsonl` sits untouched, the phantom file accumulates bytes) — it looks exactly like the boot-window loss but is a probe bug. Always derive the name from the run kind, or write every `*.jsonl` present in the dir (see Tier 9g).
|
|
179
180
|
>
|
|
180
181
|
> **2026-10-12 update (test-state leak fix + U11 hybrid worker + Δ-wave, commits `468fe609`..`a56129ec`, 8 commits)**: (1) **Unit-test state leak vào user root (fix 2 tầng)** — fixture chạy từ bare `/tmp` cwd (không repo marker) đẩy run-state vào `~/.pi/agent/extensions/pi-crew/` qua no-repo fallback của `scopeBaseRoot()`; async-notifier mỗi live session toast các fixture failure thành `Error: pi-crew run failed: team_… (inline-seam-fixture/none)` (104 run-dir rò từ tháng 6, dọn 2026-10-11). Tầng 1: runner isolation per-suite (`PI_CREW_HOME` + `PI_TEAMS_HOME` — CẢ HAI, mirror pair 1-tên defeatable); tầng 2: run-index temp-cwd guard (non-temp root × TERMINAL × bare-temp cwd không marker ⇒ ẩn khỏi listing; by-ID vẫn resolve). Probe rule: leak detector đếm run-dirs user-root before/after suite. (2) **U11 hybrid 2-tier worker (DEFAULT-OFF)** — embedded tier trên U4 stack, shared slot budget 2 tier, fallback 2-faults, events `tier.decision/fault/fallback`; LIVE acceptance `team_20261011150612`: embedded `session_created.elapsedMs` **9ms** vs subprocess spawn **317ms** (~35-60×); row decision-table mới ở trên kèm STALE-BUNDLE SIGNATURE. (3) **Δ-wave benches + gates**: `check-lazy-imports` AST hóa (budget ratchet 523, fixture 4 tests); **b14** run-index scan (readdir vô hại 1.4µs/dir — per-entry work 0.3ms/dir mới là chi phí; notifier recent-40 an toàn mọi scale; F4: scan-cache bucket TTL non-sliding + `set()` reset bucket rỗng — scan >1s tự roll bucket giữa chừng, cần decision record nếu bucket nào phục vụ scan >1s); **b15** broker scan-cost (Δ3 verdict NO-GO có bằng chứng: N≤11 tasks/run thực tế, scan 0.006-0.05ms/call vs I/O floor 8.1ms — candidate indexes parked tới khi run thật >50 tasks); `hosted-git-info` dead-require dropped (KHÔNG resolve ở đâu cả — nested `node_modules/@peer/...` không nằm resolution path của pi-crew; generic parser là de-facto path 100% thời gian). (4) **U12 track** — 7 blacklist items mới từ upstream `cdaf6c227`, triggers 1/4 → vẫn track-only (`docs/reviews/pi-upstream-tracking-u12.md`). Suite anchors @ 2026-10-12 (`a56129ec`): unit **9063 tests / 9060 pass / 0 fail / 3 skipped / 0 cancelled**, integration **144 / 140 pass / 0 fail / 4 skipped** (EXIT=0, env scrub), `test:critical` **120**, bundle **1,758,732 B (1717.5 KB)** md5 `472cb93254c69f7302b99e47269e02ea`, event registry **182 registered / 147 emitted** (`tier.*` registered; DRIFT 3 = mailbox cũ đã biết, owner broker lane).
|
|
182
|
+
>
|
|
183
|
+
> **2026-10-13 update (pi-durable wave U22/U16/U21/U13-ADJ/U9-ADJ, branch `feat/pi-durable-upgrade`, commits `85917a44`..`1acd01e7`, 15 commits; spec `sdd-pi-durable-2026-10-13/`)**: (1) **U22 events.jsonl durability** — worker-atomic-writer hardened (fsync file+dir, per-request timeout `PI_CREW_WORKER_ATOMIC_TIMEOUT_MS`, `on("exit")` drain, temp-dir cache; opt-in như cũ); torn-vs-poison unified scanner (`parseJsonlWindow`) trên CẢ 4 đường read + sidecar `<events>.poison.jsonl`. **Marker LIFETIME RULE (bài học M1 — 2 reviewer độc lập hội tụ, 9k-test suite KHÔNG bắt được)**: marker chỉ hợp lệ cho file instance nó sinh ra — rotate MOVE sidecar theo archive, compact DELETE sidecar, và mọi marker-hit được RE-PARSE 1 lần trước khi skip (dòng parse được = marker đã lành, prune ngay) — layer này tự chữa cả marker bẩn same-uid; read-path giờ CÓ write side-effect (trust note security review). `sweepOldArchives` quét cả `<archive>.poison.jsonl`. (2) **Parameter-property recurrence (US-011 = R3-1 tái diễn)**: `WorkerRequestBookkeeper` constructor(private …) giết strip-types child — bắt bởi gate của PHASE KẾ (không phải phase tác giả); reinforced rule: chạy 1 file test spawn-child sau mọi phase đụng constructor syntax. (3) **U21 run-lock dual-guard** (file→socket, release socket→file, WAIT giữ nguyên): RR-011 token đăng ký NGAY sau file-acquire (cửa sổ socket-listen macrotask từng để token "vô hình" — steal-corpse hazard); flake F1 `team-tool-resume-liveness` từng note 2026-10-07 thực chất là RACE ASSUMPTION — giờ fix bằng acquired-signal deterministic (class flaky đóng). Deferred: uid-namespace cho lock name (F2 LOW). (4) **U16 usage ledger**: SDK chỉ có `getSessionStats()` — cả `getStats` lẫn fallback `session.stats` đều chết (embedded usage luôn undefined trước fix); 1 parser duy nhất, cost number|object(`total`), lifetimeUsage đủ cacheRead+cost; LIVE evidence: `team action=summary` hiển thị cacheRead. (5) **U13-ADJ layer-3 + never-fail-task belt** (chi tiết row U13 ở decision table). (6) **U9-ADJ noProgress hook** (dormant — chưa có production caller) + `owner`/`intent` additive (validators permissive cả tasks.json lẫn sqlite). (7) **Process**: lazy-imports budget 523→525 (poison-marker + process-lock, ratchet justified); dist committed (T11j `--committed-hash` yêu cầu khớp fresh build); 2 MAJOR bắt bởi review ĐỘC LẬP sau khi mọi gate xanh — durability paths CẦN review round, unit xanh không đủ. Suite anchors @ 2026-10-13 (`1acd01e7`): unit **9147 tests / 9143 pass / 1 fail = `subagent-tools-integration` teardown-race flake (14/14 ×2 isolated — class đã biết 2026-09-26, KHÔNG phải regression) / 3 skipped**, integration **144 / 140 pass / 0 fail / 4 skipped**, `test:critical` **120**, bundle **1,776,600 B (1735.0 KB)** md5 `c197855f917997a404ae39111d8ccfdc` (`--committed-hash` OK).
|
|
184
|
+
|
|
185
|
+
> **2026-10-14 update (pi-durable FOLLOW-UP: P1 usage-tracker 5-field + m5 usage-bridge, P2 F2 uid-namespace lock name, P3 F4 noProgress budget; branch `feat/pi-durable-followup` 6 commits trên v0.11.19 `f10da4cc`)**: (1) **F2** — `runLockSocketName` prefix uid (`${uid}:run:${abs}`, "noid" fallback): abstract namespace là SYSTEM-WIDE — cùng path 2 user = collision; deploy-window an toàn vì file-lock vẫn guard chính. (2) **F4** — noProgress hook race `NO_PROGRESS_HOOK_TIMEOUT_MS=5000` (constant KHÔNG env — hook dormant, tránh allowlist surface); timer resolve false + log riêng; throw-path bit-for-bit (sync-throw trước timer tạo → timer undefined → propagates); Promise.race subscribe loser ⇒ late rejection không unhandled. (3) **P1** — tracker 5-field (cacheRead+cost NUMBER — object phải qua parser ở caller); m5 tách `live-session/usage-bridge.ts` (1963→1926, headroom 74; lazy-import ratchet 525→526); LIVE: tasks.json run thật `cacheRead:8512` + `cost:0.00743`. (4) **X1 audit cwd-oracle trust vs 0.11.12: PARTIAL hướng an toàn** — core byte-identical; 2 delta default-ON đều GIẢM over-trust; không đường nào tin layer-1 nhiều hơn baseline. (5) **PROBE QUIRK (durable)**: `timeout N pi -p` + sync team tool — wrapper có thể KHÔNG thoát sau run completed (12-10 treo 20h, 14-10 ~9' cho tới KILL) — run bên trong vẫn hoàn tất xanh; lấy evidence TỪ RUN-DIR (không phụ thuộc wrapper), dọn bằng kill PID trực tiếp (không pattern). (6) **Gate exit qua pipe**: `cmd | tail; echo $?` bắt exit của TAIL — lazy-imports từng 526>525 mà exit hiển thị 0; dùng log-file/pipefail cho mọi gate exit. Suite anchors @ 2026-10-14 (`b683c723`+skill): unit **9159 / 9155 pass / 1 fail = heartbeat-pulse timing flake (4/4 ×2 isolated, class timing-under-load) / 3 skipped**, integration **144 / 140 / 0 / 4**, `test:critical` **120**, bundle **1777312 B (1735.7 KB)** md5 `f1e5cc77608acdb6ef155f7adc4ebad6` (committed==fresh). Deferred mới: `state/usage.ts` LifetimeUsage 3-field lệch triết lý cacheRead vs tracker (m6 unify).
|
|
186
|
+
|
|
187
|
+
> **2026-10-14 update #2 (dup-noti idle-gate + release 0.11.20 follow-up, commits `afc4fe9c`..HEAD trên cùng wave)**: (1) **DUP-NOTI RCA + FIX (live-caught 6×: P3/X1/E5 + 3 shards)** — noti trùng KHÔNG phải do emit 2 lần mà do `sendUserMessage(deliverAs:"followUp")` KHÔNG retract được (SDK `clearQueue()` all-or-nothing — dùng là phá luôn steering của user): leader consume mid-turn (get_subagent_result wait=true) → message ĐÃ queue vẫn deliver ở turn boundary = 1 turn phí mỗi lần. Rule-2 re-check canh SAI cửa sổ (chặn emit mới, không unsend được). **Fix = idle-gate** trong `subagent-manager-setup.ts`: `leaderTurnActive(ctx.currentCtx.isIdle())` (capability-guarded — host thiếu = bit-for-bit, pinned bởi Rule1-3 integration chạy trên fallback) + `runWhenLeaderIdle` (retry 500ms, cap 10' — muộn hơn never nhưng re-check vẫn chặn consumed quá cap); coalescer flush idle-gate TRƯỚC splice; batch path NOTIFY_DEFER_MS bị subsume (đã xóa). **Probe-quirk mở rộng**: fresh `pi -p` + prompt nặng (đụng crew_agent/team tool) có thể treo 0-output trong khi prompt đơn giản OK (provider-side, không phải pi-crew) — idle-gate live-probe qua đường này có thể bị chặn bởi môi trường; khi đó dựa vào unit (5/5 pin đúng race semantics) + Rule1-3 14/14. (2) **Goal FALSE-GREEN fix**: `goalDeclaresReadOnly` (marker EN+VI literal, explicit-intent) + bước (1b) — live-proven run `14022151` ("goal declared read-only (no edits expected)"). (3) **m6**: state/usage.ts dead 3-field family xóa; RoleUsage +cacheRead; ledger-vs-display docblock (#38 chỉ đúng cho display/budget-proxy). (4) Suite anchors @ 0.11.20-tree (`8e70e66f`): unit **9146 / 9143 pass / 0 fail / 0 cancelled / 3 skipped** (composition đổi sau m6 −dead-describe), critical **120**, bundle **1,778,118 B (1736.4 KB)** md5 `8e70e66f543106b1ac34902bd4047302` (committed==fresh), census 15.
|
|
181
188
|
|
|
182
189
|
## Core principle: disk ≠ live Pi
|
|
183
190
|
|
|
@@ -257,6 +257,14 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
|
|
|
257
257
|
PI_CREW_CLASSIFIER_MODEL: { kind: "gap", reason: "child-executor retry classifier (runner)" },
|
|
258
258
|
PI_CREW_VERIFY_GATE: { kind: "gap", reason: "verify-gate pre-gate before verifier spawns (runner)" },
|
|
259
259
|
PI_CREW_VERIFY_GATE_TIMEOUT_MS: { kind: "gap", reason: "verify-gate per-check timeout (runner)" },
|
|
260
|
+
PI_CREW_VERIFY_DETERMINISM: {
|
|
261
|
+
kind: "gap",
|
|
262
|
+
reason: "U13-ADJ layer-3 determinism rerun toggle — resolved in the runner before the verifier spawns; deliberately unforwarded (each process decides its own gate determinism tier, exactly like PI_CREW_VERIFY_GATE above)",
|
|
263
|
+
},
|
|
264
|
+
PI_CREW_RUN_LOCK_SOCKET: {
|
|
265
|
+
kind: "gap",
|
|
266
|
+
reason: "U21 run-lock dual-guard toggle — withRunLock also runs in the runner; deliberately unforwarded this wave (socket dies with each process, so a host-vs-runner toggle mismatch is harmless: each process just picks its own admission tier)",
|
|
267
|
+
},
|
|
260
268
|
PI_CREW_RPC_DIALOG_ANSWER: { kind: "gap", reason: "rpc-worker dialog policy (runner)" },
|
|
261
269
|
PI_CREW_RPC_TURN_TIMEOUT_MS: { kind: "gap", reason: "rpc-worker turn timeout (runner)" },
|
|
262
270
|
PI_CREW_MAX_AUTO_FALLBACKS: { kind: "gap", reason: "model-fallback cap resolved in the runner" },
|
package/src/config/env-vars.ts
CHANGED
|
@@ -472,6 +472,12 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
472
472
|
name: "PI_CREW_STATE_BACKEND",
|
|
473
473
|
doc: "U8 (upgrade-spec 2026-10-09) run-state store backend select: 'sqlite' stores manifest+tasks+worker-status in <runId>/state.sqlite via node:sqlite with ONE transaction per batch write (all-or-nothing; manifest.json/tasks.json stay written as read-side mirrors for legacy consumers — loads read the db); unset or any other value keeps the default JSON file backend. events.jsonl audit spine unchanged in both modes (state-store.ts / sqlite-run-state.ts isSqliteStateBackend)",
|
|
474
474
|
},
|
|
475
|
+
PI_CREW_RUN_LOCK_SOCKET: {
|
|
476
|
+
name: "PI_CREW_RUN_LOCK_SOCKET",
|
|
477
|
+
parser: "boolean",
|
|
478
|
+
default: true,
|
|
479
|
+
doc: "U21 (upgrade 2026-10-13) run-lock dual-guard toggle: '0'/'false'/'no' disables the kernel process-socket SUPPLEMENT on the ASYNC withRunLock path (pre-U21 file-lock-only behaviour, bit-for-bit); default ON. Host-process read only — never passed to children (the socket name is derived from the run dir path). The SYNC withRunLockSync path is always file-only by design (locks.ts acquireRunLockGuarded)",
|
|
480
|
+
},
|
|
475
481
|
PI_CREW_CHILD_PI_MODE: {
|
|
476
482
|
name: "PI_CREW_CHILD_PI_MODE",
|
|
477
483
|
doc: "U14 (upgrade-spec 2026-10-09) child-pi protocol v2 transport select: 'rpc' spawns task workers over pi --mode rpc (SDK RpcClient wire — steer receipts, clear_queue hygiene, multi-prompt reuse; child-pi-rpc.ts), 'json' / unset keeps the --mode json -p stdio path (phase-1 default; both modes fully regression-tested). Surface TUI panes are unaffected",
|
|
@@ -505,6 +511,11 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
505
511
|
default: "120000",
|
|
506
512
|
doc: "U13 per-check timeout in ms for the deterministic verify_gate pre-gate commands. Invalid/≤0 → 120000, mirroring runPhaseGates (runtime/verification/verify-gate.ts resolveVerifyGateTimeoutMs)",
|
|
507
513
|
},
|
|
514
|
+
PI_CREW_VERIFY_DETERMINISM: {
|
|
515
|
+
name: "PI_CREW_VERIFY_DETERMINISM",
|
|
516
|
+
parser: "boolean",
|
|
517
|
+
doc: "U13-ADJ (upgrade 2026-10-13, PHASE 4) layer-3 determinism rerun, DEFAULT OFF: '1' reruns the most critical default check (usually test:critical) once after a GREEN gate; disagreement (flaky) or a rerun executor failure downgrades the verdict to INCONCLUSIVE (never FAILED, never a task failure) with a 'deterministic-check: flaky <name>' note. Flag off keeps the gate bit-for-bit pre-U13-ADJ (runtime/verification/verify-gate.ts resolveVerifyDeterminismEnabled)",
|
|
518
|
+
},
|
|
508
519
|
PI_CREW_RPC_DIALOG_ANSWER: {
|
|
509
520
|
name: "PI_CREW_RPC_DIALOG_ANSWER",
|
|
510
521
|
doc: "W7 EXPERIMENTAL dialog auto-answer policy for RPC-mode extension_ui_request dialogs (select/confirm/input/editor): 'cancel' (default) answers {cancelled:true} — universal safe answer; 'block' leaves the server promise pending (debug). Auto-confirm deliberately NOT implemented (security); invalid → 'cancel' (rpc-worker.ts resolveDialogAnswerPolicy)",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Subagent manager installer for pi-crew.
|
|
3
3
|
*
|
|
4
4
|
* Wires the SubagentManager singleton with:
|
|
5
|
-
* • a terminal-status callback (Rules 1 + 2 + 3):
|
|
5
|
+
* • a terminal-status callback (Rules 1 + 2 + 3 + idle-gate):
|
|
6
6
|
* - Rule 1 (batch coalescing): explicit batchId → ONE consolidated notify
|
|
7
7
|
* when all members terminal.
|
|
8
8
|
* - Rule 2 (consume-race fix): resultConsumed re-check so a leader that
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* produce 1 notice — not N drips delivered one-per-turn at turn
|
|
13
13
|
* boundaries (the symptom: leader joins all, then redundant per-agent
|
|
14
14
|
* "changed state" notices keep dripping in over later turns).
|
|
15
|
+
* - Idle-gate (2026-10-14, dup-close): a queued followUp wake-up cannot be
|
|
16
|
+
* retracted (clearQueue is all-or-nothing), so while the leader's turn
|
|
17
|
+
* is active the emit is DEFERRED and the resultConsumed re-check re-runs
|
|
18
|
+
* once the session is idle — the moment the message would actually
|
|
19
|
+
* deliver. A leader that proactively joins the result mid-turn
|
|
20
|
+
* (get_subagent_result wait=true) never sees the stale drip.
|
|
15
21
|
* • an internal event forwarder (subagent.stuck-blocked → notification),
|
|
16
22
|
* • a hard cap on concurrent subagents (4).
|
|
17
23
|
*
|
|
@@ -27,13 +33,6 @@ import { sendAgentWakeUp } from "./subagent-helpers.ts";
|
|
|
27
33
|
const MAX_CONCURRENT_SUBAGENTS = 4;
|
|
28
34
|
const SUBAGENT_DEFAULT_TIMEOUT_MS = 1000;
|
|
29
35
|
|
|
30
|
-
/**
|
|
31
|
-
* Defer window for the BATCH path (explicit batchId). Gives the leader a chance
|
|
32
|
-
* to consume batch members before the consolidated notify emits; the
|
|
33
|
-
* resultConsumed re-check then suppresses.
|
|
34
|
-
*/
|
|
35
|
-
const NOTIFY_DEFER_MS = 1500;
|
|
36
|
-
|
|
37
36
|
/**
|
|
38
37
|
* Coalesce window for NON-batch background completions (Rule 3). Completions
|
|
39
38
|
* within this window (debounced — the timer resets on each new arrival) merge
|
|
@@ -46,6 +45,63 @@ const NOTIFY_DEFER_MS = 1500;
|
|
|
46
45
|
*/
|
|
47
46
|
const NOTIFY_COALESCE_MS = 800;
|
|
48
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Idle-gate retry cadence (dup-close, 2026-10-14). A queued followUp wake-up
|
|
50
|
+
* CANNOT be retracted once `sendUserMessage` has queued it (the SDK's
|
|
51
|
+
* `clearQueue()` is all-or-nothing — it would also destroy the user's own
|
|
52
|
+
* pending steering). So while the leader's turn is ACTIVE we keep DEFERRING
|
|
53
|
+
* the emit and re-running the resultConsumed check; the wake-up only queues
|
|
54
|
+
* once the session is idle — the moment it will actually be delivered — which
|
|
55
|
+
* closes the duplicate class where the leader proactively joins the result
|
|
56
|
+
* mid-turn and then gets a stale "changed state" drip at the turn boundary
|
|
57
|
+
* (live-caught 6× 2026-10-13/14: P3/X1/E5 + 3 research shards).
|
|
58
|
+
*/
|
|
59
|
+
const IDLE_RETRY_MS = 500;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hard cap for the idle-gate deferral. Leader turns can be long; after the
|
|
63
|
+
* cap the wake-up is emitted anyway (late beats never) — the resultConsumed
|
|
64
|
+
* re-check still runs first, so a consumed agent never re-notifies even past
|
|
65
|
+
* the cap.
|
|
66
|
+
*/
|
|
67
|
+
const IDLE_EMIT_CAP_MS = 10 * 60_000;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when the leader's own turn is ACTIVE (session streaming). Capability-
|
|
71
|
+
* guarded: an older host without `ctx.isIdle` (or a bare/absent currentCtx)
|
|
72
|
+
* reports "not active" so the emit path stays bit-for-bit with the pre-gate
|
|
73
|
+
* behavior.
|
|
74
|
+
* @internal — exported for unit tests.
|
|
75
|
+
*/
|
|
76
|
+
export function leaderTurnActive(ctx: RegistrationContext): boolean {
|
|
77
|
+
const c = ctx.currentCtx;
|
|
78
|
+
if (!c || typeof c.isIdle !== "function") return false;
|
|
79
|
+
try {
|
|
80
|
+
return c.isIdle() === false;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run `fn` once the leader turn is idle (or immediately when it already is /
|
|
88
|
+
* the host lacks the capability / the cap expires). `fn` must be safe to call
|
|
89
|
+
* late: it re-runs its own consume/owner re-checks first.
|
|
90
|
+
* @internal — exported for unit tests.
|
|
91
|
+
*/
|
|
92
|
+
export function runWhenLeaderIdle(ctx: RegistrationContext, fn: () => void): void {
|
|
93
|
+
const deadline = Date.now() + IDLE_EMIT_CAP_MS;
|
|
94
|
+
const attempt = (): void => {
|
|
95
|
+
if (ctx.cleanedUp) return;
|
|
96
|
+
if (!leaderTurnActive(ctx) || Date.now() >= deadline) {
|
|
97
|
+
fn();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setTimeout(attempt, IDLE_RETRY_MS);
|
|
101
|
+
};
|
|
102
|
+
attempt();
|
|
103
|
+
}
|
|
104
|
+
|
|
49
105
|
/** A pending non-batch completion awaiting coalesced emit. */
|
|
50
106
|
interface PendingCompletion {
|
|
51
107
|
agentId: string;
|
|
@@ -64,6 +120,7 @@ interface CompletionCoalescer {
|
|
|
64
120
|
function createCompletionCoalescer(pi: ExtensionAPI, ctx: RegistrationContext): CompletionCoalescer {
|
|
65
121
|
let pending: PendingCompletion[] = [];
|
|
66
122
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
123
|
+
let cycleDeadline = 0;
|
|
67
124
|
|
|
68
125
|
/** True if the completion is still deliverable (not consumed, current session). */
|
|
69
126
|
const isLive = (c: PendingCompletion): boolean => {
|
|
@@ -80,17 +137,29 @@ function createCompletionCoalescer(pi: ExtensionAPI, ctx: RegistrationContext):
|
|
|
80
137
|
pending = [];
|
|
81
138
|
return;
|
|
82
139
|
}
|
|
140
|
+
// Idle-gate (dup-close): defer the emit while the leader's own turn is
|
|
141
|
+
// active — a queued followUp cannot be retracted, so we only queue it at
|
|
142
|
+
// the moment it will actually be delivered. The consume re-check below
|
|
143
|
+
// then runs at THAT moment: a leader that joined the result mid-turn
|
|
144
|
+
// suppresses the notify entirely instead of receiving a stale drip.
|
|
145
|
+
// Past the cap we emit anyway (late beats never).
|
|
146
|
+
if (leaderTurnActive(ctx) && Date.now() < cycleDeadline) {
|
|
147
|
+
timer = setTimeout(flush, IDLE_RETRY_MS);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
83
150
|
const batch = pending.splice(0);
|
|
84
|
-
if (batch.length
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
151
|
+
if (batch.length > 0) {
|
|
152
|
+
// Rule 2: drop agents the leader already consumed during the window.
|
|
153
|
+
const live = batch.filter(isLive);
|
|
154
|
+
if (live.length === 1) emitIndividualCompletion(pi, ctx, live[0]!);
|
|
155
|
+
else if (live.length > 1) emitConsolidatedCompletions(pi, ctx, live);
|
|
156
|
+
}
|
|
157
|
+
if (pending.length === 0) cycleDeadline = 0;
|
|
90
158
|
};
|
|
91
159
|
|
|
92
160
|
return {
|
|
93
161
|
enqueue(completion) {
|
|
162
|
+
if (pending.length === 0) cycleDeadline = Date.now() + IDLE_EMIT_CAP_MS;
|
|
94
163
|
pending.push(completion);
|
|
95
164
|
if (timer) clearTimeout(timer);
|
|
96
165
|
timer = setTimeout(flush, NOTIFY_COALESCE_MS);
|
|
@@ -250,9 +319,10 @@ function onTerminalStatus(
|
|
|
250
319
|
const agentRunId = record.runId;
|
|
251
320
|
const agentBatchId = record.batchId;
|
|
252
321
|
|
|
253
|
-
// Rule 1 (batch): defer + BatchBarrier consolidated emit
|
|
322
|
+
// Rule 1 (batch): defer + BatchBarrier consolidated emit, idle-gated so the
|
|
323
|
+
// consume re-check runs at the moment the wake-up actually queues.
|
|
254
324
|
if (agentBatchId) {
|
|
255
|
-
|
|
325
|
+
runWhenLeaderIdle(ctx, () => {
|
|
256
326
|
if (ctx.cleanedUp) return;
|
|
257
327
|
const fresh = ctx.subagentManager.getRecord(agentId);
|
|
258
328
|
const persisted = ctx.currentCtx ? readPersistedSubagentRecord(ctx.currentCtx.cwd, agentId) : undefined;
|
|
@@ -290,7 +360,7 @@ function onTerminalStatus(
|
|
|
290
360
|
}
|
|
291
361
|
// Either we just emitted the consolidated notify, or we are still
|
|
292
362
|
// waiting for other members — in both cases do NOT emit individually.
|
|
293
|
-
}
|
|
363
|
+
});
|
|
294
364
|
return;
|
|
295
365
|
}
|
|
296
366
|
|
|
@@ -131,6 +131,42 @@ export async function runMockChildPi(
|
|
|
131
131
|
stderr: "[MOCK] rate limit: mock failure",
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
// U16 (usage ledger): same happy-path shape as json-success, but the
|
|
135
|
+
// message_end events carry the REAL message-level usage shape — usage
|
|
136
|
+
// nested under message.role="assistant" with cost as an OBJECT
|
|
137
|
+
// ({input, output, cacheRead, cacheWrite, total} per @earendil-works/pi-ai
|
|
138
|
+
// Usage) — so the lifetimeUsage accumulator and the subprocess usage
|
|
139
|
+
// parsers see exactly what a real child pi emits. Two events so the
|
|
140
|
+
// accumulation (sum) across messages is asserted, not a single snapshot.
|
|
141
|
+
if (mock === "json-usage-ledger") {
|
|
142
|
+
const text = `[MOCK] JSON usage ledger for ${input.agent.name}`;
|
|
143
|
+
const usageEvent = (tokens: { input: number; output: number; cacheRead: number; cacheWrite: number }, cost: number) =>
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
type: "message_end",
|
|
146
|
+
message: {
|
|
147
|
+
role: "assistant",
|
|
148
|
+
usage: {
|
|
149
|
+
...tokens,
|
|
150
|
+
totalTokens: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite,
|
|
151
|
+
cost: {
|
|
152
|
+
input: 0.01,
|
|
153
|
+
output: 0.01,
|
|
154
|
+
cacheRead: 0.005,
|
|
155
|
+
cacheWrite: 0.005,
|
|
156
|
+
total: cost,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const stdout = [
|
|
162
|
+
JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } }),
|
|
163
|
+
usageEvent({ input: 10, output: 5, cacheRead: 40, cacheWrite: 3 }, 0.035),
|
|
164
|
+
usageEvent({ input: 7, output: 2, cacheRead: 20, cacheWrite: 1 }, 0.0203),
|
|
165
|
+
].join("\n");
|
|
166
|
+
await observe(input, `${stdout}\n`);
|
|
167
|
+
return { exitCode: 0, stdout, stderr: "" };
|
|
168
|
+
}
|
|
169
|
+
|
|
134
170
|
// RR-013 (F04) test seam: reproduce the MuxSurface A1 degrade result WITHOUT a
|
|
135
171
|
// real multiplexer. child-executor's `childResult.surface?.degraded` branch is
|
|
136
172
|
// the ONLY producer of TaskExecutionResult.surfaceLost, and runTeamTask offers
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* the false-green is no longer SILENT. It is deliberately conservative:
|
|
11
11
|
* - read-only / doc-only workflows are never accused (they legitimately make
|
|
12
12
|
* no project-code edits; their outputs land in gitignored .crew/),
|
|
13
|
+
* - a goal that EXPLICITLY declares read-only intent (marker phrase) is
|
|
14
|
+
* never accused either, even when the workflow shape is mutating —
|
|
15
|
+
* explicit intent beats workflow shape (see `goalDeclaresReadOnly`),
|
|
13
16
|
* - a false-green is only flagged for CODE-MUTATING runs (executor /
|
|
14
17
|
* test-engineer steps) in a git repo whose working tree is empty,
|
|
15
18
|
* - status is downgraded to "failed" ONLY when a corroborating signal (a
|
|
@@ -59,6 +62,38 @@ export function isGitWorkingTreeClean(cwd: string): { clean: boolean; repoRoot:
|
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Goal-text markers that EXPLICITLY declare a read-only task. Each entry is
|
|
67
|
+
* matched as a substring, case-insensitively, against whitespace-collapsed
|
|
68
|
+
* lowercase goal text (`goalDeclaresReadOnly`). Keep this list short and
|
|
69
|
+
* literal — one phrase per line — so it is trivial to extend. Longer phrases
|
|
70
|
+
* already covered by a shorter entry are intentionally omitted (e.g.
|
|
71
|
+
* "do not modify, create, or delete" is covered by "do not modify").
|
|
72
|
+
*/
|
|
73
|
+
const READ_ONLY_GOAL_MARKERS: readonly string[] = [
|
|
74
|
+
"read-only",
|
|
75
|
+
"read only",
|
|
76
|
+
"do not modify",
|
|
77
|
+
"don't modify",
|
|
78
|
+
"no file modifications",
|
|
79
|
+
"không sửa",
|
|
80
|
+
"không chỉnh sửa",
|
|
81
|
+
"chỉ đọc",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Explicit-intent detector, deliberately conservative: returns true ONLY when
|
|
86
|
+
* the goal text contains one of the literal READ_ONLY_GOAL_MARKERS phrases
|
|
87
|
+
* (case-insensitive, whitespace-collapsed). No semantic guessing — a goal
|
|
88
|
+
* that merely *sounds* non-mutating is NOT suppressed. Battery/probe goal
|
|
89
|
+
* conventions use exactly these phrases.
|
|
90
|
+
*/
|
|
91
|
+
export function goalDeclaresReadOnly(goal: string | undefined): boolean {
|
|
92
|
+
if (typeof goal !== "string" || goal.length === 0) return false;
|
|
93
|
+
const normalized = goal.toLowerCase().replace(/\s+/g, " ").trim();
|
|
94
|
+
return READ_ONLY_GOAL_MARKERS.some((marker) => normalized.includes(marker));
|
|
95
|
+
}
|
|
96
|
+
|
|
62
97
|
/**
|
|
63
98
|
* Assess whether a completed run achieved its goal. Pure function over manifest
|
|
64
99
|
* + tasks + workflow — no side effects, easy to unit-test.
|
|
@@ -78,6 +113,18 @@ export function assessGoalAchievement(
|
|
|
78
113
|
};
|
|
79
114
|
}
|
|
80
115
|
|
|
116
|
+
// (1b) Explicit-intent detector: the WORKFLOW is mutating, but the GOAL TEXT
|
|
117
|
+
// explicitly declares a read-only task — a clean tree is then EXPECTED,
|
|
118
|
+
// never an accusation. Deliberately conservative (literal markers only,
|
|
119
|
+
// no semantic guessing); battery/probe conventions use these phrases.
|
|
120
|
+
if (goalDeclaresReadOnly(manifest.goal)) {
|
|
121
|
+
return {
|
|
122
|
+
achieved: "unknown",
|
|
123
|
+
reason: "goal declared read-only (no edits expected)",
|
|
124
|
+
signals: [],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
81
128
|
// (2) Need a git repo to inspect the working tree.
|
|
82
129
|
const tree = isGitWorkingTreeClean(manifest.cwd);
|
|
83
130
|
if (tree.clean === "unknown" || !tree.repoRoot) {
|
|
@@ -43,7 +43,6 @@ import {
|
|
|
43
43
|
// in a single place: the shared user prompt. See G3 fix.
|
|
44
44
|
import { createStreamingOutput, type StreamingOutputHandle } from "../output/streaming-output.ts";
|
|
45
45
|
import { buildSensitivePathConstraint } from "../sensitive-paths.ts";
|
|
46
|
-
import { trackTaskUsage } from "../usage-tracker.ts";
|
|
47
46
|
import {
|
|
48
47
|
buildYieldReminder,
|
|
49
48
|
DEFAULT_YIELD_CONFIG,
|
|
@@ -88,6 +87,7 @@ import {
|
|
|
88
87
|
import { hasLiveControlRealtimeListeners, subscribeLiveControlRealtime } from "./live-control-realtime.ts";
|
|
89
88
|
import { buildExtensionBridge } from "./live-extension-bridge.ts";
|
|
90
89
|
import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
|
|
90
|
+
import { type SessionStatsLike, trackUsageFromMessageEndEvent, usageFromSessionStats } from "./usage-bridge.ts";
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Module-scoped latch for the optional peer dependency import. When N
|
|
@@ -234,8 +234,8 @@ type LiveSessionLike = {
|
|
|
234
234
|
steer?: (text: string) => Promise<void>;
|
|
235
235
|
abort?: () => Promise<void> | void;
|
|
236
236
|
dispose?: () => void;
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
/** U16: SDK AgentSession session-level stats surface (aggregate tokens + cost) — shape: usage-bridge.ts (SessionStatsLike). */
|
|
238
|
+
getSessionStats?: () => SessionStatsLike;
|
|
239
239
|
bindExtensions?: (bindings?: Record<string, unknown>) => Promise<void>;
|
|
240
240
|
getActiveToolNames?: () => string[];
|
|
241
241
|
setActiveToolsByName?: (names: string[]) => void;
|
|
@@ -463,24 +463,6 @@ function applyModelProviderOverride(session: LiveSessionLike | undefined, provid
|
|
|
463
463
|
}
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
/**
|
|
467
|
-
* Type-safe extractor for message role from streaming events.
|
|
468
|
-
* Handles the case where message may be a Record with a role field.
|
|
469
|
-
*/
|
|
470
|
-
function extractMessageRole(obj: Record<string, unknown> | undefined): string | undefined {
|
|
471
|
-
const message = obj?.message ? asRecord(obj.message) : undefined;
|
|
472
|
-
return isString(message?.role) ? message.role : undefined;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Type-safe extractor for message usage from streaming events.
|
|
477
|
-
* Handles the case where message may be a Record with usage data.
|
|
478
|
-
*/
|
|
479
|
-
function extractMessageUsage(obj: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
480
|
-
const message = obj?.message ? asRecord(obj.message) : undefined;
|
|
481
|
-
return message?.usage && typeof message.usage === "object" ? (message.usage as Record<string, unknown>) : undefined;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
466
|
/**
|
|
485
467
|
* Type-safe extractor for tool name from streaming events.
|
|
486
468
|
* Handles various event structures: { tool: { name } }, { toolName }, { name }.
|
|
@@ -525,15 +507,6 @@ function finalAssistantText(event: unknown): string[] {
|
|
|
525
507
|
return textFromContent(message.content);
|
|
526
508
|
}
|
|
527
509
|
|
|
528
|
-
function numberField(obj: Record<string, unknown> | undefined, keys: string[]): number | undefined {
|
|
529
|
-
if (!obj) return undefined;
|
|
530
|
-
for (const key of keys) {
|
|
531
|
-
const value = obj[key];
|
|
532
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
533
|
-
}
|
|
534
|
-
return undefined;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
510
|
/**
|
|
538
511
|
* F7: resolve the enabledModels allowlist for the current project, but only
|
|
539
512
|
* if the `reliability.scopeModels` toggle is ON. Returns an empty
|
|
@@ -794,19 +767,9 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
|
|
|
794
767
|
session.setActiveToolsByName(active);
|
|
795
768
|
}
|
|
796
769
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
const input = numberField(obj, ["input", "inputTokens", "input_tokens"]);
|
|
801
|
-
const output = numberField(obj, ["output", "outputTokens", "output_tokens"]);
|
|
802
|
-
const cacheRead = numberField(obj, ["cacheRead", "cache_read"]);
|
|
803
|
-
const cacheWrite = numberField(obj, ["cacheWrite", "cache_write"]);
|
|
804
|
-
const cost = numberField(obj, ["cost"]);
|
|
805
|
-
const turns = numberField(obj, ["turns", "turnCount", "turn_count"]);
|
|
806
|
-
return [input, output, cacheRead, cacheWrite, cost, turns].some((value) => value !== undefined)
|
|
807
|
-
? { input, output, cacheRead, cacheWrite, cost, turns }
|
|
808
|
-
: undefined;
|
|
809
|
-
}
|
|
770
|
+
// U16 (Fix B): the usageFromStats thin adapter moved to usage-bridge.ts (m5);
|
|
771
|
+
// re-exported here so the historical import surface keeps working.
|
|
772
|
+
export { usageFromStats } from "./usage-bridge.ts";
|
|
810
773
|
|
|
811
774
|
/**
|
|
812
775
|
* U4 hung-tool escape hatch marker error. Thrown when a timed-out prompt did
|
|
@@ -1540,17 +1503,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1540
1503
|
}
|
|
1541
1504
|
}
|
|
1542
1505
|
}
|
|
1543
|
-
//
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
if (u) {
|
|
1547
|
-
trackTaskUsage(input.task.id, {
|
|
1548
|
-
input: typeof u.input === "number" ? u.input : 0,
|
|
1549
|
-
output: typeof u.output === "number" ? u.output : 0,
|
|
1550
|
-
cacheWrite: typeof u.cacheWrite === "number" ? u.cacheWrite : 0,
|
|
1551
|
-
});
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1506
|
+
// P1: accumulate lifetime usage that survives compaction — message-level
|
|
1507
|
+
// deltas incl. cacheRead + numeric cost (parsed in usage-bridge).
|
|
1508
|
+
trackUsageFromMessageEndEvent(input.task.id, event);
|
|
1554
1509
|
input.onEvent?.(event);
|
|
1555
1510
|
const text = [...eventText(event), ...finalAssistantText(event)].join("\n");
|
|
1556
1511
|
if (text.trim()) {
|
|
@@ -1798,7 +1753,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1798
1753
|
emit: emitEmbedded,
|
|
1799
1754
|
})
|
|
1800
1755
|
: undefined;
|
|
1801
|
-
|
|
1756
|
+
// U16 (Fix A): the real SDK surface is getSessionStats() — see usage-bridge.
|
|
1757
|
+
const usage = usageFromSessionStats(session);
|
|
1802
1758
|
updateLiveAgentStatus(agentId, "completed");
|
|
1803
1759
|
markLiveAgentCompleted(agentId);
|
|
1804
1760
|
return {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { UsageState } from "../../state/types.ts";
|
|
2
|
+
import { usageFromValue } from "../session-usage.ts";
|
|
3
|
+
import { trackTaskUsage } from "../usage-tracker.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* m5 (P1): the live-session usage bridge, extracted from
|
|
7
|
+
* live-session-runtime.ts (1963 → ≤2000-line wc-gate relief). Owns every pure
|
|
8
|
+
* shape/parser helper that maps pi SDK session surfaces onto pi-crew's usage
|
|
9
|
+
* ledger — the runtime module keeps only the call sites.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
13
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isString(value: unknown): value is string {
|
|
17
|
+
return typeof value === "string";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* U16: SDK AgentSession session-level stats surface (aggregate tokens + cost);
|
|
22
|
+
* shaped like live-agent-manager's LiveSessionHandle so the session can be
|
|
23
|
+
* registered there without a cast (extra SDK fields like cost pass structurally).
|
|
24
|
+
*/
|
|
25
|
+
export interface SessionStatsLike {
|
|
26
|
+
tokens?: { input?: number; output?: number; cacheWrite?: number; cacheRead?: number };
|
|
27
|
+
contextUsage?: { percent?: number | null; window?: number };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Type-safe extractor for message role from streaming events.
|
|
32
|
+
* Handles the case where message may be a Record with a role field.
|
|
33
|
+
*/
|
|
34
|
+
function extractMessageRole(obj: Record<string, unknown> | undefined): string | undefined {
|
|
35
|
+
const message = obj?.message ? asRecord(obj.message) : undefined;
|
|
36
|
+
return isString(message?.role) ? message.role : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Type-safe extractor for message usage from streaming events.
|
|
41
|
+
* Handles the case where message may be a Record with usage data.
|
|
42
|
+
*/
|
|
43
|
+
function extractMessageUsage(obj: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
44
|
+
const message = obj?.message ? asRecord(obj.message) : undefined;
|
|
45
|
+
return message?.usage && typeof message.usage === "object" ? (message.usage as Record<string, unknown>) : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** U16 (Fix B): thin adapter — session-stats parsing is delegated to the single
|
|
49
|
+
* shared parser (`usageFromValue`), which accepts BOTH the nested SDK
|
|
50
|
+
* `SessionStats` shape (`tokens.{input,output,cacheRead,cacheWrite}` + `cost`)
|
|
51
|
+
* and flat legacy shapes. Re-exported by live-session-runtime.ts to keep its
|
|
52
|
+
* historical import surface green. */
|
|
53
|
+
export function usageFromStats(stats: unknown): UsageState | undefined {
|
|
54
|
+
return usageFromValue(stats);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Minimal session surface the completion site needs. */
|
|
58
|
+
interface SessionWithStats {
|
|
59
|
+
getSessionStats?: () => unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* U16 (Fix A): the SDK surface is getSessionStats() — the old getStats()
|
|
64
|
+
* name and session.stats fallback were both dead against the real SDK.
|
|
65
|
+
*/
|
|
66
|
+
export function usageFromSessionStats(session: SessionWithStats | undefined): UsageState | undefined {
|
|
67
|
+
return usageFromStats(session?.getSessionStats?.());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* P1 (AC4): accumulate lifetime usage that survives compaction from one
|
|
72
|
+
* streaming event. Only assistant `message_end` events contribute. The
|
|
73
|
+
* message-level usage object is parsed by the SHARED parser so the new
|
|
74
|
+
* `cacheRead` field and the message-level cost OBJECT (`{…, total}`) both land
|
|
75
|
+
* as NUMBERS in the tracker — usage-tracker deltas accept numeric cost only
|
|
76
|
+
* (D2 aggregate tier). Non-finite fields parse to undefined and are skipped
|
|
77
|
+
* (pre-P1 the raw `typeof` check passed 0 — same arithmetic result).
|
|
78
|
+
*/
|
|
79
|
+
export function trackUsageFromMessageEndEvent(taskId: string, event: unknown): void {
|
|
80
|
+
const obj = asRecord(event);
|
|
81
|
+
if (obj?.type !== "message_end" || extractMessageRole(obj) !== "assistant") return;
|
|
82
|
+
const raw = extractMessageUsage(obj);
|
|
83
|
+
if (!raw) return;
|
|
84
|
+
const usage = usageFromValue(raw);
|
|
85
|
+
if (!usage) return;
|
|
86
|
+
trackTaskUsage(taskId, {
|
|
87
|
+
input: usage.input,
|
|
88
|
+
output: usage.output,
|
|
89
|
+
cacheWrite: usage.cacheWrite,
|
|
90
|
+
cacheRead: usage.cacheRead,
|
|
91
|
+
cost: usage.cost,
|
|
92
|
+
});
|
|
93
|
+
}
|