session-orchestrator 5.4.0 → 5.5.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ## Why this module exists
|
|
5
5
|
*
|
|
6
|
-
* `skills/session-start/SKILL.md` § Phase 4 names
|
|
7
|
-
* with a module path and an entry function (re-measured 2026-
|
|
6
|
+
* `skills/session-start/SKILL.md` § Phase 4 names 20 measurement probes, each
|
|
7
|
+
* with a module path and an entry function (re-measured 2026-10-02 — see the
|
|
8
8
|
* registry Census below; the family stood at 18 when this module was written
|
|
9
|
-
* on 2026-08-23
|
|
10
|
-
*
|
|
9
|
+
* on 2026-08-23, at 19 on 2026-09-11, and gained `events-retention` with
|
|
10
|
+
* #1401 — update this count with every registration).
|
|
11
11
|
* Measured 2026-08-23 at `4f6404e`, NONE of them had a mechanical caller:
|
|
12
12
|
*
|
|
13
13
|
* ```
|
|
@@ -44,12 +44,14 @@
|
|
|
44
44
|
* @module scripts/lib/session-start-probes
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
|
-
import {
|
|
48
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
47
|
+
import { execFile } from 'node:child_process';
|
|
48
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
49
49
|
import path from 'node:path';
|
|
50
50
|
import { pathToFileURL } from 'node:url';
|
|
51
|
+
import { promisify } from 'node:util';
|
|
51
52
|
|
|
52
53
|
import { emitEvent } from './events.mjs';
|
|
54
|
+
import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE } from './events-schema.mjs';
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* Resolve a sibling module to an absolute `file:` URL.
|
|
@@ -66,6 +68,8 @@ import { emitEvent } from './events.mjs';
|
|
|
66
68
|
*/
|
|
67
69
|
const local = (rel) => pathToFileURL(path.join(import.meta.dirname, rel)).href;
|
|
68
70
|
|
|
71
|
+
const execFileAsync = promisify(execFile);
|
|
72
|
+
|
|
69
73
|
/** Fallback hint when nothing cheaper than an on-demand CLI call is available. */
|
|
70
74
|
const CI_UNKNOWN_HINT_DEFAULT = 'run `glab ci status` on demand';
|
|
71
75
|
|
|
@@ -93,24 +97,30 @@ const PUSHED_FOLLOW_UP_REASONS = new Set([
|
|
|
93
97
|
* missing). Full, not short: `checkCiStatus({ sha })` matches against GitLab's
|
|
94
98
|
* full pipeline SHAs and refuses anything shorter.
|
|
95
99
|
*
|
|
96
|
-
* NAMED CEILING (BV-004): one `git rev-parse` with a 2s timeout,
|
|
97
|
-
*
|
|
98
|
-
*
|
|
100
|
+
* NAMED CEILING (BV-004): one `git rev-parse` with a 2s timeout, called only
|
|
101
|
+
* from {@link ciStatusFollowUp} — i.e. only when the `ci-status` probe already
|
|
102
|
+
* ran (network opt-in) and its reading needs a commit SHA.
|
|
103
|
+
*
|
|
104
|
+
* ASYNC on purpose (#1396): it runs inside the probe's budget race, and only a
|
|
105
|
+
* call that yields the event loop is (a) counted as the probe's OWN work time
|
|
106
|
+
* and (b) preemptible by the race. A synchronous spawn is subtracted from
|
|
107
|
+
* `workMs` as loop-blocked time (see {@link startLoopBlockedMeter}), so a 2s
|
|
108
|
+
* hang would never have scored `budget-exceeded`. An abandoned child is not
|
|
109
|
+
* waited for: the hook ends with `process.exit(0)`.
|
|
99
110
|
*
|
|
100
111
|
* @param {string|undefined} repoRoot
|
|
101
112
|
* @param {string[]} refs
|
|
102
|
-
* @returns {string[]|null}
|
|
113
|
+
* @returns {Promise<string[]|null>}
|
|
103
114
|
*/
|
|
104
|
-
function revParseShas(repoRoot, refs) {
|
|
115
|
+
async function revParseShas(repoRoot, refs) {
|
|
105
116
|
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
106
117
|
try {
|
|
107
|
-
const
|
|
118
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', ...refs], {
|
|
108
119
|
cwd: repoRoot,
|
|
109
120
|
encoding: 'utf8',
|
|
110
121
|
timeout: 2000,
|
|
111
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
112
122
|
});
|
|
113
|
-
const shas = String(
|
|
123
|
+
const shas = String(stdout).trim().split('\n').map((s) => s.trim());
|
|
114
124
|
const valid = shas.length === refs.length
|
|
115
125
|
&& shas.every((s) => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(s));
|
|
116
126
|
return valid ? shas : null;
|
|
@@ -121,16 +131,6 @@ function revParseShas(repoRoot, refs) {
|
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
133
|
|
|
124
|
-
/**
|
|
125
|
-
* Full SHA of the last PUSHED commit (`@{upstream}`), or `null`.
|
|
126
|
-
*
|
|
127
|
-
* @param {string|undefined} repoRoot
|
|
128
|
-
* @returns {string|null}
|
|
129
|
-
*/
|
|
130
|
-
function lastPushedSha(repoRoot) {
|
|
131
|
-
return revParseShas(repoRoot, ['@{upstream}'])?.[0] ?? null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
134
|
/**
|
|
135
135
|
* `ci-status` follow-up (#1332): when HEAD has no CI data, ask for the verdict
|
|
136
136
|
* of the last PUSHED commit — the commit whose pipeline can actually be red
|
|
@@ -154,25 +154,28 @@ function lastPushedSha(repoRoot) {
|
|
|
154
154
|
* the local HEAD — there the pushed SHA is a different question even when it
|
|
155
155
|
* equals local HEAD.
|
|
156
156
|
*
|
|
157
|
+
* The resolved SHA is handed to the runner through `deliver` BEFORE the CLI
|
|
158
|
+
* round trip, so a requery that overruns the budget or throws still leaves
|
|
159
|
+
* the pushed SHA in the reading the renderer receives (#1396).
|
|
160
|
+
*
|
|
157
161
|
* @param {*} result The HEAD reading from `checkCiStatus`
|
|
158
162
|
* @param {{repoRoot?: string}} ctx
|
|
159
163
|
* @param {(extra: object) => Promise<*>} requery Re-invokes the probe with extra options
|
|
164
|
+
* @param {(partial: *) => void} deliver Records a partial result as the fallback
|
|
160
165
|
* @returns {Promise<*>}
|
|
161
166
|
*/
|
|
162
|
-
async function ciPushedFollowUp(result, ctx, requery) {
|
|
167
|
+
async function ciPushedFollowUp(result, ctx, requery, deliver) {
|
|
163
168
|
const reason = result?.details?.reason;
|
|
164
|
-
if (result?.status !== 'unknown' || !PUSHED_FOLLOW_UP_REASONS.has(reason)) {
|
|
165
|
-
return result;
|
|
166
|
-
}
|
|
167
169
|
// One spawn for both: an unresolvable `@{upstream}` fails the whole call,
|
|
168
170
|
// which is exactly the "no upstream" answer.
|
|
169
|
-
const shas = revParseShas(ctx?.repoRoot, ['HEAD', '@{upstream}']);
|
|
170
|
-
// `pushed.sha: null` records "no upstream"
|
|
171
|
+
const shas = await revParseShas(ctx?.repoRoot, ['HEAD', '@{upstream}']);
|
|
172
|
+
// `pushed.sha: null` records "no upstream" as DATA — the renderer never asks git.
|
|
171
173
|
if (!shas) return { ...result, pushed: { sha: null } };
|
|
172
174
|
const [head, sha] = shas;
|
|
173
175
|
if (reason === 'no-pipeline-for-head-sha' && sha === head) {
|
|
174
176
|
return { ...result, pushed: { sha, sameAsHead: true } };
|
|
175
177
|
}
|
|
178
|
+
deliver({ ...result, pushed: { sha } });
|
|
176
179
|
let verdict;
|
|
177
180
|
try {
|
|
178
181
|
verdict = await requery({ sha });
|
|
@@ -183,6 +186,36 @@ async function ciPushedFollowUp(result, ctx, requery) {
|
|
|
183
186
|
return { ...result, pushed: { sha, verdict } };
|
|
184
187
|
}
|
|
185
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The `ci-status` follow-up (#1332, #1396): every git fact a `ci-status`
|
|
191
|
+
* banner names is resolved HERE, inside the probe's budget race, and carried
|
|
192
|
+
* on the result as data — the renderer stays a pure function of the result.
|
|
193
|
+
* #1396: the renderer used to run `git rev-parse` itself after a fallen-back
|
|
194
|
+
* follow-up, outside the budget, where `orchestrator.probes.completed` could
|
|
195
|
+
* never score it `timed_out`.
|
|
196
|
+
*
|
|
197
|
+
* - an `unknown` reading on a {@link PUSHED_FOLLOW_UP_REASONS} reason →
|
|
198
|
+
* {@link ciPushedFollowUp} (`pushed`)
|
|
199
|
+
* - a `green` reading beside a set-aside FAILED same-commit pipeline →
|
|
200
|
+
* `head: { sha }` for {@link droppedFailedLine} (#1390 P5)
|
|
201
|
+
*
|
|
202
|
+
* @param {*} result
|
|
203
|
+
* @param {{repoRoot?: string}} ctx
|
|
204
|
+
* @param {(extra: object) => Promise<*>} requery
|
|
205
|
+
* @param {(partial: *) => void} deliver
|
|
206
|
+
* @returns {Promise<*>}
|
|
207
|
+
*/
|
|
208
|
+
async function ciStatusFollowUp(result, ctx, requery, deliver) {
|
|
209
|
+
if (result?.status === 'unknown' && PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) {
|
|
210
|
+
return ciPushedFollowUp(result, ctx, requery, deliver);
|
|
211
|
+
}
|
|
212
|
+
if (result?.status === 'green' && droppedFailedCount(result) > 0) {
|
|
213
|
+
const sha = (await revParseShas(ctx?.repoRoot, ['HEAD']))?.[0] ?? null;
|
|
214
|
+
return { ...result, head: { sha } };
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
186
219
|
/**
|
|
187
220
|
* `CI <status> (#<pipeline>)` for a pushed-SHA verdict, or `null` when there
|
|
188
221
|
* is no readable verdict (the caller then falls back to the command hint).
|
|
@@ -222,18 +255,18 @@ function commitRunsCommand(gh, sha) {
|
|
|
222
255
|
*
|
|
223
256
|
* @param {object} result The probe result (possibly carrying `pushed` from
|
|
224
257
|
* {@link ciPushedFollowUp})
|
|
225
|
-
* @param {{repoRoot?: string}} [ctx]
|
|
226
258
|
* @returns {string}
|
|
227
259
|
*/
|
|
228
|
-
function ciUnknownHint(result
|
|
260
|
+
function ciUnknownHint(result) {
|
|
229
261
|
if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return CI_UNKNOWN_HINT_DEFAULT;
|
|
230
262
|
// The GitHub reason names the gh CLI; a glab command there would be wrong.
|
|
231
263
|
const gh = result.details.cliUsed === 'gh';
|
|
232
264
|
const onDemand = gh ? 'run `gh run list` on demand' : CI_UNKNOWN_HINT_DEFAULT;
|
|
233
265
|
const pushed = result.pushed && typeof result.pushed === 'object' ? result.pushed : null;
|
|
234
|
-
//
|
|
235
|
-
// Without `pushed` (follow-up overran or threw
|
|
236
|
-
|
|
266
|
+
// Pure (#1396): the SHA is whatever the follow-up resolved inside the budget.
|
|
267
|
+
// Without `pushed` (the follow-up overran or threw before resolving it) the
|
|
268
|
+
// hint degrades to the on-demand command — it never asks git itself.
|
|
269
|
+
const sha = pushed ? pushed.sha : null;
|
|
237
270
|
if (!sha) return onDemand;
|
|
238
271
|
const short = sha.slice(0, 8);
|
|
239
272
|
if (pushed?.sameAsHead) return `last pushed: ${short} = HEAD — ${onDemand}`;
|
|
@@ -277,22 +310,22 @@ function droppedFailedCount(r) {
|
|
|
277
310
|
* #857 ref-preference result; this only stops the failure from vanishing
|
|
278
311
|
* behind a silent green.
|
|
279
312
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* `checkCiStatus` publishes the sha it read, take it
|
|
313
|
+
* The HEAD sha comes from `r.head.sha`, resolved by {@link ciStatusFollowUp}
|
|
314
|
+
* inside the budget (#1396) — the reading carries no sha, and the GitLab path
|
|
315
|
+
* read the local HEAD, so HEAD is the commit it spoke for. Without it (the
|
|
316
|
+
* follow-up fell back) the line names the CLI's failed-run listing instead.
|
|
317
|
+
* REVISIT TRIGGER: once `checkCiStatus` publishes the sha it read, take it
|
|
318
|
+
* from the reading.
|
|
285
319
|
*
|
|
286
320
|
* Only the GitLab path publishes `droppedStatuses` today (`candidateEvidence`
|
|
287
321
|
* is called from `checkGitlab` alone); the gh form exists so a GitHub producer
|
|
288
322
|
* can never print a glab command (#1339 P1 class) — see {@link commitRunsCommand}.
|
|
289
323
|
*
|
|
290
324
|
* @param {*} r A green reading with `droppedFailedCount(r) > 0`
|
|
291
|
-
* @param {{repoRoot?: string}} [ctx]
|
|
292
325
|
* @returns {string}
|
|
293
326
|
*/
|
|
294
|
-
function droppedFailedLine(r
|
|
295
|
-
const sha =
|
|
327
|
+
function droppedFailedLine(r) {
|
|
328
|
+
const sha = typeof r.head?.sha === 'string' ? r.head.sha : null;
|
|
296
329
|
const gh = r.details.cliUsed === 'gh';
|
|
297
330
|
let check;
|
|
298
331
|
if (sha) check = commitRunsCommand(gh, sha);
|
|
@@ -432,11 +465,15 @@ function startLoopBlockedMeter() {
|
|
|
432
465
|
* — the #1157 SKILL.md-size split extracted Phase 4's procedure out of
|
|
433
466
|
* `SKILL.md` itself, so the original 2026-08-23 citation of SKILL.md lines
|
|
434
467
|
* 693-835 no longer resolves; re-run the census against the reference file):
|
|
435
|
-
* grep -oE '
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
468
|
+
* grep -oE "fn: 'check[A-Za-z]+'" scripts/lib/session-start-probes.mjs \
|
|
469
|
+
* | sed "s/fn: '//;s/'//" > fns.txt # -> 20
|
|
470
|
+
* grep -oFf fns.txt \
|
|
471
|
+
* skills/session-start/references/phase-4-ssot-environment-check.md \
|
|
472
|
+
* | sort -u | wc -l # -> 20
|
|
473
|
+
* i.e. every registered entry function is named in the reference file
|
|
474
|
+
* (2026-10-02, after `events-retention` / #1401; 19 on 2026-09-11). The older
|
|
475
|
+
* census `grep -oE 'via \`(await )?check[A-Za-z]+'` returned 0 at `86e636a9`
|
|
476
|
+
* — the prose no longer says "via" — so it is retired, not re-counted.
|
|
440
477
|
* (Prior census, 2026-08-23 at `4f6404e`: 16 + 2 = 18 — one probe was
|
|
441
478
|
* registered since without a matching update here. This count is deliberately
|
|
442
479
|
* NOT pinned by an exact-equality test against `PROBES.length` — the registry
|
|
@@ -456,11 +493,19 @@ function startLoopBlockedMeter() {
|
|
|
456
493
|
* - `precondition` optional; returns a skip-reason string to skip the probe
|
|
457
494
|
* - `render` optional; maps a result to a banner line. Default:
|
|
458
495
|
* `result.message` when severity is warn/alert.
|
|
459
|
-
* - `followUp` optional; `async (result, ctx, requery) => result'`
|
|
460
|
-
* INSIDE the budget race after the entry function, where
|
|
461
|
-
* `requery(extra)` re-invokes it with `{...args, ...extra}
|
|
462
|
-
*
|
|
463
|
-
*
|
|
496
|
+
* - `followUp` optional; `async (result, ctx, requery, deliver) => result'`
|
|
497
|
+
* run INSIDE the budget race after the entry function, where
|
|
498
|
+
* `requery(extra)` re-invokes it with `{...args, ...extra}`
|
|
499
|
+
* and `deliver(partial)` replaces the fallback result. If it
|
|
500
|
+
* throws or overruns the budget, the last delivered result
|
|
501
|
+
* stands (recorded `followUp: 'threw'|'budget-exceeded'`).
|
|
502
|
+
* - `render` is a PURE function of the result (#1396): anything it names
|
|
503
|
+
* that the entry function does not return is resolved in `followUp`,
|
|
504
|
+
* inside the budget — never in the renderer.
|
|
505
|
+
* - `telemetry` optional; PURE `(result) => object|undefined` — the numbers
|
|
506
|
+
* the probe measured, persisted as `measure` on its element
|
|
507
|
+
* of `orchestrator.probes.completed` for `ran-*` outcomes
|
|
508
|
+
* (#1489), bounded by {@link probeMeasure}.
|
|
464
509
|
*/
|
|
465
510
|
export const PROBES = [
|
|
466
511
|
{
|
|
@@ -514,8 +559,8 @@ export const PROBES = [
|
|
|
514
559
|
args: ({ repoRoot }) => ({ repoRoot }),
|
|
515
560
|
// #1332: on `no-pipeline-for-head-sha` / `no-check-runs-for-head`, query
|
|
516
561
|
// the last PUSHED commit's verdict via `checkCiStatus({ sha })` — see the
|
|
517
|
-
// ceiling on the function.
|
|
518
|
-
followUp:
|
|
562
|
+
// ceiling on the function. #1396: also resolves every SHA the renderer names.
|
|
563
|
+
followUp: ciStatusFollowUp,
|
|
519
564
|
// Bespoke shape: `{status, ok, details, …}` with no `message` field. The
|
|
520
565
|
// banner text is prescribed by SKILL.md § Phase 4.
|
|
521
566
|
//
|
|
@@ -525,7 +570,7 @@ export const PROBES = [
|
|
|
525
570
|
// probe. Without these two lines a degraded ci-status result scored `'ok'`
|
|
526
571
|
// and rendered nothing — "could not read" displayed exactly like "green",
|
|
527
572
|
// which is the confusion the probe's own migration removed one layer down.
|
|
528
|
-
render: (r
|
|
573
|
+
render: (r) => {
|
|
529
574
|
if (!r || typeof r !== 'object') return null;
|
|
530
575
|
if (r.degraded) return typeof r.message === 'string' && r.message ? r.message : null;
|
|
531
576
|
// `status: 'unknown'` is the SAME collapse one level over: HEAD carries
|
|
@@ -539,7 +584,7 @@ export const PROBES = [
|
|
|
539
584
|
// #1337: a RED pushed commit is an alert, and the banner says what the
|
|
540
585
|
// rule judges (HR-106) — same 🚨 as a red HEAD.
|
|
541
586
|
const mark = r.pushed?.verdict?.status === 'red' ? '🚨' : '⚠';
|
|
542
|
-
return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r
|
|
587
|
+
return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r)}`;
|
|
543
588
|
}
|
|
544
589
|
if (r.status === 'red') {
|
|
545
590
|
const pid = r.details?.currentPipelineId ?? '?';
|
|
@@ -556,7 +601,7 @@ export const PROBES = [
|
|
|
556
601
|
lines.push(`⚠ CI green on HEAD, but ${r.allowFailureJobs.length} allow_failure job(s) FAILED: ${names}. A pipeline reports success regardless of these.`);
|
|
557
602
|
}
|
|
558
603
|
// #1390 P5: a failed same-commit pipeline the ref preference set aside.
|
|
559
|
-
if (droppedFailedCount(r) > 0) lines.push(droppedFailedLine(r
|
|
604
|
+
if (droppedFailedCount(r) > 0) lines.push(droppedFailedLine(r));
|
|
560
605
|
// Both findings can hold at once; `pushBanner` takes a multi-line
|
|
561
606
|
// string verbatim, so neither swallows the other.
|
|
562
607
|
return lines.length > 0 ? lines.join('\n') : null;
|
|
@@ -705,6 +750,40 @@ export const PROBES = [
|
|
|
705
750
|
network: false,
|
|
706
751
|
args: ({ repoRoot, env }) => ({ repoRoot, env }),
|
|
707
752
|
},
|
|
753
|
+
{
|
|
754
|
+
// #1401 part 3: archive COUNT retention vs the TIME window the ledger's
|
|
755
|
+
// readers declare (`REQUIRED_EVENTS_WINDOW_DAYS`). Without any ledger there
|
|
756
|
+
// is nothing to measure — recorded as `skipped`, never as `ran-clean`. A
|
|
757
|
+
// bare, empty or foreign-only `_archive/` is no ledger either: the probe
|
|
758
|
+
// answers those `not-measured` at severity ok, which the runner would
|
|
759
|
+
// record as `ran-clean` (see {@link hasRotationArchive}).
|
|
760
|
+
id: 'events-retention',
|
|
761
|
+
spec: local('./events-retention-banner.mjs'),
|
|
762
|
+
fn: 'checkEventsRetention',
|
|
763
|
+
network: false,
|
|
764
|
+
precondition: ({ repoRoot }) => {
|
|
765
|
+
const metrics = path.join(repoRoot, '.orchestrator', 'metrics');
|
|
766
|
+
return existsSync(path.join(metrics, 'events.jsonl')) || hasRotationArchive(metrics)
|
|
767
|
+
? null
|
|
768
|
+
: 'no-events-ledger';
|
|
769
|
+
},
|
|
770
|
+
args: ({ repoRoot, config }) => ({ repoRoot, config }),
|
|
771
|
+
// #1489: the coverage reaches the ledger on every measured answer, warn or
|
|
772
|
+
// not — a warning that never fires is otherwise indistinguishable from a
|
|
773
|
+
// dead one (HR-105). Counts and day spans only: no path, no timestamp.
|
|
774
|
+
// A degraded answer carries no `archives`, so it persists no measure. Every
|
|
775
|
+
// measured answer has a finite `coverageDays` — a span with no stamp is
|
|
776
|
+
// degraded since #1498 — and `probeMeasure` drops a non-finite value, so a
|
|
777
|
+
// regression there reads as "not measured", never as a number.
|
|
778
|
+
telemetry: (r) => (Number.isInteger(r?.archives)
|
|
779
|
+
? {
|
|
780
|
+
coverage_days: Math.round(r.coverageDays * 1000) / 1000,
|
|
781
|
+
archives: r.archives,
|
|
782
|
+
max_backups: r.maxBackups,
|
|
783
|
+
required_days: r.requiredDays ?? null,
|
|
784
|
+
}
|
|
785
|
+
: undefined),
|
|
786
|
+
},
|
|
708
787
|
];
|
|
709
788
|
|
|
710
789
|
// ---------------------------------------------------------------------------
|
|
@@ -730,6 +809,68 @@ function pluginVersion() {
|
|
|
730
809
|
}
|
|
731
810
|
}
|
|
732
811
|
|
|
812
|
+
/**
|
|
813
|
+
* NAMED CEILING (BV-004): keys per probe `measure`. `events-retention`, the one
|
|
814
|
+
* probe with telemetry today, persists 4. Revisit when a probe needs more than
|
|
815
|
+
* 8 — the cap exists so one probe cannot inflate every SessionStart record.
|
|
816
|
+
*/
|
|
817
|
+
const MEASURE_MAX_KEYS = 8;
|
|
818
|
+
const MEASURE_KEY_RE = /^[a-z][a-z0-9_]{0,31}$/;
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* A probe's `telemetry(result)`, bounded for the ledger (#1489): at most
|
|
822
|
+
* {@link MEASURE_MAX_KEYS} snake_case keys whose values are finite numbers or
|
|
823
|
+
* `null`; any other entry is dropped. Strings are refused outright, not
|
|
824
|
+
* vetted: `orchestrator.probes.completed` also travels over the optional
|
|
825
|
+
* webhook unredacted, and a string is how a path or a repo slug gets there.
|
|
826
|
+
* A missing, throwing or empty telemetry yields `undefined` — no `measure`
|
|
827
|
+
* key, which reads as "not measured", never as zero.
|
|
828
|
+
*
|
|
829
|
+
* Reading the returned object stays inside the `try`: `Object.entries` runs
|
|
830
|
+
* its getters and a Proxy's `ownKeys` trap, and either may throw — which,
|
|
831
|
+
* outside it, rejected the whole runner against its never-rejects contract.
|
|
832
|
+
*
|
|
833
|
+
* @param {{telemetry?: Function}} probe
|
|
834
|
+
* @param {*} result — the probe's delivered result
|
|
835
|
+
* @returns {Record<string, number|null>|undefined}
|
|
836
|
+
*/
|
|
837
|
+
function probeMeasure(probe, result) {
|
|
838
|
+
if (typeof probe.telemetry !== 'function') return undefined;
|
|
839
|
+
try {
|
|
840
|
+
const raw = probe.telemetry(result);
|
|
841
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
842
|
+
const entries = Object.entries(raw)
|
|
843
|
+
.filter(([k, v]) => MEASURE_KEY_RE.test(k) && (v === null || (typeof v === 'number' && Number.isFinite(v))))
|
|
844
|
+
.slice(0, MEASURE_MAX_KEYS);
|
|
845
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
846
|
+
} catch {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Does `<metricsDir>/_archive/` hold at least one rotated events archive? The
|
|
853
|
+
* `events-retention` precondition's second half.
|
|
854
|
+
*
|
|
855
|
+
* It lists the directory exactly as `checkEventsRetention` does (same
|
|
856
|
+
* `ARCHIVE_NAME_RE` filter), so the precondition skips precisely the inputs the
|
|
857
|
+
* probe would answer `not-measured`. The directory existing is not a ledger
|
|
858
|
+
* (#1401 review: an empty or foreign-only `_archive/` was recorded
|
|
859
|
+
* `ran-clean`). Any listing error other than ENOENT answers `true`: the probe
|
|
860
|
+
* then runs and reports the unlistable directory as `degraded`, because an
|
|
861
|
+
* unreadable source must never be skipped as absent.
|
|
862
|
+
*
|
|
863
|
+
* @param {string} metricsDir — `<repoRoot>/.orchestrator/metrics`
|
|
864
|
+
* @returns {boolean}
|
|
865
|
+
*/
|
|
866
|
+
function hasRotationArchive(metricsDir) {
|
|
867
|
+
try {
|
|
868
|
+
return readdirSync(path.join(metricsDir, ARCHIVE_DIR_NAME)).some((n) => ARCHIVE_NAME_RE.test(n));
|
|
869
|
+
} catch (err) {
|
|
870
|
+
return err?.code !== 'ENOENT';
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
733
874
|
/**
|
|
734
875
|
* Race `promise` against a budget denominated in the probe's OWN work time —
|
|
735
876
|
* wall-clock elapsed MINUS the time a synchronous sibling held the event loop
|
|
@@ -973,7 +1114,12 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
|
973
1114
|
// the race below: a timeout with `delivered` set is `delivered`.
|
|
974
1115
|
try {
|
|
975
1116
|
return {
|
|
976
|
-
__probeResult: await probe.followUp(
|
|
1117
|
+
__probeResult: await probe.followUp(
|
|
1118
|
+
first,
|
|
1119
|
+
ctx,
|
|
1120
|
+
(extra) => fn({ ...probe.args(ctx), ...extra }),
|
|
1121
|
+
(partial) => { delivered = { __probeResult: partial }; },
|
|
1122
|
+
),
|
|
977
1123
|
};
|
|
978
1124
|
} catch {
|
|
979
1125
|
followUpFailure = 'threw';
|
|
@@ -1001,24 +1147,49 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
|
1001
1147
|
if (raced.__absent || raced.__probeError?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
1002
1148
|
record('skipped', { reason: 'module-absent' });
|
|
1003
1149
|
} else {
|
|
1004
|
-
|
|
1150
|
+
// The thrown value is probe-supplied: String() of an object without a
|
|
1151
|
+
// prototype (or with a throwing toString) throws itself, which would
|
|
1152
|
+
// reject the runner — the contract is that it never rejects.
|
|
1153
|
+
let reason;
|
|
1154
|
+
try {
|
|
1155
|
+
reason = String(raced.__probeError?.message ?? raced.__probeError).slice(0, 200);
|
|
1156
|
+
} catch {
|
|
1157
|
+
reason = 'probe-threw-unprintable';
|
|
1158
|
+
}
|
|
1159
|
+
record('error', { reason });
|
|
1005
1160
|
}
|
|
1006
1161
|
return;
|
|
1007
1162
|
}
|
|
1008
1163
|
|
|
1009
1164
|
const result = raced?.__probeResult;
|
|
1010
|
-
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1165
|
+
// A custom `severityOf`, a `render`, or a getter on the result the
|
|
1166
|
+
// defaults read is probe-supplied code that can throw. The runner never
|
|
1167
|
+
// rejects, so such a throw degrades this probe the way a throwing
|
|
1168
|
+
// precondition does: `error` with a fixed reason, no banner line.
|
|
1169
|
+
let severity;
|
|
1170
|
+
let line;
|
|
1171
|
+
let stage = 'severity-threw';
|
|
1172
|
+
try {
|
|
1173
|
+
severity = severityOf(result, probe);
|
|
1174
|
+
stage = 'render-threw';
|
|
1175
|
+
// The renderer gets the RESULT only (#1396): no `ctx`, so it has no
|
|
1176
|
+
// repo to spawn git against. It runs after the budget race, where
|
|
1177
|
+
// nothing it did would be measured; a repo-local fact a banner names
|
|
1178
|
+
// is resolved in the probe's `followUp`, inside the race, and carried
|
|
1179
|
+
// as data.
|
|
1180
|
+
line = typeof probe.render === 'function'
|
|
1181
|
+
? probe.render(result)
|
|
1182
|
+
: defaultRender(result, severity);
|
|
1183
|
+
} catch {
|
|
1184
|
+
record('error', { reason: stage, workMs });
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const measure = probeMeasure(probe, result);
|
|
1018
1188
|
record(severity === 'ok' ? 'ran-clean' : severity === 'warn' ? 'ran-warn' : 'ran-alert', {
|
|
1019
1189
|
severity,
|
|
1020
1190
|
workMs,
|
|
1021
1191
|
...(followUpFailure ? { followUp: followUpFailure } : {}),
|
|
1192
|
+
...(measure ? { measure } : {}),
|
|
1022
1193
|
...(line ? { line } : {}),
|
|
1023
1194
|
});
|
|
1024
1195
|
}),
|
|
@@ -1082,6 +1253,9 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
|
1082
1253
|
// A follow-up that fell back to the delivered result is otherwise
|
|
1083
1254
|
// invisible (the outcome is `ran-*`); this is its revisit trigger's input.
|
|
1084
1255
|
...(typeof r.followUp === 'string' ? { follow_up: r.followUp } : {}),
|
|
1256
|
+
// What the probe MEASURED, warn or not (#1489, HR-105): without it a
|
|
1257
|
+
// probe that never warns reads the same whether it is rare or dead.
|
|
1258
|
+
...(r.measure ? { measure: r.measure } : {}),
|
|
1085
1259
|
})),
|
|
1086
1260
|
};
|
|
1087
1261
|
|
|
@@ -93,8 +93,9 @@
|
|
|
93
93
|
* without usage turns, or unreadable, #1474), or when the agent has a start
|
|
94
94
|
* record but its stop found no transcript of its own and no other record of the
|
|
95
95
|
* same agent did (`start_record_found: true`, `subagent_transcript_found: false`
|
|
96
|
-
* — mostly `workflow-subagent`)
|
|
97
|
-
*
|
|
96
|
+
* — mostly `workflow-subagent`), or when its only own-transcript records are
|
|
97
|
+
* schema_version 1 (#1487 Pkt 1 — v1 has no priceable buckets). A phantom stop
|
|
98
|
+
* (#939, no start record) is no agent and no candidate. A record whose four token buckets
|
|
98
99
|
* are all 0 is priced at $0 whatever its model, and never turns a null total into
|
|
99
100
|
* a number on its own. A record whose turns span several models carries
|
|
100
101
|
* `models_usage` (#1470) and is priced part by part. `cost_records_priced` /
|
|
@@ -104,11 +105,16 @@
|
|
|
104
105
|
* Blind spots the ledger cannot close: an agent whose start record lies outside
|
|
105
106
|
* the hook's tail window reads `start_record_found: false` and is
|
|
106
107
|
* indistinguishable from a phantom, and an agent that left neither a start nor a
|
|
107
|
-
* stop record is invisible.
|
|
108
|
-
* ledger
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
108
|
+
* stop record is invisible. "Never a partial sum" holds over the agents the
|
|
109
|
+
* ledger can see. Until #1487 Pkt 1 (2026-10-02) a session spanning the
|
|
110
|
+
* 2026-09-09 schema boundary was priced over its v2 records only — its v1
|
|
111
|
+
* agents were never cost candidates, so the v2 half's sum read as the whole
|
|
112
|
+
* bill (4 distinct fleet sessions, 31 ledgers, 2026-10-01). A v1 agent is now an
|
|
113
|
+
* unpriced candidate: such a session reports `total_cost_usd: null` with
|
|
114
|
+
* `cost_records_priced < cost_records_total`. Re-measured 2026-10-02 (28
|
|
115
|
+
* ledgers, 1,887 ledger/session pairs): the same 4 sessions turn null, and 498
|
|
116
|
+
* pure-v1 pairs change only `cost_records_total` (0/0 → 0/N, cost null before
|
|
117
|
+
* and after). FORWARD-ONLY like the #949 cut.
|
|
112
118
|
*
|
|
113
119
|
* @module session-token-rollup
|
|
114
120
|
*/
|
|
@@ -238,10 +244,10 @@ function recordCostUsd(record) {
|
|
|
238
244
|
* @property {number|null} total_token_input_uncached - Sum of token_input_uncached over the same one record per agent.
|
|
239
245
|
* @property {number|null} total_token_cache_read - Sum of token_cache_read over the same one record per agent.
|
|
240
246
|
* @property {number|null} total_token_cache_creation - Sum of token_cache_creation over the same one record per agent.
|
|
241
|
-
* @property {number|null} total_cost_usd - Σ cost over the agents counted in cost_records_priced; null when cost_records_priced < cost_records_total (#1475 — any unpriced candidate: unknown model, no model, no tokens,
|
|
247
|
+
* @property {number|null} total_cost_usd - Σ cost over the agents counted in cost_records_priced; null when cost_records_priced < cost_records_total (#1475 — any unpriced candidate: unknown model, no model, no tokens, a started agent without a transcript, or a v1 agent, #1487 Pkt 1), null when there is nothing to price (cost_records_total 0), and null when no record with a non-zero bucket was priced (all-zero records alone never yield a fabricated $0). Never 0 for unknown — see telemetry/pricing.mjs.
|
|
242
248
|
* @property {number} cost_records_priced - How many agents of cost_records_total carry a known cost on their last found v2 record: priced by the table (per model part when the record carries models_usage, #1470), or all four token buckets 0 (priced at $0 whatever the model, #1474).
|
|
243
|
-
* @property {number} cost_records_total - How many agents were candidates for pricing, each counted once however often it stopped: every agent with a v2 record whose own transcript was found — priced on its LAST such record, so one whose last read yielded no tokens (transcript oversized, without usage turns, or unreadable, #1474) is unpriced — plus every started agent whose
|
|
244
|
-
* @property {number} legacy_v1_records - Token-bearing records EXCLUDED from every total above because their schema_version < 2 (their token_input is a different quantity).
|
|
249
|
+
* @property {number} cost_records_total - How many agents were candidates for pricing, each counted once however often it stopped: every agent with a v2 record whose own transcript was found — priced on its LAST such record, so one whose last read yielded no tokens (transcript oversized, without usage turns, or unreadable, #1474) is unpriced — plus every started agent whose stops found no transcript of its own (`start_record_found: true`, `subagent_transcript_found: false`), plus every agent with a schema_version 1 own-transcript record (#1487 Pkt 1) — each of these two only when the agent has no v2 own-transcript record in the session. All three latter kinds have an unknown cost. Phantom stops (no start record, #939) are never candidates. priced < total nulls total_cost_usd.
|
|
250
|
+
* @property {number} legacy_v1_records - Token-bearing records EXCLUDED from every token total above because their schema_version < 2 (their token_input is a different quantity). Their agents are unpriced cost candidates (cost_records_total, #1487 Pkt 1).
|
|
245
251
|
* @property {2} _token_schema - The token contract these totals were computed under.
|
|
246
252
|
*/
|
|
247
253
|
|
|
@@ -341,13 +347,20 @@ export function rollupSessionTokens({
|
|
|
341
347
|
const addNonNegative = (acc, value) =>
|
|
342
348
|
typeof value === 'number' && value >= 0 ? (acc ?? 0) + value : acc;
|
|
343
349
|
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
350
|
+
// Real agents that may have no v2 record of their own transcript — each an
|
|
351
|
+
// unpriced candidate, counted once: a started agent whose stop found no
|
|
352
|
+
// transcript (#1475), and an agent seen in a v1 record (#1487 Pkt 1 — v1
|
|
353
|
+
// carries no priceable buckets). Resolved against `agents` only AFTER the
|
|
354
|
+
// pass: a later v2 stop of the same agent that found its transcript prices it
|
|
355
|
+
// (measured 2026-10-01: 4 agents in one fleet session, `workflow-subagent`
|
|
356
|
+
// found:false first, then found:true) and must not null a known cost. A record
|
|
357
|
+
// without an agent_id cannot be joined to another and stands alone.
|
|
358
|
+
const maybeUnpriced = new Set();
|
|
359
|
+
let unkeyedUnpriced = 0;
|
|
360
|
+
const addMaybeUnpriced = (record) => {
|
|
361
|
+
if (record.agent_id === undefined || record.agent_id === null) unkeyedUnpriced += 1;
|
|
362
|
+
else maybeUnpriced.add(record.agent_id);
|
|
363
|
+
};
|
|
351
364
|
|
|
352
365
|
// One agent, one count (see module header § One record per agent). Per agent:
|
|
353
366
|
// `last` is its last stop that found its transcript — the cost candidate —
|
|
@@ -358,9 +371,6 @@ export function rollupSessionTokens({
|
|
|
358
371
|
// cannot be joined to another and stands alone.
|
|
359
372
|
/** @type {Map<unknown, {last: object, lastWithTokens: object|null}>} */
|
|
360
373
|
const agents = new Map();
|
|
361
|
-
/** Started agents whose stops found no transcript — each counts once. */
|
|
362
|
-
const startedWithoutTranscript = new Set();
|
|
363
|
-
let unkeyedStartedWithoutTranscript = 0;
|
|
364
374
|
|
|
365
375
|
for (const record of matched) {
|
|
366
376
|
// Provenance gate (#949) — a record whose tokens describe the PARENT
|
|
@@ -369,28 +379,22 @@ export function rollupSessionTokens({
|
|
|
369
379
|
// sentinel: a session of only untrustworthy records reports "no data",
|
|
370
380
|
// which is true, instead of a fabricated 0.
|
|
371
381
|
if (!isTokenBearing(record)) {
|
|
372
|
-
// …but
|
|
382
|
+
// …but an agent that really ran without a readable transcript has an
|
|
373
383
|
// unknown cost: an unpriced candidate (total, not priced), so priced <
|
|
374
384
|
// total nulls `total_cost_usd` (#1475). Phantoms never reach this branch.
|
|
375
|
-
if (
|
|
376
|
-
isV2(record) &&
|
|
377
|
-
isStartedWithoutTranscript(record) &&
|
|
378
|
-
!agentsWithTranscript.has(record.agent_id)
|
|
379
|
-
) {
|
|
380
|
-
if (record.agent_id === undefined || record.agent_id === null) {
|
|
381
|
-
unkeyedStartedWithoutTranscript += 1;
|
|
382
|
-
} else {
|
|
383
|
-
startedWithoutTranscript.add(record.agent_id);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
385
|
+
if (isStartedWithoutTranscript(record)) addMaybeUnpriced(record);
|
|
386
386
|
continue;
|
|
387
387
|
}
|
|
388
388
|
|
|
389
389
|
// Schema gate (#1244) — a v1 record's token_input is raw uncached input,
|
|
390
390
|
// a different quantity from a v2 record's billable prompt volume. Count it
|
|
391
|
-
// so the boundary is visible, never sum it.
|
|
391
|
+
// so the boundary is visible, never sum it. Its agent ran, though, and v1
|
|
392
|
+
// cannot be priced: it is an unpriced cost candidate (#1487 Pkt 1), so a
|
|
393
|
+
// session spanning the 2026-09-09 boundary reports a null cost instead of
|
|
394
|
+
// the v2 half's partial sum.
|
|
392
395
|
if (!isV2(record)) {
|
|
393
396
|
legacyV1 += 1;
|
|
397
|
+
addMaybeUnpriced(record);
|
|
394
398
|
continue;
|
|
395
399
|
}
|
|
396
400
|
|
|
@@ -452,7 +456,8 @@ export function rollupSessionTokens({
|
|
|
452
456
|
if (!allZero) sumCost = (sumCost ?? 0) + cost;
|
|
453
457
|
}
|
|
454
458
|
}
|
|
455
|
-
|
|
459
|
+
for (const id of maybeUnpriced) if (!agents.has(id)) costTotal += 1;
|
|
460
|
+
costTotal += unkeyedUnpriced;
|
|
456
461
|
|
|
457
462
|
return {
|
|
458
463
|
match_status: 'matched',
|
|
@@ -192,7 +192,9 @@ const CLOSING_DIAGNOSTIC_EVENTS = new Map([
|
|
|
192
192
|
// scripts/lib/autopilot/worktree-pipeline.mjs (release_failed/released only).
|
|
193
193
|
['orchestrator.session.lock.released', null],
|
|
194
194
|
['orchestrator.session.lock.release_failed', null],
|
|
195
|
-
//
|
|
195
|
+
// hooks/on-session-end.mjs — reading the lock during teardown. Since #1494 also
|
|
196
|
+
// hooks/_lib/lock-bootstrap.mjs at SessionStart (`unreadable` only); skipping that
|
|
197
|
+
// one too loses nothing, because the same start emits orchestrator.session.started.
|
|
196
198
|
['orchestrator.session.lock.read_anomaly', null],
|
|
197
199
|
// Wave lifecycle: hooks/post-tool-batch-wave-signal.mjs emits wave.completed MID-session
|
|
198
200
|
// (real activity, still counted); only the SessionEnd final-wave stamp is a closing
|