session-orchestrator 5.3.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/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- 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-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- 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 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- 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 +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- 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-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- 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 +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- 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 +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- 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 +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -86,6 +86,45 @@ export {
|
|
|
86
86
|
export const DEFAULT_TTL_HOURS = 4;
|
|
87
87
|
export const LOCK_PATH = '.orchestrator/session.lock';
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* How far in the future a `last_heartbeat` may lie and still count as live
|
|
91
|
+
* (#1494). Every heartbeat is stamped by `Date.now()` on the writing host, so
|
|
92
|
+
* on one host it is never ahead of the clock unless that clock was stepped
|
|
93
|
+
* backwards (NTP at boot, a manual change) — the tolerance absorbs such a step
|
|
94
|
+
* and cross-host skew on a shared checkout. Ceiling (BV-004): a clock stepped
|
|
95
|
+
* back by more than 5 minutes makes every live lock written just before it read
|
|
96
|
+
* as stale, and the next SessionStart reclaims it. Revisit if a `reclaimed`
|
|
97
|
+
* event follows a clock step on a host whose previous session was still running.
|
|
98
|
+
*
|
|
99
|
+
* Exported as the ONE copy of this tolerance (#1505.1/.2): the session registry
|
|
100
|
+
* (`session-registry.mjs` `_ageMinutes`) and the lock reaper (`lock-reaper.mjs`
|
|
101
|
+
* `ageHoursOf`, `currentSessionAgeHours`) apply the same future-stamp rule.
|
|
102
|
+
*/
|
|
103
|
+
export const FUTURE_HEARTBEAT_TOLERANCE_MS = 5 * 60 * 1000;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The lock manager's own transient files beside `session.lock` (#1505.3): the
|
|
107
|
+
* `writeLockAtomic()` tmp (6 random bytes), the `createSessionLockExclusive()`
|
|
108
|
+
* tmp and the `reclaimIfNotLive()` tombstone (8 random bytes each). A process
|
|
109
|
+
* SIGKILLed between creating one and its cleanup leaves it behind for good.
|
|
110
|
+
* Anchored and exact so `session.lock` itself — or any foreign file — can
|
|
111
|
+
* never match.
|
|
112
|
+
*/
|
|
113
|
+
const LOCK_LEFTOVER_RE = /^\.session\.lock\.(?:tmp\.[0-9a-f]{12}|create\.tmp\.[0-9a-f]{16}|reclaim\.[0-9a-f]{16})$/;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Minimum age before {@link sweepLockLeftovers} removes a leftover. Every one
|
|
117
|
+
* of them lives inside ONE synchronous call (write → rename/link → unlink),
|
|
118
|
+
* milliseconds long. Ceiling (BV-004): the age is taken from the later of
|
|
119
|
+
* mtime and ctime, because a tombstone is a RENAMED lock whose mtime is the
|
|
120
|
+
* old lock's last write; rename updates ctime (measured 2026-10-03 on APFS;
|
|
121
|
+
* ext4/xfs do too). On a filesystem whose rename touches neither, a tombstone
|
|
122
|
+
* could look old mid-reclaim — the tombstone content check below still keeps
|
|
123
|
+
* every live one. Revisit if a `reclaimed` event is followed by a lost
|
|
124
|
+
* link-back on such a filesystem.
|
|
125
|
+
*/
|
|
126
|
+
export const LOCK_LEFTOVER_MIN_AGE_MS = 60 * 60 * 1000;
|
|
127
|
+
|
|
89
128
|
/**
|
|
90
129
|
* Where the durable lock-ownership proof lives, relative to the repo root
|
|
91
130
|
* (#987 Part 1). `.orchestrator/runtime/` is machine-local, gitignored state —
|
|
@@ -156,17 +195,18 @@ function lockAgeHours(lock) {
|
|
|
156
195
|
* liveness rule thresholds against, surfaced as a number so callers (the
|
|
157
196
|
* Phase-1.2 stale-lock AUQ, recovery diagnostics) can report WHY a lock was
|
|
158
197
|
* classified stale instead of asserting a PID verdict the lock cannot support
|
|
159
|
-
* (#1137).
|
|
198
|
+
* (#1137). Reads `last_heartbeat` only, like `isLockLive()`: a lock without one
|
|
199
|
+
* has no heartbeat age, so this returns `null` rather than an age invented from
|
|
200
|
+
* `started_at` (#595, 2026-10-02 — the v1 fallback is gone).
|
|
160
201
|
*
|
|
161
|
-
* @param {{ last_heartbeat?: string
|
|
162
|
-
* @returns {number|null} minutes since the last heartbeat, or null
|
|
202
|
+
* @param {{ last_heartbeat?: string }} lock
|
|
203
|
+
* @returns {number|null} minutes since the last heartbeat, or null when the lock
|
|
204
|
+
* carries no parseable `last_heartbeat`.
|
|
163
205
|
*/
|
|
164
206
|
function heartbeatAgeMinutes(lock) {
|
|
165
207
|
if (!lock || typeof lock !== 'object') return null;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
: lock.started_at;
|
|
169
|
-
const ts = Date.parse(hbStr);
|
|
208
|
+
if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return null;
|
|
209
|
+
const ts = Date.parse(lock.last_heartbeat);
|
|
170
210
|
if (Number.isNaN(ts)) return null;
|
|
171
211
|
return (Date.now() - ts) / (60 * 1000);
|
|
172
212
|
}
|
|
@@ -179,20 +219,19 @@ function heartbeatAgeMinutes(lock) {
|
|
|
179
219
|
* `session_id` field carries a UUID and the caller wants to preserve the
|
|
180
220
|
* always-semantic id alongside it).
|
|
181
221
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* on
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* `
|
|
195
|
-
* co-change set the removal needs.
|
|
222
|
+
* A lock WITHOUT `last_heartbeat` (#595 sunset, 2026-10-02) is returned as-is:
|
|
223
|
+
* its heartbeat is no longer filled in from `started_at`, and it is deliberately not
|
|
224
|
+
* rejected either. It stays VISIBLE — `readLockDetailed()` reports `ok` and
|
|
225
|
+
* `acquire()` classifies it `stale-heartbeat` (heartbeat age `null`), which
|
|
226
|
+
* lets the SessionStart bootstrap reclaim it — while `isLockLive()` reads it
|
|
227
|
+
* as not live. Rejecting it here would make it `corrupt`: still reclaimable
|
|
228
|
+
* (`acquire()` reports `corrupt` since 2026-10-02 — until then the
|
|
229
|
+
* create-or-fail hit EEXIST and the vanished-race branch answered `active`
|
|
230
|
+
* with `existingLock: null` on every attempt, wedging the repo), but invisible
|
|
231
|
+
* to every `readLock()` caller, which would lose its session_id, host and
|
|
232
|
+
* started_at. No writer has produced such a lock since Epic #583:
|
|
233
|
+
* `buildLock()`, the hook bootstrap and `updateHeartbeat()` all set
|
|
234
|
+
* `last_heartbeat`.
|
|
196
235
|
*
|
|
197
236
|
* @param {string} raw
|
|
198
237
|
* @returns {object|null}
|
|
@@ -203,18 +242,9 @@ function parseLock(raw) {
|
|
|
203
242
|
// The six-field predicate lives in ONE place (#1153 P7) — see
|
|
204
243
|
// `./session-lock-shape.mjs` for the other consumer and the fail-open
|
|
205
244
|
// drift this sharing prevents.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const normalised = { ...obj };
|
|
210
|
-
if (typeof normalised.last_heartbeat !== 'string' || normalised.last_heartbeat.length === 0) {
|
|
211
|
-
normalised.last_heartbeat = normalised.started_at;
|
|
212
|
-
}
|
|
213
|
-
// semantic_session_id stays undefined when absent — callers that need it
|
|
214
|
-
// should fall back to session_id.
|
|
215
|
-
return normalised;
|
|
216
|
-
}
|
|
217
|
-
return null;
|
|
245
|
+
// semantic_session_id stays undefined when absent — callers that need it
|
|
246
|
+
// should fall back to session_id.
|
|
247
|
+
return isLockShape(obj) ? obj : null;
|
|
218
248
|
} catch {
|
|
219
249
|
return null;
|
|
220
250
|
}
|
|
@@ -241,14 +271,21 @@ function buildLock({ sessionId, mode, ttlHours, semanticSessionId }) {
|
|
|
241
271
|
session_id: sessionId,
|
|
242
272
|
started_at: startedAt,
|
|
243
273
|
last_heartbeat: startedAt,
|
|
244
|
-
mode
|
|
274
|
+
// mode and ttl_hours are coerced here because acquire()/forceAcquire() take
|
|
275
|
+
// them from hand-written callers (dispatcher claimRepo, the phase-1.2 prose)
|
|
276
|
+
// unchecked: `mode: undefined` vanished from the JSON and `ttlHours: null`
|
|
277
|
+
// or "4" had the wrong type, so the lock failed isLockShape() — and a lock
|
|
278
|
+
// that fails the shape is reclaimed as 'corrupt' by the next session even
|
|
279
|
+
// while its heartbeat is fresh. 'unknown' classifies like any unknown mode
|
|
280
|
+
// (parallel-ok, see the acquire() wrapper).
|
|
281
|
+
mode: typeof mode === 'string' && mode.trim().length > 0 ? mode : 'unknown',
|
|
245
282
|
pid: process.pid,
|
|
246
283
|
// `host` stays the RAW hostname — it is an on-the-wire event field
|
|
247
284
|
// (orchestrator.session.lock.acquired) and feeds the privacy-hash contract.
|
|
248
285
|
// `host_id` is the additive normalised twin every comparison reads (#1072).
|
|
249
286
|
host: os.hostname(),
|
|
250
287
|
host_id: stableHostname(),
|
|
251
|
-
ttl_hours: ttlHours,
|
|
288
|
+
ttl_hours: Number.isFinite(ttlHours) && ttlHours > 0 ? ttlHours : DEFAULT_TTL_HOURS,
|
|
252
289
|
};
|
|
253
290
|
if (typeof semanticSessionId === 'string' && semanticSessionId.length > 0) {
|
|
254
291
|
lock.semantic_session_id = semanticSessionId;
|
|
@@ -266,24 +303,32 @@ function buildLock({ sessionId, mode, ttlHours, semanticSessionId }) {
|
|
|
266
303
|
*
|
|
267
304
|
* Liveness rule: a lock is live when (now - last_heartbeat) < ttl_hours.
|
|
268
305
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* `
|
|
306
|
+
* No `started_at` fallback (#595 sunset, 2026-10-02): a lock with no
|
|
307
|
+
* `last_heartbeat` is NOT live — nothing is heartbeating it, so it must not
|
|
308
|
+
* hold the repo for a TTL window counted from its start. The rule is mirrored
|
|
309
|
+
* in `scripts/lib/harness-audit/categories/category4.mjs` `lockIsLive()`, and
|
|
310
|
+
* `tests/lib/lock-ttl-parity.test.mjs` holds the two to identical verdicts.
|
|
311
|
+
* See `skills/_shared/state-ownership.md` § Schema v1 Sunset.
|
|
312
|
+
*
|
|
313
|
+
* A heartbeat stamped more than {@link FUTURE_HEARTBEAT_TOLERANCE_MS} in the
|
|
314
|
+
* future is NOT live either (#1494): `now - heartbeat` went negative, stayed
|
|
315
|
+
* below every TTL, and such a lock — a hand edit, a clock stepped backwards —
|
|
316
|
+
* held the repo until the wall clock caught up, a year for a year-ahead stamp.
|
|
317
|
+
* Same rule as the orphan-reaper's throttle marker (#1487,
|
|
318
|
+
* `orphan-reaper/scan-throttle.mjs` `shouldScanNow`), plus a tolerance: a
|
|
319
|
+
* marker in the future only costs one extra scan, a lock misread as dead is
|
|
320
|
+
* taken over while its owner still runs.
|
|
274
321
|
*
|
|
275
|
-
* @param {{ last_heartbeat: string,
|
|
322
|
+
* @param {{ last_heartbeat: string, ttl_hours?: number }} lock
|
|
276
323
|
* @param {number} [nowMs]
|
|
277
324
|
* @returns {boolean}
|
|
278
325
|
*/
|
|
279
326
|
export function isLockLive(lock, nowMs = Date.now()) {
|
|
280
327
|
if (!lock || typeof lock !== 'object') return false;
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
? lock.last_heartbeat
|
|
284
|
-
: lock.started_at;
|
|
285
|
-
const heartbeatMs = Date.parse(hbStr);
|
|
328
|
+
if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return false;
|
|
329
|
+
const heartbeatMs = Date.parse(lock.last_heartbeat);
|
|
286
330
|
if (Number.isNaN(heartbeatMs)) return false;
|
|
331
|
+
if (heartbeatMs > nowMs + FUTURE_HEARTBEAT_TOLERANCE_MS) return false;
|
|
287
332
|
const ttlHours = typeof lock.ttl_hours === 'number' ? lock.ttl_hours : DEFAULT_TTL_HOURS;
|
|
288
333
|
const ttlMs = ttlHours * 3600 * 1000;
|
|
289
334
|
return (nowMs - heartbeatMs) < ttlMs;
|
|
@@ -328,9 +373,12 @@ function writeLockAtomic(lockFile, lock) {
|
|
|
328
373
|
* the file-lock primitive (tryAcquireFileLock) backing the state-lock and
|
|
329
374
|
* staging-fence-lock modules.
|
|
330
375
|
*
|
|
331
|
-
* Used
|
|
332
|
-
*
|
|
333
|
-
* `
|
|
376
|
+
* Used by the no-existing-lock branch of {@link acquire} and by the
|
|
377
|
+
* single-winner reclaim (`forceAcquire({ onlyIfNotLive: true })` →
|
|
378
|
+
* `reclaimIfNotLive()`), which moves the old lock aside first and then needs
|
|
379
|
+
* exactly this create-or-fail. The unconditional overwrite paths (a plain
|
|
380
|
+
* `forceAcquire`, `updateHeartbeat`) keep using `writeLockAtomic` because they
|
|
381
|
+
* MUST replace an existing lock, not fail on it.
|
|
334
382
|
*
|
|
335
383
|
* @param {string} lockFile Absolute path to .orchestrator/session.lock.
|
|
336
384
|
* @param {object} lock Lock object to serialize.
|
|
@@ -481,8 +529,11 @@ export function readLockDetailed(opts = {}) {
|
|
|
481
529
|
* Returns one of:
|
|
482
530
|
* { ok: true, lock, exclusivityClass? }
|
|
483
531
|
* — lock created
|
|
484
|
-
* { ok: false, reason: 'active', existingLock, exclusivityClass? }
|
|
485
|
-
* — local lock held
|
|
532
|
+
* { ok: false, reason: 'active', existingLock, ageHours, exclusivityClass? }
|
|
533
|
+
* — local lock held: its last_heartbeat is within ttl_hours. `ageHours`
|
|
534
|
+
* is the age from `started_at` (null when unparseable). `existingLock`
|
|
535
|
+
* is null, `ageHours` null, only when a lost create race found the
|
|
536
|
+
* winner's lock already gone.
|
|
486
537
|
* { ok: false, reason: 'stale-heartbeat', existingLock, ageHours, heartbeatAgeMinutes, exclusivityClass? }
|
|
487
538
|
* — local lock stale: its last_heartbeat is older than ttl_hours. This is
|
|
488
539
|
* the ONLY stale reason (#1137). It replaced the `stale-pid-dead` /
|
|
@@ -492,6 +543,18 @@ export function readLockDetailed(opts = {}) {
|
|
|
492
543
|
* recorded pids dead, INCLUDING the currently heartbeating session's
|
|
493
544
|
* own lock), so `stale-pid-alive` was unreachable same-host and every
|
|
494
545
|
* stale lock rendered as "confirmed dead" in the recovery AUQ.
|
|
546
|
+
* { ok: false, reason: 'corrupt', existingLock: null, exclusivityClass? }
|
|
547
|
+
* — a lock file is present but fails parseLock() (invalid JSON, or not
|
|
548
|
+
* the six-field lock shape). It can never be a live lock — every
|
|
549
|
+
* writer writes atomically — so the SessionStart bootstrap reclaims it
|
|
550
|
+
* with one stderr WARN. Before 2026-10-02 this case answered 'active'
|
|
551
|
+
* with `existingLock: null` on every attempt and wedged the repo.
|
|
552
|
+
* { ok: false, reason: 'unreadable', existingLock: null, error, exclusivityClass? }
|
|
553
|
+
* — the lock path exists but cannot be read (EACCES, or a directory at
|
|
554
|
+
* the path: EISDIR). Nothing was written and the path is left as it
|
|
555
|
+
* is; the SessionStart bootstrap reports it (stderr WARN + a
|
|
556
|
+
* `orchestrator.session.lock.read_anomaly` event) instead of reclaiming
|
|
557
|
+
* it. Until #1494 this answered 'active' / existingLock:null silently.
|
|
495
558
|
* { ok: false, reason: 'missing-session-id' }
|
|
496
559
|
* — no usable `sessionId` given; NOTHING was written. Same reason string
|
|
497
560
|
* and same predicate as forceAcquire() (see its docblock for why an
|
|
@@ -536,6 +599,10 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
536
599
|
return { ok: false, reason: 'missing-session-id' };
|
|
537
600
|
}
|
|
538
601
|
|
|
602
|
+
// #1505.3: remove what a SIGKILLed writer or reclaimer left beside the lock.
|
|
603
|
+
// Result-neutral and best-effort: it never reads or touches `session.lock`.
|
|
604
|
+
try { sweepLockLeftovers({ repoRoot }); } catch { /* never blocks acquire */ }
|
|
605
|
+
|
|
539
606
|
const lockFile = lockPathFor(repoRoot);
|
|
540
607
|
|
|
541
608
|
// -------------------------------------------------------------------------
|
|
@@ -634,7 +701,16 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
634
701
|
// produced the #744 incident: a live heartbeating session was
|
|
635
702
|
// misclassified 'stale-pid-dead' mid-wave.
|
|
636
703
|
if (isLockLive(existing)) {
|
|
637
|
-
|
|
704
|
+
// ageHours is the figure the Phase-1.2 "active" prompt renders
|
|
705
|
+
// ("started <ageHours>h ago"); it was missing from this shape, so the
|
|
706
|
+
// prompt printed a value nothing had computed (#1494).
|
|
707
|
+
return {
|
|
708
|
+
ok: false,
|
|
709
|
+
reason: 'active',
|
|
710
|
+
existingLock: existing,
|
|
711
|
+
ageHours: lockAgeHours(existing),
|
|
712
|
+
exclusivityClass: callerClass,
|
|
713
|
+
};
|
|
638
714
|
}
|
|
639
715
|
|
|
640
716
|
// Heartbeat expired — ONE stale reason, derived from the same signal the
|
|
@@ -657,12 +733,42 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
657
733
|
};
|
|
658
734
|
};
|
|
659
735
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
736
|
+
// A file that is present but fails parseLock() — invalid JSON, or JSON
|
|
737
|
+
// without the six-field lock shape — gets its OWN reason instead of
|
|
738
|
+
// collapsing into the vanished-race branch below. It used to: readLock()
|
|
739
|
+
// returned null, the create-or-fail hit EEXIST on the file still there,
|
|
740
|
+
// the re-read was null again, and every attempt answered
|
|
741
|
+
// `active` / `existingLock: null` — which the bootstrap cannot reclaim, so
|
|
742
|
+
// one damaged file blocked every new session in the repo for good.
|
|
743
|
+
// Such a file can never be a live lock: all four production writers
|
|
744
|
+
// (createSessionLockExclusive, writeLockAtomic via forceAcquire and
|
|
745
|
+
// updateHeartbeat, the bootstrap's writeJsonAtomicSync) write a full
|
|
746
|
+
// buildLock() body via tmp+link / tmp+rename, so no reader ever sees a
|
|
747
|
+
// partial write. Reported, not cleared — the caller decides (see the
|
|
748
|
+
// decision-deferred note in the module header).
|
|
749
|
+
// Ceiling (BV-004): a future writer emitting a body that fails
|
|
750
|
+
// isLockShape() would be classified corrupt too. Revisit if the lock
|
|
751
|
+
// schema ever changes a required field's type or name.
|
|
752
|
+
const corrupt = () => ({ ok: false, reason: 'corrupt', existingLock: null, exclusivityClass: callerClass });
|
|
753
|
+
|
|
754
|
+
// A lock path that exists but cannot be read — EACCES, or a DIRECTORY at
|
|
755
|
+
// the path (EISDIR) — gets its own reason too (#1494, #599). It used to
|
|
756
|
+
// fall through to the create-or-fail, hit EEXIST and answer
|
|
757
|
+
// 'active' / existingLock:null, which nobody reports: the bootstrap
|
|
758
|
+
// neither forced nor warned, and every session ran without a lock, with
|
|
759
|
+
// no trace. Never reclaimed — what sits there was not written by a lock
|
|
760
|
+
// writer, and a directory may hold content nobody here created (PSA-003).
|
|
761
|
+
const unreadable = (error) => ({ ok: false, reason: 'unreadable', existingLock: null, error, exclusivityClass: callerClass });
|
|
762
|
+
|
|
763
|
+
const existing = readLockDetailed({ repoRoot });
|
|
764
|
+
|
|
765
|
+
if (existing.status === 'ok') {
|
|
663
766
|
// A lock is present — classify it.
|
|
664
|
-
return classifyExisting(existing);
|
|
767
|
+
return classifyExisting(existing.lock);
|
|
665
768
|
}
|
|
769
|
+
if (existing.status === 'corrupt') return corrupt();
|
|
770
|
+
if (existing.status === 'unreadable') return unreadable(existing.error);
|
|
771
|
+
// 'absent' → create below.
|
|
666
772
|
|
|
667
773
|
// No existing lock — create one with a TOCTOU-safe create-or-fail (#590).
|
|
668
774
|
// Two concurrent SessionStart hooks can both reach this branch having each
|
|
@@ -679,15 +785,15 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
679
785
|
|
|
680
786
|
// reason === 'exists' — we lost the create race. Re-read the now-present
|
|
681
787
|
// lock and classify it exactly as if we had seen it on the up-front check.
|
|
682
|
-
const raced =
|
|
683
|
-
if (raced ===
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
return
|
|
788
|
+
const raced = readLockDetailed({ repoRoot });
|
|
789
|
+
if (raced.status === 'ok') return classifyExisting(raced.lock);
|
|
790
|
+
if (raced.status === 'corrupt') return corrupt();
|
|
791
|
+
if (raced.status === 'unreadable') return unreadable(raced.error);
|
|
792
|
+
// The EEXIST winner's lock vanished before we could re-read it (ENOENT).
|
|
793
|
+
// Defensive fallback: report 'active' so the caller defers rather than
|
|
794
|
+
// racing again — mirrors tryAcquireStateLock's vanish-race handling (a
|
|
795
|
+
// lost-then-vanished race resolves conservatively).
|
|
796
|
+
return { ok: false, reason: 'active', existingLock: null, ageHours: null, exclusivityClass: callerClass };
|
|
691
797
|
} catch (err) {
|
|
692
798
|
return { ok: false, reason: 'fs-error', error: err.message, exclusivityClass: callerClass };
|
|
693
799
|
}
|
|
@@ -718,7 +824,14 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
718
824
|
* no information while breaking the direct `forceAcquire()` calls documented in
|
|
719
825
|
* `skills/session-start/references/phase-1-2-session-lock.md`.
|
|
720
826
|
*
|
|
721
|
-
*
|
|
827
|
+
* `onlyIfNotLive: true` (#1494) turns the unconditional overwrite into a
|
|
828
|
+
* single-winner takeover of a stale or corrupt lock: a lock that is live when
|
|
829
|
+
* it is moved aside is put back, and every lost race returns a fresh
|
|
830
|
+
* `acquire()` result instead of `ok: true` — see `reclaimIfNotLive()`. The
|
|
831
|
+
* SessionStart bootstrap passes it for its `stale-heartbeat` / `corrupt`
|
|
832
|
+
* reclaims; the operator-approved force-take of a LIVE lock (Phase 1.2) omits it.
|
|
833
|
+
*
|
|
834
|
+
* @param {{ sessionId: string, mode: string, ttlHours?: number, repoRoot?: string, semanticSessionId?: string, onlyIfNotLive?: boolean }} args
|
|
722
835
|
*/
|
|
723
836
|
/**
|
|
724
837
|
* A `sessionId` is usable only when it is a non-blank string. ONE predicate,
|
|
@@ -733,10 +846,13 @@ function hasUsableSessionId(sessionId) {
|
|
|
733
846
|
return typeof sessionId === 'string' && sessionId.trim().length > 0;
|
|
734
847
|
}
|
|
735
848
|
|
|
736
|
-
export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, semanticSessionId } = {}) {
|
|
849
|
+
export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, semanticSessionId, onlyIfNotLive = false } = {}) {
|
|
737
850
|
if (!hasUsableSessionId(sessionId)) {
|
|
738
851
|
return { ok: false, reason: 'missing-session-id' };
|
|
739
852
|
}
|
|
853
|
+
if (onlyIfNotLive === true) {
|
|
854
|
+
return reclaimIfNotLive({ sessionId, mode, ttlHours, repoRoot, semanticSessionId });
|
|
855
|
+
}
|
|
740
856
|
try {
|
|
741
857
|
const replacedLock = readLock({ repoRoot });
|
|
742
858
|
const lock = buildLock({ sessionId, mode, ttlHours, semanticSessionId });
|
|
@@ -753,6 +869,143 @@ export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, re
|
|
|
753
869
|
}
|
|
754
870
|
}
|
|
755
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Single-winner takeover of a stale or corrupt lock — `forceAcquire({
|
|
874
|
+
* onlyIfNotLive: true })` (#1494).
|
|
875
|
+
*
|
|
876
|
+
* The plain force is last-writer-wins: two SessionStarts that both saw the same
|
|
877
|
+
* stale lock both forced it, both got `ok: true`, and the later rename won while
|
|
878
|
+
* the loser kept running as if it held the lock. Here the lock is first renamed
|
|
879
|
+
* to a unique tombstone. A rename moves exactly one directory entry, so only one
|
|
880
|
+
* caller can hold the moved file; it is judged only then, from the tombstone:
|
|
881
|
+
* - not live (stale, heartbeat-less, unparseable) → replaced via the same
|
|
882
|
+
* create-or-fail link as a fresh acquire();
|
|
883
|
+
* - live → a racer finished its takeover between our acquire() read and our
|
|
884
|
+
* rename, and what we moved is ITS lock: linked back, never replaced.
|
|
885
|
+
* Every losing path — the rename finds nothing (ENOENT: a racer moved it
|
|
886
|
+
* first), the create finds a lock (EEXIST), the moved lock was live — ends in
|
|
887
|
+
* ONE fresh `acquire()`, which reads whatever sits at the path now and answers
|
|
888
|
+
* as on a first look (normally `active` with the winner's lock).
|
|
889
|
+
*
|
|
890
|
+
* Ceilings (BV-004), both reachable with TWO SessionStarts:
|
|
891
|
+
* (1) A racer's fresh lock that we move and link back is absent for that
|
|
892
|
+
* window, and any reader in it sees no lock. The reader most likely to land
|
|
893
|
+
* there is the winner's own bootstrap re-reading the lock it just acquired, so
|
|
894
|
+
* `hooks/_lib/lock-bootstrap.mjs` re-reads on ENOENT (5 × 10 ms) instead of
|
|
895
|
+
* giving up the enrichment; a third reader just sees "absent" briefly.
|
|
896
|
+
* (2) A lock that turned LIVE after both starts read it stale — its own owner
|
|
897
|
+
* heartbeated in between — is moved by one start while the other's rename
|
|
898
|
+
* finds nothing and its fresh `acquire()` creates a lock in the empty path. The
|
|
899
|
+
* link-back then fails with EEXIST and the owner's lock is dropped; the owner
|
|
900
|
+
* learns it at its next heartbeat (updateHeartbeat() returns false on the
|
|
901
|
+
* session mismatch). Not a regression: the plain force overwrote that live lock
|
|
902
|
+
* unconditionally. Revisit if a `reclaimed` event pair within one second shows
|
|
903
|
+
* up in events.jsonl again, or a session's heartbeat starts failing right after
|
|
904
|
+
* another start reclaimed its lock.
|
|
905
|
+
*
|
|
906
|
+
* @param {{ sessionId: string, mode: string, ttlHours: number, repoRoot?: string, semanticSessionId?: string }} args
|
|
907
|
+
* @returns {object} forceAcquire()'s `{ ok: true, lock, replacedLock? }` or
|
|
908
|
+
* `fs-error` shape, or — on a lost race — the result of a fresh `acquire()`.
|
|
909
|
+
*/
|
|
910
|
+
function reclaimIfNotLive({ sessionId, mode, ttlHours, repoRoot, semanticSessionId }) {
|
|
911
|
+
const lockFile = lockPathFor(repoRoot);
|
|
912
|
+
const reacquire = () => acquire({ sessionId, mode, ttlHours, repoRoot, semanticSessionId, quiet: true });
|
|
913
|
+
const tomb = path.join(path.dirname(lockFile), `.session.lock.reclaim.${crypto.randomBytes(8).toString('hex')}`);
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
fs.renameSync(lockFile, tomb);
|
|
917
|
+
} catch (err) {
|
|
918
|
+
if (err && err.code === 'ENOENT') return reacquire();
|
|
919
|
+
return { ok: false, reason: 'fs-error', error: err.message };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
let keepTomb = false;
|
|
923
|
+
try {
|
|
924
|
+
let raw;
|
|
925
|
+
try {
|
|
926
|
+
raw = fs.readFileSync(tomb, 'utf8');
|
|
927
|
+
} catch {
|
|
928
|
+
// Unjudgeable — something unreadable replaced the stale file after
|
|
929
|
+
// acquire() read it. Not ours to drop: put it back (a directory cannot
|
|
930
|
+
// be hard-linked, and renaming a directory never replaces a file); if
|
|
931
|
+
// the path is taken by then, it stays under the tombstone name.
|
|
932
|
+
keepTomb = true;
|
|
933
|
+
try {
|
|
934
|
+
if (fs.lstatSync(tomb).isDirectory()) fs.renameSync(tomb, lockFile);
|
|
935
|
+
else { fs.linkSync(tomb, lockFile); keepTomb = false; }
|
|
936
|
+
} catch { /* stays at the tombstone name */ }
|
|
937
|
+
return reacquire();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const moved = parseLock(raw);
|
|
941
|
+
if (moved !== null && isLockLive(moved)) {
|
|
942
|
+
try { fs.linkSync(tomb, lockFile); } catch { /* ceiling (2) above */ }
|
|
943
|
+
return reacquire();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const lock = buildLock({ sessionId, mode, ttlHours, semanticSessionId });
|
|
947
|
+
const created = createSessionLockExclusive(lockFile, lock);
|
|
948
|
+
if (created.ok) {
|
|
949
|
+
const result = { ok: true, lock };
|
|
950
|
+
if (moved !== null) result.replacedLock = moved;
|
|
951
|
+
return result;
|
|
952
|
+
}
|
|
953
|
+
if (created.reason === 'exists') return reacquire();
|
|
954
|
+
return created;
|
|
955
|
+
} finally {
|
|
956
|
+
if (!keepTomb) {
|
|
957
|
+
try { fs.unlinkSync(tomb); } catch { /* best-effort */ }
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Remove the lock manager's own leftovers beside `session.lock` (#1505.3) —
|
|
964
|
+
* files named exactly like {@link LOCK_LEFTOVER_RE}, regular files only (lstat:
|
|
965
|
+
* a symlink, directory or anything else is never followed nor removed), last
|
|
966
|
+
* touched more than {@link LOCK_LEFTOVER_MIN_AGE_MS} ago. A tombstone is
|
|
967
|
+
* removed only on the decision its killed reclaimer would have taken itself:
|
|
968
|
+
* readable and not a live lock. An unreadable one is kept, as
|
|
969
|
+
* `reclaimIfNotLive()` keeps it. Unlinking a tombstone removes only that NAME —
|
|
970
|
+
* when it is still a hard link to the primary lock's inode (killed between
|
|
971
|
+
* link-back and unlink), `session.lock` keeps its content.
|
|
972
|
+
*
|
|
973
|
+
* Never throws; a file that vanished or cannot be removed is skipped.
|
|
974
|
+
*
|
|
975
|
+
* @param {{ repoRoot?: string, nowMs?: number }} [opts]
|
|
976
|
+
* @returns {{ removed: string[] }} basenames removed
|
|
977
|
+
*/
|
|
978
|
+
export function sweepLockLeftovers({ repoRoot, nowMs = Date.now() } = {}) {
|
|
979
|
+
const removed = [];
|
|
980
|
+
let dir;
|
|
981
|
+
let names;
|
|
982
|
+
try {
|
|
983
|
+
dir = path.dirname(lockPathFor(repoRoot));
|
|
984
|
+
names = fs.readdirSync(dir);
|
|
985
|
+
} catch {
|
|
986
|
+
return { removed };
|
|
987
|
+
}
|
|
988
|
+
for (const name of names) {
|
|
989
|
+
if (!LOCK_LEFTOVER_RE.test(name)) continue;
|
|
990
|
+
const full = path.join(dir, name);
|
|
991
|
+
try {
|
|
992
|
+
const st = fs.lstatSync(full);
|
|
993
|
+
if (!st.isFile()) continue;
|
|
994
|
+
// A future mtime/ctime (clock stepped back) yields a negative age: kept.
|
|
995
|
+
if (!(nowMs - Math.max(st.mtimeMs, st.ctimeMs) > LOCK_LEFTOVER_MIN_AGE_MS)) continue;
|
|
996
|
+
if (name.startsWith('.session.lock.reclaim.')) {
|
|
997
|
+
let raw;
|
|
998
|
+
try { raw = fs.readFileSync(full, 'utf8'); } catch { continue; }
|
|
999
|
+
const moved = parseLock(raw);
|
|
1000
|
+
if (moved !== null && isLockLive(moved, nowMs)) continue;
|
|
1001
|
+
}
|
|
1002
|
+
fs.unlinkSync(full);
|
|
1003
|
+
removed.push(name);
|
|
1004
|
+
} catch { /* vanished or unremovable — best-effort */ }
|
|
1005
|
+
}
|
|
1006
|
+
return { removed };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
756
1009
|
/**
|
|
757
1010
|
* Extract an ownership-proof object from a lock — the field-name translation
|
|
758
1011
|
* layer `isLockOwnedByProof()` expects. Intended usage: a caller that just
|