pi-crew 0.11.18 → 0.11.19
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 +23 -0
- package/NOTICE.md +13 -0
- package/dist/index.mjs +569 -521
- package/package.json +3 -3
- package/skills/real-test-pi-crew/SKILL.md +4 -1
- package/src/config/env-allowlist-registry.ts +8 -0
- package/src/config/env-vars.ts +11 -0
- package/src/runtime/child-pi/mock-fixtures.ts +36 -0
- package/src/runtime/live-session/live-session-runtime.ts +17 -24
- 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 +90 -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/verification/verify-gate.ts +164 -7
- package/src/state/atomic-write.ts +10 -3
- package/src/state/coordination/locks.ts +118 -2
- package/src/state/coordination/process-lock.ts +199 -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/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.19",
|
|
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,8 @@ 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).
|
|
181
184
|
|
|
182
185
|
## Core principle: disk ≠ live Pi
|
|
183
186
|
|
|
@@ -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)",
|
|
@@ -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
|
|
@@ -43,6 +43,7 @@ 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 { usageFromValue } from "../session-usage.ts";
|
|
46
47
|
import { trackTaskUsage } from "../usage-tracker.ts";
|
|
47
48
|
import {
|
|
48
49
|
buildYieldReminder,
|
|
@@ -234,8 +235,13 @@ type LiveSessionLike = {
|
|
|
234
235
|
steer?: (text: string) => Promise<void>;
|
|
235
236
|
abort?: () => Promise<void> | void;
|
|
236
237
|
dispose?: () => void;
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
/** U16: SDK AgentSession session-level stats surface (aggregate tokens + cost);
|
|
239
|
+
* shaped like live-agent-manager's LiveSessionHandle so the session can be
|
|
240
|
+
* registered there without a cast (extra SDK fields like cost pass structurally). */
|
|
241
|
+
getSessionStats?: () => {
|
|
242
|
+
tokens?: { input?: number; output?: number; cacheWrite?: number; cacheRead?: number };
|
|
243
|
+
contextUsage?: { percent?: number | null; window?: number };
|
|
244
|
+
};
|
|
239
245
|
bindExtensions?: (bindings?: Record<string, unknown>) => Promise<void>;
|
|
240
246
|
getActiveToolNames?: () => string[];
|
|
241
247
|
setActiveToolsByName?: (names: string[]) => void;
|
|
@@ -525,15 +531,6 @@ function finalAssistantText(event: unknown): string[] {
|
|
|
525
531
|
return textFromContent(message.content);
|
|
526
532
|
}
|
|
527
533
|
|
|
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
534
|
/**
|
|
538
535
|
* F7: resolve the enabledModels allowlist for the current project, but only
|
|
539
536
|
* if the `reliability.scopeModels` toggle is ON. Returns an empty
|
|
@@ -794,18 +791,12 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
|
|
|
794
791
|
session.setActiveToolsByName(active);
|
|
795
792
|
}
|
|
796
793
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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;
|
|
794
|
+
/** U16 (Fix B): thin adapter — session-stats parsing is delegated to the single
|
|
795
|
+
* shared parser (`usageFromValue`), which accepts BOTH the nested SDK
|
|
796
|
+
* `SessionStats` shape (`tokens.{input,output,cacheRead,cacheWrite}` + `cost`)
|
|
797
|
+
* and flat legacy shapes. Exported for unit tests. */
|
|
798
|
+
export function usageFromStats(stats: unknown): UsageState | undefined {
|
|
799
|
+
return usageFromValue(stats);
|
|
809
800
|
}
|
|
810
801
|
|
|
811
802
|
/**
|
|
@@ -1798,7 +1789,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1798
1789
|
emit: emitEmbedded,
|
|
1799
1790
|
})
|
|
1800
1791
|
: undefined;
|
|
1801
|
-
|
|
1792
|
+
// U16 (Fix A): the SDK surface is getSessionStats() — the old getStats()
|
|
1793
|
+
// name and session.stats fallback were both dead against the real SDK.
|
|
1794
|
+
const usage = usageFromStats(session.getSessionStats?.());
|
|
1802
1795
|
updateLiveAgentStatus(agentId, "completed");
|
|
1803
1796
|
markLiveAgentCompleted(agentId);
|
|
1804
1797
|
return {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { usageFromValue } from "../session-usage.ts";
|
|
2
|
+
|
|
1
3
|
export interface ParsedPiUsage {
|
|
2
4
|
input?: number;
|
|
3
5
|
output?: number;
|
|
@@ -24,14 +26,6 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
24
26
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function numberField(obj: Record<string, unknown>, keys: string[]): number | undefined {
|
|
28
|
-
for (const key of keys) {
|
|
29
|
-
const value = obj[key];
|
|
30
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
31
|
-
}
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
29
|
function mergeUsage(target: ParsedPiUsage, source: ParsedPiUsage): ParsedPiUsage {
|
|
36
30
|
return {
|
|
37
31
|
input: source.input ?? target.input,
|
|
@@ -43,24 +37,17 @@ function mergeUsage(target: ParsedPiUsage, source: ParsedPiUsage): ParsedPiUsage
|
|
|
43
37
|
};
|
|
44
38
|
}
|
|
45
39
|
|
|
40
|
+
/**
|
|
41
|
+
* U16: usage extraction is delegated to the single shared parser
|
|
42
|
+
* (`usageFromValue` in session-usage.ts) so the subprocess tier accepts BOTH
|
|
43
|
+
* cost shapes — the session-level aggregate NUMBER and the message-level
|
|
44
|
+
* OBJECT with `total` (@earendil-works/pi-ai `Usage.cost`) — instead of
|
|
45
|
+
* silently dropping the object form. Same field aliases as before
|
|
46
|
+
* (inputTokens/input_tokens/cost_usd/…) and same nested fallbacks
|
|
47
|
+
* (usage/message/tokenUsage/tokens/stats).
|
|
48
|
+
*/
|
|
46
49
|
function extractUsage(value: unknown): ParsedPiUsage | undefined {
|
|
47
|
-
|
|
48
|
-
if (!obj) return undefined;
|
|
49
|
-
const direct: ParsedPiUsage = {
|
|
50
|
-
input: numberField(obj, ["input", "inputTokens", "input_tokens"]),
|
|
51
|
-
output: numberField(obj, ["output", "outputTokens", "output_tokens"]),
|
|
52
|
-
cacheRead: numberField(obj, ["cacheRead", "cache_read", "cacheReadTokens", "cache_read_tokens"]),
|
|
53
|
-
cacheWrite: numberField(obj, ["cacheWrite", "cache_write", "cacheWriteTokens", "cache_write_tokens"]),
|
|
54
|
-
cost: numberField(obj, ["cost", "costUsd", "cost_usd"]),
|
|
55
|
-
turns: numberField(obj, ["turns", "turnCount", "turn_count"]),
|
|
56
|
-
};
|
|
57
|
-
if (Object.values(direct).some((entry) => entry !== undefined)) return direct;
|
|
58
|
-
// Pi --mode json nests usage under message for message_end / turn_end events.
|
|
59
|
-
for (const key of ["usage", "message", "tokenUsage", "tokens", "stats"]) {
|
|
60
|
-
const nested = extractUsage(obj[key]);
|
|
61
|
-
if (nested) return nested;
|
|
62
|
-
}
|
|
63
|
-
return undefined;
|
|
50
|
+
return usageFromValue(value);
|
|
64
51
|
}
|
|
65
52
|
|
|
66
53
|
function textFromContent(content: unknown): string[] {
|
|
@@ -959,6 +959,10 @@ export async function reconcileAllStaleRuns(
|
|
|
959
959
|
// so locks whose holder died (kill -9 / crash) do not linger. The sweep is
|
|
960
960
|
// strictly safer than acquire-time steal (stale AND holder-dead only), so a
|
|
961
961
|
// live holder is never disturbed. Best-effort.
|
|
962
|
+
// U21 (2026-10-13): socket holders need no sweep — the kernel process socket
|
|
963
|
+
// of an async run-lock holder dies with its process. This sweep covers the
|
|
964
|
+
// SYNC path (withRunLockSync, file-only by design) + legacy file-only holders;
|
|
965
|
+
// remove after the file-lock migration window closes.
|
|
962
966
|
try {
|
|
963
967
|
const runsRoots = [
|
|
964
968
|
path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir),
|
|
@@ -24,6 +24,33 @@ export interface RetryAttemptInfo {
|
|
|
24
24
|
attemptId: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* U9-ADJ (upgrade 2026-10-13 §U9): terminal sentinel thrown when the retry
|
|
29
|
+
* loop stops because the `noProgress` hook detected a stuck loop. Distinct
|
|
30
|
+
* from ordinary give-up (exhaustion throws the ORIGINAL error untouched):
|
|
31
|
+
* this carries `reason: "no_progress"` plus the attempt info, with the
|
|
32
|
+
* original error preserved as `cause`. Callers distinguish the two exits via
|
|
33
|
+
* `error instanceof RetryNoProgressError` or `reason === "no_progress"`.
|
|
34
|
+
*/
|
|
35
|
+
export class RetryNoProgressError extends Error {
|
|
36
|
+
readonly reason: "no_progress";
|
|
37
|
+
readonly attempt: number;
|
|
38
|
+
readonly attemptId: string;
|
|
39
|
+
readonly durationMsLast: number;
|
|
40
|
+
|
|
41
|
+
constructor(details: { attempt: number; attemptId: string; durationMsLast: number; error: Error }) {
|
|
42
|
+
super(
|
|
43
|
+
`Retry loop stopped after attempt ${details.attempt} (${details.attemptId}): no progress between attempts (last attempt took ${details.durationMsLast}ms). Original error: ${details.error.message}`,
|
|
44
|
+
{ cause: details.error },
|
|
45
|
+
);
|
|
46
|
+
this.name = "RetryNoProgressError";
|
|
47
|
+
this.reason = "no_progress";
|
|
48
|
+
this.attempt = details.attempt;
|
|
49
|
+
this.attemptId = details.attemptId;
|
|
50
|
+
this.durationMsLast = details.durationMsLast;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
export interface RetryHooks {
|
|
28
55
|
onAttemptFailed?: (attempt: number, error: Error, nextDelayMs: number, info: RetryAttemptInfo) => void;
|
|
29
56
|
onRetryGivenUp?: (attempts: number, error: Error, info: RetryAttemptInfo) => void;
|
|
@@ -40,6 +67,29 @@ export interface RetryHooks {
|
|
|
40
67
|
* (classifyAmbientNoiseGate), dormant unless runtime.classifierEnabled.
|
|
41
68
|
*/
|
|
42
69
|
shouldRetry?: (attempt: number, error: Error, info: RetryAttemptInfo) => boolean | Promise<boolean>;
|
|
70
|
+
/**
|
|
71
|
+
* U9-ADJ (upgrade 2026-10-13 §U9): no-progress gate consulted after a
|
|
72
|
+
* failed attempt ONCE `shouldRetry` (if present) has approved queueing the
|
|
73
|
+
* next one, and BEFORE onAttemptFailed + backoff sleep. The CALLER owns the
|
|
74
|
+
* "no progress" semantics (e.g. compare checkpoint depth / error identity
|
|
75
|
+
* between attempts) — the executor stays generic and task-agnostic.
|
|
76
|
+
* `lastResult` / `previousResult` are the `Error`s thrown by the just-failed
|
|
77
|
+
* attempt and the one before it (`previousResult` is `undefined` on the
|
|
78
|
+
* first failure — nothing to compare yet); `durationMsLast` is the wall
|
|
79
|
+
* time of the just-failed attempt in ms. Returning true stops the loop with
|
|
80
|
+
* `RetryNoProgressError` (`reason: "no_progress"`) instead of spawning the
|
|
81
|
+
* next attempt — `onRetryGivenUp` still fires first (same terminal contract
|
|
82
|
+
* as the cancellation exit). SOFT: a throwing/rejecting hook is logged via
|
|
83
|
+
* logInternalError (the repo's console.error wrapper) and treated as false —
|
|
84
|
+
* a broken detector must never change retry behavior. Unregistered ⇒
|
|
85
|
+
* behaviour is bit-for-bit pre-U9-ADJ.
|
|
86
|
+
*/
|
|
87
|
+
noProgress?: (context: {
|
|
88
|
+
attempt: number;
|
|
89
|
+
lastResult: unknown;
|
|
90
|
+
previousResult: unknown;
|
|
91
|
+
durationMsLast: number;
|
|
92
|
+
}) => boolean | Promise<boolean>;
|
|
43
93
|
}
|
|
44
94
|
|
|
45
95
|
export const DEFAULT_RETRY_POLICY: RetryPolicy = {
|
|
@@ -91,13 +141,18 @@ export async function executeWithRetry<T>(
|
|
|
91
141
|
maxAttempts: Math.max(1, policy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts),
|
|
92
142
|
};
|
|
93
143
|
let lastError: Error | undefined;
|
|
144
|
+
// U9-ADJ: result (thrown Error) of the PREVIOUS failed attempt, fed to the
|
|
145
|
+
// noProgress hook as `previousResult`. `undefined` until a first failure.
|
|
146
|
+
let previousFailedResult: unknown;
|
|
94
147
|
for (let attempt = 1; attempt <= normalized.maxAttempts; attempt += 1) {
|
|
95
148
|
throwIfCancelled(hooks.signal);
|
|
96
149
|
const info = retryAttemptInfo(attempt, hooks);
|
|
150
|
+
const attemptStartedAtMs = Date.now();
|
|
97
151
|
try {
|
|
98
152
|
return await fn(attempt, info);
|
|
99
153
|
} catch (error) {
|
|
100
154
|
lastError = asError(error);
|
|
155
|
+
const durationMsLast = Date.now() - attemptStartedAtMs;
|
|
101
156
|
// Never retry if aborted — sleep() would immediately reject on every attempt.
|
|
102
157
|
if (hooks.signal?.aborted) {
|
|
103
158
|
hooks.onRetryGivenUp?.(attempt, lastError, info);
|
|
@@ -124,6 +179,41 @@ export async function executeWithRetry<T>(
|
|
|
124
179
|
throw lastError;
|
|
125
180
|
}
|
|
126
181
|
}
|
|
182
|
+
// U9-ADJ: no-progress gate — consulted once per failed attempt, after
|
|
183
|
+
// retryability + shouldRetry, BEFORE the backoff sleep + next spawn.
|
|
184
|
+
// Only the caller knows task semantics, so it compares the just-failed
|
|
185
|
+
// attempt against the previous one and decides. true ⇒ stop the loop
|
|
186
|
+
// with the distinct no_progress reason (not ordinary give-up exhaustion).
|
|
187
|
+
// SOFT: hook errors are logged (logInternalError) and treated as false.
|
|
188
|
+
if (hooks.noProgress) {
|
|
189
|
+
let noProgressDecision = false;
|
|
190
|
+
try {
|
|
191
|
+
noProgressDecision = await hooks.noProgress({
|
|
192
|
+
attempt,
|
|
193
|
+
lastResult: lastError,
|
|
194
|
+
previousResult: previousFailedResult,
|
|
195
|
+
durationMsLast,
|
|
196
|
+
});
|
|
197
|
+
} catch (noProgressError) {
|
|
198
|
+
logInternalError(
|
|
199
|
+
"retry-executor.noProgress",
|
|
200
|
+
noProgressError,
|
|
201
|
+
"soft-fail: treating as progress, retry stays queued",
|
|
202
|
+
"error",
|
|
203
|
+
);
|
|
204
|
+
noProgressDecision = false;
|
|
205
|
+
}
|
|
206
|
+
if (noProgressDecision === true) {
|
|
207
|
+
hooks.onRetryGivenUp?.(attempt, lastError, info);
|
|
208
|
+
throw new RetryNoProgressError({
|
|
209
|
+
attempt,
|
|
210
|
+
attemptId: info.attemptId,
|
|
211
|
+
durationMsLast,
|
|
212
|
+
error: lastError,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
previousFailedResult = lastError;
|
|
127
217
|
const delay = calculateRetryDelay(attempt, normalized);
|
|
128
218
|
hooks.onAttemptFailed?.(attempt, lastError, delay, info);
|
|
129
219
|
try {
|
|
@@ -13,7 +13,45 @@ function numberField(obj: Record<string, unknown>, keys: string[]): number | und
|
|
|
13
13
|
return undefined;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* U16 (D2 — two-tier cost): cost arrives EITHER as a session-level aggregate
|
|
18
|
+
* NUMBER (SDK `SessionStats.cost`) OR as a message-level OBJECT with a `total`
|
|
19
|
+
* field (`@earendil-works/pi-ai` `Usage.cost = {input, output, cacheRead,
|
|
20
|
+
* cacheWrite, total}`). Accept both; an object without a finite `total` yields
|
|
21
|
+
* undefined — never NaN.
|
|
22
|
+
*/
|
|
23
|
+
function costField(obj: Record<string, unknown>): number | undefined {
|
|
24
|
+
for (const key of ["cost", "costUsd", "cost_usd"]) {
|
|
25
|
+
const raw = obj[key];
|
|
26
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
27
|
+
const record = asRecord(raw);
|
|
28
|
+
const total = record ? record.total : undefined;
|
|
29
|
+
if (typeof total === "number" && Number.isFinite(total)) return total;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mergeUsageFields(primary: UsageState, fallback: UsageState): UsageState {
|
|
35
|
+
return {
|
|
36
|
+
input: primary.input ?? fallback.input,
|
|
37
|
+
output: primary.output ?? fallback.output,
|
|
38
|
+
cacheRead: primary.cacheRead ?? fallback.cacheRead,
|
|
39
|
+
cacheWrite: primary.cacheWrite ?? fallback.cacheWrite,
|
|
40
|
+
cost: primary.cost ?? fallback.cost,
|
|
41
|
+
turns: primary.turns ?? fallback.turns,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* U16: the single usage-shape parser (session-level `SessionStats` AND
|
|
47
|
+
* message-level `Usage`). Flat/direct fields win; nested payloads
|
|
48
|
+
* (`usage`/`message`/`tokenUsage`/`tokens`/`stats`) fill only the MISSING
|
|
49
|
+
* fields, so the SDK session shape `{tokens: {…}, cost}` maps completely while
|
|
50
|
+
* flat legacy shapes parse unchanged. `message` restores the legacy
|
|
51
|
+
* pi-json-output.ts nested key (git d29df7dd — Pi --mode json nests usage
|
|
52
|
+
* under `message` for message_end/turn_end events) for back-compat.
|
|
53
|
+
*/
|
|
54
|
+
export function usageFromValue(value: unknown): UsageState | undefined {
|
|
17
55
|
const obj = asRecord(value);
|
|
18
56
|
if (!obj) return undefined;
|
|
19
57
|
const direct: UsageState = {
|
|
@@ -21,14 +59,15 @@ function usageFromValue(value: unknown): UsageState | undefined {
|
|
|
21
59
|
output: numberField(obj, ["output", "outputTokens", "output_tokens"]),
|
|
22
60
|
cacheRead: numberField(obj, ["cacheRead", "cache_read", "cacheReadTokens", "cache_read_tokens"]),
|
|
23
61
|
cacheWrite: numberField(obj, ["cacheWrite", "cache_write", "cacheWriteTokens", "cache_write_tokens"]),
|
|
24
|
-
cost:
|
|
62
|
+
cost: costField(obj),
|
|
25
63
|
turns: numberField(obj, ["turns", "turnCount", "turn_count"]),
|
|
26
64
|
};
|
|
27
|
-
|
|
28
|
-
for (const key of ["usage", "tokenUsage", "tokens", "stats"]) {
|
|
65
|
+
let merged: UsageState | undefined = Object.values(direct).some((entry) => entry !== undefined) ? direct : undefined;
|
|
66
|
+
for (const key of ["usage", "message", "tokenUsage", "tokens", "stats"]) {
|
|
29
67
|
const nested = usageFromValue(obj[key]);
|
|
30
|
-
if (nested)
|
|
68
|
+
if (nested) merged = merged ? mergeUsageFields(merged, nested) : nested;
|
|
31
69
|
}
|
|
70
|
+
if (merged) return merged;
|
|
32
71
|
const message = asRecord(obj.message);
|
|
33
72
|
return message ? usageFromValue(message.usage) : undefined;
|
|
34
73
|
}
|
|
@@ -48,6 +87,10 @@ function compactUsage(total: UsageState, foundKeys: Set<keyof UsageState>): Usag
|
|
|
48
87
|
if (foundKeys.size === 0) return undefined;
|
|
49
88
|
const compact: UsageState = {};
|
|
50
89
|
for (const key of foundKeys) compact[key] = total[key];
|
|
90
|
+
// U16 (Fix D): stable ledger schema — once ANY usage was seen, `cost` is
|
|
91
|
+
// always present (0 when no source ever reported it) so downstream
|
|
92
|
+
// consumers can rely on `usage.cost` being a number.
|
|
93
|
+
compact.cost = compact.cost ?? 0;
|
|
51
94
|
return compact;
|
|
52
95
|
}
|
|
53
96
|
|
|
@@ -70,7 +70,7 @@ import { buildSyntheticTerminalEvidence, cancellationReasonFromSignal } from "..
|
|
|
70
70
|
import { checkProcessLiveness } from "../process-status.ts";
|
|
71
71
|
import { DEFAULT_RETRY_POLICY } from "../recovery/retry-executor.ts";
|
|
72
72
|
import { runWorker } from "../run-worker.ts";
|
|
73
|
-
import { parseSessionUsage } from "../session-usage.ts";
|
|
73
|
+
import { parseSessionUsage, usageFromValue } from "../session-usage.ts";
|
|
74
74
|
import { recordSupervisorContact, supervisorContactFromEvent } from "../supervisor-contact.ts";
|
|
75
75
|
import type { ResultSource, TaskExecutionResult } from "./post-execution.ts";
|
|
76
76
|
import type { StreamBridgeHandle, TaskExecutionContext } from "./pre-execution.ts";
|
|
@@ -937,7 +937,10 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
937
937
|
if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
|
|
938
938
|
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
|
|
939
939
|
if (msg?.role === "assistant") {
|
|
940
|
-
|
|
940
|
+
// U16 (Fix D): parse via the shared parser so message-level usage
|
|
941
|
+
// (cost is an OBJECT {…, total} per @earendil-works/pi-ai Usage)
|
|
942
|
+
// contributes cost + cacheRead to the ledger too.
|
|
943
|
+
const usage = usageFromValue(msg.usage);
|
|
941
944
|
if (usage) {
|
|
942
945
|
// RT-8: spread before accumulating lifetimeUsage instead of
|
|
943
946
|
// mutating in place — preserves the immutable-snapshot invariant.
|
|
@@ -946,7 +949,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
946
949
|
lifetimeUsage: {
|
|
947
950
|
input: (task.lifetimeUsage?.input ?? 0) + (usage.input ?? 0),
|
|
948
951
|
output: (task.lifetimeUsage?.output ?? 0) + (usage.output ?? 0),
|
|
952
|
+
cacheRead: (task.lifetimeUsage?.cacheRead ?? 0) + (usage.cacheRead ?? 0),
|
|
949
953
|
cacheWrite: (task.lifetimeUsage?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0),
|
|
954
|
+
cost: (task.lifetimeUsage?.cost ?? 0) + (usage.cost ?? 0),
|
|
950
955
|
},
|
|
951
956
|
};
|
|
952
957
|
}
|
|
@@ -178,7 +178,7 @@ export async function prepareTaskExecutionContext(
|
|
|
178
178
|
// it outlives the worker, which would blind the stale-reconciler's
|
|
179
179
|
// PID-liveness gate forever.
|
|
180
180
|
agentProgress: input.task.agentProgress ?? emptyCrewAgentProgress(),
|
|
181
|
-
// Lifetime usage accumulator — survives compaction unlike session
|
|
181
|
+
// Lifetime usage accumulator — survives compaction unlike session stats snapshots
|
|
182
182
|
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
183
183
|
...(dependencyContextText ? { dependencyContextText } : {}),
|
|
184
184
|
} as TeamTaskState;
|