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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* events-retention-banner.mjs — session-start probe: does the rotated events
|
|
3
|
+
* ledger still hold the TIME WINDOW its readers need? (#1401 part 3, variant a)
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* `.orchestrator/metrics/events.jsonl` rotates into
|
|
8
|
+
* `_archive/events-<firstTs>_<lastTs>.jsonl`, and `events-rotation.max-backups`
|
|
9
|
+
* bounds the NUMBER of archives kept (`events-rotation.mjs` `pruneArchives`).
|
|
10
|
+
* The readers of the rotated ledger need a TIME window instead. Count and time
|
|
11
|
+
* are different units (`.claude/rules/host-resources.md` § HR-103), so nothing
|
|
12
|
+
* noticed when a high write rate made "five archives" mean "less history than
|
|
13
|
+
* a reader reads": the pruned records simply stopped existing.
|
|
14
|
+
*
|
|
15
|
+
* Each reader declares its need as `REQUIRED_EVENTS_WINDOW_DAYS` beside its call
|
|
16
|
+
* to `readEventsWithRotations(`, `scanEventsBackwards(` or
|
|
17
|
+
* `listEventSourcesNewestFirst(` — a finite number of days, or `null` when no
|
|
18
|
+
* day count bounds its question (it then cannot set a retention floor, and its
|
|
19
|
+
* declaration comment states what a cut history does to its answer). This probe
|
|
20
|
+
* takes the MAX of the finite declarations and compares it against the span
|
|
21
|
+
* the retained ledger covers. No new Session Config key: it reads the existing
|
|
22
|
+
* `events-rotation.max-backups` from the config the runner passes.
|
|
23
|
+
*
|
|
24
|
+
* ## When it warns (HR-101 — a signal may only warn if it is rare)
|
|
25
|
+
*
|
|
26
|
+
* Only when the archive ring is FULL (`archives >= max-backups`). Below that
|
|
27
|
+
* the rotator prunes nothing under the current config, so the ledger only
|
|
28
|
+
* grows — a two-hour-old repo covering two hours is not "short retention", and
|
|
29
|
+
* judging it so would fire on every young repo's start.
|
|
30
|
+
*
|
|
31
|
+
* "Not full" does not mean "never pruned": after `max-backups` is RAISED, the
|
|
32
|
+
* archives pruned under the old, smaller ring stay gone. The oldest retained
|
|
33
|
+
* archive then opens with a rotation record whose `archived_as` names a
|
|
34
|
+
* predecessor that no longer exists — that is the hole, and the silent
|
|
35
|
+
* coverage figure then starts at the oldest archive exactly as on a full ring,
|
|
36
|
+
* never at a legacy `events.jsonl.N` older than the hole.
|
|
37
|
+
*
|
|
38
|
+
* ## Rare vs. broken (HR-105, #1489 item 2)
|
|
39
|
+
*
|
|
40
|
+
* Measured 2026-10-02: 4 of 35 fleet ledgers hold any archive, at most 3 at
|
|
41
|
+
* `max-backups: 5`, so the warning has never fired — and a warning that never
|
|
42
|
+
* fires looks the same whether it is rare or dead. Every MEASURED answer
|
|
43
|
+
* therefore carries the measurement, warn or not: `coverageDays`,
|
|
44
|
+
* `oldestEventAt`, `requiredDays`/`requiredBy`, `archives`, `maxBackups`.
|
|
45
|
+
* `coverageDays` is now minus the oldest archive's start on a FULL ring — the
|
|
46
|
+
* span the verdict judges — and below it when that archive's predecessor is
|
|
47
|
+
* gone (see above); otherwise now minus the oldest stamp of any source
|
|
48
|
+
* (archives, legacy ring, active file), where nothing was pruned.
|
|
49
|
+
* The registry entry's `telemetry` (`session-start-probes.mjs`) persists
|
|
50
|
+
* `coverage_days`, `archives`, `max_backups` and `required_days` as `measure`
|
|
51
|
+
* on this probe's element of `orchestrator.probes.completed`; `oldestEventAt`
|
|
52
|
+
* and `requiredBy` reach the caller only.
|
|
53
|
+
*
|
|
54
|
+
* ## Three states, never two
|
|
55
|
+
*
|
|
56
|
+
* - **not measured** — no live ledger and no `ARCHIVE_NAME_RE` archive in
|
|
57
|
+
* `_archive/` (absent, empty, or holding only foreign files):
|
|
58
|
+
* `{severity:'ok', kind:'not-measured', reason:'no-events-ledger'}`. The
|
|
59
|
+
* registry's precondition (`hasRotationArchive` in
|
|
60
|
+
* `session-start-probes.mjs`) lists `_archive/` the same way and skips
|
|
61
|
+
* exactly these inputs, so the runner records them as `skipped`, never as
|
|
62
|
+
* `ran-clean` — which is what severity ok would otherwise become.
|
|
63
|
+
* - **unmeasurable** — `_archive/` exists but cannot be listed, a reader's
|
|
64
|
+
* declaration cannot be read, the oldest retained stamp lies after now
|
|
65
|
+
* (a future-dated archive or a clock set back — the span would be
|
|
66
|
+
* negative), or no source carries a stamp at all (no archive, and no
|
|
67
|
+
* parseable `timestamp` in the head of the active file or a legacy-ring
|
|
68
|
+
* file — #1498: it answered `ring-not-full` with `coverageDays: null` and
|
|
69
|
+
* recorded as `ran-clean`): `{severity:'warn', degraded:true}`. An
|
|
70
|
+
* unreadable source must never read as "retention is fine".
|
|
71
|
+
* - **measured** — `{severity:'ok', kind:'ring-not-full'|'no-finite-window'|'covered', …}`
|
|
72
|
+
* or `{severity:'warn', kind:'retention-short', message, …}`.
|
|
73
|
+
*
|
|
74
|
+
* Cost: two `readdirSync` of `_archive/` per session start (the registry
|
|
75
|
+
* precondition, then this probe), below a full ring one ≤64 KiB head read of
|
|
76
|
+
* the oldest archive (for its rotation record) and — unless that shows a
|
|
77
|
+
* pruned predecessor — of the active file and of each legacy-ring file, and — on EVERY measured start,
|
|
78
|
+
* before the ring check, because `requiredDays` is part of the silent
|
|
79
|
+
* measurement — the import of every reader module to read its declaration:
|
|
80
|
+
* 20-28 ms cold for all seven in a fresh process (measured 2026-10-02, load
|
|
81
|
+
* average ~8); two of them are session-start probes and two the SessionStart
|
|
82
|
+
* backfill, already loaded in that process. So a reader module that fails to import degrades this probe on
|
|
83
|
+
* every start, not only on a full ring.
|
|
84
|
+
*
|
|
85
|
+
* @module scripts/lib/events-retention-banner
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync } from 'node:fs';
|
|
89
|
+
import path from 'node:path';
|
|
90
|
+
|
|
91
|
+
import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE, LEGACY_RING_MAX, ROTATION_EVENT } from './events-schema.mjs';
|
|
92
|
+
|
|
93
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
94
|
+
|
|
95
|
+
/** `_parseEventsRotation`'s own default (`scripts/lib/config/events-rotation.mjs`). */
|
|
96
|
+
const DEFAULT_MAX_BACKUPS = 5;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How much of a ledger file's head is read for its first timestamp. NAMED
|
|
100
|
+
* CEILING (BV-004): one record is ~300 B in this repo's ledger, so 64 KiB holds
|
|
101
|
+
* hundreds; a file whose first 64 KiB carry no parseable `timestamp` reports
|
|
102
|
+
* no stamp rather than a guessed one.
|
|
103
|
+
*/
|
|
104
|
+
const HEAD_BYTES = 64 * 1024;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every module that reads the rotated ledger. Census 2026-10-02 @ `84c107a1`:
|
|
108
|
+
* `rg -n "readEventsWithRotations\(|scanEventsBackwards\(|listEventSourcesNewestFirst\(" --glob '!tests/**' scripts hooks skills`
|
|
109
|
+
* → 6 call sites beside the definitions in `events.mjs` (and its internal
|
|
110
|
+
* `listEventSourcesNewestFirst` call); the seventh, `session-close-backfill`,
|
|
111
|
+
* joined with #1498. A test re-runs that census, so an eighth reader cannot
|
|
112
|
+
* join unlisted.
|
|
113
|
+
*/
|
|
114
|
+
export const EVENTS_WINDOW_READERS = Object.freeze([
|
|
115
|
+
{ label: 'eval/engine', spec: './eval/engine.mjs' },
|
|
116
|
+
{ label: 'telemetry/sync', spec: './telemetry/sync.mjs' },
|
|
117
|
+
{ label: 'tmux-layout/telemetry-stats', spec: './tmux-layout/telemetry-stats.mjs' },
|
|
118
|
+
{ label: 'maintenance-due-banner', spec: './maintenance-due-banner.mjs' },
|
|
119
|
+
{ label: 'instruction-budget-guard', spec: './instruction-budget-guard.mjs' },
|
|
120
|
+
{ label: 'backfill-abandoned-sessions', spec: '../backfill-abandoned-sessions.mjs' },
|
|
121
|
+
{ label: 'session-close-backfill', spec: './session-close-backfill.mjs' },
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Epoch ms of the START of an archive's range, from its name alone. An
|
|
126
|
+
* `unknown` first stamp falls back to the LAST stamp — a later instant, so the
|
|
127
|
+
* covered span is understated and the probe errs toward reporting.
|
|
128
|
+
*
|
|
129
|
+
* Total over `ARCHIVE_NAME_RE` matches, the only names it is given: the regex
|
|
130
|
+
* guarantees two digit stamps, and `Date.UTC` normalizes an out-of-range field
|
|
131
|
+
* (month 13 → January of the next year) instead of returning NaN. The NaN
|
|
132
|
+
* return exists only to type the regex miss; no caller can reach it.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} name — matches `ARCHIVE_NAME_RE`
|
|
135
|
+
* @returns {number} epoch ms; NaN only for a name outside `ARCHIVE_NAME_RE`
|
|
136
|
+
*/
|
|
137
|
+
function archiveStartMs(name) {
|
|
138
|
+
const m = /^events-(\d{8}T\d{6}Z|unknown)_(\d{8}T\d{6}Z)/.exec(name);
|
|
139
|
+
if (!m) return Number.NaN;
|
|
140
|
+
const stamp = m[1] === 'unknown' ? m[2] : m[1];
|
|
141
|
+
const p = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
|
142
|
+
return p ? Date.UTC(+p[1], +p[2] - 1, +p[3], +p[4], +p[5], +p[6]) : Number.NaN;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The parseable JSON records within the first {@link HEAD_BYTES} of `file`, in
|
|
147
|
+
* file order; `[]` when absent or unreadable. Malformed lines — and the partial
|
|
148
|
+
* last line of the head — are skipped, never guessed at.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} file
|
|
151
|
+
* @returns {Array<*>}
|
|
152
|
+
*/
|
|
153
|
+
function headRecords(file) {
|
|
154
|
+
let fd;
|
|
155
|
+
let text = '';
|
|
156
|
+
try {
|
|
157
|
+
fd = openSync(file, 'r');
|
|
158
|
+
const buf = Buffer.alloc(HEAD_BYTES);
|
|
159
|
+
const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
|
|
160
|
+
text = buf.subarray(0, n).toString('utf8');
|
|
161
|
+
} catch {
|
|
162
|
+
/* absent or unreadable — no records from this file */
|
|
163
|
+
} finally {
|
|
164
|
+
if (fd !== undefined) {
|
|
165
|
+
try {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
} catch {
|
|
168
|
+
/* best-effort */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const records = [];
|
|
173
|
+
for (const line of text.split('\n')) {
|
|
174
|
+
try {
|
|
175
|
+
records.push(JSON.parse(line));
|
|
176
|
+
} catch {
|
|
177
|
+
/* malformed, or the partial last line of the head */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return records;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Epoch ms of the first parseable `timestamp` within the head of `file` — the
|
|
185
|
+
* file's oldest event, since every ledger file is append-ordered. NaN when
|
|
186
|
+
* absent, unreadable, or stampless: the caller then has no stamp from this
|
|
187
|
+
* file, never a guessed one.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} file
|
|
190
|
+
* @returns {number}
|
|
191
|
+
*/
|
|
192
|
+
function firstTimestampMs(file) {
|
|
193
|
+
for (const rec of headRecords(file)) {
|
|
194
|
+
const ms = Date.parse(rec?.timestamp);
|
|
195
|
+
if (Number.isFinite(ms)) return ms;
|
|
196
|
+
}
|
|
197
|
+
return Number.NaN;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Was history pruned before `oldest`, the oldest retained archive? Its head
|
|
202
|
+
* carries the rotation record written when its predecessor was archived, and
|
|
203
|
+
* that record's `archived_as` names the predecessor: an archive name absent
|
|
204
|
+
* from `names` was pruned — under a smaller ring, before `max-backups` was
|
|
205
|
+
* raised — or deleted. `false` when the head holds no rotation record: the
|
|
206
|
+
* hole then goes unseen, it is never invented.
|
|
207
|
+
*
|
|
208
|
+
* Named ceiling (BV-004, #1498). A head without a rotation record is the FIRST
|
|
209
|
+
* archive a ledger ever wrote (its file predates any rotation, and the legacy
|
|
210
|
+
* ring wrote none) — no archive can have been pruned before it, so nothing is
|
|
211
|
+
* missed there. The tombstone is appended right after the rename, so it sits on
|
|
212
|
+
* the first lines, far inside {@link HEAD_BYTES}. The blind case is a later
|
|
213
|
+
* archive whose tombstone was never written (`maybeRotate` → `recordWritten:
|
|
214
|
+
* false`) AND whose predecessor is gone. Measured 2026-10-02 over the 5 fleet
|
|
215
|
+
* `_archive/` dirs: 4 of 4 first archives carry no tombstone, 2 of 2 later
|
|
216
|
+
* archives carry it on line 0. Revisit if a census finds a later archive
|
|
217
|
+
* without one.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} archiveDir
|
|
220
|
+
* @param {string} oldest — a member of `names`
|
|
221
|
+
* @param {string[]} names — the retained `ARCHIVE_NAME_RE` archives
|
|
222
|
+
* @returns {boolean}
|
|
223
|
+
*/
|
|
224
|
+
function predecessorPruned(archiveDir, oldest, names) {
|
|
225
|
+
const rotation = headRecords(path.join(archiveDir, oldest)).find((r) => r?.event === ROTATION_EVENT);
|
|
226
|
+
if (typeof rotation?.archived_as !== 'string') return false;
|
|
227
|
+
const predecessor = path.basename(rotation.archived_as);
|
|
228
|
+
return ARCHIVE_NAME_RE.test(predecessor) && !names.includes(predecessor);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The largest finite window any reader declares, with the reader naming it.
|
|
233
|
+
*
|
|
234
|
+
* @param {ReadonlyArray<{label: string, spec: string}>} readers
|
|
235
|
+
* @returns {Promise<{days: number|null, by: string|null}>}
|
|
236
|
+
* @throws when a reader cannot be imported or declares a non-window value
|
|
237
|
+
*/
|
|
238
|
+
async function maxRequiredWindow(readers) {
|
|
239
|
+
const values = await Promise.all(
|
|
240
|
+
readers.map(async (r) => {
|
|
241
|
+
const mod = await import(new URL(r.spec, import.meta.url).href);
|
|
242
|
+
const v = mod.REQUIRED_EVENTS_WINDOW_DAYS;
|
|
243
|
+
if (v !== null && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
|
|
244
|
+
throw new Error(`${r.label} declares no REQUIRED_EVENTS_WINDOW_DAYS`);
|
|
245
|
+
}
|
|
246
|
+
return { days: v, by: r.label };
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
let best = { days: null, by: null };
|
|
250
|
+
for (const v of values) {
|
|
251
|
+
if (v.days !== null && (best.days === null || v.days > best.days)) best = v;
|
|
252
|
+
}
|
|
253
|
+
return best;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Compare the retained events ledger's time span against the window its
|
|
258
|
+
* readers need. Never throws; never blocks.
|
|
259
|
+
*
|
|
260
|
+
* @param {object} opts
|
|
261
|
+
* @param {string} opts.repoRoot
|
|
262
|
+
* @param {object} [opts.config] — parsed Session Config (`events-rotation.max-backups`)
|
|
263
|
+
* @param {number} [opts.now=Date.now()] — injectable clock, epoch ms
|
|
264
|
+
* @returns {Promise<null|{severity: 'ok'|'warn', kind: string, message?: string,
|
|
265
|
+
* degraded?: boolean, reason?: string, coverageDays?: number,
|
|
266
|
+
* oldestEventAt?: string, requiredDays?: number|null, requiredBy?: string|null,
|
|
267
|
+
* archives?: number, maxBackups?: number}>} `null` only without a `repoRoot`.
|
|
268
|
+
*/
|
|
269
|
+
export async function checkEventsRetention({ repoRoot, config, now = Date.now() } = {}) {
|
|
270
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
271
|
+
const metricsDir = path.join(repoRoot, '.orchestrator', 'metrics');
|
|
272
|
+
const archiveDir = path.join(metricsDir, ARCHIVE_DIR_NAME);
|
|
273
|
+
const activePath = path.join(metricsDir, 'events.jsonl');
|
|
274
|
+
const nowMs = typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
|
|
275
|
+
|
|
276
|
+
let names;
|
|
277
|
+
try {
|
|
278
|
+
names = readdirSync(archiveDir).filter((n) => ARCHIVE_NAME_RE.test(n));
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (err?.code !== 'ENOENT') {
|
|
281
|
+
return {
|
|
282
|
+
severity: 'warn',
|
|
283
|
+
degraded: true,
|
|
284
|
+
kind: 'unmeasurable',
|
|
285
|
+
message: `⚠ events-retention: could not list ${ARCHIVE_DIR_NAME}/ (${err?.code ?? 'error'}) — retention span not measured, which is not the same as covered.`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
names = [];
|
|
289
|
+
}
|
|
290
|
+
// Mirrored by the registry precondition, which skips these inputs before the
|
|
291
|
+
// runner could record this severity-ok answer as `ran-clean`.
|
|
292
|
+
if (names.length === 0 && !existsSync(activePath)) {
|
|
293
|
+
return { severity: 'ok', kind: 'not-measured', reason: 'no-events-ledger' };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let required;
|
|
297
|
+
try {
|
|
298
|
+
required = await maxRequiredWindow(EVENTS_WINDOW_READERS);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return {
|
|
301
|
+
severity: 'warn',
|
|
302
|
+
degraded: true,
|
|
303
|
+
kind: 'unmeasurable',
|
|
304
|
+
message: `⚠ events-retention: required window not readable (${String(err?.message ?? err).slice(0, 120)}) — retention span not judged, which is not the same as covered.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const raw = config?.['events-rotation']?.['max-backups'];
|
|
309
|
+
const maxBackups = Number.isInteger(raw) && raw >= 1 ? raw : DEFAULT_MAX_BACKUPS;
|
|
310
|
+
const ringFull = names.length >= maxBackups;
|
|
311
|
+
|
|
312
|
+
// A FULL ring is judged on its archives alone. The rotator prunes archives
|
|
313
|
+
// but never the legacy ring, so a legacy `events.jsonl.N` from before the
|
|
314
|
+
// migration outlives the pruned archives and its stamp would span the hole
|
|
315
|
+
// they left (#1489 review MED-1: a `.1` from April turned 20 hours of
|
|
316
|
+
// contiguous history into "173.5d, covered"). Below a full ring the same
|
|
317
|
+
// hole exists when an archive was pruned under an earlier, smaller ring
|
|
318
|
+
// (`max-backups` raised since): the oldest archive's rotation record names a
|
|
319
|
+
// predecessor that is gone. Only without such a hole does every source
|
|
320
|
+
// count toward the silent coverage figure.
|
|
321
|
+
const oldestArchive = names.reduce(
|
|
322
|
+
(a, b) => (a === null || archiveStartMs(b) < archiveStartMs(a) ? b : a),
|
|
323
|
+
null,
|
|
324
|
+
);
|
|
325
|
+
const holeBeforeArchives = ringFull || (oldestArchive !== null && predecessorPruned(archiveDir, oldestArchive, names));
|
|
326
|
+
const starts = [
|
|
327
|
+
...names.map(archiveStartMs),
|
|
328
|
+
...(holeBeforeArchives
|
|
329
|
+
? []
|
|
330
|
+
: [
|
|
331
|
+
...Array.from({ length: LEGACY_RING_MAX }, (_, i) => `${activePath}.${i + 1}`)
|
|
332
|
+
.filter((p) => existsSync(p))
|
|
333
|
+
.map(firstTimestampMs),
|
|
334
|
+
firstTimestampMs(activePath),
|
|
335
|
+
]),
|
|
336
|
+
].filter(Number.isFinite);
|
|
337
|
+
const oldestMs = starts.length > 0 ? Math.min(...starts) : Number.NaN;
|
|
338
|
+
|
|
339
|
+
// Every retained source dated after now — a future-dated archive, or a clock
|
|
340
|
+
// set back — makes the span negative. That is no measurement, and an
|
|
341
|
+
// unmeasured span must never record as clean, so it degrades like an
|
|
342
|
+
// unreadable source instead of persisting a negative `coverage_days`.
|
|
343
|
+
if (oldestMs > nowMs) {
|
|
344
|
+
return {
|
|
345
|
+
severity: 'warn',
|
|
346
|
+
degraded: true,
|
|
347
|
+
kind: 'unmeasurable',
|
|
348
|
+
message: `⚠ events-retention: the oldest retained event is dated ${new Date(oldestMs).toISOString()}, after now — retention span not measured, which is not the same as covered.`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// No stamp at all (#1498) — reachable only without archives (every archive
|
|
353
|
+
// dates itself by name), when the first 64 KiB of the active file and of
|
|
354
|
+
// every legacy-ring file carry no parseable `timestamp`. That span was not
|
|
355
|
+
// measured; answered `ring-not-full` with `coverageDays: null` it recorded
|
|
356
|
+
// as `ran-clean`. Census 2026-10-02: 0 of 37 fleet `events.jsonl` heads are
|
|
357
|
+
// stampless, so this degrades rarely (HR-101) — and visibly (HR-105).
|
|
358
|
+
if (!Number.isFinite(oldestMs)) {
|
|
359
|
+
return {
|
|
360
|
+
severity: 'warn',
|
|
361
|
+
degraded: true,
|
|
362
|
+
kind: 'unmeasurable',
|
|
363
|
+
message: `⚠ events-retention: no parseable timestamp in the first ${HEAD_BYTES / 1024} KiB of events.jsonl (or of a legacy events.jsonl.N) and no ${ARCHIVE_DIR_NAME}/ archive — retention span not measured, which is not the same as covered.`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const measured = {
|
|
368
|
+
coverageDays: (nowMs - oldestMs) / DAY_MS,
|
|
369
|
+
oldestEventAt: new Date(oldestMs).toISOString(),
|
|
370
|
+
requiredDays: required.days,
|
|
371
|
+
requiredBy: required.by,
|
|
372
|
+
archives: names.length,
|
|
373
|
+
maxBackups,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Ring not full ⇒ nothing is pruned under this config, so the retained
|
|
377
|
+
// ledger only grows; any older hole is already excluded from the figure
|
|
378
|
+
// above. Never a warning here — see the module header (HR-101).
|
|
379
|
+
if (!ringFull) return { severity: 'ok', kind: 'ring-not-full', ...measured };
|
|
380
|
+
if (required.days === null) return { severity: 'ok', kind: 'no-finite-window', ...measured };
|
|
381
|
+
// Finite here: the ring is full, so `names` is non-empty and
|
|
382
|
+
// `archiveStartMs` is total over `ARCHIVE_NAME_RE` matches.
|
|
383
|
+
if (measured.coverageDays >= required.days) return { severity: 'ok', kind: 'covered', ...measured };
|
|
384
|
+
const cov = measured.coverageDays.toFixed(1);
|
|
385
|
+
return {
|
|
386
|
+
severity: 'warn',
|
|
387
|
+
kind: 'retention-short',
|
|
388
|
+
// Names what the rule judged (HR-106): the measured span against the
|
|
389
|
+
// reader's DECLARED window — not a claim about how the reader reads.
|
|
390
|
+
message: `⚠ events-retention: the rotated events ledger covers ${cov}d (${names.length} archives at events-rotation.max-backups: ${maxBackups}), less than the ${required.days}d window ${required.by} declares as REQUIRED_EVENTS_WINDOW_DAYS — events older than ${cov}d are not retained; raise events-rotation.max-backups or max-size-mb.`,
|
|
391
|
+
...measured,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* synchronous fs calls (the hook is short-lived; async adds complexity).
|
|
7
7
|
*
|
|
8
8
|
* Issue #251 (CRITICAL). Rotation fires only at session-start, not per-append —
|
|
9
|
-
* per
|
|
9
|
+
* a size check per append buys nothing when one 10 MiB file takes days to
|
|
10
|
+
* weeks to fill (measured growth: see {@link pruneArchives}).
|
|
10
11
|
*
|
|
11
12
|
* Rename safety (POSIX): atomic rename is safe with in-flight writers. Old fds
|
|
12
13
|
* continue writing to the original inode (now the archive); new writers will
|
|
@@ -104,8 +105,9 @@ function uniqueArchivePath(dir, firstTs, lastTs) {
|
|
|
104
105
|
const to = lastTs ? compactStamp(lastTs) : compactStamp(new Date().toISOString());
|
|
105
106
|
const base = `events-${from}_${to}`;
|
|
106
107
|
let candidate = path.join(dir, `${base}.jsonl`);
|
|
107
|
-
// Ceiling (BV-004): 999 same-range archives. Rotation fires
|
|
108
|
-
//
|
|
108
|
+
// Ceiling (BV-004): 999 same-range archives. Rotation fires every ~52 days in
|
|
109
|
+
// this repo and every ~5 days in the busiest fleet repo (measured 2026-10-02,
|
|
110
|
+
// see `pruneArchives`), and the range is content-derived, so a second
|
|
109
111
|
// collision already implies something is re-rotating identical content.
|
|
110
112
|
// Revisit if a `pruned`-less archive dir is ever seen holding a `-3` suffix.
|
|
111
113
|
for (let n = 2; existsSync(candidate); n += 1) {
|
|
@@ -122,8 +124,19 @@ function uniqueArchivePath(dir, firstTs, lastTs) {
|
|
|
122
124
|
*
|
|
123
125
|
* The cap is kept rather than dropped because `events-rotation.max-backups` is
|
|
124
126
|
* a live, documented config key: a key whose reader silently stops enforcing it
|
|
125
|
-
* is the "config key nobody produces" defect in reverse.
|
|
126
|
-
*
|
|
127
|
+
* is the "config key nobody produces" defect in reverse.
|
|
128
|
+
*
|
|
129
|
+
* What `max-backups: 5` retains, measured 2026-10-02 (line bytes summed per
|
|
130
|
+
* calendar day of each record's `timestamp`, via a `node` read of
|
|
131
|
+
* `.orchestrator/metrics/events.jsonl` and `_archive/`): this repo's active
|
|
132
|
+
* ledger grew 2,770,612 B over 13.7 days (2026-09-18 → 2026-10-02), i.e.
|
|
133
|
+
* ~202,570 B/day with a 1,207,177 B peak day; across 35 fleet ledgers the median
|
|
134
|
+
* active-file rate was 59,388 B/day, and the busiest repo averaged ~2.1 MB/day
|
|
135
|
+
* (43,978,706 B over 2026-09-11 → 2026-10-02) with a 10,248,010 B peak day.
|
|
136
|
+
* One archive holds ≥10 MiB, so five archives retain ~8.5 months here and ~3.5
|
|
137
|
+
* weeks in the busiest repo — not the "roughly two years" this comment used to
|
|
138
|
+
* claim from a ~6 KiB/day figure. Whether that suffices is judged per reader
|
|
139
|
+
* window by `events-retention-banner.mjs`.
|
|
127
140
|
*
|
|
128
141
|
* Only names matching {@link ARCHIVE_NAME_RE} are eligible, and the archive
|
|
129
142
|
* just written is never a candidate. A failed unlink is swallowed: leaving one
|
|
@@ -221,7 +234,9 @@ export function maybeRotate({ logPath, maxSizeMb, maxBackups, enabled } = {}) {
|
|
|
221
234
|
|
|
222
235
|
// Read BEFORE the rename: the content is what names the archive. Cost
|
|
223
236
|
// ceiling (BV-004): one full read of a file at the rotation threshold —
|
|
224
|
-
// ~50 ms and ~40 MB transient at the default 10 MB cap, paid
|
|
237
|
+
// ~50 ms and ~40 MB transient at the default 10 MB cap, paid once per
|
|
238
|
+
// rotation (every ~52 days here, ~5 days in the busiest fleet repo —
|
|
239
|
+
// measured 2026-10-02, see `pruneArchives`).
|
|
225
240
|
// Revisit if `max-size-mb` is ever raised past ~100.
|
|
226
241
|
const { records, malformedLines } = parseEventLines(readFileSync(logPath, 'utf8'));
|
|
227
242
|
const { firstTs, lastTs } = summarizeEventRecords(records);
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -826,7 +826,13 @@ export function listEventSourcesNewestFirst(opts = {}) {
|
|
|
826
826
|
* a stop, the walk ends with `truncated: true` — which is NOT "not found":
|
|
827
827
|
* the caller must report undeterminable, never a clean negative.
|
|
828
828
|
* @returns {{stopped: boolean, truncated: boolean, malformed_lines: number,
|
|
829
|
-
* sources: string[], unreadable: string[]}}
|
|
829
|
+
* sources: string[], unreadable: string[], gaps: object[]}} `gaps`
|
|
830
|
+
* (#1489): every rotation tombstone the walk passed, on any line and
|
|
831
|
+
* regardless of `filter`, whose archive is not one of this walk's sources — pruned
|
|
832
|
+
* (`missing-archive`) or renamed out of `ARCHIVE_NAME_RE`
|
|
833
|
+
* (`unindexed-archive`), the same two kinds and fields
|
|
834
|
+
* {@link readEventsWithRotations} reports. Non-empty ⇒ the history the walk
|
|
835
|
+
* read is CUT, so a walk that found nothing has not proven "never happened".
|
|
830
836
|
*/
|
|
831
837
|
export function scanEventsBackwards(opts = {}) {
|
|
832
838
|
const { onRecord, filter, budgetMs } = opts;
|
|
@@ -840,16 +846,58 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
840
846
|
|
|
841
847
|
const scanned = [];
|
|
842
848
|
const unreadable = [];
|
|
849
|
+
const gaps = [];
|
|
843
850
|
let malformed = 0;
|
|
844
851
|
let stopped = false;
|
|
845
852
|
let truncated = false;
|
|
846
853
|
|
|
854
|
+
const sources = listEventSourcesNewestFirst(opts);
|
|
855
|
+
const listed = new Set(sources.map((s) => s.path));
|
|
856
|
+
// Same construction as `discoverArchives`, so a listed archive matches by string.
|
|
857
|
+
const ownArchiveDir = path.join(
|
|
858
|
+
path.dirname(opts.filePath ?? eventsFilePath(opts.repoRoot)),
|
|
859
|
+
ARCHIVE_DIR_NAME,
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Every rotation tombstone the walk passes is checked, on whatever line it
|
|
864
|
+
* sits — the semantics of {@link readEventsWithRotations}, which checks every
|
|
865
|
+
* rotation record of every source. Not only first lines (#1489 review
|
|
866
|
+
* LOW-2): `maybeRotate` renames, prunes, THEN appends the tombstone, from an
|
|
867
|
+
* async SessionStart hook, so a concurrent append can land first and push it
|
|
868
|
+
* to line 2. Called BEFORE the caller's `filter`, which names another event
|
|
869
|
+
* and would otherwise hide the cut. Resolved by basename against this
|
|
870
|
+
* ledger's own `_archive/`, never by the absolute provenance value (#1411).
|
|
871
|
+
*/
|
|
872
|
+
const noteCut = (line, source) => {
|
|
873
|
+
if (!line.includes(ROTATION_EVENT)) return;
|
|
874
|
+
let record;
|
|
875
|
+
try {
|
|
876
|
+
record = JSON.parse(line);
|
|
877
|
+
} catch {
|
|
878
|
+
return; // counted by `consume` like every other malformed line
|
|
879
|
+
}
|
|
880
|
+
const target = record?.event === ROTATION_EVENT ? record.archived_as : null;
|
|
881
|
+
if (typeof target !== 'string' || target.length === 0) return;
|
|
882
|
+
const sibling = path.join(ownArchiveDir, path.basename(target));
|
|
883
|
+
if (listed.has(sibling)) return;
|
|
884
|
+
gaps.push({
|
|
885
|
+
kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
|
|
886
|
+
path: sibling,
|
|
887
|
+
archived_as: target,
|
|
888
|
+
first_ts: record.first_ts ?? null,
|
|
889
|
+
last_ts: record.last_ts ?? null,
|
|
890
|
+
reported_by: source.path,
|
|
891
|
+
});
|
|
892
|
+
};
|
|
893
|
+
|
|
847
894
|
/** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */
|
|
848
895
|
const consume = (block, source) => {
|
|
849
896
|
const lines = block.toString('utf8').split('\n');
|
|
850
897
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
851
898
|
const line = lines[i];
|
|
852
899
|
if (!line) continue;
|
|
900
|
+
noteCut(line, source);
|
|
853
901
|
if (typeof filter === 'string' && !line.includes(filter)) continue;
|
|
854
902
|
let record;
|
|
855
903
|
try {
|
|
@@ -863,7 +911,7 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
863
911
|
return false;
|
|
864
912
|
};
|
|
865
913
|
|
|
866
|
-
for (const source of
|
|
914
|
+
for (const source of sources) {
|
|
867
915
|
if (stopped || truncated) break;
|
|
868
916
|
if (outOfTime()) {
|
|
869
917
|
truncated = true;
|
|
@@ -915,5 +963,5 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
915
963
|
}
|
|
916
964
|
}
|
|
917
965
|
|
|
918
|
-
return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable };
|
|
966
|
+
return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable, gaps };
|
|
919
967
|
}
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* <navigatorDir>/auflagen/<session_id>.json — conditions issued to a session
|
|
11
11
|
*
|
|
12
12
|
* `<navigatorDir>` is `NAVIGATOR_CONFIG_DIR` (trimmed; empty/whitespace counts as
|
|
13
|
-
* unset) or `<home>/.config/navigator`. Files are 0600, directories 0700
|
|
14
|
-
* timestamps
|
|
13
|
+
* unset) or `<home>/.config/navigator`. Files are 0600, directories 0700.
|
|
14
|
+
* Check-in and lease timestamps are UTC with `Z`, in a form per file: the
|
|
15
|
+
* check-in's `zeit` is seconds, `date -u +%FT%TZ` (`YYYY-MM-DDTHH:MM:SSZ`,
|
|
16
|
+
* stamped by the CLI); the lease's `seit` / `laeuft_ab` are seconds or
|
|
17
|
+
* milliseconds (`LEASE_TS_RE`); the auflagen content belongs to the navigator.
|
|
15
18
|
*
|
|
16
19
|
* "A navigator is active" means exactly one thing: a readable, well-formed lease
|
|
17
20
|
* whose `laeuft_ab` lies in the future. Everything else is fail-closed — an
|
|
@@ -122,6 +125,21 @@ export function utcSecondsTimestamp(date = new Date()) {
|
|
|
122
125
|
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
/**
|
|
129
|
+
* When the Standard-Auflagen apply to a check-in written at `zeit` if no
|
|
130
|
+
* `auflagen/<session_id>.json` has appeared: {@link CHECKIN_FALLBACK_MIN} later
|
|
131
|
+
* while a navigator is active (it may still write one), and at `zeit` itself
|
|
132
|
+
* otherwise — with no active navigator nobody will, so the fallback is due at
|
|
133
|
+
* once (#1501 item 8: this deadline lived only as prose in session-start).
|
|
134
|
+
* @param {string} zeit the check-in's `zeit`, as {@link utcSecondsTimestamp} writes it
|
|
135
|
+
* @param {'active'|'none'|'unreadable'} navigatorState from {@link readNavigatorLease}
|
|
136
|
+
* @returns {string} same form as `zeit`
|
|
137
|
+
*/
|
|
138
|
+
export function fallbackDueAt(zeit, navigatorState) {
|
|
139
|
+
if (navigatorState !== 'active') return zeit;
|
|
140
|
+
return utcSecondsTimestamp(new Date(Date.parse(zeit) + CHECKIN_FALLBACK_MIN * 60_000));
|
|
141
|
+
}
|
|
142
|
+
|
|
125
143
|
/** @param {unknown} v */
|
|
126
144
|
function isPlainObject(v) {
|
|
127
145
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -34,6 +34,7 @@ export { assertNodeVersion, assertDepInstalled, checkEnvironment } from './env-c
|
|
|
34
34
|
|
|
35
35
|
// B) Scope / pattern primitives
|
|
36
36
|
export {
|
|
37
|
+
/** @deprecated Use findOwnScopeFile from scope-gate.mjs because first-file selection ignores manifest ownership and can select a peer session's manifest. */
|
|
37
38
|
findScopeFile,
|
|
38
39
|
getEnforcementLevel,
|
|
39
40
|
gateEnabled,
|
|
@@ -27,8 +27,11 @@ export const DEFAULT_LOCK_TTL_HOURS = 4;
|
|
|
27
27
|
/**
|
|
28
28
|
* Heartbeat-based liveness for a parsed session.lock — a self-contained mirror
|
|
29
29
|
* of session-lock.mjs `isLockLive` (which is the SSOT for the semantics). A lock
|
|
30
|
-
* is live when (now - last_heartbeat) < ttl_hours
|
|
31
|
-
*
|
|
30
|
+
* is live when (now - last_heartbeat) < ttl_hours. A lock without
|
|
31
|
+
* `last_heartbeat` is not live — no `started_at` fallback since the #595 sunset
|
|
32
|
+
* (2026-10-02), same as the SSOT. Nor is one stamped more than 5 minutes in the
|
|
33
|
+
* future (#1494 — the SSOT's FUTURE_HEARTBEAT_TOLERANCE_MS). Returns false for
|
|
34
|
+
* malformed input (→ treated as an orphaned lease).
|
|
32
35
|
*
|
|
33
36
|
* Exported (additive-only) so tests/lib/lock-ttl-parity.test.mjs can drive both
|
|
34
37
|
* this mirror and the SSOT with identical fixtures and assert identical verdicts.
|
|
@@ -39,11 +42,10 @@ export const DEFAULT_LOCK_TTL_HOURS = 4;
|
|
|
39
42
|
*/
|
|
40
43
|
export function lockIsLive(lock, nowMs) {
|
|
41
44
|
if (!lock || typeof lock !== 'object') return false;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
: lock.started_at;
|
|
45
|
-
const ms = Date.parse(hb);
|
|
45
|
+
if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return false;
|
|
46
|
+
const ms = Date.parse(lock.last_heartbeat);
|
|
46
47
|
if (Number.isNaN(ms)) return false;
|
|
48
|
+
if (ms > nowMs + 5 * 60 * 1000) return false;
|
|
47
49
|
const ttlHours = typeof lock.ttl_hours === 'number' ? lock.ttl_hours : DEFAULT_LOCK_TTL_HOURS;
|
|
48
50
|
return (nowMs - ms) < ttlHours * 3600 * 1000;
|
|
49
51
|
}
|
|
@@ -1306,6 +1306,14 @@ export function computeInstructionBudget(opts = {}) {
|
|
|
1306
1306
|
/** Ledger event the backlog number below is read from (`reconcile/engine.mjs`). */
|
|
1307
1307
|
const RECONCILE_EVENT = 'orchestrator.reconcile.completed';
|
|
1308
1308
|
|
|
1309
|
+
/**
|
|
1310
|
+
* Events-ledger window for the #1401 census (`events-retention-banner.mjs`):
|
|
1311
|
+
* `null` — {@link readLastReconcileCapped} wants the NEWEST reconcile record,
|
|
1312
|
+
* whose age no constant bounds; a pruned one reads as not measured (`null`,
|
|
1313
|
+
* rendered as absence), never as a measured `0`.
|
|
1314
|
+
*/
|
|
1315
|
+
export const REQUIRED_EVENTS_WINDOW_DAYS = null;
|
|
1316
|
+
|
|
1309
1317
|
/**
|
|
1310
1318
|
* Wall-clock budget for the backwards ledger walk. Deliberately small: this
|
|
1311
1319
|
* runs inside a session-start probe whose own budget is 2 s, and the number it
|