session-orchestrator 3.17.0 → 3.19.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/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +105 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -13
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +73 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
package/hooks/hooks-pi.json
CHANGED
|
@@ -88,6 +88,16 @@
|
|
|
88
88
|
}
|
|
89
89
|
]
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
"matcher": "bash",
|
|
93
|
+
"hooks": [
|
|
94
|
+
{
|
|
95
|
+
"type": "command",
|
|
96
|
+
"command": "sh \"$PI_PLUGIN_ROOT/hooks/run-node.sh\" \"$PI_PLUGIN_ROOT/hooks/post-bash-write-verify.mjs\"",
|
|
97
|
+
"timeout": 5
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
},
|
|
91
101
|
{
|
|
92
102
|
"matcher": "*",
|
|
93
103
|
"hooks": [
|
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator v3.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v3.19.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -74,11 +74,21 @@
|
|
|
74
74
|
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-memory-propose-audit.mjs\"",
|
|
75
75
|
"timeout": 5
|
|
76
76
|
},
|
|
77
|
+
{
|
|
78
|
+
"type": "command",
|
|
79
|
+
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-sessions-ledger-guard.mjs\"",
|
|
80
|
+
"timeout": 5
|
|
81
|
+
},
|
|
77
82
|
{
|
|
78
83
|
"type": "command",
|
|
79
84
|
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-templates-first.mjs\"",
|
|
80
85
|
"timeout": 5
|
|
81
86
|
},
|
|
87
|
+
{
|
|
88
|
+
"type": "command",
|
|
89
|
+
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-issue-budget.mjs\"",
|
|
90
|
+
"timeout": 5
|
|
91
|
+
},
|
|
82
92
|
{
|
|
83
93
|
"type": "command",
|
|
84
94
|
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/enforce-commands.mjs\"",
|
|
@@ -108,6 +118,16 @@
|
|
|
108
118
|
}
|
|
109
119
|
]
|
|
110
120
|
},
|
|
121
|
+
{
|
|
122
|
+
"matcher": "Bash",
|
|
123
|
+
"hooks": [
|
|
124
|
+
{
|
|
125
|
+
"type": "command",
|
|
126
|
+
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/post-bash-write-verify.mjs\"",
|
|
127
|
+
"timeout": 5
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
},
|
|
111
131
|
{
|
|
112
132
|
"matcher": "*",
|
|
113
133
|
"hooks": [
|
package/hooks/on-session-end.mjs
CHANGED
|
@@ -31,7 +31,14 @@ if (!shouldRunHook('on-session-end')) process.exit(0);
|
|
|
31
31
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
32
32
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
33
33
|
import { backfillAbandonedSession } from '../scripts/lib/session-close-backfill.mjs';
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
readLockDetailed,
|
|
36
|
+
release,
|
|
37
|
+
isLockLive,
|
|
38
|
+
loadOwnerProof,
|
|
39
|
+
isLockOwnedByProof,
|
|
40
|
+
OWNER_PROOF_RELPATH,
|
|
41
|
+
} from '../scripts/lib/session-lock.mjs';
|
|
35
42
|
import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
|
|
36
43
|
|
|
37
44
|
// ---------------------------------------------------------------------------
|
|
@@ -77,6 +84,19 @@ async function readStdinJson() {
|
|
|
77
84
|
* keyed by, and the id the backfill (C1 #724) uses when the stdin session_id is
|
|
78
85
|
* a harness UUID with no lock.acquired bridge.
|
|
79
86
|
*
|
|
87
|
+
* #863 defect (c) — `semanticSessionId` is gated by the SAME "ending session
|
|
88
|
+
* IS the recorded one" check as `durationMs` above. `current-session.json` is
|
|
89
|
+
* a single repo-global file: it always reflects whichever session most
|
|
90
|
+
* recently ran SessionStart, which may be a DIFFERENT, still-live session
|
|
91
|
+
* when multiple windows share this repo. Before this fix, a foreign
|
|
92
|
+
* terminating window (its own stdin `session_id` explicitly present, but NOT
|
|
93
|
+
* equal to the recorded session) still inherited the CURRENTLY-recorded
|
|
94
|
+
* session's `semantic_session_id` unconditionally — main()'s `ownBySemantic`
|
|
95
|
+
* check then matched that OTHER, still-running session's lock and released
|
|
96
|
+
* it. Gating this field closes that window: an unrelated/mismatched ending
|
|
97
|
+
* session now resolves `semanticSessionId: null`, so `ownBySemantic` can
|
|
98
|
+
* never accidentally fire on someone else's identity.
|
|
99
|
+
*
|
|
80
100
|
* @param {object|null} input
|
|
81
101
|
* @param {string} projectRoot
|
|
82
102
|
* @returns {Promise<{sessionId: string|null, semanticSessionId: string|null, durationMs: number}>}
|
|
@@ -109,12 +129,18 @@ async function resolveSession(input, projectRoot) {
|
|
|
109
129
|
if (sessionId === null) sessionId = recordedId;
|
|
110
130
|
|
|
111
131
|
// Only trust the recorded start time when the ending session IS the recorded one.
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
132
|
+
const isRecordedSession = sessionId !== null && sessionId === recordedId;
|
|
133
|
+
const durationMs = startedAtMs !== null && isRecordedSession
|
|
134
|
+
? Math.max(0, Date.now() - startedAtMs)
|
|
135
|
+
: 0;
|
|
136
|
+
|
|
137
|
+
// #863 defect (c) — same guard as durationMs above: only surface the
|
|
138
|
+
// recorded semantic id when THIS ending session is genuinely the one
|
|
139
|
+
// current-session.json describes. See the docblock above for the exact
|
|
140
|
+
// contamination scenario this closes.
|
|
141
|
+
const resolvedSemanticSessionId = isRecordedSession ? semanticSessionId : null;
|
|
116
142
|
|
|
117
|
-
return { sessionId, semanticSessionId, durationMs };
|
|
143
|
+
return { sessionId, semanticSessionId: resolvedSemanticSessionId, durationMs };
|
|
118
144
|
}
|
|
119
145
|
|
|
120
146
|
// ---------------------------------------------------------------------------
|
|
@@ -173,14 +199,93 @@ async function main() {
|
|
|
173
199
|
// reapRepoLock(), which independently NEVER reaps a live lease (and only
|
|
174
200
|
// ever reaps on this same host with a dead recorded PID).
|
|
175
201
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
202
|
+
// readLockDetailed (additive, see its JSDoc in session-lock.mjs) replaces
|
|
203
|
+
// readLock() here so an unreadable/corrupt lock file is DISTINGUISHABLE
|
|
204
|
+
// from a genuinely absent one, instead of both collapsing to the same
|
|
205
|
+
// `null`. Only 'absent' is treated as "nothing to release/reconcile" —
|
|
206
|
+
// 'unreadable'/'corrupt' is an anomaly worth a breadcrumb, since a lock we
|
|
207
|
+
// cannot even parse is neither released nor reconciled below, and used to
|
|
208
|
+
// silently look identical to "no lock, all clear".
|
|
209
|
+
const lockDetail = readLockDetailed({ repoRoot: projectRoot });
|
|
210
|
+
|
|
211
|
+
if (lockDetail.status === 'unreadable' || lockDetail.status === 'corrupt') {
|
|
212
|
+
try {
|
|
213
|
+
await emitEvent('orchestrator.session.lock.read_anomaly', {
|
|
214
|
+
session_id: sessionId,
|
|
215
|
+
status: lockDetail.status,
|
|
216
|
+
...(lockDetail.status === 'unreadable' ? { error: lockDetail.error } : {}),
|
|
217
|
+
});
|
|
218
|
+
} catch { /* observability is best-effort */ }
|
|
219
|
+
} else if (lockDetail.status === 'ok') {
|
|
220
|
+
const lock = lockDetail.lock;
|
|
178
221
|
const ownByUuid = sessionId !== null && lock.session_id === sessionId;
|
|
179
|
-
const
|
|
222
|
+
const ownBySemanticStrict =
|
|
180
223
|
semanticSessionId !== null && lock.semantic_session_id === semanticSessionId;
|
|
224
|
+
// #863 (d) — lock-shape trap: some on-disk locks store the semantic id
|
|
225
|
+
// directly in `session_id` with no separate `semantic_session_id` field
|
|
226
|
+
// at all (the "generated-semantic" acquisition path in
|
|
227
|
+
// on-session-start.mjs mints `session_id === the semantic id`, and
|
|
228
|
+
// bootstrapLock's v2 enrichment step never ran for that lock). Without
|
|
229
|
+
// this fallback, ownBySemanticStrict is dead code for that shape.
|
|
230
|
+
const ownBySemanticFallback =
|
|
231
|
+
semanticSessionId !== null && lock.session_id === semanticSessionId;
|
|
232
|
+
const ownBySemantic = ownBySemanticStrict || ownBySemanticFallback;
|
|
181
233
|
|
|
182
|
-
|
|
183
|
-
|
|
234
|
+
// #987 Part 2 — persisted ownership proof (pid + host + started_at,
|
|
235
|
+
// written at lock genesis by bootstrapLock Step 2b, see
|
|
236
|
+
// writeOwnerProof() in session-lock.mjs). loadOwnerProof() is
|
|
237
|
+
// fail-closed: a missing/corrupt proof file yields null, and a null
|
|
238
|
+
// proof presented to isLockOwnedByProof() yields false — the hook then
|
|
239
|
+
// degrades to exactly the pre-#987 (proof-less) behaviour below.
|
|
240
|
+
const proof = loadOwnerProof({ repoRoot: projectRoot });
|
|
241
|
+
const ownByProof = isLockOwnedByProof(lock, proof);
|
|
242
|
+
|
|
243
|
+
// #906-class fix: the semantic session id
|
|
244
|
+
// (`<branch>-<date>-<mode>-<n>`) is NOT globally unique — the
|
|
245
|
+
// id-counter can hand out the SAME id to two different session
|
|
246
|
+
// processes (live-observed twice on 2026-07-29: `main-2026-07-29-deep-1`
|
|
247
|
+
// and `main-2026-07-29-session-1`, each assigned to two distinct
|
|
248
|
+
// sessions). A live lock matched ONLY via a collidable semantic id
|
|
249
|
+
// (strict OR fallback, never corroborated by a non-collidable factor)
|
|
250
|
+
// is therefore NEVER trusted for release.
|
|
251
|
+
//
|
|
252
|
+
// #987 Part 2 — the discrimination the #906-class fix escalated as
|
|
253
|
+
// "structurally unobservable" is now OBSERVABLE via the persisted
|
|
254
|
+
// owner proof: a genuine same-session UUID rotation (clear/compact)
|
|
255
|
+
// and a foreign same-day semantic collision used to produce the
|
|
256
|
+
// IDENTICAL shape here (UUID mismatch + semantic match on a live
|
|
257
|
+
// lock), so the previous fix conservatively left BOTH un-released
|
|
258
|
+
// (bounded by the ttl_hours reaper). The proof written at lock genesis
|
|
259
|
+
// (pid + host + started_at — presence-at-genesis, never the collidable
|
|
260
|
+
// session_id/semantic id, see the FACTOR CHOICE docblock on
|
|
261
|
+
// isLockOwnedByProof()) is exactly the discriminator that RCR-007
|
|
262
|
+
// escalation named: on a self-rotation the on-disk lock is FROZEN at
|
|
263
|
+
// its pre-rotation values (bootstrapLock's shouldForce is false for a
|
|
264
|
+
// rotated UUID, so it bails without touching the lock), which are the
|
|
265
|
+
// SAME values the proof captured at genesis → ownByProof is true → the
|
|
266
|
+
// lock is released correctly. A foreign same-day collision wrote its
|
|
267
|
+
// lock in a DIFFERENT process at a DIFFERENT millisecond → ownByProof
|
|
268
|
+
// is false → semanticOnlyLive stays true → no release, the foreign
|
|
269
|
+
// lease survives (the never-release-a-foreign-lock invariant is
|
|
270
|
+
// untouched). No proof on disk (pre-#987 sessions, failed proof
|
|
271
|
+
// write) → ownByProof false → the conservative pre-#987 behaviour.
|
|
272
|
+
const semanticOnlyLive = !ownByUuid && !ownByProof && ownBySemantic && isLockLive(lock);
|
|
273
|
+
const releaseEligible = (ownByUuid || ownBySemantic) && !semanticOnlyLive;
|
|
274
|
+
|
|
275
|
+
if (releaseEligible) {
|
|
276
|
+
// Defense-in-depth (#987): when a persisted proof exists, hand it to
|
|
277
|
+
// release() so the delete is double-gated (session_id match AND
|
|
278
|
+
// proof match) at the fs layer too. TRAP — release()'s proof gate
|
|
279
|
+
// triggers on `proof !== undefined`, so a null proof MUST be
|
|
280
|
+
// spread-guarded out: passing `proof: null` would fail
|
|
281
|
+
// isLockOwnedByProof() unconditionally and refuse EVERY release.
|
|
282
|
+
// A 'proof-mismatch' result flows into the existing release_failed
|
|
283
|
+
// breadcrumb below (releaseResult.reason surfaces verbatim).
|
|
284
|
+
const releaseResult = release({
|
|
285
|
+
sessionId: lock.session_id,
|
|
286
|
+
repoRoot: projectRoot,
|
|
287
|
+
...(proof ? { proof } : {}),
|
|
288
|
+
});
|
|
184
289
|
// release() has a no-throw contract (always returns a structured
|
|
185
290
|
// result). A matched ownership that still fails to delete — an
|
|
186
291
|
// fs-error, or an unexpected non-delete outcome other than the benign
|
|
@@ -194,20 +299,75 @@ async function main() {
|
|
|
194
299
|
reason: releaseResult.ok
|
|
195
300
|
? (releaseResult.reason ?? 'not-deleted')
|
|
196
301
|
: (releaseResult.reason ?? 'fs-error'),
|
|
302
|
+
caller: 'on-session-end',
|
|
197
303
|
});
|
|
198
304
|
} catch { /* observability is best-effort */ }
|
|
305
|
+
} else {
|
|
306
|
+
// #952 (A) — SUCCESS breadcrumb. Until now ONLY the failure path
|
|
307
|
+
// emitted: a release that actually worked left zero trace, so
|
|
308
|
+
// "the hook-lock vanished somewhere in the start chain" (#914
|
|
309
|
+
// residual 3) was forensically undecidable — an absent lock could
|
|
310
|
+
// equally mean "we released it cleanly at SessionEnd" or "something
|
|
311
|
+
// else deleted it". Both REACHED outcomes now emit, distinguished by
|
|
312
|
+
// `outcome`:
|
|
313
|
+
// 'deleted' — we unlinked it ourselves (the normal close).
|
|
314
|
+
// 'already-gone' — ownership matched, but the lock had ALREADY
|
|
315
|
+
// vanished between readLock() and release()
|
|
316
|
+
// (release() reason 'no-lock'). This is the
|
|
317
|
+
// forensically INTERESTING one: a third party
|
|
318
|
+
// removed our lock while we still held it, which
|
|
319
|
+
// IS the #914 disappearance class. Staying silent
|
|
320
|
+
// on it would leave exactly that signal unobservable.
|
|
321
|
+
//
|
|
322
|
+
// `end_reason` (not `reason`) deliberately: the sibling
|
|
323
|
+
// `…lock.release_failed` event above uses `reason` for the FAILURE
|
|
324
|
+
// reason, so reusing the key for the SessionEnd reason
|
|
325
|
+
// (clear|logout|prompt_input_exit|other) inside the same
|
|
326
|
+
// `orchestrator.session.lock.*` namespace would make any consumer
|
|
327
|
+
// roll-up over `.reason` mix "clear" with "fs-error".
|
|
328
|
+
//
|
|
329
|
+
// Emitted at the CALL-SITE, never inside release(): session-lock.mjs
|
|
330
|
+
// deliberately carries no dependency on events.mjs, and release() is
|
|
331
|
+
// synchronous while emitEvent() is async. `caller` keeps the two
|
|
332
|
+
// release call-sites (this hook and the autopilot worktree pipeline)
|
|
333
|
+
// distinguishable in the single stream.
|
|
334
|
+
try {
|
|
335
|
+
await emitEvent('orchestrator.session.lock.released', {
|
|
336
|
+
session_id: sessionId,
|
|
337
|
+
lock_session_id: lock.session_id,
|
|
338
|
+
semantic_session_id: semanticSessionId,
|
|
339
|
+
end_reason: reason,
|
|
340
|
+
caller: 'on-session-end',
|
|
341
|
+
outcome: benignAlreadyGone ? 'already-gone' : 'deleted',
|
|
342
|
+
verified: releaseResult.verified === true,
|
|
343
|
+
});
|
|
344
|
+
} catch { /* observability is best-effort */ }
|
|
345
|
+
|
|
346
|
+
// #987 hygiene — the successful own release consumed the genesis
|
|
347
|
+
// proof; remove it best-effort. A leftover would be harmless (its
|
|
348
|
+
// millisecond started_at cannot match any FUTURE lock, so it
|
|
349
|
+
// self-invalidates), this just avoids the stale artifact. ENOENT
|
|
350
|
+
// (no proof was ever written) lands in the same swallow.
|
|
351
|
+
try {
|
|
352
|
+
await fs.unlink(path.join(projectRoot, OWNER_PROOF_RELPATH));
|
|
353
|
+
} catch { /* best-effort — a leftover proof is self-invalidating */ }
|
|
199
354
|
}
|
|
200
355
|
} else {
|
|
201
|
-
// Root-cause reconciliation fallback:
|
|
202
|
-
// semantic id matched the recorded lock
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
356
|
+
// Root-cause reconciliation fallback: either NEITHER the UUID nor the
|
|
357
|
+
// semantic id matched the recorded lock, OR the ONLY match was a
|
|
358
|
+
// collidable semantic comparison (strict or fallback) on a lock that
|
|
359
|
+
// is still live (semanticOnlyLive). attemptLockReconciliation() is
|
|
360
|
+
// the extracted, DI-testable seam (Issue #748) — it internally
|
|
361
|
+
// no-ops when the lease is still live (isLockLive), which is exactly
|
|
362
|
+
// what makes it safe to route the semanticOnlyLive case here too: it
|
|
363
|
+
// is otherwise best-effort, and reapRepoLock() never touches a live
|
|
364
|
+
// lease, a cross-host lease, or a lease whose recorded PID is still
|
|
365
|
+
// alive on this host.
|
|
208
366
|
await attemptLockReconciliation({ repoRoot: projectRoot, sessionId, lock });
|
|
209
367
|
}
|
|
210
368
|
}
|
|
369
|
+
// 'absent' — no lock file at all; nothing to release or reconcile
|
|
370
|
+
// (mirrors the pre-existing `if (lock)` guard's false branch).
|
|
211
371
|
} catch { /* best-effort — never block teardown */ }
|
|
212
372
|
}
|
|
213
373
|
|
|
@@ -542,6 +542,29 @@ async function main() {
|
|
|
542
542
|
});
|
|
543
543
|
} catch { /* hook must remain non-blocking */ }
|
|
544
544
|
|
|
545
|
+
// Epic #926 — close-through backfill at SessionStart (decoupled from /close).
|
|
546
|
+
// hooks/on-session-end.mjs already backfills, but SessionEnd only fires on a
|
|
547
|
+
// REGULAR close: a session killed by Ctrl-C, a timeout or a crash leaves no
|
|
548
|
+
// ledger entry, and the backfill then waits for the NEXT clean close. Running
|
|
549
|
+
// it here reconstructs the PREVIOUS abandoned session whatever killed it.
|
|
550
|
+
//
|
|
551
|
+
// ORDERING IS LOAD-BEARING — this MUST stay before the
|
|
552
|
+
// `orchestrator.session.started` emit at the end of main(). That ordering is
|
|
553
|
+
// this session's structural self-exclusion: our own started-event is not in
|
|
554
|
+
// events.jsonl yet, so we are not a backfill candidate at all. (On a
|
|
555
|
+
// clear/compact/resume re-fire an earlier started-event IS present; that case
|
|
556
|
+
// is caught by the core's own `skipped-own-live-lock` guard against the lock
|
|
557
|
+
// bootstrapped above.) A live FOREIGN session is likewise protected: the core
|
|
558
|
+
// evaluates lock ownership against the CANDIDATE, so a candidate holding a
|
|
559
|
+
// live lock is skipped before the dead-by-age relaxation is consulted.
|
|
560
|
+
//
|
|
561
|
+
// Best-effort like every other probe here: any failure is swallowed so a
|
|
562
|
+
// backfill problem can never block a session start.
|
|
563
|
+
try {
|
|
564
|
+
const { backfillOnSessionStart } = await import('../scripts/backfill-abandoned-sessions.mjs');
|
|
565
|
+
await backfillOnSessionStart({ repoRoot: projectRoot });
|
|
566
|
+
} catch { /* hook must remain non-blocking */ }
|
|
567
|
+
|
|
545
568
|
let peers = [];
|
|
546
569
|
try {
|
|
547
570
|
await sweepZombies().catch(() => ({ removed: [], logged: 0 }));
|