session-orchestrator 3.22.0 → 3.23.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/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-transition.mjs — the PROCESS-BOUNDARY teardown of a source repo root.
|
|
3
|
+
*
|
|
4
|
+
* Issue #1069 ("EnterWorktree/CwdChanged als eindeutigen Ein-Root-Übergang
|
|
5
|
+
* modellieren"), operator decision 2026-08-28: **Prozessgrenze**, not live
|
|
6
|
+
* migration. The source session ENDS regularly in the old root; a NEW session
|
|
7
|
+
* with its own identity starts in the destination worktree. Nothing is
|
|
8
|
+
* transferred — no lock hand-off, no registry rewrite, no proof migration.
|
|
9
|
+
*
|
|
10
|
+
* The gap this closes: session-start Phase 0.5 Worktree-Promotion calls
|
|
11
|
+
* `enterWorktree()` and then "exits Phase 0 immediately". It released nothing
|
|
12
|
+
* on the OLD root (measured 2026-08-28: zero `deregisterSelf` / `release(`
|
|
13
|
+
* call sites across `skills/session-start/SKILL.md` and
|
|
14
|
+
* `skills/_shared/parallel-aware-auq.md`). The abandoned registry entry keeps
|
|
15
|
+
* reading FRESH to `detectPeers()` for `freshnessMin=15` minutes and is only
|
|
16
|
+
* removed by `sweepZombies()` after `thresholdMin=60` — so the promoted-away
|
|
17
|
+
* root advertises a PHANTOM peer for up to an hour, and the abandoned
|
|
18
|
+
* `session.lock` blocks/skews every exclusivity decision in that root until
|
|
19
|
+
* its TTL lapses.
|
|
20
|
+
*
|
|
21
|
+
* Ownership rules are NOT re-derived here. The lock half is the same shape
|
|
22
|
+
* `hooks/on-session-end.mjs` uses: `readLockDetailed()` to distinguish an
|
|
23
|
+
* absent lock from an unparseable one, an exact raw `lock.session_id ===
|
|
24
|
+
* sessionId` compare BEFORE any proof is loaded, then `loadOwnerProof()`
|
|
25
|
+
* handed to `release()` as the second identity factor (#987/#989).
|
|
26
|
+
*
|
|
27
|
+
* ## Deliberate deltas vs `hooks/on-session-end.mjs` (its release block)
|
|
28
|
+
*
|
|
29
|
+
* The two teardowns share the ownership gate and now the
|
|
30
|
+
* `orchestrator.session.lock.released` breadcrumb, and diverge in four places
|
|
31
|
+
* ON PURPOSE. Each divergence is a narrower authority, never an oversight:
|
|
32
|
+
*
|
|
33
|
+
* 1. **No reconciliation on a raw-ID mismatch.** SessionEnd calls
|
|
34
|
+
* `attemptLockReconciliation()`, which may reap a provably-dead foreign
|
|
35
|
+
* lease. A worktree promotion is not a lifecycle end for the foreign
|
|
36
|
+
* session and carries no mandate over its lease: this function returns
|
|
37
|
+
* `lock-session-mismatch:<owner>` and touches nothing.
|
|
38
|
+
* 2. **No owner-proof unlink after a successful release.** SessionEnd removes
|
|
39
|
+
* the genesis proof as hygiene because the session is ending. Here the
|
|
40
|
+
* session continues in the DESTINATION root; the source proof is
|
|
41
|
+
* self-invalidating (its millisecond `started_at` can never match a future
|
|
42
|
+
* lock) and deleting it would be a write outside the teardown's remit.
|
|
43
|
+
* 3. **No `orchestrator.session.lock.read_anomaly` breadcrumb.** An
|
|
44
|
+
* unparseable lock aborts this teardown outright and is reported in the
|
|
45
|
+
* RETURN value (`ok: false`, `reason: 'lock-unreadable'|'lock-corrupt'`),
|
|
46
|
+
* which the caller must surface as a WARN. SessionEnd has no return channel
|
|
47
|
+
* to its caller, so there the event is the only place the anomaly can land.
|
|
48
|
+
* 4. **No `orchestrator.session.lock.release_failed` breadcrumb.** Same
|
|
49
|
+
* reason as (3): a failed release surfaces as `ok: false` plus
|
|
50
|
+
* `reason: 'lock-<release-reason>'` to a caller that is required to WARN on
|
|
51
|
+
* it, so the stream is not the only witness.
|
|
52
|
+
*
|
|
53
|
+
* @module scripts/lib/session-transition
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import path from 'node:path';
|
|
57
|
+
|
|
58
|
+
import { deregisterSelf, readRegistry, repoPathHash } from './session-registry.mjs';
|
|
59
|
+
import { readLockDetailed, loadOwnerProof, release } from './session-lock.mjs';
|
|
60
|
+
import { emitEvent } from './events.mjs';
|
|
61
|
+
|
|
62
|
+
/** Event name emitted once per completed source-root departure. */
|
|
63
|
+
export const ROOT_LEFT_EVENT = 'orchestrator.session.root_left';
|
|
64
|
+
|
|
65
|
+
/** Shared lock-release breadcrumb — same event the other two `release()` call sites emit. */
|
|
66
|
+
export const LOCK_RELEASED_EVENT = 'orchestrator.session.lock.released';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Release this session's claim on `repoRoot` because the session is leaving it
|
|
70
|
+
* for good. Ordering is load-bearing and fixed:
|
|
71
|
+
*
|
|
72
|
+
* 1. `readLockDetailed()` — OWNERSHIP IS ESTABLISHED BEFORE ANYTHING IS
|
|
73
|
+
* REMOVED. A lock this session does not own, or one that cannot be parsed,
|
|
74
|
+
* aborts the teardown with `ok: false` and zero side effects. (This used to
|
|
75
|
+
* run second, after the registry entry had already been deleted: a caller
|
|
76
|
+
* passing the wrong `sessionId` destroyed a live registry entry and was
|
|
77
|
+
* told `ok: true` — the registry half of the very phantom-peer state the
|
|
78
|
+
* function exists to prevent.)
|
|
79
|
+
* 2. `deregisterSelf(sessionId)` — the host-wide registry entry. The registry
|
|
80
|
+
* is one file per harness session id, so this removes the session's ONLY
|
|
81
|
+
* entry; the destination worktree's own SessionStart registers itself
|
|
82
|
+
* afresh under its own identity (that is what "process boundary" means).
|
|
83
|
+
* Gated a second time on the entry's `repo_path_hash`: an entry that
|
|
84
|
+
* describes a DIFFERENT root is not this root's claim, and deleting it
|
|
85
|
+
* would silently unregister a session living somewhere else.
|
|
86
|
+
* 3. `release()` of the old root's `session.lock`, gated exactly as
|
|
87
|
+
* `hooks/on-session-end.mjs` gates it, followed by the shared
|
|
88
|
+
* `orchestrator.session.lock.released` breadcrumb (`caller:
|
|
89
|
+
* 'session-transition'`) so this third `release()` call site is visible on
|
|
90
|
+
* the same stream as the other two.
|
|
91
|
+
* 4. `emitEvent(ROOT_LEFT_EVENT)` into the OLD root's events.jsonl — the
|
|
92
|
+
* breadcrumb that says which root was left and why. Emitted even when a
|
|
93
|
+
* step above found nothing: "nothing to release" is itself a fact worth
|
|
94
|
+
* recording, and a departure that left no trace is exactly the
|
|
95
|
+
* indistinguishable-from-a-crash state #1069 was filed about. NOT emitted
|
|
96
|
+
* on an aborted teardown (foreign lock, unparseable lock, invalid args):
|
|
97
|
+
* the event asserts that a root was LEFT, and on those branches nothing
|
|
98
|
+
* was — writing it would put a fiction in a stream that, unlike the
|
|
99
|
+
* registry, may belong to the session that actually owns this root.
|
|
100
|
+
*
|
|
101
|
+
* The caller must invoke this BEFORE the destination worktree's own Phase 1.2
|
|
102
|
+
* `acquire()` runs — never both roots owning a live lock/registry entry at
|
|
103
|
+
* once, which is the double-live-UUID state the issue's acceptance criteria
|
|
104
|
+
* forbid.
|
|
105
|
+
*
|
|
106
|
+
* NEVER THROWS. Every failure is reported in the returned structure, because
|
|
107
|
+
* the sole caller is a promotion path that has already created the destination
|
|
108
|
+
* worktree: a thrown error there would abort the transition halfway, leaving
|
|
109
|
+
* precisely the two-live-roots state this function exists to prevent.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} args
|
|
112
|
+
* @param {string} args.repoRoot — absolute path of the root being LEFT.
|
|
113
|
+
* @param {string} args.sessionId — the raw (physical) session id that owns the
|
|
114
|
+
* registry entry and the lock in `repoRoot`. NOT the semantic id.
|
|
115
|
+
* @param {string|null} [args.semanticSessionId] — semantic id, when known.
|
|
116
|
+
* Omitted from the event payload when null/absent rather than emitted as
|
|
117
|
+
* `null` (same honest-encoding rule as `sessionAttribution()` in events.mjs).
|
|
118
|
+
* @param {string} [args.reason='unspecified'] — why the root is being left,
|
|
119
|
+
* e.g. `'worktree-promotion'`. Recorded verbatim in the event payload.
|
|
120
|
+
* Defaults to `'unspecified'` rather than guessing `'worktree-promotion'`:
|
|
121
|
+
* a mislabelled reason is worse than an admittedly unknown one.
|
|
122
|
+
* @param {object} [deps] — test seams (same shape as `foreign-dispatch.mjs`).
|
|
123
|
+
* @param {Function} [deps.emitFn] — event-emitter seam; defaults to `emitEvent`.
|
|
124
|
+
* @returns {Promise<{
|
|
125
|
+
* ok: boolean,
|
|
126
|
+
* steps: { deregistered: boolean, released: boolean, emitted: boolean },
|
|
127
|
+
* reason?: string
|
|
128
|
+
* }>}
|
|
129
|
+
* `steps` are literal outcomes: `deregistered` = a registry file was removed,
|
|
130
|
+
* `released` = the lock file was deleted, `emitted` = the `root_left` event
|
|
131
|
+
* was appended.
|
|
132
|
+
*
|
|
133
|
+
* `ok: false` means the departure is NOT provably complete — invalid input, a
|
|
134
|
+
* lock owned by someone else, an unparseable lock, a registry entry pinned to
|
|
135
|
+
* a different root, a registry unlink that failed, a contradictory owner
|
|
136
|
+
* proof, or a filesystem error. Callers WARN on it and continue; see the
|
|
137
|
+
* `parallel-aware: leaveSourceRoot: <reason>` contract in
|
|
138
|
+
* `skills/_shared/parallel-aware-auq.md`.
|
|
139
|
+
*
|
|
140
|
+
* `reason` is present whenever something other than a full clean teardown
|
|
141
|
+
* happened; `'already-gone'` is the idempotent-rerun case (nothing left to
|
|
142
|
+
* remove), which is `ok: true`.
|
|
143
|
+
*/
|
|
144
|
+
export async function leaveSourceRoot({
|
|
145
|
+
repoRoot,
|
|
146
|
+
sessionId,
|
|
147
|
+
semanticSessionId = null,
|
|
148
|
+
reason = 'unspecified',
|
|
149
|
+
} = {}, deps = {}) {
|
|
150
|
+
const emit = deps.emitFn ?? emitEvent;
|
|
151
|
+
const steps = { deregistered: false, released: false, emitted: false };
|
|
152
|
+
|
|
153
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0
|
|
154
|
+
|| typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
155
|
+
// Nothing is attempted on unusable input — in particular no event, since
|
|
156
|
+
// an event without a resolvable root has nowhere honest to be written.
|
|
157
|
+
return { ok: false, steps, reason: 'invalid-args' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// -- Step 1: ownership, BEFORE any removal --------------------------------
|
|
161
|
+
// Mirrors hooks/on-session-end.mjs's release block: discriminate the read
|
|
162
|
+
// first, compare the RAW session_id before touching the proof, and only then
|
|
163
|
+
// hand the proof to release() as a second factor. An 'absent' lock is the
|
|
164
|
+
// idempotent-rerun / never-acquired case and does NOT block the teardown.
|
|
165
|
+
const lockDetail = readLockDetailed({ repoRoot });
|
|
166
|
+
|
|
167
|
+
if (lockDetail.status === 'unreadable' || lockDetail.status === 'corrupt') {
|
|
168
|
+
// Fail-closed and VISIBLE (issue #1069 AC: "fehlende oder widersprüchliche
|
|
169
|
+
// Proofs degradieren sichtbar und fail-closed"). A lock we cannot parse is
|
|
170
|
+
// neither released nor proven foreign — ownership stays indeterminate, so
|
|
171
|
+
// nothing is removed and nothing is claimed.
|
|
172
|
+
return { ok: false, steps, reason: `lock-${lockDetail.status}` };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (lockDetail.status === 'ok' && lockDetail.lock.session_id !== sessionId) {
|
|
176
|
+
// A different session owns this root's lock, so this root is not ours to
|
|
177
|
+
// tear down — not the lock, and not the registry entry either. Aborting
|
|
178
|
+
// with `ok: false` (rather than the pre-#1069-review `ok: true`) is the
|
|
179
|
+
// point: a caller that passed the wrong id has NOT departed anything, and
|
|
180
|
+
// reporting success let that mistake through silently.
|
|
181
|
+
return { ok: false, steps, reason: `lock-session-mismatch:${lockDetail.lock.session_id}` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let ok = true;
|
|
185
|
+
let outcome = null;
|
|
186
|
+
|
|
187
|
+
// -- Step 2: registry (host-wide) -----------------------------------------
|
|
188
|
+
// Second ownership factor, on the registry's own terms: the entry records the
|
|
189
|
+
// root it belongs to as a hash. When it names a DIFFERENT root, the entry is
|
|
190
|
+
// some other checkout's live claim and unlinking it would unregister a
|
|
191
|
+
// session that is still running there. Legacy v1 entries carry no hash — the
|
|
192
|
+
// gate is skipped rather than fail-closed, because rejecting them would make
|
|
193
|
+
// this teardown a no-op against exactly the old entries most likely to be
|
|
194
|
+
// stale (same back-compat rule as `_validEntry`'s optional `mode`).
|
|
195
|
+
let registryGateOk = true;
|
|
196
|
+
try {
|
|
197
|
+
const entry = (await readRegistry()).find((e) => e.session_id === sessionId);
|
|
198
|
+
if (entry && typeof entry.repo_path_hash === 'string' && entry.repo_path_hash.length > 0
|
|
199
|
+
&& entry.repo_path_hash !== repoPathHash(repoRoot)) {
|
|
200
|
+
registryGateOk = false;
|
|
201
|
+
ok = false;
|
|
202
|
+
outcome ??= 'registry-root-mismatch';
|
|
203
|
+
}
|
|
204
|
+
} catch { /* an unreadable registry is handled by deregisterSelf below */ }
|
|
205
|
+
|
|
206
|
+
if (registryGateOk) {
|
|
207
|
+
try {
|
|
208
|
+
steps.deregistered = await deregisterSelf(sessionId);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
ok = false;
|
|
211
|
+
outcome ??= `deregister-failed:${err?.message ?? 'unknown'}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// -- Step 3: lock (this root only) ----------------------------------------
|
|
216
|
+
if (lockDetail.status === 'ok') {
|
|
217
|
+
const proof = loadOwnerProof({ repoRoot });
|
|
218
|
+
// `proof` is null whenever ownership cannot be proven; release() treats
|
|
219
|
+
// null as absent (#989) and degrades to the session_id-only path, so it
|
|
220
|
+
// is passed through unguarded — the spread-guard call sites needed
|
|
221
|
+
// before that fix are exactly what the API contract removed.
|
|
222
|
+
const result = release({ sessionId, repoRoot, proof });
|
|
223
|
+
steps.released = result.ok === true && result.deleted === true;
|
|
224
|
+
// 'no-lock' = the lock vanished between the read above and the release —
|
|
225
|
+
// benign (idempotent rerun / concurrent SessionEnd), and forensically the
|
|
226
|
+
// interesting half: someone else removed a lock we still held.
|
|
227
|
+
const alreadyGone = result.ok === true && result.reason === 'no-lock';
|
|
228
|
+
if (!steps.released) {
|
|
229
|
+
if (!alreadyGone) ok = false;
|
|
230
|
+
outcome ??= `lock-${result.reason ?? 'not-deleted'}`;
|
|
231
|
+
}
|
|
232
|
+
if (steps.released || alreadyGone) {
|
|
233
|
+
// ARCH-MED-1 — this is the THIRD `release()` call site in the repo and
|
|
234
|
+
// was the only one silent on `orchestrator.session.lock.*`. Emitted at
|
|
235
|
+
// the call site, never inside release(): session-lock.mjs carries no
|
|
236
|
+
// dependency on events.mjs, and release() is synchronous.
|
|
237
|
+
try {
|
|
238
|
+
await emit(LOCK_RELEASED_EVENT, {
|
|
239
|
+
session_id: sessionId,
|
|
240
|
+
...(semanticSessionId ? { semantic_session_id: semanticSessionId } : {}),
|
|
241
|
+
caller: 'session-transition',
|
|
242
|
+
outcome: steps.released ? 'deleted' : 'already-gone',
|
|
243
|
+
verified: result.verified === true,
|
|
244
|
+
}, { repoRoot });
|
|
245
|
+
} catch { /* observability is best-effort — the return value still tells */ }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (outcome === null && !steps.deregistered && !steps.released) {
|
|
250
|
+
outcome = 'already-gone';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// -- Step 4: breadcrumb ----------------------------------------------------
|
|
254
|
+
try {
|
|
255
|
+
await emit(ROOT_LEFT_EVENT, {
|
|
256
|
+
session_id: sessionId,
|
|
257
|
+
...(semanticSessionId ? { semantic_session_id: semanticSessionId } : {}),
|
|
258
|
+
// NEVER the absolute path. emitEvent forwards the whole payload to the
|
|
259
|
+
// optional Clank webhook with no redaction, and an absolute repo root on
|
|
260
|
+
// this host is `/Users/<operator>/…`. The hash is the SAME function the
|
|
261
|
+
// session registry keys its entries by (`repo_path_hash`), so a consumer
|
|
262
|
+
// can still join a departure to the registry entry it removed; the
|
|
263
|
+
// basename matches the registry's `repo_name`. Same rule as the
|
|
264
|
+
// `board_written` / `mirror_completed` payloads and
|
|
265
|
+
// `relativeWorktreePath` in worktree-pipeline.mjs.
|
|
266
|
+
from_root_hash: repoPathHash(repoRoot),
|
|
267
|
+
from_root_basename: path.basename(path.resolve(repoRoot)),
|
|
268
|
+
reason,
|
|
269
|
+
}, { repoRoot });
|
|
270
|
+
steps.emitted = true;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
ok = false;
|
|
273
|
+
outcome ??= `emit-failed:${err?.message ?? 'unknown'}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return outcome === null ? { ok, steps } : { ok, steps, reason: outcome };
|
|
277
|
+
}
|
|
@@ -20,11 +20,15 @@
|
|
|
20
20
|
* by the time this probe fires. Gating on "no live lock" would make the
|
|
21
21
|
* banner structurally silent forever. Instead:
|
|
22
22
|
*
|
|
23
|
-
* - `lastLedgerAt` =
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* - `lastLedgerAt` = the NEWEST instant any PARSEABLE `sessions.jsonl`
|
|
24
|
+
* record proves the ledger was alive at — a genuine
|
|
25
|
+
* record contributes its `completed_at` (or, absent
|
|
26
|
+
* one, its `started_at`), a backfill stub
|
|
27
|
+
* contributes ONLY its `started_at`. See
|
|
28
|
+
* "Backfill-stub self-erasure fix" below for why the
|
|
29
|
+
* stub's `completed_at` is excluded, and "#1125 —
|
|
30
|
+
* newest-across-all, not genuine-first" for why the
|
|
31
|
+
* two kinds are maxed rather than ranked.
|
|
28
32
|
* - `cutoff` = the CURRENT session's `session.lock`
|
|
29
33
|
* `started_at` (via `readLock()`); when no lock is
|
|
30
34
|
* readable, `cutoff = now` (all events count).
|
|
@@ -58,24 +62,50 @@
|
|
|
58
62
|
* inflate `deltaHours` for perfectly healthy, promptly-closed sessions
|
|
59
63
|
* (a session's own mid-session events would newly count as "after" the
|
|
60
64
|
* anchor), reintroducing false positives on the opposite side. (a) is
|
|
61
|
-
* chosen: `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* future backfill variant drops one of them while keeping the other; OR
|
|
65
|
+
* chosen: a stub's `completed_at` is NEVER an anchor candidate. Stub
|
|
66
|
+
* recognition uses EITHER marker (OR, not AND) deliberately — both are set by
|
|
67
|
+
* the same producer today, but requiring both would silently stop matching the
|
|
68
|
+
* day a future backfill variant drops one of them while keeping the other; OR
|
|
66
69
|
* degrades gracefully (still catches it), AND does not.
|
|
67
70
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
71
|
+
* #1125 — newest-across-ALL records, not genuine-first: excluding the stub's
|
|
72
|
+
* `completed_at` (above) is correct; excluding the whole STUB from the anchor
|
|
73
|
+
* search was not. The original implementation scanned from EOF backward and
|
|
74
|
+
* returned the first GENUINE record it met, reaching the stub `started_at`
|
|
75
|
+
* fallback only when no genuine record existed ANYWHERE. That is a PRIORITY
|
|
76
|
+
* ORDER, and it lets an OLD genuine record outrank a NEWER stub: whenever the
|
|
77
|
+
* most recent ledger activity is a stub, the anchor fell back to a stale
|
|
78
|
+
* genuine `completed_at` and the reported gap ballooned by the difference.
|
|
79
|
+
* Measured in the vault repo 2026-08-22 (issue #1125): a 173h `alert` — the
|
|
80
|
+
* probe's TOP severity — against a ledger whose newest records were from the
|
|
81
|
+
* same day, contradicted by three independent counter-measurements (231 of 233
|
|
82
|
+
* records carrying `completed_at`, newest `2026-08-22T16:23Z`;
|
|
83
|
+
* `backfill-abandoned-sessions.mjs --dry-run` finding 0 to backfill; all 15
|
|
84
|
+
* sessions since 2026-08-15 closed). A permanent top-severity false alarm
|
|
85
|
+
* trains exactly the looking-away this probe exists to prevent, so it is a
|
|
86
|
+
* defect of the same class as a missed gap, not a cosmetic one.
|
|
87
|
+
*
|
|
88
|
+
* The anchor is therefore the MAXIMUM over every record's own contribution:
|
|
89
|
+
* - GENUINE record → its `completed_at`; or, when that field is absent or
|
|
90
|
+
* unparseable, its `started_at` as a floor.
|
|
91
|
+
* - BACKFILL STUB → its `started_at` ONLY (never the fabricated
|
|
92
|
+
* `completed_at` — that is the axis-(a) rule above).
|
|
93
|
+
* A stub written for an abandoned session still PROVES the ledger was alive at
|
|
94
|
+
* that instant: that session IS recorded (as abandoned), so activity around it
|
|
95
|
+
* is not evidence of an unrecorded close-through. Taking a MAX can only move
|
|
96
|
+
* the anchor FORWARD relative to the genuine-only anchor, so it never
|
|
97
|
+
* re-introduces axis (b)'s inflation of `deltaHours`.
|
|
98
|
+
*
|
|
99
|
+
* Stub-anchored reporting (deliberately NOT null): when the winning anchor is a
|
|
100
|
+
* stub's `started_at` — either because every record is a stub (no session has
|
|
101
|
+
* EVER genuinely closed, a STRONGER close-through signal than an ordinary stale
|
|
102
|
+
* ledger) or merely because the newest ledger activity happens to be a stub —
|
|
103
|
+
* the result carries `stubFallback: true` so the message can say the anchor is
|
|
104
|
+
* a stub `started_at` (grounded in the real `orchestrator.session.started`
|
|
105
|
+
* event in the common case — see `synthesizeRecord()`) rather than imply a
|
|
106
|
+
* measured close. The module's usual fail-quiet convention (null on
|
|
107
|
+
* missing/empty/ambiguous input) does not extend to "we have data but all of it
|
|
108
|
+
* is synthetic" — that state IS the failure this banner exists to catch.
|
|
79
109
|
*
|
|
80
110
|
* Severity: warn above `2 × DEFAULT_TTL_HOURS` (8h, imported from
|
|
81
111
|
* `session-lock.mjs` rather than duplicated), alert above 24h.
|
|
@@ -158,57 +188,92 @@ function isBackfillStub(record) {
|
|
|
158
188
|
}
|
|
159
189
|
|
|
160
190
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* lines (bad JSON, non-object) are skipped, not treated as fatal.
|
|
191
|
+
* Parse one record field into an anchor candidate. Returns `null` when the
|
|
192
|
+
* field is absent, not a string, or not a parseable timestamp.
|
|
164
193
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
*
|
|
194
|
+
* @param {unknown} value
|
|
195
|
+
* @returns {{iso: string, ms: number}|null}
|
|
196
|
+
*/
|
|
197
|
+
function tsCandidate(value) {
|
|
198
|
+
if (typeof value !== 'string') return null;
|
|
199
|
+
const ms = Date.parse(value);
|
|
200
|
+
return Number.isFinite(ms) ? { iso: value, ms } : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Keep whichever of the two candidates is newer. A `null` candidate never
|
|
205
|
+
* displaces a real one. On an exact tie the LATER-seen candidate wins, which
|
|
206
|
+
* (with the forward scan in `lastLedgerEntry()`) preserves the previous
|
|
207
|
+
* "nearest-EOF record wins" tie-break.
|
|
208
|
+
*
|
|
209
|
+
* @param {{iso: string, ms: number}|null} current
|
|
210
|
+
* @param {{iso: string, ms: number}|null} candidate
|
|
211
|
+
* @returns {{iso: string, ms: number}|null}
|
|
212
|
+
*/
|
|
213
|
+
function keepNewer(current, candidate) {
|
|
214
|
+
if (candidate === null) return current;
|
|
215
|
+
if (current === null) return candidate;
|
|
216
|
+
return candidate.ms >= current.ms ? candidate : current;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Scan all `sessions.jsonl` lines and return the anchor instant to measure
|
|
221
|
+
* ledger staleness against: the NEWEST instant any record proves the ledger
|
|
222
|
+
* was alive at. Malformed or non-conforming lines (bad JSON, non-object) are
|
|
223
|
+
* skipped, not treated as fatal.
|
|
224
|
+
*
|
|
225
|
+
* Which timestamp each record kind contributes, and why (full reasoning in the
|
|
226
|
+
* module header, "#1125 — newest-across-ALL records"):
|
|
227
|
+
* - GENUINE (`!isBackfillStub()`) → `completed_at`, written by the real
|
|
228
|
+
* session-end path; falling back to `started_at` when `completed_at` is
|
|
229
|
+
* absent or unparseable, so a truncated/in-flight record still contributes
|
|
230
|
+
* the coverage it does prove. Taking the newer of the two can only move the
|
|
231
|
+
* anchor forward, never backward, so it does not inflate `deltaHours`.
|
|
232
|
+
* - BACKFILL STUB → `started_at` ONLY. Its `completed_at` may be the backfill
|
|
233
|
+
* RUN's own wall-clock rather than a measurement of when the session ended
|
|
234
|
+
* (see `synthesizeRecord()`), and anchoring on that would let a backfill run
|
|
235
|
+
* retroactively erase a real multi-day gap.
|
|
236
|
+
*
|
|
237
|
+
* The two kinds are MAXED, not ranked: an older genuine record must not outrank
|
|
238
|
+
* a newer stub (that priority order was the #1125 false-alert defect). When the
|
|
239
|
+
* winning candidate is a stub's `started_at`, `stubFallback: true` is set on the
|
|
240
|
+
* result so the caller can say the anchor is a stub start, not a measured close;
|
|
241
|
+
* the key is omitted (`undefined`) on the genuine path.
|
|
176
242
|
*
|
|
177
243
|
* @param {string[]} lines
|
|
178
244
|
* @returns {{iso: string, ms: number, stubFallback?: true}|null}
|
|
179
245
|
*/
|
|
180
246
|
function lastLedgerEntry(lines) {
|
|
181
|
-
let
|
|
247
|
+
let newestGenuine = null; // newest genuine completed_at (or started_at floor)
|
|
248
|
+
let newestStub = null; // newest stub started_at
|
|
182
249
|
|
|
183
|
-
for (
|
|
250
|
+
for (const line of lines) {
|
|
184
251
|
let record;
|
|
185
252
|
try {
|
|
186
|
-
record = JSON.parse(
|
|
253
|
+
record = JSON.parse(line);
|
|
187
254
|
} catch {
|
|
188
255
|
continue;
|
|
189
256
|
}
|
|
190
257
|
if (!record || typeof record !== 'object') continue;
|
|
191
258
|
|
|
192
259
|
if (isBackfillStub(record)) {
|
|
193
|
-
|
|
194
|
-
// own wall-clock) — remember it only as a fallback candidate, and only
|
|
195
|
-
// the first (nearest-EOF, i.e. newest-by-position) one seen.
|
|
196
|
-
if (newestStub === null && typeof record.started_at === 'string') {
|
|
197
|
-
const startedMs = Date.parse(record.started_at);
|
|
198
|
-
if (Number.isFinite(startedMs)) newestStub = { iso: record.started_at, ms: startedMs };
|
|
199
|
-
}
|
|
260
|
+
newestStub = keepNewer(newestStub, tsCandidate(record.started_at));
|
|
200
261
|
continue;
|
|
201
262
|
}
|
|
202
263
|
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
264
|
+
let own = tsCandidate(record.completed_at);
|
|
265
|
+
const started = tsCandidate(record.started_at);
|
|
266
|
+
// `completed_at` is the normal anchor and wins ties; `started_at` only
|
|
267
|
+
// takes over when it is genuinely newer or `completed_at` is unusable.
|
|
268
|
+
if (started !== null && (own === null || started.ms > own.ms)) own = started;
|
|
269
|
+
newestGenuine = keepNewer(newestGenuine, own);
|
|
207
270
|
}
|
|
208
271
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
272
|
+
if (newestGenuine === null && newestStub === null) return null;
|
|
273
|
+
|
|
274
|
+
const stubWins = newestGenuine === null || (newestStub !== null && newestStub.ms > newestGenuine.ms);
|
|
275
|
+
if (stubWins) return { iso: newestStub.iso, ms: newestStub.ms, stubFallback: true };
|
|
276
|
+
return { iso: newestGenuine.iso, ms: newestGenuine.ms };
|
|
212
277
|
}
|
|
213
278
|
|
|
214
279
|
/**
|
|
@@ -282,7 +347,7 @@ function resolveCutoffMs(repoRoot, nowMs) {
|
|
|
282
347
|
* event exists, the foreign event is not after the last ledger entry, or the
|
|
283
348
|
* resulting gap is under the warn threshold. Never throws.
|
|
284
349
|
*
|
|
285
|
-
* When the anchor
|
|
350
|
+
* When the newest anchor is a backfill stub (`ledger.stubFallback`),
|
|
286
351
|
* `lastLedgerAt` is a STUB's `started_at`, not a genuine `completed_at` — the
|
|
287
352
|
* message says so explicitly rather than implying a real close was measured.
|
|
288
353
|
*
|
|
@@ -327,11 +392,13 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
|
327
392
|
|
|
328
393
|
const severity = deltaHours > ALERT_THRESHOLD_HOURS ? 'alert' : 'warn';
|
|
329
394
|
|
|
330
|
-
// stubFallback (see lastLedgerEntry()):
|
|
331
|
-
// backfill stub — ledger.iso is
|
|
332
|
-
// completed_at. Say so explicitly rather than implying a real close.
|
|
395
|
+
// stubFallback (see lastLedgerEntry()): the newest anchor in sessions.jsonl
|
|
396
|
+
// is a backfill stub — ledger.iso is that STUB's started_at, not a measured
|
|
397
|
+
// completed_at. Say so explicitly rather than implying a real close. (The
|
|
398
|
+
// wording deliberately does NOT claim "stub-only": since #1125 a stub can
|
|
399
|
+
// win the anchor while older genuine records exist.)
|
|
333
400
|
const ledgerDescription = ledger.stubFallback
|
|
334
|
-
? `
|
|
401
|
+
? `newest sessions.jsonl entry is a backfill stub — its started_at ${ledger.iso}`
|
|
335
402
|
: `last sessions.jsonl entry ${ledger.iso}`;
|
|
336
403
|
|
|
337
404
|
const base =
|