pi-crew 0.11.19 → 0.11.21
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 +32 -0
- package/dist/index.mjs +479 -479
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +25 -0
- package/src/extension/registration/subagent-manager-setup.ts +87 -17
- package/src/runtime/goal-workflow/goal-achievement.ts +47 -0
- package/src/runtime/live-session/live-session-runtime.ts +11 -48
- package/src/runtime/live-session/usage-bridge.ts +93 -0
- package/src/runtime/recovery/retry-executor.ts +53 -1
- package/src/runtime/usage-tracker.ts +41 -8
- package/src/state/coordination/locks.ts +15 -1
- package/src/state/coordination/process-lock.ts +27 -2
- package/src/state/usage.ts +15 -21
package/package.json
CHANGED
|
@@ -182,6 +182,10 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
|
|
|
182
182
|
>
|
|
183
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
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.
|
|
188
|
+
|
|
185
189
|
## Core principle: disk ≠ live Pi
|
|
186
190
|
|
|
187
191
|
Two locations hold pi-crew state:
|
|
@@ -1252,6 +1256,27 @@ When a tier fails, the recovery is usually quick. Match the symptom to the cause
|
|
|
1252
1256
|
| `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) |
|
|
1253
1257
|
| 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 |
|
|
1254
1258
|
|
|
1259
|
+
## Release gate — battery → push → GitHub Actions GREEN → publish (canonical, 2026-10-14)
|
|
1260
|
+
|
|
1261
|
+
Local `npm run ci` là LINUX-ONLY — không thay được matrix 3-OS của GitHub Actions.
|
|
1262
|
+
Live-proven 2026-10-14: **0.11.19 và 0.11.20 đều publish khi CI GitHub ĐỎ** (local ci + pre-push
|
|
1263
|
+
hook xanh hết) — windows fail do child-env thiếu `SYSTEMROOT` (node con không khởi tạo Winsock được,
|
|
1264
|
+
stdout rỗng); macOS fail do transient ECONNREFUSED khiến accepting-probe 1 lần coi holder SỐNG là
|
|
1265
|
+
corpse → xóa file socket + rebind (STEAL lock) — fix bằng 3-strike takeover (`process-lock.ts`).
|
|
1266
|
+
|
|
1267
|
+
**Quy trình BẮT BUỘC cho mọi release (theo thứ tự, không bỏ bước):**
|
|
1268
|
+
|
|
1269
|
+
1. Cập nhật skill (wave entry) + CHANGELOG heading cho version sắp release — commit.
|
|
1270
|
+
2. Chạy battery theo decision table (đủ tier cho paths đổi) — xanh hết.
|
|
1271
|
+
3. `npm version patch` (tạo release commit + tag).
|
|
1272
|
+
4. `git push origin main --follow-tags` — pre-push hook tự chạy full unit local.
|
|
1273
|
+
5. **`gh run watch <runId> --exit-status` (hoặc poll `gh run list`) — CHỜ matrix 3-OS XANH TOÀN BỘ.**
|
|
1274
|
+
CI đỏ ⇒ KHÔNG publish — fix, push lại, chờ lại. (Check nhanh fail: `gh run view <id>` +
|
|
1275
|
+
`gh api repos/<o>/<r>/actions/jobs/<jobId>//logs` — KHÔNG grep "not ok" mù: tên test có thể
|
|
1276
|
+
chứa literal "not ok" ("malformed JSON is not ok with a message") — grep kèm assertion block.)
|
|
1277
|
+
6. CHỈ KHI xanh: `npm publish` → poll `npm view <pkg> dist-tags.latest` → `gh release create`.
|
|
1278
|
+
7. Post-release: knowledge.md entry SHIPPED + dọn branch/scratch.
|
|
1279
|
+
|
|
1255
1280
|
## Performance budget (per-tier soft limits)
|
|
1256
1281
|
|
|
1257
1282
|
| Tier | Soft limit | Hard limit | What happens over hard limit |
|
|
@@ -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
|
|
|
@@ -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,8 +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 { usageFromValue } from "../session-usage.ts";
|
|
47
|
-
import { trackTaskUsage } from "../usage-tracker.ts";
|
|
48
46
|
import {
|
|
49
47
|
buildYieldReminder,
|
|
50
48
|
DEFAULT_YIELD_CONFIG,
|
|
@@ -89,6 +87,7 @@ import {
|
|
|
89
87
|
import { hasLiveControlRealtimeListeners, subscribeLiveControlRealtime } from "./live-control-realtime.ts";
|
|
90
88
|
import { buildExtensionBridge } from "./live-extension-bridge.ts";
|
|
91
89
|
import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
|
|
90
|
+
import { type SessionStatsLike, trackUsageFromMessageEndEvent, usageFromSessionStats } from "./usage-bridge.ts";
|
|
92
91
|
|
|
93
92
|
/**
|
|
94
93
|
* Module-scoped latch for the optional peer dependency import. When N
|
|
@@ -235,13 +234,8 @@ type LiveSessionLike = {
|
|
|
235
234
|
steer?: (text: string) => Promise<void>;
|
|
236
235
|
abort?: () => Promise<void> | void;
|
|
237
236
|
dispose?: () => void;
|
|
238
|
-
/** U16: SDK AgentSession session-level stats surface (aggregate tokens + cost)
|
|
239
|
-
|
|
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
|
-
};
|
|
237
|
+
/** U16: SDK AgentSession session-level stats surface (aggregate tokens + cost) — shape: usage-bridge.ts (SessionStatsLike). */
|
|
238
|
+
getSessionStats?: () => SessionStatsLike;
|
|
245
239
|
bindExtensions?: (bindings?: Record<string, unknown>) => Promise<void>;
|
|
246
240
|
getActiveToolNames?: () => string[];
|
|
247
241
|
setActiveToolsByName?: (names: string[]) => void;
|
|
@@ -469,24 +463,6 @@ function applyModelProviderOverride(session: LiveSessionLike | undefined, provid
|
|
|
469
463
|
}
|
|
470
464
|
}
|
|
471
465
|
|
|
472
|
-
/**
|
|
473
|
-
* Type-safe extractor for message role from streaming events.
|
|
474
|
-
* Handles the case where message may be a Record with a role field.
|
|
475
|
-
*/
|
|
476
|
-
function extractMessageRole(obj: Record<string, unknown> | undefined): string | undefined {
|
|
477
|
-
const message = obj?.message ? asRecord(obj.message) : undefined;
|
|
478
|
-
return isString(message?.role) ? message.role : undefined;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Type-safe extractor for message usage from streaming events.
|
|
483
|
-
* Handles the case where message may be a Record with usage data.
|
|
484
|
-
*/
|
|
485
|
-
function extractMessageUsage(obj: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
486
|
-
const message = obj?.message ? asRecord(obj.message) : undefined;
|
|
487
|
-
return message?.usage && typeof message.usage === "object" ? (message.usage as Record<string, unknown>) : undefined;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
466
|
/**
|
|
491
467
|
* Type-safe extractor for tool name from streaming events.
|
|
492
468
|
* Handles various event structures: { tool: { name } }, { toolName }, { name }.
|
|
@@ -791,13 +767,9 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
|
|
|
791
767
|
session.setActiveToolsByName(active);
|
|
792
768
|
}
|
|
793
769
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
* and flat legacy shapes. Exported for unit tests. */
|
|
798
|
-
export function usageFromStats(stats: unknown): UsageState | undefined {
|
|
799
|
-
return usageFromValue(stats);
|
|
800
|
-
}
|
|
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";
|
|
801
773
|
|
|
802
774
|
/**
|
|
803
775
|
* U4 hung-tool escape hatch marker error. Thrown when a timed-out prompt did
|
|
@@ -1531,17 +1503,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1531
1503
|
}
|
|
1532
1504
|
}
|
|
1533
1505
|
}
|
|
1534
|
-
//
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
if (u) {
|
|
1538
|
-
trackTaskUsage(input.task.id, {
|
|
1539
|
-
input: typeof u.input === "number" ? u.input : 0,
|
|
1540
|
-
output: typeof u.output === "number" ? u.output : 0,
|
|
1541
|
-
cacheWrite: typeof u.cacheWrite === "number" ? u.cacheWrite : 0,
|
|
1542
|
-
});
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
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);
|
|
1545
1509
|
input.onEvent?.(event);
|
|
1546
1510
|
const text = [...eventText(event), ...finalAssistantText(event)].join("\n");
|
|
1547
1511
|
if (text.trim()) {
|
|
@@ -1789,9 +1753,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1789
1753
|
emit: emitEmbedded,
|
|
1790
1754
|
})
|
|
1791
1755
|
: undefined;
|
|
1792
|
-
// U16 (Fix A): the SDK surface is getSessionStats() —
|
|
1793
|
-
|
|
1794
|
-
const usage = usageFromStats(session.getSessionStats?.());
|
|
1756
|
+
// U16 (Fix A): the real SDK surface is getSessionStats() — see usage-bridge.
|
|
1757
|
+
const usage = usageFromSessionStats(session);
|
|
1795
1758
|
updateLiveAgentStatus(agentId, "completed");
|
|
1796
1759
|
markLiveAgentCompleted(agentId);
|
|
1797
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
|
+
}
|
|
@@ -83,6 +83,10 @@ export interface RetryHooks {
|
|
|
83
83
|
* logInternalError (the repo's console.error wrapper) and treated as false —
|
|
84
84
|
* a broken detector must never change retry behavior. Unregistered ⇒
|
|
85
85
|
* behaviour is bit-for-bit pre-U9-ADJ.
|
|
86
|
+
* F4 (follow-up 2026-10-14 §P3): the hook must settle within
|
|
87
|
+
* NO_PROGRESS_HOOK_TIMEOUT_MS (5s) — anything slower (or never-settling)
|
|
88
|
+
* is treated as false and logged (retry-executor.noProgressTimeout), so a
|
|
89
|
+
* hung detector can never hang the retry loop itself.
|
|
86
90
|
*/
|
|
87
91
|
noProgress?: (context: {
|
|
88
92
|
attempt: number;
|
|
@@ -130,6 +134,52 @@ function retryAttemptInfo(attempt: number, hooks: RetryHooks): RetryAttemptInfo
|
|
|
130
134
|
};
|
|
131
135
|
}
|
|
132
136
|
|
|
137
|
+
/**
|
|
138
|
+
* F4 (follow-up SPEC 2026-10-14 §P3): wall-clock budget for ONE `noProgress`
|
|
139
|
+
* hook invocation. Deliberately NOT configurable (no env var / no param): the
|
|
140
|
+
* hook is dormant (no production caller yet) and a new env-allowlist entry
|
|
141
|
+
* would be pure surface. Exceeded budget ⇒ hook treated as false (progress
|
|
142
|
+
* assumed, retry stays queued) + logInternalError("retry-executor.noProgressTimeout").
|
|
143
|
+
*/
|
|
144
|
+
const NO_PROGRESS_HOOK_TIMEOUT_MS = 5000;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* F4: race the `noProgress` hook against NO_PROGRESS_HOOK_TIMEOUT_MS. The
|
|
148
|
+
* timer RESOLVES false (never rejects); hook throws/rejections propagate
|
|
149
|
+
* unchanged so the existing soft-fail catch in executeWithRetry stays
|
|
150
|
+
* bit-for-bit. The timer handle is cleared the moment the race settles —
|
|
151
|
+
* fast hooks leave no pending handle behind, and a never-settling hook is
|
|
152
|
+
* abandoned once its budget resolved (no dangling handle keeps the loop alive).
|
|
153
|
+
*/
|
|
154
|
+
async function settleNoProgressHook(
|
|
155
|
+
hook: NonNullable<RetryHooks["noProgress"]>,
|
|
156
|
+
context: { attempt: number; lastResult: unknown; previousResult: unknown; durationMsLast: number },
|
|
157
|
+
): Promise<boolean> {
|
|
158
|
+
let timedOut = false;
|
|
159
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
160
|
+
try {
|
|
161
|
+
return await Promise.race([
|
|
162
|
+
Promise.resolve(hook(context)),
|
|
163
|
+
new Promise<false>((resolve) => {
|
|
164
|
+
timer = setTimeout(() => {
|
|
165
|
+
timedOut = true;
|
|
166
|
+
resolve(false);
|
|
167
|
+
}, NO_PROGRESS_HOOK_TIMEOUT_MS);
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
} finally {
|
|
171
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
172
|
+
if (timedOut) {
|
|
173
|
+
logInternalError(
|
|
174
|
+
"retry-executor.noProgressTimeout",
|
|
175
|
+
{ attempt: context.attempt, timeoutMs: NO_PROGRESS_HOOK_TIMEOUT_MS },
|
|
176
|
+
"soft-fail: treating as progress, retry stays queued",
|
|
177
|
+
"error",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
133
183
|
export async function executeWithRetry<T>(
|
|
134
184
|
fn: (attempt: number, info: RetryAttemptInfo) => Promise<T>,
|
|
135
185
|
policy: RetryPolicy = DEFAULT_RETRY_POLICY,
|
|
@@ -185,10 +235,12 @@ export async function executeWithRetry<T>(
|
|
|
185
235
|
// attempt against the previous one and decides. true ⇒ stop the loop
|
|
186
236
|
// with the distinct no_progress reason (not ordinary give-up exhaustion).
|
|
187
237
|
// SOFT: hook errors are logged (logInternalError) and treated as false.
|
|
238
|
+
// F4: the hook races NO_PROGRESS_HOOK_TIMEOUT_MS — slow/never-settling
|
|
239
|
+
// hooks count as false (see settleNoProgressHook).
|
|
188
240
|
if (hooks.noProgress) {
|
|
189
241
|
let noProgressDecision = false;
|
|
190
242
|
try {
|
|
191
|
-
noProgressDecision = await hooks.noProgress
|
|
243
|
+
noProgressDecision = await settleNoProgressHook(hooks.noProgress, {
|
|
192
244
|
attempt,
|
|
193
245
|
lastResult: lastError,
|
|
194
246
|
previousResult: previousFailedResult,
|
|
@@ -1,28 +1,57 @@
|
|
|
1
1
|
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* P1 (U16 follow-up) — 2-tier usage tracking. `LifetimeUsage` now carries all
|
|
5
|
+
* five ledger fields. The change is ADDITIVE: callers that still pass only
|
|
6
|
+
* input/output/cacheWrite deltas behave exactly as before.
|
|
7
|
+
*/
|
|
3
8
|
export interface LifetimeUsage {
|
|
4
9
|
input: number;
|
|
5
10
|
output: number;
|
|
6
11
|
cacheWrite: number;
|
|
12
|
+
/** P1: cumulative cache-read tokens (dropped from the ledger pre-P1). */
|
|
13
|
+
cacheRead: number;
|
|
14
|
+
/** P1: D2 aggregate cost — always a NUMBER, never the message-level cost object. */
|
|
15
|
+
cost: number;
|
|
7
16
|
}
|
|
8
17
|
|
|
9
18
|
export function emptyLifetimeUsage(): LifetimeUsage {
|
|
10
|
-
return { input: 0, output: 0, cacheWrite: 0 };
|
|
19
|
+
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
|
|
11
20
|
}
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Usage delta for `addUsage`/`trackTaskUsage` — every field optional, absent
|
|
24
|
+
* fields are skipped. `cost` MUST be a NUMBER (the D2 aggregate tier):
|
|
25
|
+
* message-level cost OBJECTS (`{input, output, cacheRead, cacheWrite, total}`)
|
|
26
|
+
* are NOT handled here — callers must first pass them through the shared
|
|
27
|
+
* parser (`usageFromValue` in session-usage.ts), which extracts `cost.total`.
|
|
28
|
+
*/
|
|
29
|
+
export interface UsageDelta {
|
|
30
|
+
input?: number;
|
|
31
|
+
output?: number;
|
|
32
|
+
cacheWrite?: number;
|
|
33
|
+
cacheRead?: number;
|
|
34
|
+
cost?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function addUsage(into: LifetimeUsage, delta: UsageDelta): void {
|
|
14
38
|
if (typeof delta.input === "number") into.input += delta.input;
|
|
15
39
|
if (typeof delta.output === "number") into.output += delta.output;
|
|
16
40
|
if (typeof delta.cacheWrite === "number") into.cacheWrite += delta.cacheWrite;
|
|
41
|
+
if (typeof delta.cacheRead === "number") into.cacheRead += delta.cacheRead;
|
|
42
|
+
if (typeof delta.cost === "number") into.cost += delta.cost;
|
|
17
43
|
}
|
|
18
44
|
|
|
19
|
-
/** @internal */
|
|
20
|
-
function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
|
|
45
|
+
/** @internal — exported for unit tests. */
|
|
46
|
+
export function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
|
|
21
47
|
if (!state) return emptyLifetimeUsage();
|
|
22
48
|
return {
|
|
23
49
|
input: state.input ?? 0,
|
|
24
50
|
output: state.output ?? 0,
|
|
25
51
|
cacheWrite: state.cacheWrite ?? 0,
|
|
52
|
+
// P1: legacy persisted states have no cacheRead/cost — both read as 0.
|
|
53
|
+
cacheRead: state.cacheRead ?? 0,
|
|
54
|
+
cost: state.cost ?? 0,
|
|
26
55
|
};
|
|
27
56
|
}
|
|
28
57
|
|
|
@@ -31,13 +60,17 @@ export function usageStateFromLifetime(lifetime: LifetimeUsage): UsageState {
|
|
|
31
60
|
input: lifetime.input,
|
|
32
61
|
output: lifetime.output,
|
|
33
62
|
cacheWrite: lifetime.cacheWrite,
|
|
34
|
-
cacheRead: 0
|
|
63
|
+
// P1: REAL values — the hard-coded `cacheRead: 0` (and the missing
|
|
64
|
+
// `cost` key) are gone. `cost` stays always-present-as-number, matching
|
|
65
|
+
// the stable-ledger convention (session-usage.ts compactUsage Fix D).
|
|
66
|
+
cacheRead: lifetime.cacheRead,
|
|
67
|
+
cost: lifetime.cost,
|
|
35
68
|
};
|
|
36
69
|
}
|
|
37
70
|
|
|
38
71
|
const taskUsageMap = new Map<string, LifetimeUsage>();
|
|
39
72
|
|
|
40
|
-
export function trackTaskUsage(taskId: string, delta:
|
|
73
|
+
export function trackTaskUsage(taskId: string, delta: UsageDelta): void {
|
|
41
74
|
const existing = taskUsageMap.get(taskId) ?? emptyLifetimeUsage();
|
|
42
75
|
addUsage(existing, delta);
|
|
43
76
|
taskUsageMap.set(taskId, existing);
|
|
@@ -60,8 +93,8 @@ export const getTaskUsage = getTrackedTaskUsage;
|
|
|
60
93
|
export const getRunUsage = getTrackedTaskUsage;
|
|
61
94
|
export const clearAllTaskUsage = clearAllTrackedTaskUsage;
|
|
62
95
|
|
|
63
|
-
/** @internal */
|
|
64
|
-
function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
|
|
96
|
+
/** @internal — exported for unit tests. */
|
|
97
|
+
export function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
|
|
65
98
|
const total = emptyLifetimeUsage();
|
|
66
99
|
for (const task of tasks) {
|
|
67
100
|
const tracked = getTrackedTaskUsage(task.id);
|