session-orchestrator 3.23.0 → 3.24.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/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessions-canonical.mjs — one record per physical session (#1167).
|
|
3
|
+
*
|
|
4
|
+
* `.orchestrator/metrics/sessions.jsonl` is APPEND-ONLY by design: nothing is
|
|
5
|
+
* ever rewritten in place, so the same physical session can appear more than
|
|
6
|
+
* once. A consumer that treats "one line = one session" therefore over-counts,
|
|
7
|
+
* and every duration/effectiveness aggregate computed from the raw file is
|
|
8
|
+
* silently wrong by however many duplicates happen to sit in its window.
|
|
9
|
+
*
|
|
10
|
+
* This module is the READ-side collapse. It never repairs the file (see § 3).
|
|
11
|
+
*
|
|
12
|
+
* ── THE THREE RULES, AND WHAT MEASURED THEM ─────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* (1) NEWEST-WINS PER `session_id`.
|
|
15
|
+
* File order is chronological, so the LAST record carrying an id is the
|
|
16
|
+
* current one. This is the same reading rule
|
|
17
|
+
* `session-close-backfill.mjs::classifyExisting()` already applies (it
|
|
18
|
+
* takes `matches[matches.length - 1]`); this module generalises it to the
|
|
19
|
+
* whole file. Measured 2026-09-02 @ c3ab480 over 286 records: one id
|
|
20
|
+
* (2026-05-10) carries a byte-identical duplicate LINE — an older
|
|
21
|
+
* collision class than (3), and rule (1) alone resolves it.
|
|
22
|
+
*
|
|
23
|
+
* (2) NARROW COLLAPSE OF THE SYSTEMIC DOUBLE-STUB CLASS.
|
|
24
|
+
* Two `abandoned` records with an EXACT `started_at` + `completed_at`
|
|
25
|
+
* tuple match are one physical session recorded twice by the two backfill
|
|
26
|
+
* writers: `hooks/on-session-end.mjs` resolves the semantic id from
|
|
27
|
+
* `current-session.json` and writes `main-YYYY-MM-DD-session-N`, while
|
|
28
|
+
* `scripts/backfill-abandoned-sessions.mjs` could resolve a semantic id
|
|
29
|
+
* ONLY via `orchestrator.session.lock.acquired` — a session that lost the
|
|
30
|
+
* lock-acquire race has no such event, so it fell through to the synthetic
|
|
31
|
+
* mint (`<branch>-<date>-abandoned-<sha8>`, `_synthetic_session_id: true`)
|
|
32
|
+
* and wrote a SECOND stub for the same session. The join-back was
|
|
33
|
+
* impossible because `raw_session_id` is null on 286/286 records
|
|
34
|
+
* (`jq -s '[.[]|select(.raw_session_id != null)]|length'` → 0, measured
|
|
35
|
+
* 2026-09-02 @ c3ab480), so the two records share no key at all — only
|
|
36
|
+
* their millisecond-identical timestamps.
|
|
37
|
+
* Measured population: 8 such pairs over 6 weeks (16 records), via
|
|
38
|
+
* `jq -r '[.started_at,.completed_at,.status]|@tsv' … | sort | uniq -d`.
|
|
39
|
+
* The NON-synthetic record survives; the synthetic mint is the artefact.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately narrow. The collapse requires BOTH records to be
|
|
42
|
+
* `status: 'abandoned'` and BOTH timestamps to be present and equal.
|
|
43
|
+
* `started_at` alone is NOT enough (two real sessions can start in the
|
|
44
|
+
* same millisecond of a re-fire), and `completed` records are never
|
|
45
|
+
* collapsed (an authoritative record is a truth claim about itself, never
|
|
46
|
+
* an artefact of a second writer).
|
|
47
|
+
*
|
|
48
|
+
* (3) AN ATTESTABLE `supersedes: X` REMOVES record X.
|
|
49
|
+
* The #1068 AC3/AC4 supersede path appends an authoritative `completed`
|
|
50
|
+
* record carrying a forward pointer to the backfilled `abandoned` stub it
|
|
51
|
+
* refutes. The stub is kept on disk verbatim (AC4 — forensic provenance);
|
|
52
|
+
* a canonical READER must drop it, or the same session is counted as both
|
|
53
|
+
* abandoned and completed.
|
|
54
|
+
*
|
|
55
|
+
* Two constraints, both measured defects of the first implementation:
|
|
56
|
+
* - ORDER-INDEPENDENT. A record is dropped iff some SURVIVING record
|
|
57
|
+
* supersedes it (a fixpoint over the supersede graph; cycles broken by
|
|
58
|
+
* keeping the newest member). Deleting in file order made a chain
|
|
59
|
+
* `C → B → A` resolve to `{C, A}` or `{C}` depending on the
|
|
60
|
+
* permutation the appends happened to land in, and a mutual pair
|
|
61
|
+
* resolved by insertion order.
|
|
62
|
+
* - ATTESTABLE ONLY. The marker is honoured only when the target is not
|
|
63
|
+
* authoritative (`status` `abandoned`, or absent on a legacy stub —
|
|
64
|
+
* never `completed`) AND the two records share a join key (equal
|
|
65
|
+
* `raw_session_id`, or byte-equal `started_at` — the shape
|
|
66
|
+
* `session-close-backfill.mjs::synthesizeRecord()` emits, since stub
|
|
67
|
+
* and superseder are synthesized from the same gathered events).
|
|
68
|
+
* Without that constraint ONE appended line could delete ANY id from
|
|
69
|
+
* EVERY reader of this module, the armed autonomy verdict included. A
|
|
70
|
+
* refused marker keeps both records and is reported (never logged)
|
|
71
|
+
* via `canonicalizeSessionsDetailed().ignoredSupersedes`.
|
|
72
|
+
*
|
|
73
|
+
* RULE ORDER: (1) → (2) → (3). The double-stub collapse must run BEFORE
|
|
74
|
+
* supersede removal: with the reverse order a `supersedes` append deleted the
|
|
75
|
+
* authentic stub first, shrank the tuple group to a single member, and the
|
|
76
|
+
* synthetic phantom then survived the very session that refuted it.
|
|
77
|
+
*
|
|
78
|
+
* ── WHAT THIS MODULE DOES NOT DO ────────────────────────────────────────────
|
|
79
|
+
* - It never writes. The 8 historical pairs stay on disk; the ledger is
|
|
80
|
+
* append-only and the duplicates are their own provenance.
|
|
81
|
+
* - It is not a phantom filter. `status: 'abandoned'` records SURVIVE here —
|
|
82
|
+
* dropping them is `session-schema/filters.mjs`'s job
|
|
83
|
+
* (`isRealSession` / `filterRealSessions` / `tailRealSessions`), and the
|
|
84
|
+
* two compose: canonicalize first, then filter.
|
|
85
|
+
*
|
|
86
|
+
* Plain Node ESM. Named exports. `canonicalizeSessions` is pure; only
|
|
87
|
+
* `readCanonicalSessions` touches the filesystem (sync, `readFileSync`).
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
import fs from 'node:fs';
|
|
91
|
+
import path from 'node:path';
|
|
92
|
+
|
|
93
|
+
const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* True when the value is a usable record object (not null, not an array).
|
|
97
|
+
* @param {unknown} v
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function isRecordObject(v) {
|
|
101
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Non-empty string guard — `''` is never a usable id or timestamp. */
|
|
105
|
+
function isNonEmptyString(v) {
|
|
106
|
+
return typeof v === 'string' && v.length > 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The timestamp a record is ordered by when a supersede CYCLE has to be broken:
|
|
111
|
+
* `completed_at` when present, else `started_at`, else `''` (sorts last).
|
|
112
|
+
* ISO-8601 strings compare lexicographically, so no Date parsing is needed.
|
|
113
|
+
* @param {object} rec
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function cycleOrderTimestamp(rec) {
|
|
117
|
+
if (isNonEmptyString(rec.completed_at)) return rec.completed_at;
|
|
118
|
+
if (isNonEmptyString(rec.started_at)) return rec.started_at;
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* True when `superseder`'s `supersedes` marker is ATTESTABLE against `target`.
|
|
124
|
+
*
|
|
125
|
+
* `supersedes` is a forward pointer inside an append-only file that anyone (or
|
|
126
|
+
* any buggy writer) can append a line to, and a reader that obeys it blindly
|
|
127
|
+
* lets a single appended line delete ANY id from EVERY consumer — including the
|
|
128
|
+
* armed autonomy verdict. So the marker is honoured only for the shape the
|
|
129
|
+
* #1068 writer actually produces: the stub it refutes is never an AUTHORITATIVE
|
|
130
|
+
* record — its `status` is `abandoned`, or absent/null on a legacy stub, but
|
|
131
|
+
* never any other declared status (a `completed` record is a truth claim about
|
|
132
|
+
* itself and can never be deleted by an appended pointer) — and both records
|
|
133
|
+
* were synthesized from the SAME gathered events, hence share an attestable
|
|
134
|
+
* join key —
|
|
135
|
+
* - equal non-empty `raw_session_id` (the #1167 harness-uuid join), or
|
|
136
|
+
* - byte-equal non-empty `started_at` (`session-close-backfill.mjs`
|
|
137
|
+
* `synthesizeRecord()` derives `startedIso` from the same event set for the
|
|
138
|
+
* stub and for the record that supersedes it).
|
|
139
|
+
* Anything else is a data-integrity anomaly: BOTH records are kept and the
|
|
140
|
+
* marker is reported via `canonicalizeSessionsDetailed().ignoredSupersedes`.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} superseder
|
|
143
|
+
* @param {object} target
|
|
144
|
+
* @returns {string|null} null when the marker is valid, else the reject reason
|
|
145
|
+
*/
|
|
146
|
+
function supersedeRejectReason(superseder, target) {
|
|
147
|
+
// Absent/null `status` is a legacy stub, not an authoritative record; any
|
|
148
|
+
// OTHER declared status (`completed` above all) is untouchable.
|
|
149
|
+
if (isNonEmptyString(target.status) && target.status !== 'abandoned') {
|
|
150
|
+
return 'target-not-abandoned';
|
|
151
|
+
}
|
|
152
|
+
const a = superseder.raw_session_id;
|
|
153
|
+
const b = target.raw_session_id;
|
|
154
|
+
if (isNonEmptyString(a) && isNonEmptyString(b) && a === b) return null;
|
|
155
|
+
if (isNonEmptyString(superseder.started_at) && superseder.started_at === target.started_at) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return 'no-shared-join-key';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Rule (3) — collapse the systemic two-writer double stub. Mutates `byId` by
|
|
163
|
+
* deleting the synthetic twin of each qualifying pair.
|
|
164
|
+
* @param {Map<string, object>} byId
|
|
165
|
+
* @returns {void}
|
|
166
|
+
*/
|
|
167
|
+
function collapseAbandonedTuples(byId) {
|
|
168
|
+
// Group ONLY the records eligible for the systemic double-stub class; every
|
|
169
|
+
// other record bypasses this pass entirely and can never be dropped by it.
|
|
170
|
+
const byTuple = new Map();
|
|
171
|
+
for (const rec of byId.values()) {
|
|
172
|
+
if (rec.status !== 'abandoned') continue;
|
|
173
|
+
if (!isNonEmptyString(rec.started_at) || !isNonEmptyString(rec.completed_at)) continue;
|
|
174
|
+
const key = `${rec.started_at} ${rec.completed_at}`;
|
|
175
|
+
const group = byTuple.get(key);
|
|
176
|
+
if (group) group.push(rec);
|
|
177
|
+
else byTuple.set(key, [rec]);
|
|
178
|
+
}
|
|
179
|
+
for (const group of byTuple.values()) {
|
|
180
|
+
if (group.length < 2) continue;
|
|
181
|
+
const authentic = group.filter((r) => r._synthetic_session_id !== true);
|
|
182
|
+
// All-synthetic (or all-authentic) groups are left intact: with no
|
|
183
|
+
// non-synthetic record to prefer there is no evidence about WHICH one is
|
|
184
|
+
// the artefact, and guessing would delete a session nobody can recover.
|
|
185
|
+
if (authentic.length === 0 || authentic.length === group.length) continue;
|
|
186
|
+
for (const rec of group) {
|
|
187
|
+
if (rec._synthetic_session_id === true) byId.delete(rec.session_id);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Rule (2) — order-independent supersede resolution. Mutates `byId` by deleting
|
|
194
|
+
* every record that a SURVIVING record supersedes, and appends every rejected
|
|
195
|
+
* marker to `ignored`.
|
|
196
|
+
*
|
|
197
|
+
* A record is dropped iff some record that itself survives supersedes it; the
|
|
198
|
+
* marking is a fixpoint over the supersede graph, so it depends on the EDGES
|
|
199
|
+
* only, never on the order the records appear in the file. A chain
|
|
200
|
+
* `C → B → A` therefore always resolves to `{C, A}` (B is dropped by the
|
|
201
|
+
* surviving C, so B's own marker no longer removes A).
|
|
202
|
+
*
|
|
203
|
+
* A cycle (`X → Y`, `Y → X`) has no fixpoint; it is broken deterministically by
|
|
204
|
+
* keeping the NEWEST member (`completed_at ?? started_at`, ties by ascending
|
|
205
|
+
* `session_id`) and re-running the propagation.
|
|
206
|
+
*
|
|
207
|
+
* @param {Map<string, object>} byId
|
|
208
|
+
* @param {Array<{by: string, target: string, reason: string}>} ignored
|
|
209
|
+
* @returns {void}
|
|
210
|
+
*/
|
|
211
|
+
function resolveSupersedes(byId, ignored) {
|
|
212
|
+
/** targetId → Set of ids of records that validly supersede it. */
|
|
213
|
+
const supersededBy = new Map();
|
|
214
|
+
for (const rec of byId.values()) {
|
|
215
|
+
const target = rec.supersedes;
|
|
216
|
+
if (!isNonEmptyString(target) || target === rec.session_id) continue;
|
|
217
|
+
const targetRec = byId.get(target);
|
|
218
|
+
// A marker pointing at an id that is not present removes nothing; it is not
|
|
219
|
+
// an anomaly either (the target may legitimately have been collapsed by
|
|
220
|
+
// rule 3 first, or simply predate this window of the ledger).
|
|
221
|
+
if (!targetRec) continue;
|
|
222
|
+
const reason = supersedeRejectReason(rec, targetRec);
|
|
223
|
+
if (reason !== null) {
|
|
224
|
+
ignored.push({ by: rec.session_id, target, reason });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const set = supersededBy.get(target);
|
|
228
|
+
if (set) set.add(rec.session_id);
|
|
229
|
+
else supersededBy.set(target, new Set([rec.session_id]));
|
|
230
|
+
}
|
|
231
|
+
if (supersededBy.size === 0) return;
|
|
232
|
+
|
|
233
|
+
const ids = [...byId.keys()];
|
|
234
|
+
/** id → 'alive' | 'dead'; absent = not yet decided. */
|
|
235
|
+
const state = new Map();
|
|
236
|
+
for (;;) {
|
|
237
|
+
let changed = true;
|
|
238
|
+
while (changed) {
|
|
239
|
+
changed = false;
|
|
240
|
+
for (const id of ids) {
|
|
241
|
+
if (state.has(id)) continue;
|
|
242
|
+
const sup = supersededBy.get(id);
|
|
243
|
+
if (!sup || sup.size === 0) {
|
|
244
|
+
state.set(id, 'alive');
|
|
245
|
+
changed = true;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
let anyAlive = false;
|
|
249
|
+
let allDead = true;
|
|
250
|
+
for (const s of sup) {
|
|
251
|
+
const st = state.get(s);
|
|
252
|
+
if (st === 'alive') anyAlive = true;
|
|
253
|
+
if (st !== 'dead') allDead = false;
|
|
254
|
+
}
|
|
255
|
+
if (anyAlive) {
|
|
256
|
+
state.set(id, 'dead');
|
|
257
|
+
changed = true;
|
|
258
|
+
} else if (allDead) {
|
|
259
|
+
state.set(id, 'alive');
|
|
260
|
+
changed = true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const undecided = ids.filter((id) => !state.has(id));
|
|
265
|
+
if (undecided.length === 0) break;
|
|
266
|
+
// Cycle: keep the newest member, then let propagation settle the rest.
|
|
267
|
+
undecided.sort((a, b) => {
|
|
268
|
+
const ta = cycleOrderTimestamp(byId.get(a));
|
|
269
|
+
const tb = cycleOrderTimestamp(byId.get(b));
|
|
270
|
+
if (ta !== tb) return ta < tb ? 1 : -1;
|
|
271
|
+
return a < b ? -1 : 1;
|
|
272
|
+
});
|
|
273
|
+
state.set(undecided[0], 'alive');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const [id, st] of state) {
|
|
277
|
+
if (st === 'dead') byId.delete(id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Collapse a raw sessions.jsonl record array to one record per physical
|
|
283
|
+
* session AND report the supersede markers that were refused. Pure — the input
|
|
284
|
+
* array is never mutated.
|
|
285
|
+
*
|
|
286
|
+
* Rules, applied in this order (see the module header for the measured
|
|
287
|
+
* justification of each):
|
|
288
|
+
* 1. newest-wins per `session_id` (file order is chronological);
|
|
289
|
+
* 2. two `abandoned` records with an exact, both-present
|
|
290
|
+
* `started_at` + `completed_at` tuple collapse to the non-synthetic one;
|
|
291
|
+
* 3. a surviving record's ATTESTABLE `supersedes: X` removes record `X`.
|
|
292
|
+
*
|
|
293
|
+
* The double-stub collapse runs BEFORE supersede removal so that a stub which
|
|
294
|
+
* is itself about to be superseded still shadows its synthetic twin — with the
|
|
295
|
+
* old order the twin outlived the record it duplicated (a `supersedes` append
|
|
296
|
+
* shrank the tuple group to one member, and the phantom survived the session
|
|
297
|
+
* that refuted it).
|
|
298
|
+
*
|
|
299
|
+
* Records without a usable `session_id` are dropped, unless
|
|
300
|
+
* `keepUnidentified: true` (they cannot be deduplicated; a COUNT-style or
|
|
301
|
+
* effectiveness-style consumer would rather keep them than shrink its `n`).
|
|
302
|
+
*
|
|
303
|
+
* Output order follows FIRST appearance of each surviving id in the input; kept
|
|
304
|
+
* unidentified records are appended after them, in their original order.
|
|
305
|
+
*
|
|
306
|
+
* @param {Array<unknown>} records
|
|
307
|
+
* @param {object} [opts]
|
|
308
|
+
* @param {boolean} [opts.keepUnidentified=false] pass id-less record objects
|
|
309
|
+
* through untouched instead of dropping them.
|
|
310
|
+
* @returns {{records: Array<object>, ignoredSupersedes: Array<{by: string,
|
|
311
|
+
* target: string, reason: string}>}}
|
|
312
|
+
*/
|
|
313
|
+
export function canonicalizeSessionsDetailed(records, { keepUnidentified = false } = {}) {
|
|
314
|
+
if (!Array.isArray(records)) return { records: [], ignoredSupersedes: [] };
|
|
315
|
+
|
|
316
|
+
// -- (1) newest-wins per id ------------------------------------------------
|
|
317
|
+
// Map insertion order = FIRST appearance of the id; the stored value is the
|
|
318
|
+
// LAST record carrying it, so a superseding append wins without reordering
|
|
319
|
+
// the ledger's chronology.
|
|
320
|
+
const byId = new Map();
|
|
321
|
+
const unidentified = [];
|
|
322
|
+
for (const rec of records) {
|
|
323
|
+
if (!isRecordObject(rec)) continue;
|
|
324
|
+
if (!isNonEmptyString(rec.session_id)) {
|
|
325
|
+
if (keepUnidentified) unidentified.push(rec);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
byId.set(rec.session_id, rec);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -- (2) narrow abandoned-tuple collapse -----------------------------------
|
|
332
|
+
collapseAbandonedTuples(byId);
|
|
333
|
+
|
|
334
|
+
// -- (3) supersede removal (order-independent, join-key constrained) -------
|
|
335
|
+
const ignoredSupersedes = [];
|
|
336
|
+
resolveSupersedes(byId, ignoredSupersedes);
|
|
337
|
+
|
|
338
|
+
return { records: [...byId.values(), ...unidentified], ignoredSupersedes };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Collapse a raw sessions.jsonl record array to one record per physical
|
|
343
|
+
* session. Pure — the input array is never mutated. Thin wrapper over
|
|
344
|
+
* `canonicalizeSessionsDetailed`, returning only the records (the array shape
|
|
345
|
+
* every consumer reads).
|
|
346
|
+
*
|
|
347
|
+
* @param {Array<unknown>} records
|
|
348
|
+
* @param {object} [opts] — see `canonicalizeSessionsDetailed`.
|
|
349
|
+
* @param {boolean} [opts.keepUnidentified=false]
|
|
350
|
+
* @returns {Array<object>} canonical records
|
|
351
|
+
*/
|
|
352
|
+
export function canonicalizeSessions(records, opts) {
|
|
353
|
+
return canonicalizeSessionsDetailed(records, opts).records;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Count DISTINCT physical sessions in RAW `sessions.jsonl` text. Pure — no fs,
|
|
358
|
+
* so an async reader keeps its own `readFile` and only the counting rule is
|
|
359
|
+
* shared (the two async consumers, `memory-banner.mjs` and
|
|
360
|
+
* `cold-start-detector.mjs`, carried byte-identical copies of this body).
|
|
361
|
+
*
|
|
362
|
+
* Blank lines (incl. the trailing newline) are skipped. A line that does not
|
|
363
|
+
* PARSE is not counted at all — it cannot be attributed to any session (this
|
|
364
|
+
* replaces the pre-#1167 "count every non-empty line, never parse" rule).
|
|
365
|
+
*
|
|
366
|
+
* `canonicalizeSessions` DROPS records without a `session_id` (they cannot be
|
|
367
|
+
* deduplicated). For a COUNT that would under-report rather than de-duplicate,
|
|
368
|
+
* so id-less records are counted as-is and only the id-bearing ones go through
|
|
369
|
+
* the identity collapse.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} raw — full file contents.
|
|
372
|
+
* @returns {number}
|
|
373
|
+
*/
|
|
374
|
+
export function countSessionsInJsonl(raw) {
|
|
375
|
+
if (typeof raw !== 'string' || raw.length === 0) return 0;
|
|
376
|
+
const parsed = [];
|
|
377
|
+
for (const line of raw.split('\n')) {
|
|
378
|
+
const trimmed = line.trim();
|
|
379
|
+
if (!trimmed) continue;
|
|
380
|
+
try {
|
|
381
|
+
parsed.push(JSON.parse(trimmed));
|
|
382
|
+
} catch {
|
|
383
|
+
/* skip malformed line */
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const identified = parsed.filter((r) => isRecordObject(r) && isNonEmptyString(r.session_id));
|
|
387
|
+
const anonymous = parsed.length - identified.length;
|
|
388
|
+
return canonicalizeSessions(identified).length + anonymous;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Read `sessions.jsonl` and return its canonical records (see
|
|
393
|
+
* `canonicalizeSessions` for the three collapse rules).
|
|
394
|
+
*
|
|
395
|
+
* Synchronous by design — every consumer of the ledger in this repo reads it
|
|
396
|
+
* with `readFileSync`, and the file is small (286 records / ~0.5 MB at the
|
|
397
|
+
* time of writing). A MISSING file (ENOENT) yields `[]` silently; an UNREADABLE
|
|
398
|
+
* one (EACCES/EISDIR/…) yields `[]` with a stderr WARN (#1188); each malformed line
|
|
399
|
+
* is skipped rather than aborting the whole read (same posture as the readers
|
|
400
|
+
* in `session-close-backfill.mjs` and `backfill-abandoned-sessions.mjs`).
|
|
401
|
+
*
|
|
402
|
+
* @param {object} [args]
|
|
403
|
+
* @param {string} [args.repoRoot] project root; the ledger is resolved as
|
|
404
|
+
* `<repoRoot>/.orchestrator/metrics/sessions.jsonl`. Defaults to
|
|
405
|
+
* `process.cwd()` when neither this nor `filePath` is given.
|
|
406
|
+
* @param {string} [args.filePath] explicit ledger path (wins over `repoRoot`).
|
|
407
|
+
* @returns {Array<object>} canonical records
|
|
408
|
+
*/
|
|
409
|
+
export function readCanonicalSessions({ repoRoot, filePath } = {}) {
|
|
410
|
+
const resolved = isNonEmptyString(filePath)
|
|
411
|
+
? filePath
|
|
412
|
+
: path.join(isNonEmptyString(repoRoot) ? repoRoot : process.cwd(), ...SESSIONS_REL);
|
|
413
|
+
|
|
414
|
+
let raw;
|
|
415
|
+
try {
|
|
416
|
+
raw = fs.readFileSync(resolved, 'utf8');
|
|
417
|
+
} catch (err) {
|
|
418
|
+
// #1188 — ENOENT and EACCES/EISDIR are different facts: a missing ledger is
|
|
419
|
+
// the ordinary fresh-repo case; an UNREADABLE one previously read as "no
|
|
420
|
+
// sessions" and made every downstream count silently wrong. Same split as
|
|
421
|
+
// readLockDetailed (session-lock.mjs § absent vs unreadable).
|
|
422
|
+
if (!err || err.code !== 'ENOENT') {
|
|
423
|
+
process.stderr.write(
|
|
424
|
+
`⚠ readCanonicalSessions: cannot read ${resolved} ` +
|
|
425
|
+
`(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
|
|
426
|
+
'treating as EMPTY, counts below are floors\n',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
// CEILING (BV-004): still [] rather than throw — SessionStart callers must
|
|
430
|
+
// not crash on a transient permissions fault. REVISIT if the warn rate in
|
|
431
|
+
// events.jsonl shows masked corruption.
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const parsed = [];
|
|
436
|
+
for (const line of raw.split('\n')) {
|
|
437
|
+
const trimmed = line.trim();
|
|
438
|
+
if (!trimmed) continue;
|
|
439
|
+
try {
|
|
440
|
+
parsed.push(JSON.parse(trimmed));
|
|
441
|
+
} catch {
|
|
442
|
+
/* skip malformed line */
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return canonicalizeSessions(parsed);
|
|
446
|
+
}
|
|
@@ -220,6 +220,54 @@ export function filterRosterNames(names, rosterSet) {
|
|
|
220
220
|
return deduped.slice(0, MAX_NAMES);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Classify a single raw invocation name into the skills or commands bucket.
|
|
225
|
+
*
|
|
226
|
+
* The Skill tool surfaces slash-commands that have NO backing `skills/` directory
|
|
227
|
+
* under the same plugin-prefixed name form as real skills (e.g.
|
|
228
|
+
* `session-orchestrator:session` for `commands/session.md`). `roster.skills` holds
|
|
229
|
+
* PREFIXED names, `roster.commands` holds BARE ones — so a prefixed command name
|
|
230
|
+
* matches neither set and used to be bucketed to 'other' (GitLab #1189: commands
|
|
231
|
+
* was `[]` in 345/345 usage pings).
|
|
232
|
+
*
|
|
233
|
+
* Decision order (skill roster wins on a spelling collision such as
|
|
234
|
+
* `memory-cleanup`, which is both a skill dir and a command file):
|
|
235
|
+
* 1. name in rosterSkills → {kind:'skill', name} (verbatim)
|
|
236
|
+
* 2. name is PREFIXED and its bare form is in rosterCommands
|
|
237
|
+
* → {kind:'command', bare}
|
|
238
|
+
* 3. otherwise → {kind:'skill', name} (verbatim)
|
|
239
|
+
*
|
|
240
|
+
* Case 2 REQUIRES the plugin prefix (session-reviewer W2 finding, 2026-09-02).
|
|
241
|
+
* Without that requirement, a BARE third-party or personal skill name that
|
|
242
|
+
* happens to collide with one of our shipped command names (`test`, `close`,
|
|
243
|
+
* `go`, `release`, `portfolio` are all plausible foreign skill names) would be
|
|
244
|
+
* recorded as OUR command — a data-integrity defect, and the same rule made
|
|
245
|
+
* `memory-cleanup` reach BOTH buckets in one ping (bare → commands, prefixed →
|
|
246
|
+
* skills), double-counting a single surface. Requiring the prefix removes both:
|
|
247
|
+
* a bare arrival can only ever be a skill-path name, so an unknown one becomes
|
|
248
|
+
* 'other'. Cost: the one genuine bare `memory-cleanup` record is recorded as
|
|
249
|
+
* 'other' rather than as the command.
|
|
250
|
+
*
|
|
251
|
+
* Case 3 keeps foreign/third-party names on the SKILLS path only, where
|
|
252
|
+
* filterRosterNames projects them to the opaque token 'other' — an unclassified
|
|
253
|
+
* name is never duplicated into `commands`, preserving the privacy invariant.
|
|
254
|
+
*
|
|
255
|
+
* @param {unknown} name raw invocation name
|
|
256
|
+
* @param {Set<string>} rosterSkills prefixed shipped-skill names
|
|
257
|
+
* @param {Set<string>} rosterCommands bare shipped-command names
|
|
258
|
+
* @returns {{kind: 'skill'|'command', name: unknown}}
|
|
259
|
+
*/
|
|
260
|
+
export function classifyInvocationName(name, rosterSkills, rosterCommands) {
|
|
261
|
+
const skills = rosterSkills instanceof Set ? rosterSkills : new Set();
|
|
262
|
+
const commands = rosterCommands instanceof Set ? rosterCommands : new Set();
|
|
263
|
+
if (typeof name !== 'string') return { kind: 'skill', name };
|
|
264
|
+
if (skills.has(name)) return { kind: 'skill', name };
|
|
265
|
+
if (!name.startsWith(SKILL_PREFIX)) return { kind: 'skill', name };
|
|
266
|
+
const bare = name.slice(SKILL_PREFIX.length);
|
|
267
|
+
if (commands.has(bare)) return { kind: 'command', name: bare };
|
|
268
|
+
return { kind: 'skill', name };
|
|
269
|
+
}
|
|
270
|
+
|
|
223
271
|
// ---------------------------------------------------------------------------
|
|
224
272
|
// Duration bucketing
|
|
225
273
|
// ---------------------------------------------------------------------------
|
|
@@ -331,12 +379,12 @@ function distinctField(records, field) {
|
|
|
331
379
|
* whitelist-clean but does NOT carry `anon_id` — the caller sets it via
|
|
332
380
|
* ensureAnonId (anon-id.mjs), keeping ID rotation isolated there.
|
|
333
381
|
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* in without a signature change). Both are
|
|
338
|
-
* become "other" — deduped, sorted, and
|
|
339
|
-
* decision).
|
|
382
|
+
* The distinct `.skill` and `.command` values of `skillInvocations` go through a
|
|
383
|
+
* single classifyInvocationName pass that routes each name to skills or commands
|
|
384
|
+
* (the `.command` field carries nothing today; it is honored so a future direct
|
|
385
|
+
* command-telemetry stream feeds in without a signature change). Both buckets are
|
|
386
|
+
* then roster-filtered — off-roster names become "other" — deduped, sorted, and
|
|
387
|
+
* capped. No frequencies are recorded (v1 decision).
|
|
340
388
|
*
|
|
341
389
|
* @param {{
|
|
342
390
|
* sessionRecord: object,
|
|
@@ -362,8 +410,26 @@ export function buildUsagePing({
|
|
|
362
410
|
const rosterSkills = rst?.skills instanceof Set ? rst.skills : new Set();
|
|
363
411
|
const rosterCommands = rst?.commands instanceof Set ? rst.commands : new Set();
|
|
364
412
|
|
|
365
|
-
|
|
366
|
-
|
|
413
|
+
// ONE classification pass over the union of both producer fields: today only
|
|
414
|
+
// `.skill` is written by hooks/skill-invocation-telemetry.mjs, `.command` is
|
|
415
|
+
// kept for the forward-compat producer documented above.
|
|
416
|
+
//
|
|
417
|
+
// `.command` records carry the BARE name, and classifyInvocationName requires
|
|
418
|
+
// the plugin prefix before it will route anything to the commands bucket (see
|
|
419
|
+
// its doc comment). So a `.command` name is normalized to the prefixed form
|
|
420
|
+
// first — the field itself is the "this is one of ours" signal that a bare
|
|
421
|
+
// `.skill` arrival lacks. Without this the forward-compat producer would be
|
|
422
|
+
// wired but dead: every record it writes would silently become 'other'.
|
|
423
|
+
const skillNames = [];
|
|
424
|
+
const commandNames = [];
|
|
425
|
+
const rawNames = [
|
|
426
|
+
...distinctField(invocations, 'skill'),
|
|
427
|
+
...distinctField(invocations, 'command').map((n) => (n.startsWith(SKILL_PREFIX) ? n : `${SKILL_PREFIX}${n}`)),
|
|
428
|
+
];
|
|
429
|
+
for (const raw of rawNames) {
|
|
430
|
+
const { kind, name } = classifyInvocationName(raw, rosterSkills, rosterCommands);
|
|
431
|
+
(kind === 'command' ? commandNames : skillNames).push(name);
|
|
432
|
+
}
|
|
367
433
|
|
|
368
434
|
return {
|
|
369
435
|
record_kind: 'usage-ping',
|
|
@@ -39,6 +39,7 @@ import { ensureAnonId } from './anon-id.mjs';
|
|
|
39
39
|
import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
|
|
40
40
|
import { loadOwnerConfig } from '../owner-yaml.mjs';
|
|
41
41
|
import { readJsonlFile } from '../io.mjs';
|
|
42
|
+
import { readCanonicalSessions } from '../sessions-canonical.mjs';
|
|
42
43
|
|
|
43
44
|
// ---------------------------------------------------------------------------
|
|
44
45
|
// Constants
|
|
@@ -93,15 +94,51 @@ function defaultSender({ env, timeoutMs }) {
|
|
|
93
94
|
// Batch build
|
|
94
95
|
// ---------------------------------------------------------------------------
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The canonical (#1167-deduplicated) session record most recently WRITTEN to
|
|
99
|
+
* the ledger — ranked by `completed_at` (falling back to `started_at` when
|
|
100
|
+
* absent), the closest analogue to "the last line of the file" once the reader
|
|
101
|
+
* no longer trusts append order.
|
|
102
|
+
*
|
|
103
|
+
* `readCanonicalSessions` reorders its output to "first appearance of each
|
|
104
|
+
* surviving id" (see sessions-canonical.mjs's own docstring) — it is NOT
|
|
105
|
+
* append order — so a raw `records[records.length - 1]` (the pre-#1186 read)
|
|
106
|
+
* silently picks the WRONG session once a `session_id` duplicate or a
|
|
107
|
+
* `supersedes` collapse reshuffles the array. A record with neither timestamp
|
|
108
|
+
* sorts last and is never chosen over a dated one.
|
|
109
|
+
*
|
|
110
|
+
* @param {Array<object>} records — canonical session records.
|
|
111
|
+
* @returns {object|null}
|
|
112
|
+
*/
|
|
113
|
+
function mostRecentSession(records) {
|
|
114
|
+
let best = null;
|
|
115
|
+
let bestTs = '';
|
|
116
|
+
for (const rec of records) {
|
|
117
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
118
|
+
const ts =
|
|
119
|
+
typeof rec.completed_at === 'string' && rec.completed_at
|
|
120
|
+
? rec.completed_at
|
|
121
|
+
: typeof rec.started_at === 'string'
|
|
122
|
+
? rec.started_at
|
|
123
|
+
: '';
|
|
124
|
+
if (ts && ts > bestTs) {
|
|
125
|
+
best = rec;
|
|
126
|
+
bestTs = ts;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return best;
|
|
130
|
+
}
|
|
131
|
+
|
|
96
132
|
/**
|
|
97
133
|
* Build ONE whitelist-projected usage-ping record from the local JSONL streams.
|
|
98
134
|
*
|
|
99
|
-
* Reads `<metricsDir>/sessions.jsonl`
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* the
|
|
135
|
+
* Reads `<metricsDir>/sessions.jsonl` (via `readCanonicalSessions`, #1186 — the
|
|
136
|
+
* #1167 newest-wins-per-`session_id` / `supersedes` collapse) +
|
|
137
|
+
* `<metricsDir>/skill-invocations.jsonl`. The most-recently-written CANONICAL
|
|
138
|
+
* session record (`mostRecentSession`, above) defines the session window:
|
|
139
|
+
* skill-invocations whose `timestamp >=` its `started_at` are included. When no
|
|
140
|
+
* session record exists, the ping falls back to `session_type: 'other'`,
|
|
141
|
+
* `duration_bucket: '<15m'`, and the invocations of the last 24 hours.
|
|
105
142
|
*
|
|
106
143
|
* anon-ID handling (persist=true, the send path): `ensureAnonId` runs on the
|
|
107
144
|
* telemetry.json record; a created/rotated ID is persisted via
|
|
@@ -137,10 +174,10 @@ export function buildBatch({
|
|
|
137
174
|
const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
|
|
138
175
|
const nowIso = now || new Date().toISOString();
|
|
139
176
|
|
|
140
|
-
const sessions =
|
|
177
|
+
const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
|
|
141
178
|
const invocations = readJsonlFile(path.join(dir, 'skill-invocations.jsonl'), { skipInvalid: true });
|
|
142
179
|
|
|
143
|
-
const sessionRecord = sessions
|
|
180
|
+
const sessionRecord = mostRecentSession(sessions);
|
|
144
181
|
|
|
145
182
|
let windowInvocations;
|
|
146
183
|
let sessionForPing;
|
|
@@ -331,8 +368,8 @@ export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.
|
|
|
331
368
|
if (count > 0) return true;
|
|
332
369
|
|
|
333
370
|
// (b) Catch-up: a session completed after the last successful flush.
|
|
334
|
-
// Reuses buildBatch's reader
|
|
335
|
-
// no second parser.
|
|
371
|
+
// Reuses buildBatch's reader (#1186: readCanonicalSessions +
|
|
372
|
+
// mostRecentSession) — same file, same #1167 dedupe, no second parser.
|
|
336
373
|
//
|
|
337
374
|
// Deliberate simplification (named ceiling): this parses the WHOLE
|
|
338
375
|
// sessions.jsonl to look at its last record. At the observed ledger size
|
|
@@ -340,8 +377,8 @@ export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.
|
|
|
340
377
|
// and it only runs once the 24h gate above has already passed. Revisit
|
|
341
378
|
// with a tail-read if any repo's sessions.jsonl passes ~10 MB.
|
|
342
379
|
const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
|
|
343
|
-
const sessions =
|
|
344
|
-
const last = sessions
|
|
380
|
+
const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
|
|
381
|
+
const last = mostRecentSession(sessions);
|
|
345
382
|
const completedMs = Date.parse(last?.completed_at);
|
|
346
383
|
return !Number.isNaN(completedMs) && completedMs > lastMs;
|
|
347
384
|
} catch {
|