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
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* worktree-cleanup.mjs — Phase 4a Auto-Promoted Worktree Cleanup helpers (#575 P3.2).
|
|
3
3
|
*
|
|
4
4
|
* Public API:
|
|
5
|
-
* - detectAutoPromotedWorktree(repoRoot, sessionId, opts): { wtPath, sessionId, branch } | null
|
|
5
|
+
* - detectAutoPromotedWorktree(repoRoot, sessionId, opts): { wtPath, sessionId, branch, source } | null
|
|
6
6
|
* - isWorktreeClean(wtPath, opts): boolean
|
|
7
7
|
* (opts.execFileFn — injectable execFileSync seam for tests; #577 HARDEN-001)
|
|
8
|
+
* - PROMOTION_MARKER_RELPATH — repo-relative path of the promotion marker
|
|
9
|
+
* written by `enterWorktree()` (SSOT for the file location; the WRITER
|
|
10
|
+
* imports this constant from here, so writer and reader can never drift).
|
|
8
11
|
*
|
|
9
12
|
* Closes #575 — Epic #568 Phase 3.2 (Parallel-Aware Sessions Auto-Promoted Worktree Cleanup)
|
|
10
13
|
* PRD: "Parallel-aware sessions" (#568; archived in the private Meta-Vault) §3 P3 Gherkin rows 2-3
|
|
@@ -21,27 +24,162 @@
|
|
|
21
24
|
* kept divergent on purpose — unifying them would break the sync/async boundary.
|
|
22
25
|
*/
|
|
23
26
|
import path from 'node:path';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
24
28
|
import { execFileSync } from 'node:child_process';
|
|
25
29
|
import { parseSessionId } from '../session-id.mjs';
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Repo-relative location of the promotion marker `enterWorktree()` drops into
|
|
33
|
+
* every worktree it creates. Deliberately inside `.orchestrator/` (the session
|
|
34
|
+
* state dir) and deliberately WITHOUT any absolute path in its payload — the
|
|
35
|
+
* source checkout is recorded as `repoPathHash()` so the file can be committed
|
|
36
|
+
* or shipped without leaking the operator's filesystem layout.
|
|
37
|
+
*
|
|
38
|
+
* @type {string}
|
|
39
|
+
*/
|
|
40
|
+
export const PROMOTION_MARKER_RELPATH = path.join('.orchestrator', 'promoted-from.json');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read + shape-validate the promotion marker of a candidate worktree.
|
|
44
|
+
*
|
|
45
|
+
* Never throws: a missing file, a directory, unreadable permissions, invalid
|
|
46
|
+
* JSON, or a payload of the wrong shape all mean "no marker" (→ legacy path).
|
|
47
|
+
*
|
|
48
|
+
* @param {string} repoRoot
|
|
49
|
+
* @returns {{branch: string, source_session_id: string} & Record<string, unknown> | null}
|
|
50
|
+
*/
|
|
51
|
+
function readPromotionMarker(repoRoot) {
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(readFileSync(path.join(repoRoot, PROMOTION_MARKER_RELPATH), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return null; // absent / unreadable / corrupt JSON — fall back to legacy detection
|
|
57
|
+
}
|
|
58
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
59
|
+
if (typeof parsed.branch !== 'string' || parsed.branch.length === 0) return null;
|
|
60
|
+
if (typeof parsed.source_session_id !== 'string' || parsed.source_session_id.length === 0) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The marker path as git reports it in `status --porcelain` — always
|
|
68
|
+
* forward-slashed, regardless of the host separator.
|
|
69
|
+
* @type {string}
|
|
70
|
+
*/
|
|
71
|
+
const PROMOTION_MARKER_GIT_PATH = PROMOTION_MARKER_RELPATH.split(path.sep).join('/');
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True for the ONE porcelain line the promotion marker itself produces:
|
|
75
|
+
* `?? .orchestrator/promoted-from.json`.
|
|
76
|
+
*
|
|
77
|
+
* Why this exists: in THIS repo `.gitignore` already lists
|
|
78
|
+
* `.orchestrator/promoted-from.json` explicitly (`git check-ignore
|
|
79
|
+
* .orchestrator/promoted-from.json` exits 0), so the marker never reaches
|
|
80
|
+
* `git status --porcelain` here at all — this exemption is redundant
|
|
81
|
+
* belt-and-braces for the repo that ships it. It earns its keep in CONSUMER
|
|
82
|
+
* repos: `.orchestrator/` is commonly only PARTLY gitignored there
|
|
83
|
+
* (`.orchestrator/metrics/*.jsonl`, `session.lock`, … but not the bare
|
|
84
|
+
* directory, and not necessarily this file), and on a worktree whose branch
|
|
85
|
+
* predates that ignore line being added, the marker would show up as an
|
|
86
|
+
* untracked file and make every promoted worktree read "dirty" — turning the
|
|
87
|
+
* Phase 4a clean path (auto-remove) into a permanent operator AUQ. Repos that
|
|
88
|
+
* gitignore `.orchestrator/` wholesale never see the line at all. A third shape exists —
|
|
89
|
+
* the directory untracked AND un-ignored, where git collapses everything into
|
|
90
|
+
* one `?? .orchestrator/` line — and is deliberately NOT matched here:
|
|
91
|
+
* discounting a whole directory could hide operator work, and such a worktree
|
|
92
|
+
* is already dirty from the session's own `session.lock` / `STATE.md` writes
|
|
93
|
+
* regardless of this marker.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately narrow: ONLY the untracked (`??`) status of exactly this path is
|
|
96
|
+
* ignored. A modified, staged, renamed or conflicted marker still counts as
|
|
97
|
+
* dirty, and no other file under `.orchestrator/` is affected — this is our own
|
|
98
|
+
* bookkeeping artefact, not operator work (PSA-003: we created it, so it is
|
|
99
|
+
* ours to discount).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} line - One `git status --porcelain` line.
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function isUntrackedPromotionMarker(line) {
|
|
105
|
+
if (!line.startsWith('?? ')) return false;
|
|
106
|
+
const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1');
|
|
107
|
+
return filePath === PROMOTION_MARKER_GIT_PATH;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Current branch of a worktree, or `null` when it cannot be determined
|
|
112
|
+
* (git error, or a detached HEAD — `git branch --show-current` prints nothing).
|
|
113
|
+
*
|
|
114
|
+
* @param {Function} execFileFn
|
|
115
|
+
* @param {string} repoRoot
|
|
116
|
+
* @returns {string|null}
|
|
117
|
+
*/
|
|
118
|
+
function currentBranchOf(execFileFn, repoRoot) {
|
|
119
|
+
try {
|
|
120
|
+
const out = execFileFn('git', ['-C', repoRoot, 'branch', '--show-current'], {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
});
|
|
123
|
+
const branch = String(out ?? '').trim();
|
|
124
|
+
return branch.length > 0 ? branch : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
27
130
|
/**
|
|
28
131
|
* Detect whether the given repoRoot is an auto-promoted sibling worktree
|
|
29
132
|
* created by `enterWorktree()` during the Phase 0.5 PROMOTION_OFFER path.
|
|
30
133
|
*
|
|
31
|
-
*
|
|
134
|
+
* Two keys, tried in this order:
|
|
135
|
+
*
|
|
136
|
+
* 1. **Marker (primary).** `<repoRoot>/.orchestrator/promoted-from.json`,
|
|
137
|
+
* written by `enterWorktree()` at creation time. Accepted when the file
|
|
138
|
+
* parses, carries `branch` + `source_session_id`, and the worktree's
|
|
139
|
+
* current branch either MATCHES the recorded one or cannot be read at all.
|
|
140
|
+
* This is the only key that survives the #1069 process boundary: since
|
|
141
|
+
* #1069 the session that RUNS in the promoted worktree is a NEW session
|
|
142
|
+
* with its OWN id, and since #1067 the worktree sits on `so/<sourceId>` —
|
|
143
|
+
* so the current session's id appears in neither the directory name nor
|
|
144
|
+
* the branch, and key 2 below can never match. Recording the fact at
|
|
145
|
+
* creation time is what makes it re-derivable later.
|
|
146
|
+
* 2. **Basename (legacy fallback).** `<basePath>/<main-repo-name>-<sessionId>/`
|
|
147
|
+
* against the CURRENT session id — still correct for worktrees created
|
|
148
|
+
* before the marker existed, and for the same-session case.
|
|
32
149
|
*
|
|
33
150
|
* Returns:
|
|
34
|
-
* { wtPath, sessionId, branch } on match
|
|
151
|
+
* { wtPath, sessionId, branch, source: 'marker'|'basename' } on match
|
|
35
152
|
* null on non-match (UUID session, non-promoted path, or git error)
|
|
36
153
|
*
|
|
37
154
|
* @param {string} repoRoot - Absolute path to the candidate worktree
|
|
38
155
|
* @param {string} sessionId - Session ID (semantic or UUID)
|
|
39
|
-
* @returns {{wtPath: string, sessionId: string, branch: string} | null}
|
|
156
|
+
* @returns {{wtPath: string, sessionId: string, branch: string, source: 'marker'|'basename'} | null}
|
|
40
157
|
*/
|
|
41
158
|
export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
|
|
42
159
|
// #577 HARDEN-001: execFileSync + args ARRAY (no shell) is structurally
|
|
43
160
|
// injection-proof — repoRoot can never be interpreted as shell metacharacters.
|
|
44
161
|
const execFileFn = opts.execFileFn ?? execFileSync;
|
|
162
|
+
|
|
163
|
+
// --- Key 1: the marker written at creation time (session-id independent) ---
|
|
164
|
+
const marker = readPromotionMarker(repoRoot);
|
|
165
|
+
if (marker) {
|
|
166
|
+
const current = currentBranchOf(execFileFn, repoRoot);
|
|
167
|
+
// `current === null` (git unavailable / detached HEAD) is accepted: the
|
|
168
|
+
// marker is written by exactly one code path, and the destructive step in
|
|
169
|
+
// Phase 4a is gated separately by `isWorktreeClean()`, which fails CLOSED
|
|
170
|
+
// on any git error. So an unverifiable branch can only ever route the
|
|
171
|
+
// operator into the AUQ, never into an automatic removal.
|
|
172
|
+
if (current === null || current === marker.branch) {
|
|
173
|
+
return {
|
|
174
|
+
wtPath: repoRoot,
|
|
175
|
+
sessionId: marker.source_session_id,
|
|
176
|
+
branch: marker.branch,
|
|
177
|
+
source: 'marker',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- Key 2: legacy basename match against the CURRENT session id ---
|
|
45
183
|
const parsed = parseSessionId(sessionId);
|
|
46
184
|
if (!parsed || parsed.format !== 'semantic') return null; // UUID-format sessions are never auto-promoted
|
|
47
185
|
|
|
@@ -72,7 +210,7 @@ export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
|
|
|
72
210
|
const isPromotedPath = path.basename(repoRoot) === expectedBasename;
|
|
73
211
|
|
|
74
212
|
if (isPromotedPath) {
|
|
75
|
-
return { wtPath: repoRoot, sessionId, branch: parsed.branch };
|
|
213
|
+
return { wtPath: repoRoot, sessionId, branch: parsed.branch, source: 'basename' };
|
|
76
214
|
}
|
|
77
215
|
return null;
|
|
78
216
|
}
|
|
@@ -82,7 +220,9 @@ export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
|
|
|
82
220
|
*
|
|
83
221
|
* A worktree is clean iff ALL three conditions hold:
|
|
84
222
|
* 1. No uncommitted changes (`git status --porcelain` is empty)
|
|
85
|
-
* 2. No untracked files (implicit in #1 — porcelain includes `??` entries)
|
|
223
|
+
* 2. No untracked files (implicit in #1 — porcelain includes `??` entries),
|
|
224
|
+
* with ONE exception: the untracked promotion marker this module's own
|
|
225
|
+
* writer drops into the worktree (see isUntrackedPromotionMarker)
|
|
86
226
|
* 3. No unpushed commits (`git status --short --branch` lacks `ahead`)
|
|
87
227
|
*
|
|
88
228
|
* On any git error, returns `false` (safer per PSA-003 — conservative default
|
|
@@ -98,7 +238,14 @@ export function isWorktreeClean(wtPath, opts = {}) {
|
|
|
98
238
|
const status = execFileFn('git', ['-C', wtPath, 'status', '--porcelain'], {
|
|
99
239
|
encoding: 'utf8',
|
|
100
240
|
});
|
|
101
|
-
|
|
241
|
+
const significant = String(status ?? '')
|
|
242
|
+
.split('\n')
|
|
243
|
+
.map((l) => l.trimEnd())
|
|
244
|
+
.filter((l) => l.length > 0)
|
|
245
|
+
// Our own promotion marker is not operator work — see
|
|
246
|
+
// isUntrackedPromotionMarker() for why it must not count as dirty.
|
|
247
|
+
.filter((l) => !isUntrackedPromotionMarker(l));
|
|
248
|
+
if (significant.length > 0) return false; // dirty (modified, untracked, or staged)
|
|
102
249
|
|
|
103
250
|
const branchStatus = execFileFn('git', ['-C', wtPath, 'status', '--short', '--branch'], {
|
|
104
251
|
encoding: 'utf8',
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - parseSessionId(id): { format: 'semantic'|'uuid', ...fields, raw } | null
|
|
7
7
|
* - DEFAULT_SESSION_ID_SOURCES — the default `sources` array (see below)
|
|
8
8
|
* - SEMANTIC_ID_RE — source-of-truth regex for semantic session IDs
|
|
9
|
-
* -
|
|
9
|
+
* - UUID_RE — regex for RFC 9562 UUID session IDs (any version 1–8)
|
|
10
10
|
*
|
|
11
11
|
* Closes #572 — Epic #568 Phase 2.1 (Parallel-Aware Sessions Semantic ID)
|
|
12
12
|
* Closes #585 — Epic #583 W2-I2 (history-aware n-increment) per audit
|
|
@@ -67,14 +67,25 @@ import { parseStateMd } from './state-md/yaml-parser.mjs';
|
|
|
67
67
|
export const SEMANTIC_ID_RE = /^([a-z0-9._/-]+)-(\d{4}-\d{2}-\d{2})-([a-z-]+)-(\d+)$/;
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Regex for UUID
|
|
70
|
+
* Regex for RFC 9562 UUID session IDs — ANY version 1–8, variant `10xx`.
|
|
71
71
|
*
|
|
72
|
-
* Matches: 8-4-4-4-12 hex digits, version nibble
|
|
73
|
-
* Case-insensitive to accept both uppercase and lowercase hex.
|
|
72
|
+
* Matches: 8-4-4-4-12 hex digits, version nibble in [1-8], variant nibble in
|
|
73
|
+
* {8,9,a,b}. Case-insensitive to accept both uppercase and lowercase hex.
|
|
74
|
+
*
|
|
75
|
+
* Why the version nibble is a RANGE and not the literal `4` (Kanevry#66 / #1091):
|
|
76
|
+
* Claude Code mints UUIDv4 session ids, but Codex CLI mints UUIDv7. Pinning `4`
|
|
77
|
+
* made `parseSessionId()` return `null` for every Codex session, so
|
|
78
|
+
* `hooks/on-session-start.mjs` fell through to a freshly generated
|
|
79
|
+
* `randomUUID()` and every later hook in that session missed the lock.
|
|
80
|
+
*
|
|
81
|
+
* The structure stays strict on purpose: the dash/length layout and the
|
|
82
|
+
* variant nibble are what discriminate a real UUID from a 36-char lookalike,
|
|
83
|
+
* so only the version nibble is widened. Version `0` (nil UUID) and `9`..`f`
|
|
84
|
+
* (unassigned / max UUID) remain rejected — RFC 9562 defines 1–8.
|
|
74
85
|
*
|
|
75
86
|
* @type {RegExp}
|
|
76
87
|
*/
|
|
77
|
-
export const
|
|
88
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
78
89
|
|
|
79
90
|
// ---------------------------------------------------------------------------
|
|
80
91
|
// Internal helpers
|
|
@@ -344,8 +355,12 @@ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
|
|
|
344
355
|
* 1. Semantic: `<branch>-<YYYY-MM-DD>-<mode>-<n>`
|
|
345
356
|
* Returns `{ format: 'semantic', branch, date, mode, n, raw }`.
|
|
346
357
|
*
|
|
347
|
-
* 2. UUID
|
|
348
|
-
*
|
|
358
|
+
* 2. RFC 9562 UUID, any version 1–8, variant `10xx`:
|
|
359
|
+
* `xxxxxxxx-xxxx-[1-8]xxx-[89ab]xxx-xxxxxxxxxxxx`
|
|
360
|
+
* Returns `{ format: 'uuid', uuid, version, raw }`, where `version` is the
|
|
361
|
+
* version nibble as an integer (4 for Claude Code's v4 ids, 7 for Codex
|
|
362
|
+
* CLI's v7 ids). Callers that only branch on `format` are unaffected —
|
|
363
|
+
* `version` is additive (Kanevry#66 / #1091).
|
|
349
364
|
*
|
|
350
365
|
* Returns `null` for any input that is not a non-empty string or does not
|
|
351
366
|
* match either known format. Never throws.
|
|
@@ -356,7 +371,7 @@ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
|
|
|
356
371
|
*
|
|
357
372
|
* @param {unknown} id - The session ID to parse.
|
|
358
373
|
* @returns {{ format: 'semantic', branch: string, date: string, mode: string, n: number, raw: string }
|
|
359
|
-
* | { format: 'uuid', uuid: string, raw: string }
|
|
374
|
+
* | { format: 'uuid', uuid: string, version: number, raw: string }
|
|
360
375
|
* | null}
|
|
361
376
|
*/
|
|
362
377
|
export function parseSessionId(id) {
|
|
@@ -375,9 +390,10 @@ export function parseSessionId(id) {
|
|
|
375
390
|
};
|
|
376
391
|
}
|
|
377
392
|
|
|
378
|
-
// Try UUID
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
// Try RFC 9562 UUID (any version 1–8). Index 14 is the version nibble, and
|
|
394
|
+
// UUID_RE has already constrained it to [1-8], so Number() cannot be NaN.
|
|
395
|
+
if (UUID_RE.test(id)) {
|
|
396
|
+
return { format: 'uuid', uuid: id, version: Number(id[14]), raw: id };
|
|
381
397
|
}
|
|
382
398
|
|
|
383
399
|
return null;
|
|
@@ -418,9 +434,9 @@ export function parseSessionId(id) {
|
|
|
418
434
|
* cannot assign the same n. The #952 collision was NOT a concurrency defect —
|
|
419
435
|
* the lock held; the candidate set was incomplete.
|
|
420
436
|
*
|
|
421
|
-
* UUID
|
|
422
|
-
* format:'uuid' which the filter excludes). Malformed
|
|
423
|
-
* also dropped (SEMANTIC_ID_RE rejects them).
|
|
437
|
+
* UUID entries of ANY version (in any source) are silently dropped
|
|
438
|
+
* (parseSessionId returns format:'uuid' which the filter excludes). Malformed
|
|
439
|
+
* semantic-looking IDs are also dropped (SEMANTIC_ID_RE rejects them).
|
|
424
440
|
*
|
|
425
441
|
* @param {object} opts
|
|
426
442
|
* @param {string} opts.branch - Current git branch (e.g. "main", "feature/foo").
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* own-session.mjs — "which session am I, and does this shared artefact belong to me?"
|
|
3
|
+
*
|
|
4
|
+
* A working copy is shared; a session is not. Every `.orchestrator/` and
|
|
5
|
+
* `<state-dir>/` artefact in this repo is written into the WORKING COPY, so a
|
|
6
|
+
* second live session reads the first one's files as if they were its own. The
|
|
7
|
+
* damage is always invisible to the writer: a peer's corrective hints briefing
|
|
8
|
+
* this session's fixer (#1058), a peer's `allowedPaths: []` locking this
|
|
9
|
+
* session out of every write (#1082/#1123).
|
|
10
|
+
*
|
|
11
|
+
* This module is the reusable half of that check, split into two pure-ish
|
|
12
|
+
* functions so a caller can resolve identity once and classify many artefacts:
|
|
13
|
+
*
|
|
14
|
+
* - {@link readOwnSessionIds} — every id that provably names THIS session.
|
|
15
|
+
* - {@link classifyManifestSession} — own / foreign / unknown for a manifest.
|
|
16
|
+
*
|
|
17
|
+
* Semantics were lifted from the `current-session.json` ownership check in
|
|
18
|
+
* `scripts/lib/quality-gate.mjs` (#1058), which is module-private there and sits
|
|
19
|
+
* behind a module this repo's hook guard-source-loader cannot bind. This is a
|
|
20
|
+
* deliberate re-implementation of the SEMANTICS, not a re-export.
|
|
21
|
+
*
|
|
22
|
+
* **Only what is PROVABLY foreign is foreign.** Every unprovable case returns
|
|
23
|
+
* `'unknown'`, and every caller is expected to treat `'unknown'` exactly as it
|
|
24
|
+
* behaved before this check existed. An ownership check that guesses turns
|
|
25
|
+
* "cannot tell" into a silent feature-off on every harness that exports no
|
|
26
|
+
* session id.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readLock } from '../session-lock.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The set of session ids that provably name THIS session — the UNION of every
|
|
33
|
+
* tier, never the first one that answers.
|
|
34
|
+
*
|
|
35
|
+
* Three sources, all read, all merged:
|
|
36
|
+
*
|
|
37
|
+
* 1. `hookInput` — the harness's own statement about the invocation being
|
|
38
|
+
* handled right now (`session_id` / `sessionId`, plus `parent_session_id`
|
|
39
|
+
* for a sub-agent invocation, whose coordinator is equally us). The only
|
|
40
|
+
* tier that is per-INVOCATION rather than per-working-copy.
|
|
41
|
+
* 2. `CLAUDE_CODE_SESSION_ID` — process-scoped, absent on harnesses that
|
|
42
|
+
* export no session env var.
|
|
43
|
+
* 3. `session.lock` `session_id` / `semantic_session_id` — repo-GLOBAL, and
|
|
44
|
+
* the identity the WRITER of a manifest uses: `wave-scope.json`'s
|
|
45
|
+
* `session` field comes from `sessionAttribution()`, which reads this same
|
|
46
|
+
* lock (`skills/wave-executor/wave-loop.md` § Scope Manifest 1).
|
|
47
|
+
*
|
|
48
|
+
* **Why the union, and not first-tier-wins.** Any id the process can
|
|
49
|
+
* legitimately claim — its own invocation, its harness env, the repo's live
|
|
50
|
+
* lock — names this session; only an id in NONE of them is somebody else's.
|
|
51
|
+
* Gating the tiers made the READER's identity a strict subset of the WRITER's,
|
|
52
|
+
* and three distinct ways of diverging all landed on the same silent failure —
|
|
53
|
+
* the OWN manifest classified `foreign`, so the write gate switched itself off
|
|
54
|
+
* for the whole wave, with an event that reads exactly like correct behaviour:
|
|
55
|
+
*
|
|
56
|
+
* - **Nested-harness divergence.** The payload `session_id` and
|
|
57
|
+
* `CLAUDE_CODE_SESSION_ID` disagree in a nested harness — already measured
|
|
58
|
+
* and documented in `resolveSessionId()` of
|
|
59
|
+
* `hooks/pre-bash-issue-budget.mjs`: *"stdin still wins: it is the id of
|
|
60
|
+
* THIS tool call, whereas the env var is the id of the process tree, and
|
|
61
|
+
* the two differ in a nested harness"*, alongside the measurement that the
|
|
62
|
+
* env var equals the `session.lock` `session_id` and survives into
|
|
63
|
+
* subagents. Under tier-gating, the payload alone decided.
|
|
64
|
+
* - **Sub-agent invocation.** A dispatched agent's own set was
|
|
65
|
+
* `{subagent-uuid}` while the manifest names the coordinator.
|
|
66
|
+
* `parent_session_id` is in tier 1 too, but a payload that carries only
|
|
67
|
+
* `session_id` still hid the coordinator's env/lock ids behind the gate.
|
|
68
|
+
* - **Peer-owned lock.** A second session that failed to acquire the lock
|
|
69
|
+
* (`bootstrapLock()` reason `active`, the lock keeps the PEER's id) writes
|
|
70
|
+
* that peer id into its OWN manifest via `sessionAttribution()`. Its own
|
|
71
|
+
* hook then read the payload tier, never reached the lock, and disarmed
|
|
72
|
+
* itself against the manifest it had just written.
|
|
73
|
+
*
|
|
74
|
+
* **The security direction is unchanged: the union only ADDS ids this process
|
|
75
|
+
* actually carries.** A manifest whose id appears in NO tier — not the
|
|
76
|
+
* invocation, not the env, not the lock — still classifies `foreign`, exactly
|
|
77
|
+
* as before; nothing here invents an id or widens what counts as a match.
|
|
78
|
+
*
|
|
79
|
+
* The cost is named rather than hidden, and it points the fail-CLOSED way: when
|
|
80
|
+
* the lock names a peer, that peer's manifest now reads `own`, so we ENFORCE a
|
|
81
|
+
* wave plan that is not ours. That is a visible, actionable deny — the inverse
|
|
82
|
+
* of tier-gating's failure, which was a silent enforcement-off. `unknown` still
|
|
83
|
+
* means unknown: an empty set can only produce `unknown`, never a mismatch.
|
|
84
|
+
*
|
|
85
|
+
* Every value is `.trim()`ed before it enters the set: a whitespace-only env
|
|
86
|
+
* var is truthy and would otherwise enter as a PHANTOM id that matches nothing
|
|
87
|
+
* — which would make every manifest read `foreign` and switch enforcement off
|
|
88
|
+
* (`.claude/rules/development.md` § env-var whitespace trap).
|
|
89
|
+
*
|
|
90
|
+
* Never throws.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} repoRoot — working copy root, for the `session.lock` tier.
|
|
93
|
+
* @param {{ hookInput?: object|null }} [opts]
|
|
94
|
+
* @returns {Set<string>} possibly EMPTY — an empty set means "identity
|
|
95
|
+
* unresolvable", which {@link classifyManifestSession} treats as `unknown`,
|
|
96
|
+
* never as a mismatch.
|
|
97
|
+
*/
|
|
98
|
+
export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
|
|
99
|
+
const ids = new Set();
|
|
100
|
+
const add = (value) => {
|
|
101
|
+
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
102
|
+
if (trimmed) ids.add(trimmed);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Source 1 — the harness's statement about THIS invocation.
|
|
106
|
+
if (hookInput && typeof hookInput === 'object') {
|
|
107
|
+
for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Source 2 — process-scoped env var.
|
|
111
|
+
add(process.env.CLAUDE_CODE_SESSION_ID);
|
|
112
|
+
|
|
113
|
+
// Source 3 — repo-global lock file (the manifest writer's own identity).
|
|
114
|
+
try {
|
|
115
|
+
const lock = readLock({ repoRoot });
|
|
116
|
+
for (const key of ['session_id', 'semantic_session_id']) add(lock?.[key]);
|
|
117
|
+
} catch {
|
|
118
|
+
/* readLock never throws by contract, but that contract is not ours to trust */
|
|
119
|
+
}
|
|
120
|
+
return ids;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Decide whether a wave-scope manifest belongs to THIS session.
|
|
125
|
+
*
|
|
126
|
+
* Three outcomes, and the middle one is load-bearing:
|
|
127
|
+
*
|
|
128
|
+
* - `'foreign'` — the manifest names at least one session id, we know at
|
|
129
|
+
* least one of our own, and NONE of them match. The only verdict that
|
|
130
|
+
* changes behaviour.
|
|
131
|
+
* - `'unknown'` — the manifest names no id (a legacy manifest written before
|
|
132
|
+
* the `session` field existed), or we could not resolve our own. Ownership
|
|
133
|
+
* is unproven in BOTH directions, so the caller must keep doing exactly
|
|
134
|
+
* what it did before.
|
|
135
|
+
* - `'own'` — an id matched.
|
|
136
|
+
*
|
|
137
|
+
* Both id fields are consulted because they address the same session under two
|
|
138
|
+
* naming schemes: `session` is the raw harness session id (a UUID on Claude
|
|
139
|
+
* Code), `semantic_session` the `<branch>-<date>-<mode>-<n>` form. A harness
|
|
140
|
+
* that resolves only the semantic one must still recognise its own manifest.
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} scope — parsed wave-scope manifest (any shape; a non-object
|
|
143
|
+
* simply yields no ids, hence `'unknown'`).
|
|
144
|
+
* @param {Set<string>} ownIds — from {@link readOwnSessionIds}.
|
|
145
|
+
* @returns {{ verdict: 'own'|'foreign'|'unknown', manifestIds: string[] }}
|
|
146
|
+
*/
|
|
147
|
+
export function classifyManifestSession(scope, ownIds) {
|
|
148
|
+
const manifestIds = [];
|
|
149
|
+
if (scope && typeof scope === 'object' && !Array.isArray(scope)) {
|
|
150
|
+
for (const key of ['session', 'semantic_session']) {
|
|
151
|
+
const value = typeof scope[key] === 'string' ? scope[key].trim() : '';
|
|
152
|
+
if (value) manifestIds.push(value);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const own = ownIds instanceof Set ? ownIds : new Set();
|
|
156
|
+
if (manifestIds.length === 0 || own.size === 0) return { verdict: 'unknown', manifestIds };
|
|
157
|
+
const matched = manifestIds.some((id) => own.has(id));
|
|
158
|
+
return { verdict: matched ? 'own' : 'foreign', manifestIds };
|
|
159
|
+
}
|
|
@@ -45,6 +45,7 @@ import crypto from 'node:crypto';
|
|
|
45
45
|
import { classifyMode } from './exclusivity-matrix.mjs';
|
|
46
46
|
import { isPidAliveOnHost } from './file-lock.mjs';
|
|
47
47
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
48
|
+
import { hostnamesMatch, lockHostCandidate, recordHostAlias, stableHostname } from './host-identity.mjs';
|
|
48
49
|
|
|
49
50
|
// isPidAliveOnHost moved into file-lock.mjs in #630 (the file-lock primitive
|
|
50
51
|
// owns it so the dependency edge points file-lock → io, never the reverse).
|
|
@@ -101,11 +102,12 @@ export const OWNER_PROOF_RELPATH = '.orchestrator/runtime/lock-owner-proof.json'
|
|
|
101
102
|
// NOT the discovery-path liveness check — since Epic #583 the discovery
|
|
102
103
|
// decision tree uses heartbeat-age via {@link isLockLive} instead, because the
|
|
103
104
|
// `pid` recorded on a session.lock is the *ephemeral hook subprocess* PID.
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
105
|
+
// NOTHING IN THIS MODULE CALLS IT any more: `acquire()` stopped consulting the
|
|
106
|
+
// pid in #744/#1137 and `checkStale()` in #1151 — the re-export is a
|
|
107
|
+
// compatibility surface for external importers only. The remaining production
|
|
108
|
+
// callers are file-lock.mjs's own stale-override path and lock-reaper.mjs,
|
|
109
|
+
// where the recorded PID IS the process being asked about. See file-lock.mjs
|
|
110
|
+
// for the full @forensic + PID-recycle trade-off note.
|
|
109
111
|
|
|
110
112
|
/**
|
|
111
113
|
* Resolve the absolute path to the lock file.
|
|
@@ -146,6 +148,28 @@ function lockAgeHours(lock) {
|
|
|
146
148
|
return (Date.now() - ts) / (3600 * 1000);
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Compute the age of a lock's heartbeat in fractional minutes.
|
|
153
|
+
*
|
|
154
|
+
* This is the diagnostic counterpart to `isLockLive()` — the SAME quantity the
|
|
155
|
+
* liveness rule thresholds against, surfaced as a number so callers (the
|
|
156
|
+
* Phase-1.2 stale-lock AUQ, recovery diagnostics) can report WHY a lock was
|
|
157
|
+
* classified stale instead of asserting a PID verdict the lock cannot support
|
|
158
|
+
* (#1137). Mirrors `isLockLive()`'s `last_heartbeat` → `started_at` fallback.
|
|
159
|
+
*
|
|
160
|
+
* @param {{ last_heartbeat?: string, started_at?: string }} lock
|
|
161
|
+
* @returns {number|null} minutes since the last heartbeat, or null if unparseable.
|
|
162
|
+
*/
|
|
163
|
+
function heartbeatAgeMinutes(lock) {
|
|
164
|
+
if (!lock || typeof lock !== 'object') return null;
|
|
165
|
+
const hbStr = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
|
|
166
|
+
? lock.last_heartbeat
|
|
167
|
+
: lock.started_at;
|
|
168
|
+
const ts = Date.parse(hbStr);
|
|
169
|
+
if (Number.isNaN(ts)) return null;
|
|
170
|
+
return (Date.now() - ts) / (60 * 1000);
|
|
171
|
+
}
|
|
172
|
+
|
|
149
173
|
/**
|
|
150
174
|
* Parse lock file contents into an object. Returns null on any parse error.
|
|
151
175
|
*
|
|
@@ -213,13 +237,22 @@ function parseLock(raw) {
|
|
|
213
237
|
*/
|
|
214
238
|
function buildLock({ sessionId, mode, ttlHours, semanticSessionId }) {
|
|
215
239
|
const startedAt = nowIso();
|
|
240
|
+
// Writing a session lock is the one moment we KNOW the current os.hostname()
|
|
241
|
+
// belongs to this machine — record it so a later reading under a different
|
|
242
|
+
// spelling can still be recognised as the same host (#1072). Best-effort:
|
|
243
|
+
// recordHostAlias never throws, and a failed write only costs the alias.
|
|
244
|
+
recordHostAlias();
|
|
216
245
|
const lock = {
|
|
217
246
|
session_id: sessionId,
|
|
218
247
|
started_at: startedAt,
|
|
219
248
|
last_heartbeat: startedAt,
|
|
220
249
|
mode,
|
|
221
250
|
pid: process.pid,
|
|
251
|
+
// `host` stays the RAW hostname — it is an on-the-wire event field
|
|
252
|
+
// (orchestrator.session.lock.acquired) and feeds the privacy-hash contract.
|
|
253
|
+
// `host_id` is the additive normalised twin every comparison reads (#1072).
|
|
222
254
|
host: os.hostname(),
|
|
255
|
+
host_id: stableHostname(),
|
|
223
256
|
ttl_hours: ttlHours,
|
|
224
257
|
};
|
|
225
258
|
if (typeof semanticSessionId === 'string' && semanticSessionId.length > 0) {
|
|
@@ -455,10 +488,15 @@ export function readLockDetailed(opts = {}) {
|
|
|
455
488
|
* — lock created
|
|
456
489
|
* { ok: false, reason: 'active', existingLock, exclusivityClass? }
|
|
457
490
|
* — local lock held (live TTL, live PID)
|
|
458
|
-
* { ok: false, reason: 'stale-
|
|
459
|
-
* — local lock stale
|
|
460
|
-
*
|
|
461
|
-
*
|
|
491
|
+
* { ok: false, reason: 'stale-heartbeat', existingLock, ageHours, heartbeatAgeMinutes, exclusivityClass? }
|
|
492
|
+
* — local lock stale: its last_heartbeat is older than ttl_hours. This is
|
|
493
|
+
* the ONLY stale reason (#1137). It replaced the `stale-pid-dead` /
|
|
494
|
+
* `stale-pid-alive` pair, which claimed a PID verdict the lock cannot
|
|
495
|
+
* support: the recorded `pid` is the ephemeral hook / `node -e`
|
|
496
|
+
* subprocess, dead within ~1s of genesis (measured 2026-08-23: 7 of 7
|
|
497
|
+
* recorded pids dead, INCLUDING the currently heartbeating session's
|
|
498
|
+
* own lock), so `stale-pid-alive` was unreachable same-host and every
|
|
499
|
+
* stale lock rendered as "confirmed dead" in the recovery AUQ.
|
|
462
500
|
* { ok: false, reason: 'fs-error', error, exclusivityClass? }
|
|
463
501
|
* — filesystem failure
|
|
464
502
|
* { ok: false, reason: 'active-incompatible-exclusive', allActiveSessions, blockingSession, exclusivityClass }
|
|
@@ -563,12 +601,8 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
563
601
|
try {
|
|
564
602
|
// Classify an existing lock into the correct failure result. Shared by the
|
|
565
603
|
// up-front readLock() check AND the create-race EEXIST-loser path below so
|
|
566
|
-
// both report identical active / stale-
|
|
604
|
+
// both report identical active / stale-heartbeat reasons.
|
|
567
605
|
const classifyExisting = (existing) => {
|
|
568
|
-
const sameHost = existing.host === os.hostname();
|
|
569
|
-
// PID liveness is only meaningful on the same host.
|
|
570
|
-
const pidAlive = sameHost ? isPidAliveOnHost(existing.pid) : null;
|
|
571
|
-
|
|
572
606
|
// Heartbeat-first liveness (#744): isLockLive is the SOLE active gate.
|
|
573
607
|
// A dead recorded PID must NOT veto a fresh last_heartbeat — the pid on
|
|
574
608
|
// a session.lock is the ephemeral hook subprocess PID, not the semantic
|
|
@@ -581,11 +615,24 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
581
615
|
return { ok: false, reason: 'active', existingLock: existing, exclusivityClass: callerClass };
|
|
582
616
|
}
|
|
583
617
|
|
|
584
|
-
// Heartbeat expired —
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
618
|
+
// Heartbeat expired — ONE stale reason, derived from the same signal the
|
|
619
|
+
// active gate above used (#1137). The former two-way split asked
|
|
620
|
+
// isPidAliveOnHost(existing.pid) and reported 'stale-pid-dead' /
|
|
621
|
+
// 'stale-pid-alive'; that question has no answer the lock can give,
|
|
622
|
+
// because `pid` is the short-lived writer subprocess, not the session.
|
|
623
|
+
// Same-host it was therefore ~always 'dead' (7/7 measured, live sessions
|
|
624
|
+
// included) and 'stale-pid-alive' was structurally unreachable. The
|
|
625
|
+
// ageHours + heartbeatAgeMinutes fields carry the evidence instead, so a
|
|
626
|
+
// recovery prompt can state the measured heartbeat age rather than a
|
|
627
|
+
// liveness verdict.
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
reason: 'stale-heartbeat',
|
|
631
|
+
existingLock: existing,
|
|
632
|
+
ageHours: lockAgeHours(existing),
|
|
633
|
+
heartbeatAgeMinutes: heartbeatAgeMinutes(existing),
|
|
634
|
+
exclusivityClass: callerClass,
|
|
635
|
+
};
|
|
589
636
|
};
|
|
590
637
|
|
|
591
638
|
const existing = readLock({ repoRoot });
|
|
@@ -978,7 +1025,7 @@ export function updateHeartbeat({ repoRoot, sessionId } = {}) {
|
|
|
978
1025
|
* lock: object|null,
|
|
979
1026
|
* ageHours: number|null,
|
|
980
1027
|
* ttlExpired: boolean,
|
|
981
|
-
*
|
|
1028
|
+
* heartbeatAgeMinutes: number|null,
|
|
982
1029
|
* host: string|null,
|
|
983
1030
|
* sameHost: boolean,
|
|
984
1031
|
* isLive: boolean
|
|
@@ -993,7 +1040,7 @@ export function checkStale({ repoRoot } = {}) {
|
|
|
993
1040
|
lock: null,
|
|
994
1041
|
ageHours: null,
|
|
995
1042
|
ttlExpired: false,
|
|
996
|
-
|
|
1043
|
+
heartbeatAgeMinutes: null,
|
|
997
1044
|
host: null,
|
|
998
1045
|
sameHost: false,
|
|
999
1046
|
isLive: false,
|
|
@@ -1002,14 +1049,22 @@ export function checkStale({ repoRoot } = {}) {
|
|
|
1002
1049
|
|
|
1003
1050
|
const ageHours = lockAgeHours(lock);
|
|
1004
1051
|
const ttlExpired = isTtlExpired(lock);
|
|
1005
|
-
|
|
1006
|
-
//
|
|
1007
|
-
const
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1052
|
+
// #1072: alias-aware, not a raw os.hostname() comparison — this machine's
|
|
1053
|
+
// hostname flips spelling, which made `sameHost` false for its own lock.
|
|
1054
|
+
const sameHost = hostnamesMatch(lockHostCandidate(lock), os.hostname());
|
|
1055
|
+
// NO `pidAlive` FIELD (#1151). #1137 kept it as an always-null shape stub;
|
|
1056
|
+
// nothing ever read it — measured @ f0766e1, zero production readers
|
|
1057
|
+
// repo-wide. Probing isPidAliveOnHost(lock.pid) answered a question about the
|
|
1058
|
+
// ephemeral writer subprocess, not the session: 2026-08-23, 7 of 7 recorded
|
|
1059
|
+
// pids were dead, including the lock of the session that was heartbeating at
|
|
1060
|
+
// that very moment, so a `false` here read as "the session is dead" and was
|
|
1061
|
+
// wrong every time. `isLive` is the verdict and `heartbeatAgeMinutes` the
|
|
1062
|
+
// magnitude behind it. `isPidAliveOnHost` itself stays exported —
|
|
1063
|
+
// file-lock.mjs and lock-reaper.mjs are legitimate callers, where the pid IS
|
|
1064
|
+
// the process being asked about.
|
|
1065
|
+
// Heartbeat-based liveness (#744) — the SAME check acquire()'s
|
|
1066
|
+
// classifyExisting uses as its sole active gate, surfaced here so callers of
|
|
1067
|
+
// checkStale() (recovery-flow diagnostics) can observe it directly.
|
|
1013
1068
|
const isLive = isLockLive(lock);
|
|
1014
1069
|
|
|
1015
1070
|
return {
|
|
@@ -1017,7 +1072,7 @@ export function checkStale({ repoRoot } = {}) {
|
|
|
1017
1072
|
lock,
|
|
1018
1073
|
ageHours,
|
|
1019
1074
|
ttlExpired,
|
|
1020
|
-
|
|
1075
|
+
heartbeatAgeMinutes: heartbeatAgeMinutes(lock),
|
|
1021
1076
|
host: lock.host,
|
|
1022
1077
|
sameHost,
|
|
1023
1078
|
isLive,
|