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
|
@@ -43,6 +43,7 @@ import { parseArgs } from 'node:util';
|
|
|
43
43
|
|
|
44
44
|
import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
|
|
45
45
|
import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst } from './lib/events.mjs';
|
|
46
|
+
import { ARCHIVE_DIR_NAME, ROTATION_EVENT } from './lib/events-schema.mjs';
|
|
46
47
|
import { getProjectDir } from './lib/platform.mjs';
|
|
47
48
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
48
49
|
import { isLockLive, readLock } from './lib/session-lock.mjs';
|
|
@@ -52,6 +53,15 @@ const LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
|
|
|
52
53
|
const SESSION_STARTED = 'orchestrator.session.started';
|
|
53
54
|
const SESSION_ENDED = 'orchestrator.session.ended';
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Events-ledger window for the #1401 census (`scripts/lib/events-retention-banner.mjs`):
|
|
58
|
+
* `null` — {@link planSessions} takes every `session.started` ever written as a
|
|
59
|
+
* candidate, so no day count bounds it (`DEFAULT_TTL_HOURS` is a minimum AGE,
|
|
60
|
+
* not a window). A pruned history yields fewer candidates; the run summary says
|
|
61
|
+
* so as `history_gaps` (#1498).
|
|
62
|
+
*/
|
|
63
|
+
export const REQUIRED_EVENTS_WINDOW_DAYS = null;
|
|
64
|
+
|
|
55
65
|
/**
|
|
56
66
|
* Default cap on how many candidates may reach the (expensive) shared core in a
|
|
57
67
|
* single run. Only the SessionStart path passes a limit; the CLI stays uncapped.
|
|
@@ -59,6 +69,25 @@ const SESSION_ENDED = 'orchestrator.session.ended';
|
|
|
59
69
|
*/
|
|
60
70
|
export const SESSION_START_LIMIT = 25;
|
|
61
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Total wall-clock budget, per SessionStart run, for scanning the ROTATED
|
|
74
|
+
* ledger on behalf of candidates whose events left the active file (#1498).
|
|
75
|
+
* ONE deadline shared by every candidate; a candidate it leaves unread is
|
|
76
|
+
* `skipped-history-unread` and retried by the next start — recorded ones drop
|
|
77
|
+
* out through the sessions.jsonl pre-filter, so the backlog drains.
|
|
78
|
+
*
|
|
79
|
+
* Named ceiling (BV-004): at ≈0.55-0.8 ms per MB per candidate (measured
|
|
80
|
+
* 2026-10-02, see `gatherSessionEvents` in `session-close-backfill.mjs`) a full
|
|
81
|
+
* default ledger (6 × 10 MB) fits ~10-15 scans per start. Measured the same
|
|
82
|
+
* day over 58 MB with 25 archived candidates (dry run, load ~5): unbudgeted
|
|
83
|
+
* 1.41 s; budget 0 — planning and active-file reads only — 0.55 s; budget 500
|
|
84
|
+
* 0.83-0.84 s, 10 candidates answered and 15 deferred. A single scan longer
|
|
85
|
+
* than the budget — a ledger past ~600 MB — never completes here; the CLI path
|
|
86
|
+
* is unbudgeted and still records it. Revisit if `skipped-history-unread`
|
|
87
|
+
* appears on more than a few consecutive starts of one repo.
|
|
88
|
+
*/
|
|
89
|
+
export const SESSION_START_ARCHIVE_SCAN_BUDGET_MS = 500;
|
|
90
|
+
|
|
62
91
|
/**
|
|
63
92
|
* Read a JSONL file into parsed objects; MISSING (ENOENT) → `[]` silently,
|
|
64
93
|
* UNREADABLE (EACCES/EISDIR/…) → `[]` with a stderr WARN (#1188); malformed
|
|
@@ -126,6 +155,23 @@ function readJsonl(filePath) {
|
|
|
126
155
|
* @returns {Array<{ sessionId: string, semanticSessionId: string|null }>}
|
|
127
156
|
*/
|
|
128
157
|
export function planSessions({ repoRoot }) {
|
|
158
|
+
return planWithGaps({ repoRoot }).plan;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* {@link planSessions} plus the count of rotation tombstones whose archive is
|
|
163
|
+
* not one of the sources read (#1498). Pruned history used to shrink the plan
|
|
164
|
+
* silently; non-zero `historyGaps` says the candidate count is a floor.
|
|
165
|
+
*
|
|
166
|
+
* The check is `readEventsWithRotations` rule 1 (`scripts/lib/events.mjs`):
|
|
167
|
+
* resolved by BASENAME against this ledger's own `_archive/` (#1411), and a
|
|
168
|
+
* file that exists but is not a source (renamed out of `ARCHIVE_NAME_RE`)
|
|
169
|
+
* counts as a gap too — existence is not reading (#1423).
|
|
170
|
+
*
|
|
171
|
+
* @param {{ repoRoot: string }} args
|
|
172
|
+
* @returns {{ plan: Array<{ sessionId: string, semanticSessionId: string|null }>, historyGaps: number }}
|
|
173
|
+
*/
|
|
174
|
+
function planWithGaps({ repoRoot }) {
|
|
129
175
|
const eventsPath = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
|
|
130
176
|
|
|
131
177
|
// Two independent UUID -> semantic bridges (#1167). `lock.acquired` is the
|
|
@@ -150,9 +196,17 @@ export function planSessions({ repoRoot }) {
|
|
|
150
196
|
// (`hooks/on-session-start.mjs` → `backfillOnSessionStart`). Reversing the
|
|
151
197
|
// newest-first source list keeps first-seen order chronological, exactly as
|
|
152
198
|
// the single-file read produced it.
|
|
153
|
-
|
|
199
|
+
const sources = listEventSourcesNewestFirst({ filePath: eventsPath });
|
|
200
|
+
const read = new Set(sources.map((s) => s.path));
|
|
201
|
+
const archiveDir = path.join(path.dirname(eventsPath), ARCHIVE_DIR_NAME);
|
|
202
|
+
let historyGaps = 0;
|
|
203
|
+
for (const source of [...sources].reverse()) {
|
|
154
204
|
forEachJsonlRecord(source.path, (ev) => {
|
|
155
205
|
if (!ev || typeof ev !== 'object') return;
|
|
206
|
+
if (ev.event === ROTATION_EVENT && typeof ev.archived_as === 'string' && ev.archived_as.length > 0
|
|
207
|
+
&& !read.has(path.join(archiveDir, path.basename(ev.archived_as)))) {
|
|
208
|
+
historyGaps += 1;
|
|
209
|
+
}
|
|
156
210
|
if (typeof ev.session_id === 'string' && typeof ev.semantic_session_id === 'string') {
|
|
157
211
|
if (ev.event === LOCK_ACQUIRED) semanticFromLock.set(ev.session_id, ev.semantic_session_id);
|
|
158
212
|
else if (ev.event === SESSION_ENDED) {
|
|
@@ -181,7 +235,7 @@ export function planSessions({ repoRoot }) {
|
|
|
181
235
|
|| (semanticSessionId !== null && runningIds.has(semanticSessionId))) continue;
|
|
182
236
|
plan.push({ sessionId, semanticSessionId });
|
|
183
237
|
}
|
|
184
|
-
return plan;
|
|
238
|
+
return { plan, historyGaps };
|
|
185
239
|
}
|
|
186
240
|
|
|
187
241
|
/**
|
|
@@ -365,6 +419,10 @@ function cheapRecordId({ sessionId, semanticSessionId }) {
|
|
|
365
419
|
* those ancient candidates exhaust the whole budget before the run ever
|
|
366
420
|
* reaches the genuinely-abandoned recent ones. Measured on a copy of this
|
|
367
421
|
* repo's live store: first-seen order → backfilled 0, truncated true.
|
|
422
|
+
* @param {number|null} [args.archiveScanBudgetMs=null]
|
|
423
|
+
* #1498 — total budget for rotated-ledger scans across the whole run, turned
|
|
424
|
+
* into ONE deadline before the first candidate. Candidates it leaves unread
|
|
425
|
+
* count as `skipped.skipped-history-unread`. `null` (CLI default) = unbounded.
|
|
368
426
|
* @returns {Promise<object>} aggregate summary
|
|
369
427
|
*/
|
|
370
428
|
export async function runMigration({
|
|
@@ -373,13 +431,17 @@ export async function runMigration({
|
|
|
373
431
|
assumeDeadBeforeMs = null,
|
|
374
432
|
limit = null,
|
|
375
433
|
newestFirst = false,
|
|
434
|
+
archiveScanBudgetMs = null,
|
|
376
435
|
}) {
|
|
377
|
-
const planned =
|
|
436
|
+
const { plan: planned, historyGaps } = planWithGaps({ repoRoot });
|
|
378
437
|
const plan = newestFirst ? [...planned].reverse() : planned;
|
|
379
438
|
const summary = {
|
|
380
439
|
repoRoot,
|
|
381
440
|
mode: apply ? 'apply' : 'dry-run',
|
|
382
441
|
total: plan.length,
|
|
442
|
+
// #1498: rotated archives the ledger names but no longer holds. Non-zero ⇒
|
|
443
|
+
// sessions they recorded are not candidates and `total` is a floor.
|
|
444
|
+
history_gaps: historyGaps,
|
|
383
445
|
backfilled: 0,
|
|
384
446
|
would_backfill: 0,
|
|
385
447
|
dead_by_age: 0,
|
|
@@ -397,6 +459,10 @@ export async function runMigration({
|
|
|
397
459
|
// here so the dry-run total matches what --apply actually writes.
|
|
398
460
|
const projected = new Set();
|
|
399
461
|
|
|
462
|
+
const archiveScanDeadlineMs = Number.isFinite(archiveScanBudgetMs)
|
|
463
|
+
? Date.now() + archiveScanBudgetMs
|
|
464
|
+
: null;
|
|
465
|
+
|
|
400
466
|
for (const item of plan) {
|
|
401
467
|
// -- Cheap pre-filter: already recorded → skip WITHOUT entering the core --
|
|
402
468
|
const known = cheapRecordId(item);
|
|
@@ -423,6 +489,7 @@ export async function runMigration({
|
|
|
423
489
|
dryRun: !apply,
|
|
424
490
|
relaxDeadByAge: true,
|
|
425
491
|
assumeDeadBeforeMs,
|
|
492
|
+
archiveScanDeadlineMs,
|
|
426
493
|
});
|
|
427
494
|
switch (res.action) {
|
|
428
495
|
case 'backfilled':
|
|
@@ -529,16 +596,21 @@ async function emitBackfillCompleted(repoRoot, item, res) {
|
|
|
529
596
|
* @param {object} args
|
|
530
597
|
* @param {string} args.repoRoot
|
|
531
598
|
* @param {number|null} [args.limit=SESSION_START_LIMIT]
|
|
599
|
+
* @param {number|null} [args.archiveScanBudgetMs=SESSION_START_ARCHIVE_SCAN_BUDGET_MS]
|
|
532
600
|
* @returns {Promise<object|null>} the summary, or null when disabled/failed
|
|
533
601
|
*/
|
|
534
|
-
export async function backfillOnSessionStart({
|
|
602
|
+
export async function backfillOnSessionStart({
|
|
603
|
+
repoRoot,
|
|
604
|
+
limit = SESSION_START_LIMIT,
|
|
605
|
+
archiveScanBudgetMs = SESSION_START_ARCHIVE_SCAN_BUDGET_MS,
|
|
606
|
+
} = {}) {
|
|
535
607
|
try {
|
|
536
608
|
// Escape hatch for operators who never want ledger writes at session start.
|
|
537
609
|
if (process.env.SO_DISABLE_STARTUP_BACKFILL === '1') return null;
|
|
538
610
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
|
|
539
611
|
// newestFirst is mandatory here — see runMigration's param docs for the
|
|
540
612
|
// measured failure (budget burned on ancient already-recorded candidates).
|
|
541
|
-
const summary = await runMigration({ repoRoot, apply: true, limit, newestFirst: true });
|
|
613
|
+
const summary = await runMigration({ repoRoot, apply: true, limit, newestFirst: true, archiveScanBudgetMs });
|
|
542
614
|
// The telemetry flush rides the SAME start-time trigger, for the same reason
|
|
543
615
|
// the backfill does — see flushTelemetryOnSessionStart.
|
|
544
616
|
await flushTelemetryOnSessionStart(repoRoot);
|
|
@@ -596,6 +668,9 @@ function renderHuman(summary) {
|
|
|
596
668
|
lines.push(`Backfill abandoned sessions — ${summary.mode}`);
|
|
597
669
|
lines.push(` repo: ${summary.repoRoot}`);
|
|
598
670
|
lines.push(` sessions seen: ${summary.total}`);
|
|
671
|
+
if (summary.history_gaps > 0) {
|
|
672
|
+
lines.push(` history gaps: ${summary.history_gaps} rotated archive(s) no longer readable — sessions seen is a floor`);
|
|
673
|
+
}
|
|
599
674
|
if (summary.mode === 'apply') {
|
|
600
675
|
lines.push(` backfilled: ${summary.backfilled}`);
|
|
601
676
|
} else {
|
|
@@ -43,8 +43,9 @@
|
|
|
43
43
|
* a record that never left.
|
|
44
44
|
*
|
|
45
45
|
* Idempotence: dry-run is pure (reads only). `--apply` re-checks store+archive
|
|
46
|
-
* membership
|
|
47
|
-
*
|
|
46
|
+
* membership and appends inside ONE store-lock section (`withLearningsLock`),
|
|
47
|
+
* so a second `--apply` — sequential or concurrent — appends nothing and a
|
|
48
|
+
* later dry-run reports 0 orphans.
|
|
48
49
|
*
|
|
49
50
|
* NOTE on discovery: every file here is read through `node:fs`, never grep — a
|
|
50
51
|
* single NUL byte makes a tracked file invisible to grep (silent skip, exit 1,
|
|
@@ -70,7 +71,7 @@ import { parseSessionConfig } from './lib/config.mjs';
|
|
|
70
71
|
import { subjectToSlug, parseFrontmatter } from './lib/vault-mirror/utils.mjs';
|
|
71
72
|
import { kebab } from './lib/learnings/kebab.mjs';
|
|
72
73
|
import { validateLearning } from './lib/learnings/schema.mjs';
|
|
73
|
-
import { appendLearning, isBackupOf } from './lib/learnings/io.mjs';
|
|
74
|
+
import { appendLearning, isBackupOf, withLearningsLock } from './lib/learnings/io.mjs';
|
|
74
75
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
75
76
|
|
|
76
77
|
const DEFAULT_RULES_DIR = '.claude/rules';
|
|
@@ -907,28 +908,54 @@ export async function main(argv = [], deps = {}) {
|
|
|
907
908
|
});
|
|
908
909
|
}
|
|
909
910
|
|
|
910
|
-
// ── Apply (append-only, re-checked)
|
|
911
|
+
// ── Apply (append-only, re-checked under the store lock) ─────────────────
|
|
911
912
|
let applied = 0;
|
|
912
913
|
let skippedAlreadyPresent = 0;
|
|
913
914
|
if (apply) {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
915
|
+
const candidates = records.filter((r) => r.status === 'orphan' && r.restorable && r.record);
|
|
916
|
+
const failApply = (r, err) => {
|
|
917
|
+
r.applied = false;
|
|
918
|
+
r.apply_error = err.message;
|
|
919
|
+
process.stderr.write(`backfill-learnings-from-vault: append failed for ${r.learning_id}: ${err.message}\n`);
|
|
920
|
+
};
|
|
921
|
+
try {
|
|
922
|
+
// The membership re-check and the append are ONE critical section
|
|
923
|
+
// (#1487 item 12): checked outside the lock, two concurrent --apply runs
|
|
924
|
+
// — or one racing a session-end append — both saw "absent" and both
|
|
925
|
+
// appended. One section for the whole loop, not one per record: a single
|
|
926
|
+
// acquisition and a single read of store + archive instead of N of each.
|
|
927
|
+
// Hold time is linear in N small appends — fine for the tens of orphans a
|
|
928
|
+
// backfill restores; revisit (per-record sections) if a run ever holds
|
|
929
|
+
// the lock near the 10 s other writers wait. Archive movers (sweep/prune)
|
|
930
|
+
// also write under this lock, so the archive read is consistent too.
|
|
931
|
+
// appendLearning() re-enters the held lock (reentrant per async call
|
|
932
|
+
// chain, io.mjs).
|
|
933
|
+
await withLearningsLock(storeAbs, async () => {
|
|
934
|
+
const present = new Set([...idsInStore(storeAbs), ...idsInStore(archiveAbs)]);
|
|
935
|
+
for (const r of candidates) {
|
|
936
|
+
// This is what makes a second --apply a no-op rather than a
|
|
937
|
+
// duplicate append; `present.add` below covers two rules naming one id.
|
|
938
|
+
if (present.has(r.learning_id)) {
|
|
939
|
+
skippedAlreadyPresent += 1;
|
|
940
|
+
r.applied = false;
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
try {
|
|
944
|
+
await appendLearning(storeAbs, r.record);
|
|
945
|
+
applied += 1;
|
|
946
|
+
r.applied = true;
|
|
947
|
+
present.add(r.learning_id);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
failApply(r, err);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
} catch (err) {
|
|
954
|
+
// Lock not acquired (LearningsLockError), its directory not creatable, or
|
|
955
|
+
// a store read failed inside the section: no candidate still unhandled
|
|
956
|
+
// was written. Surface it per record, like any other append failure,
|
|
957
|
+
// instead of reporting a silent success.
|
|
958
|
+
for (const r of candidates) if (r.applied === undefined) failApply(r, err);
|
|
932
959
|
}
|
|
933
960
|
}
|
|
934
961
|
|
|
@@ -93,6 +93,9 @@ function runCodex(args) {
|
|
|
93
93
|
const result = spawnSync('codex', args, {
|
|
94
94
|
encoding: 'utf8',
|
|
95
95
|
timeout: CODEX_COMMAND_TIMEOUT_MS,
|
|
96
|
+
// 'codex plugin list --available --json' exceeds the 1 MiB default once a
|
|
97
|
+
// few marketplaces are registered (measured 2026-09-22: 2.19 MB → ENOBUFS).
|
|
98
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
96
99
|
});
|
|
97
100
|
|
|
98
101
|
if (result.error) {
|
|
@@ -15,7 +15,17 @@
|
|
|
15
15
|
* 1 — source rules not found, or TARGET is not an existing directory
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readlinkSync,
|
|
23
|
+
symlinkSync,
|
|
24
|
+
statSync,
|
|
25
|
+
lstatSync,
|
|
26
|
+
unlinkSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from 'node:fs';
|
|
19
29
|
import path from 'node:path';
|
|
20
30
|
import { fileURLToPath } from 'node:url';
|
|
21
31
|
|
|
@@ -66,7 +76,9 @@ function _isDir(p) {
|
|
|
66
76
|
function linkPath(source, dest, label) {
|
|
67
77
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
68
78
|
if (_isSymlink(dest)) {
|
|
69
|
-
|
|
79
|
+
// existsSync follows the link: false means it points at nothing.
|
|
80
|
+
const state = existsSync(dest) ? 'symlink exists' : 'dangling symlink — not replacing';
|
|
81
|
+
process.stdout.write(` SKIP: ${label} (${state})\n`);
|
|
70
82
|
return 'skip';
|
|
71
83
|
}
|
|
72
84
|
if (existsSync(dest)) {
|
|
@@ -119,6 +131,38 @@ if (_isDir(SOURCE_SKILLS_DIR)) {
|
|
|
119
131
|
}
|
|
120
132
|
}
|
|
121
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Remove the links an earlier install left for a wrapper this plugin no longer
|
|
136
|
+
* ships (#1501 item 6: 05c0264f dropped `commands/navigator.md` and
|
|
137
|
+
* `skills/navigator`, and every installed project kept a dead link). An entry is
|
|
138
|
+
* removed only when it is a symlink, dangles, AND resolves inside this plugin's
|
|
139
|
+
* `.cursor/<kind>/` — so it is provably ours. Regular files and live links are
|
|
140
|
+
* the project's own; a dangling link into another root is not ours to delete
|
|
141
|
+
* and gets a WARN — unless this plugin ships that name, where linkPath() has
|
|
142
|
+
* already reported it as dangling.
|
|
143
|
+
* @param {'rules'|'commands'|'skills'} kind
|
|
144
|
+
*/
|
|
145
|
+
function pruneDeadLinks(kind) {
|
|
146
|
+
const sourceDir = path.join(SO_ROOT, '.cursor', kind);
|
|
147
|
+
const targetDir = path.join(TARGET, '.cursor', kind);
|
|
148
|
+
if (!_isDir(targetDir)) return;
|
|
149
|
+
for (const name of readdirSync(targetDir)) {
|
|
150
|
+
const dest = path.join(targetDir, name);
|
|
151
|
+
if (!_isSymlink(dest) || existsSync(dest)) continue;
|
|
152
|
+
const pointsTo = path.resolve(path.dirname(dest), readlinkSync(dest));
|
|
153
|
+
const rel = path.relative(sourceDir, pointsTo);
|
|
154
|
+
const ours = rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
155
|
+
if (ours) {
|
|
156
|
+
unlinkSync(dest);
|
|
157
|
+
process.stdout.write(` PRUNE: ${kind}/${name} (dangling link into ${sourceDir})\n`);
|
|
158
|
+
} else if (!existsSync(path.join(sourceDir, name))) {
|
|
159
|
+
process.stdout.write(` WARN: ${kind}/${name} (dangling link outside this plugin, left alone: ${pointsTo})\n`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const kind of ['rules', 'commands', 'skills']) pruneDeadLinks(kind);
|
|
165
|
+
|
|
122
166
|
function renderHooksJson(soRoot) {
|
|
123
167
|
const runNode = shQuote(path.join(soRoot, 'hooks', 'run-node.sh'));
|
|
124
168
|
const bridge = shQuote(path.join(soRoot, 'scripts', 'lib', 'cursor-hook-bridge.mjs'));
|
package/scripts/emit-session.mjs
CHANGED
|
@@ -480,9 +480,11 @@ async function main() {
|
|
|
480
480
|
// explicit `total_tokens` means the caller already rolled up, so nothing is
|
|
481
481
|
// merged. No matching records or a failed read OMITS the fields with a WARN —
|
|
482
482
|
// never a fabricated 0; a null total is omitted rather than written — for
|
|
483
|
-
// `total_cost_usd` that is any session with an unpriced
|
|
484
|
-
// (
|
|
485
|
-
//
|
|
483
|
+
// `total_cost_usd` that is any session with an unpriced cost candidate
|
|
484
|
+
// (unknown model, no model, no tokens, a started agent without a transcript,
|
|
485
|
+
// or a v1 agent — the complete list is the `session-token-rollup.mjs` module
|
|
486
|
+
// doc, #1498), which the persisted `cost_records_priced < cost_records_total`
|
|
487
|
+
// then explains.
|
|
486
488
|
// match_status explains the omission; diagnostic fields stay out of the record.
|
|
487
489
|
if (ownUuid !== null && !hasOwn('total_tokens')) {
|
|
488
490
|
let rollup = null;
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
CURRENT_ANONYMIZATION_VERSION,
|
|
61
61
|
} from './lib/learnings.mjs';
|
|
62
62
|
import { withLearningsLock } from './lib/learnings/io.mjs';
|
|
63
|
-
import { findProjectRoot, resolveInstructionFile, expandTilde } from './lib/common.mjs';
|
|
63
|
+
import { findProjectRoot, resolveInstructionFile, expandTilde, redactHomeDir } from './lib/common.mjs';
|
|
64
64
|
import { parseSessionConfig } from './lib/config.mjs';
|
|
65
65
|
import { createSecretValueMasker } from './lib/secret-masker.mjs';
|
|
66
66
|
import { stableHostname, readHostAliases } from './lib/host-identity.mjs';
|
|
@@ -85,21 +85,28 @@ const DEFAULT_VAULT_SUBPATH = path.join('01-projects', 'session-orchestrator', '
|
|
|
85
85
|
* — forwarded to parseSessionConfig (tests pass `{ env: {}, ownerConfig: undefined }`
|
|
86
86
|
* for hermetic, owner.yaml-free resolution — issue #653 bleed guard).
|
|
87
87
|
* @returns {string|null} the resolved output path, or null when vault-dir is
|
|
88
|
-
* unconfigured/unresolvable.
|
|
88
|
+
* unconfigured/unresolvable or vault-integration is not enabled.
|
|
89
89
|
*/
|
|
90
90
|
export function resolveDefaultOutput({ repoRoot, hostPaths } = {}) {
|
|
91
91
|
const root = repoRoot ?? findProjectRoot();
|
|
92
92
|
const instr = resolveInstructionFile(root);
|
|
93
93
|
if (!instr) return null;
|
|
94
94
|
|
|
95
|
-
let
|
|
95
|
+
let vi;
|
|
96
96
|
try {
|
|
97
97
|
const content = readFileSync(instr.path, 'utf8');
|
|
98
98
|
const config = parseSessionConfig(content, hostPaths ? { hostPaths } : undefined);
|
|
99
|
-
|
|
99
|
+
vi = config?.['vault-integration'];
|
|
100
100
|
} catch {
|
|
101
101
|
return null;
|
|
102
102
|
}
|
|
103
|
+
// #1506.4a: this default target IS the vault, so it obeys the off switch the
|
|
104
|
+
// other vault writers obey (vault-backfill, board-writer, narrative-mirror:
|
|
105
|
+
// `enabled !== true` → no vault write), already host-lowered by
|
|
106
|
+
// parseSessionConfig (env SO_VAULT_INTEGRATION > owner.yaml > committed). An
|
|
107
|
+
// explicit `--output` is the caller's own path and is not gated.
|
|
108
|
+
if (vi?.enabled !== true) return null;
|
|
109
|
+
const vaultDir = vi['vault-dir'];
|
|
103
110
|
if (!vaultDir || typeof vaultDir !== 'string' || vaultDir.trim() === '') return null;
|
|
104
111
|
|
|
105
112
|
// Expand a `~`-prefixed vault-dir (e.g. the committed Session Config default
|
|
@@ -604,7 +611,8 @@ function parseArgs(argv) {
|
|
|
604
611
|
if (!resolved) {
|
|
605
612
|
process.stderr.write(
|
|
606
613
|
'export-hw-learnings: could not resolve vault-integration.vault-dir for the default ' +
|
|
607
|
-
'--output path (no CLAUDE.md/AGENTS.md found,
|
|
614
|
+
'--output path (no CLAUDE.md/AGENTS.md found, vault-integration.enabled is not true, ' +
|
|
615
|
+
'or vault-dir is unset). ' +
|
|
608
616
|
'Pass --output <path> explicitly.\n'
|
|
609
617
|
);
|
|
610
618
|
process.exit(1);
|
|
@@ -661,7 +669,9 @@ async function promoteHwLearningsUnlocked(opts) {
|
|
|
661
669
|
|
|
662
670
|
const flags = [];
|
|
663
671
|
if (malformed.length > 0) {
|
|
664
|
-
|
|
672
|
+
// Not promoted, and kept verbatim by the rewrite below (rewriteLearnings
|
|
673
|
+
// re-reads them under the lock — #1489); a WARN, not a loss.
|
|
674
|
+
flags.push(`${malformed.length} malformed line(s) in learnings.jsonl were not read (kept verbatim)`);
|
|
665
675
|
}
|
|
666
676
|
|
|
667
677
|
if (hwPrivate.length === 0) {
|
|
@@ -763,7 +773,8 @@ if (isMain) {
|
|
|
763
773
|
};
|
|
764
774
|
|
|
765
775
|
run().catch((err) => {
|
|
766
|
-
|
|
776
|
+
// The message can carry absolute store/output paths — never print the home dir.
|
|
777
|
+
process.stderr.write(`export failed: ${redactHomeDir(String(err?.message ?? err))}\n`);
|
|
767
778
|
process.exit(1);
|
|
768
779
|
});
|
|
769
780
|
}
|
|
@@ -16,7 +16,15 @@
|
|
|
16
16
|
* payload `{ session, repo, modus, kandidaten, navigator_state }` — no paths, no
|
|
17
17
|
* quota, no `auftrag_ref`.
|
|
18
18
|
*
|
|
19
|
-
* stdout: one JSON line
|
|
19
|
+
* stdout: one JSON line
|
|
20
|
+
* `{ ok, path, navigator_state, navigator_adresse, fallback_due_at, event }`.
|
|
21
|
+
* `navigator_adresse` is the active lease's `adresse` exactly as
|
|
22
|
+
* `readNavigatorLease()` validated it (a ListAgents peer name), else `null` — no
|
|
23
|
+
* active lease, no address in it, or one the validator refused. It is the only
|
|
24
|
+
* value the caller may address a hint to; an unvalidated string never reaches it.
|
|
25
|
+
* `fallback_due_at` is `fallbackDueAt(zeit, navigator_state)`: when the
|
|
26
|
+
* Standard-Auflagen apply if no `auflagen/` file has appeared by then. It is
|
|
27
|
+
* stdout only — the check-in FILE schema, which the navigator reads, is unchanged.
|
|
20
28
|
*
|
|
21
29
|
* Exit codes:
|
|
22
30
|
* 0 — check-in written (an event failure only WARNs; `event: false` on stdout)
|
|
@@ -31,6 +39,7 @@ import path from 'node:path';
|
|
|
31
39
|
import { emitEvent } from './lib/events.mjs';
|
|
32
40
|
import {
|
|
33
41
|
checkinPath,
|
|
42
|
+
fallbackDueAt,
|
|
34
43
|
navigatorDir,
|
|
35
44
|
readNavigatorLease,
|
|
36
45
|
utcSecondsTimestamp,
|
|
@@ -106,7 +115,12 @@ async function main() {
|
|
|
106
115
|
return 1;
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
const
|
|
118
|
+
const navigator = await readNavigatorLease();
|
|
119
|
+
const navigatorState = navigator.state;
|
|
120
|
+
// readNavigatorLease() returns a lease only when it passed validation, its
|
|
121
|
+
// `adresse` included (an invalid one makes the whole lease `unreadable`).
|
|
122
|
+
const navigatorAdresse =
|
|
123
|
+
navigator.state === 'active' && typeof navigator.lease.adresse === 'string' ? navigator.lease.adresse : null;
|
|
110
124
|
|
|
111
125
|
let event = true;
|
|
112
126
|
try {
|
|
@@ -132,7 +146,16 @@ async function main() {
|
|
|
132
146
|
// Report the path with `~` for the home prefix: stdout lands in transcripts.
|
|
133
147
|
const home = os.homedir();
|
|
134
148
|
const shownPath = target.startsWith(`${home}${path.sep}`) ? `~${target.slice(home.length)}` : target;
|
|
135
|
-
process.stdout.write(
|
|
149
|
+
process.stdout.write(
|
|
150
|
+
JSON.stringify({
|
|
151
|
+
ok: true,
|
|
152
|
+
path: shownPath,
|
|
153
|
+
navigator_state: navigatorState,
|
|
154
|
+
navigator_adresse: navigatorAdresse,
|
|
155
|
+
fallback_due_at: fallbackDueAt(record.zeit, navigatorState),
|
|
156
|
+
event,
|
|
157
|
+
}) + '\n',
|
|
158
|
+
);
|
|
136
159
|
return 0;
|
|
137
160
|
}
|
|
138
161
|
|
|
@@ -38,6 +38,7 @@ function clampNumber(value, { min, max, fallback }) {
|
|
|
38
38
|
* @property {string|null} kill_switch @property {string|null} kill_switch_detail @property {string[]} sessions
|
|
39
39
|
* @property {string|null} host_class @property {string|null} resource_verdict_at_start
|
|
40
40
|
* @property {boolean} fallback_to_manual @property {boolean} dry_run @property {number} total_tokens_used
|
|
41
|
+
* @property {number} tokens_unknown_sessions — #1487 Pkt 4: iterations whose own record carried no token figure; > 0 makes total_tokens_used a lower bound
|
|
41
42
|
* @property {string|null} worktree_path @property {string|null} parent_run_id @property {number} stall_recovery_count
|
|
42
43
|
* @property {number|null} blocked_by_issue — Phase D (#341): issue number this loop was waiting on (OPEN-4 commit-deps); null when not blocked
|
|
43
44
|
* @property {boolean} aborted_by_cohort — Phase D (#341): set by cohort-abort policy (OPEN-5); mutated by W3 P5 orchestration-glue, not by runLoop itself
|
|
@@ -138,7 +139,12 @@ export async function runLoop(opts = {}) {
|
|
|
138
139
|
// totals as `usage`; the accumulator sums them into `total_tokens_used`.
|
|
139
140
|
// Zero or multiple matches throw → FAILED_WAVE, never foreign token usage.
|
|
140
141
|
// CEILING: an own record without token totals adds 0 — the budget
|
|
141
|
-
// undercounts, it never over-fires.
|
|
142
|
+
// undercounts, it never over-fires. Such sessions are counted in
|
|
143
|
+
// `tokens_unknown_sessions` (#1487 Pkt 4), and the switch still fires on the
|
|
144
|
+
// known lower bound: every fire stays correct, none is lost. Failing closed on
|
|
145
|
+
// an unknown session instead would stop an armed run on every
|
|
146
|
+
// coordinator-direct session (no subagents → no rollup totals); revisit if
|
|
147
|
+
// the session record ever carries the coordinator's own usage.
|
|
142
148
|
max_tokens: opts.maxTokens ?? 0,
|
|
143
149
|
iterations_completed: 0,
|
|
144
150
|
kill_switch: null,
|
|
@@ -149,6 +155,9 @@ export async function runLoop(opts = {}) {
|
|
|
149
155
|
fallback_to_manual: false,
|
|
150
156
|
dry_run: dryRun,
|
|
151
157
|
total_tokens_used: 0,
|
|
158
|
+
// #1487 Pkt 4 — completed iterations whose own record carried no token
|
|
159
|
+
// figure; non-zero means `total_tokens_used` is a lower bound.
|
|
160
|
+
tokens_unknown_sessions: 0,
|
|
152
161
|
// ADR-364 additive fields — forward-compat; callers may omit, defaults are null/0.
|
|
153
162
|
worktree_path: opts.worktreePath ?? null,
|
|
154
163
|
parent_run_id: opts.parentRunId ?? null,
|
|
@@ -274,15 +283,21 @@ export async function runLoop(opts = {}) {
|
|
|
274
283
|
}
|
|
275
284
|
state.iterations_completed += 1;
|
|
276
285
|
|
|
277
|
-
// Accumulate own-record output tokens (total_tokens fallback)
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
+
// Accumulate own-record output tokens (total_tokens fallback). An unassignable
|
|
287
|
+
// child record already threw → FAILED_WAVE above. An own record carrying
|
|
288
|
+
// neither figure has UNKNOWN usage, not 0 (#1487 Pkt 4): it adds nothing, so
|
|
289
|
+
// `total_tokens_used` is a lower bound, and it is counted in
|
|
290
|
+
// `tokens_unknown_sessions` so the record says how much the sum cannot see.
|
|
291
|
+
const usage = sessionResult?.usage;
|
|
292
|
+
const rawTokens = usage !== null && typeof usage === 'object'
|
|
293
|
+
? (usage.output_tokens ?? usage.total_tokens)
|
|
294
|
+
: undefined;
|
|
295
|
+
// `Number(null)` is 0 — a null figure is a missing one, never a measured 0.
|
|
296
|
+
const outTokens = rawTokens === null || rawTokens === undefined ? NaN : Number(rawTokens);
|
|
297
|
+
if (Number.isFinite(outTokens) && outTokens >= 0) {
|
|
298
|
+
cumulativeTokens += outTokens;
|
|
299
|
+
} else {
|
|
300
|
+
state.tokens_unknown_sessions += 1;
|
|
286
301
|
}
|
|
287
302
|
state.total_tokens_used = cumulativeTokens;
|
|
288
303
|
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* `hooks/on-stop.mjs` refreshes it at every turn-end and
|
|
9
9
|
* `hooks/post-tool-batch-wave-signal.mjs` at every tool batch.
|
|
10
10
|
* 2. `autopilot.jsonl` mtime — the legacy marker, used only when no lock path
|
|
11
|
-
* is supplied or the lock is absent/corrupt/
|
|
11
|
+
* is supplied or the lock is absent/corrupt/heartbeat-less (`started_at`
|
|
12
|
+
* is never a stand-in — it does not advance during a run).
|
|
12
13
|
*
|
|
13
14
|
* The header of this module used to claim `autopilot.jsonl` carries "one record
|
|
14
15
|
* per session". It does not: `telemetry.mjs` writes ONE record per /autopilot
|
|
@@ -74,11 +75,13 @@ function readHeartbeatMs(sessionLockPath) {
|
|
|
74
75
|
return null;
|
|
75
76
|
}
|
|
76
77
|
if (typeof lock !== 'object' || lock === null) return null;
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// `last_heartbeat` only — no `started_at` stand-in, matching `isLockLive()`
|
|
79
|
+
// since the #595 sunset (2026-10-02). `started_at` never advances during a
|
|
80
|
+
// run, so a lock without a heartbeat would read as a stall once 600 s passed
|
|
81
|
+
// (or as fresh within 30 s of its genesis) — a measurement the lock never
|
|
82
|
+
// made. No heartbeat → `null` → the mtime marker, exactly as for a lock
|
|
83
|
+
// with no timestamp at all.
|
|
84
|
+
const raw = lock.last_heartbeat;
|
|
82
85
|
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
83
86
|
const ms = Date.parse(raw);
|
|
84
87
|
return Number.isFinite(ms) ? ms : null;
|
|
@@ -95,9 +98,10 @@ function readHeartbeatMs(sessionLockPath) {
|
|
|
95
98
|
* @param {string} [opts.autopilotJsonlPath] — path to autopilot.jsonl (default
|
|
96
99
|
* '.orchestrator/metrics/autopilot.jsonl').
|
|
97
100
|
* @param {string} [opts.sessionLockPath] — path to `session.lock`. When supplied
|
|
98
|
-
* and readable, its `last_heartbeat`
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
+
* and readable, its `last_heartbeat` REPLACES the mtime marker. A lock
|
|
102
|
+
* without a parsable `last_heartbeat` contributes nothing (no `started_at`
|
|
103
|
+
* fallback, as in `session-lock.mjs` `isLockLive()`) and the mtime marker is
|
|
104
|
+
* used. Omitted → mtime only, which is what every pre-existing caller gets.
|
|
101
105
|
* @param {number} [opts.stallTimeoutSeconds] — kill-switch threshold (default
|
|
102
106
|
* 600). NOT range-clamped here; caller is responsible for bounds.
|
|
103
107
|
* @param {() => number} [opts.nowMs] — wall-clock supplier (default Date.now).
|