session-orchestrator 4.1.0 → 5.0.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/session-plan/SKILL.md +1 -1
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +128 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/commands/ux-grill.md +51 -0
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +117 -50
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +9 -0
- package/docs/components.md +9 -9
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +13 -6
- package/docs/github-mirror-protection.md +61 -20
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/migration-v5.md +62 -0
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +16 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +70 -3
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +401 -16
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +170 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +5 -4
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +151 -8
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +412 -9
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +450 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +27 -71
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +429 -56
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +56 -27
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/mcp-server.sh +16 -1
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +7 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +60 -209
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +8 -4
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +11 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +27 -67
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
- package/skills/session-end/session-metrics-write.md +33 -12
- package/skills/session-plan/SKILL.md +46 -180
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +41 -7
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +32 -127
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
- package/skills/wave-executor/references/wave-loop-review.md +19 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
|
|
64
64
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
65
65
|
import { fileURLToPath } from 'node:url';
|
|
66
|
-
import { basename, resolve } from 'node:path';
|
|
66
|
+
import { basename, resolve, sep } from 'node:path';
|
|
67
|
+
import { homedir } from 'node:os';
|
|
67
68
|
import { resolveInstructionFile } from './common.mjs';
|
|
68
69
|
import { isSessionConfigHeading } from './config/section-extractor.mjs';
|
|
69
70
|
|
|
@@ -317,6 +318,36 @@ export function lintClaudeMd(opts = {}) {
|
|
|
317
318
|
* @param {number} [opts.maxLineChars] forwarded to lintClaudeMd (default DEFAULT_MAX_LINE_CHARS).
|
|
318
319
|
* @returns {{ severity: 'warn', message: string } | null}
|
|
319
320
|
*/
|
|
321
|
+
/**
|
|
322
|
+
* Replace a leading home-directory prefix with the literal `$HOME`.
|
|
323
|
+
*
|
|
324
|
+
* Why `$HOME` and not `~`: the banner emits the path inside DOUBLE QUOTES, and
|
|
325
|
+
* no POSIX shell expands a tilde inside double quotes — `node "~/x.mjs"` fails,
|
|
326
|
+
* `node "$HOME/x.mjs"` works. Why not `path.relative(repoRoot, __filename)`
|
|
327
|
+
* (the first proposal): measured 2026-09-11, it only redacts when the repo and
|
|
328
|
+
* the plugin share a home ancestor —
|
|
329
|
+
* repoRoot `~/Projects/bewerbungs-assistent`, plugin under `~/.claude/plugins`
|
|
330
|
+
* → `../../.claude/plugins/…` (private, 98 chars)
|
|
331
|
+
* repoRoot on a tmp/other volume, plugin under the home dir
|
|
332
|
+
* → `../../../../Users/<name>/.claude/…` (LEAKS, and longer)
|
|
333
|
+
* and it is cwd-bound, which contradicts the `--repo-root` echo whose whole
|
|
334
|
+
* purpose is cwd-independence. `$HOME` collapse is unconditional, cwd-free, and
|
|
335
|
+
* leaks nothing in either case.
|
|
336
|
+
*
|
|
337
|
+
* A path OUTSIDE the home directory is returned unchanged — it carries no
|
|
338
|
+
* username to redact, and rewriting it would break the command.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} p absolute path
|
|
341
|
+
* @returns {string} `p` with a leading `homedir()` replaced by `$HOME`
|
|
342
|
+
*/
|
|
343
|
+
function homeCollapsed(p) {
|
|
344
|
+
const home = homedir();
|
|
345
|
+
if (typeof p !== 'string' || home.length === 0) return p;
|
|
346
|
+
if (p === home) return '$HOME';
|
|
347
|
+
if (p.startsWith(home + sep)) return '$HOME' + p.slice(home.length);
|
|
348
|
+
return p;
|
|
349
|
+
}
|
|
350
|
+
|
|
320
351
|
export function checkClaudeMdBudgetLint(opts = {}) {
|
|
321
352
|
const repoRoot = opts.repoRoot ?? process.cwd();
|
|
322
353
|
|
|
@@ -343,9 +374,28 @@ export function checkClaudeMdBudgetLint(opts = {}) {
|
|
|
343
374
|
if (!result || result.violations.length === 0) return null;
|
|
344
375
|
|
|
345
376
|
const ruleNames = [...new Set(result.violations.map((v) => v.rule))].join(', ');
|
|
377
|
+
// The hint MUST name a path the operator can actually run — `scripts/lib/…`
|
|
378
|
+
// repo-root-relative is only valid inside THIS repo's own checkout. A
|
|
379
|
+
// consumer repo has no such file: it either has no `scripts/lib/` at all,
|
|
380
|
+
// or a foreign one. `__filename` (module-scope, see the CLI section below)
|
|
381
|
+
// is the absolute path of the module that is EXECUTING RIGHT NOW — it is
|
|
382
|
+
// never wrong, because we could not be inside this function otherwise, and
|
|
383
|
+
// it needs no plugin-root lookup at all (no env var, no marketplace-cache
|
|
384
|
+
// scan, no "not resolvable" case to handle — see plugin-update-banner.mjs's
|
|
385
|
+
// module docstring for why guessing a plugin root from an env var is the
|
|
386
|
+
// WRONG move here: `$CLAUDE_PLUGIN_ROOT` can name a checkout that differs
|
|
387
|
+
// from the code that is actually loaded and running). `repoRoot` is echoed
|
|
388
|
+
// back too, so the copied command re-lints the exact file this banner
|
|
389
|
+
// reports on regardless of the operator's cwd when they paste it.
|
|
390
|
+
// PRIVACY (2026-09-11): the same message line already redacts `filePath` via
|
|
391
|
+
// `basename()`, while `__filename` and `repoRoot` went out verbatim — both
|
|
392
|
+
// CP1-shaped (`/Users/<name>/…`) on a personal host. The banner's route into
|
|
393
|
+
// public view is copy-paste into an issue or an agent report, the documented
|
|
394
|
+
// "agent reports carry private slugs" class. `homeCollapsed()` keeps the
|
|
395
|
+
// command EXECUTABLE (so #1302 stays closed) while dropping the username.
|
|
346
396
|
const message =
|
|
347
397
|
`⚠ CLAUDE.md budget lint: ${result.violations.length} violation(s) (${ruleNames}) in ${basename(filePath)} — ` +
|
|
348
|
-
`run \`node
|
|
398
|
+
`run \`node "${homeCollapsed(__filename)}" --repo-root "${homeCollapsed(repoRoot)}" --mode warn\` for details.`;
|
|
349
399
|
|
|
350
400
|
return { severity: 'warn', message };
|
|
351
401
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { matchBlockHeader } from './block-header.mjs';
|
|
2
2
|
import { preprocessBlockLines } from './block-preprocess.mjs';
|
|
3
|
+
import { _coerceInteger } from './coercers.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* issue-budget.mjs — Parser for the `issue-budget:` block-style Session Config key.
|
|
@@ -17,10 +18,13 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
|
|
|
17
18
|
* `scripts/lib/spiral-carryover.mjs` `runCli()` (programmatic path), both via
|
|
18
19
|
* `scripts/lib/issue-budget.mjs`.
|
|
19
20
|
*
|
|
20
|
-
* Returns `{ "max-per-session", mode, overflow }`.
|
|
21
|
-
* Tolerant parser: malformed values
|
|
21
|
+
* Returns `{ "max-per-session", "max-per-session-raw", mode, overflow }`.
|
|
22
|
+
* Tolerant parser: malformed values fall back to defaults (the
|
|
22
23
|
* `reconcile.min-rule-days` posture, NOT the noisier handover-gate WARN) —
|
|
23
|
-
* a hook must never spam stderr on every single Bash call.
|
|
24
|
+
* a hook must never spam stderr on every single Bash call. ONE exception, and
|
|
25
|
+
* it is a safety one: a malformed per-session OVERRIDE with a valid base number
|
|
26
|
+
* (`7 (feature: x)`) keeps the base 7 and WARNs on stderr, because falling back
|
|
27
|
+
* to the built-in 12 there would silently LOOSEN a cap the operator tightened.
|
|
24
28
|
*
|
|
25
29
|
* Consumers: `scripts/lib/config.mjs`, `scripts/lib/issue-budget.mjs`,
|
|
26
30
|
* `hooks/pre-bash-issue-budget.mjs`, `skills/session-end/SKILL.md` Phase 5.
|
|
@@ -44,7 +48,9 @@ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
|
|
|
44
48
|
* Defaults:
|
|
45
49
|
* max-per-session: 12 integer >= 0; 0 means "no issue may be created"
|
|
46
50
|
* (a valid, deliberately harsh setting). Malformed
|
|
47
|
-
* or negative input falls back to 12
|
|
51
|
+
* or negative input falls back to 12 — EXCEPT a
|
|
52
|
+
* malformed override over a valid base (`7 (x: y)`),
|
|
53
|
+
* which keeps 7 and WARNs.
|
|
48
54
|
* mode: strict strict | warn | off
|
|
49
55
|
* overflow: collect-issue collect-issue | vault-note
|
|
50
56
|
*
|
|
@@ -54,12 +60,29 @@ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
|
|
|
54
60
|
* mode: strict
|
|
55
61
|
* overflow: collect-issue
|
|
56
62
|
*
|
|
63
|
+
* NEW POLICY (not a restatement of an existing rule): `max-per-session` accepts
|
|
64
|
+
* the SAME per-session-type override syntax `agents-per-wave` already uses —
|
|
65
|
+
* `12 (feature: 6)` parses via `_coerceInteger` into
|
|
66
|
+
* `{ default: 12, feature: 6 }`. The key set is OPEN (any session-type label
|
|
67
|
+
* the operator writes), because the session-type vocabulary lives in
|
|
68
|
+
* session-start, not here.
|
|
69
|
+
*
|
|
70
|
+
* The parsed override object is returned under `"max-per-session-raw"`, while
|
|
71
|
+
* `"max-per-session"` stays STRICTLY NUMERIC (the `.default`). That split is
|
|
72
|
+
* load-bearing: three consumers read the key as a number
|
|
73
|
+
* (`scripts/lib/issue-budget.mjs` `chargeIssueBudget`,
|
|
74
|
+
* `hooks/pre-bash-issue-budget.mjs`, and the config-parity docs), and handing
|
|
75
|
+
* any of them an object would surface as `[object Object]` in a cap comparison
|
|
76
|
+
* rather than as an error. Resolution against the CURRENT session type is
|
|
77
|
+
* `resolveMaxPerSession()` in `scripts/lib/issue-budget.mjs`.
|
|
78
|
+
*
|
|
57
79
|
* @param {string} content — full file contents
|
|
58
|
-
* @returns {{ "max-per-session": number, mode: string, overflow: string }}
|
|
80
|
+
* @returns {{ "max-per-session": number, "max-per-session-raw": number|{default: number, [k: string]: number}, mode: string, overflow: string }}
|
|
59
81
|
*/
|
|
60
82
|
export function _parseIssueBudget(content) {
|
|
61
83
|
const defaults = {
|
|
62
84
|
'max-per-session': 12,
|
|
85
|
+
'max-per-session-raw': 12,
|
|
63
86
|
mode: 'strict',
|
|
64
87
|
overflow: 'collect-issue',
|
|
65
88
|
};
|
|
@@ -84,6 +107,7 @@ export function _parseIssueBudget(content) {
|
|
|
84
107
|
if (blockLines.length === 0) return { ...defaults };
|
|
85
108
|
|
|
86
109
|
let maxPerSession = defaults['max-per-session'];
|
|
110
|
+
let maxPerSessionRaw = defaults['max-per-session-raw'];
|
|
87
111
|
let mode = defaults.mode;
|
|
88
112
|
let overflow = defaults.overflow;
|
|
89
113
|
|
|
@@ -101,10 +125,45 @@ export function _parseIssueBudget(content) {
|
|
|
101
125
|
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
|
|
102
126
|
|
|
103
127
|
switch (k) {
|
|
104
|
-
case 'max-per-session':
|
|
105
|
-
// Non-negative integer
|
|
106
|
-
|
|
128
|
+
case 'max-per-session': {
|
|
129
|
+
// Non-negative integer, or the `N (type: M)` override form. A leading
|
|
130
|
+
// '-' fails \d+ inside `_coerceInteger`, which throws → default.
|
|
131
|
+
// Tolerant by contract: this parser runs on every Bash call via the
|
|
132
|
+
// hook, so a malformed value must fall back silently, never throw.
|
|
133
|
+
let coerced;
|
|
134
|
+
try {
|
|
135
|
+
coerced = _coerceInteger(new Map([['max-per-session', v]]), 'max-per-session', 12);
|
|
136
|
+
} catch {
|
|
137
|
+
// A malformed OVERRIDE (`7 (feature: x)`) must never restore the
|
|
138
|
+
// built-in 12: the operator wrote a base cap of 7, and discarding the
|
|
139
|
+
// whole value because the parenthesised part is unparseable LOOSENS
|
|
140
|
+
// the cap by three on one typo — silently, in the direction nobody
|
|
141
|
+
// would choose. Keep the base number, drop the override, and say so
|
|
142
|
+
// once (this branch is reachable only on a malformed value, so it
|
|
143
|
+
// cannot become per-Bash-call stderr spam).
|
|
144
|
+
const base = v.match(/^(\d+)\s*\(/);
|
|
145
|
+
if (base) {
|
|
146
|
+
const n = Number.parseInt(base[1], 10);
|
|
147
|
+
if (Number.isInteger(n) && n >= 0) {
|
|
148
|
+
maxPerSession = n;
|
|
149
|
+
maxPerSessionRaw = n;
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`⚠ issue-budget: malformed per-session override in 'max-per-session': '${v}' — ` +
|
|
152
|
+
`kept the base cap ${n} and dropped the override.\n`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (typeof coerced === 'number') {
|
|
159
|
+
maxPerSession = coerced;
|
|
160
|
+
maxPerSessionRaw = coerced;
|
|
161
|
+
} else if (Number.isInteger(coerced?.default) && coerced.default >= 0) {
|
|
162
|
+
maxPerSession = coerced.default;
|
|
163
|
+
maxPerSessionRaw = coerced;
|
|
164
|
+
}
|
|
107
165
|
break;
|
|
166
|
+
}
|
|
108
167
|
|
|
109
168
|
case 'mode':
|
|
110
169
|
if (MODES.includes(v.toLowerCase())) mode = v.toLowerCase();
|
|
@@ -118,6 +177,7 @@ export function _parseIssueBudget(content) {
|
|
|
118
177
|
|
|
119
178
|
return {
|
|
120
179
|
'max-per-session': maxPerSession,
|
|
180
|
+
'max-per-session-raw': maxPerSessionRaw,
|
|
121
181
|
mode,
|
|
122
182
|
overflow,
|
|
123
183
|
};
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* `owner.yaml`, the self-alias ledger) moves with it, so the precedence has to
|
|
6
6
|
* live in exactly ONE place. Before #1223 three copies with three different
|
|
7
7
|
* precedences existed: `owner-yaml.mjs`'s import-time homedir-only
|
|
8
|
-
* `OWNER_YAML_PATH`, `owner-config-loader.mjs`'s XDG-only +
|
|
9
|
-
* `resolveOwnerConfigPath()`, and `host-identity.mjs`'s
|
|
8
|
+
* `OWNER_YAML_PATH`, the now-removed `owner-config-loader.mjs`'s XDG-only +
|
|
9
|
+
* untrimmed `resolveOwnerConfigPath()`, and `host-identity.mjs`'s
|
|
10
|
+
* `_privateDir()`. The
|
|
10
11
|
* live consequence: `SO_CONFIG_HOME=<sandbox>` moved the alias ledger but NOT
|
|
11
12
|
* owner.yaml, which kept being read from the operator's REAL home — the
|
|
12
13
|
* CLAUDE.md (Codex CLI alias: AGENTS.md) "vault-dir resolves HOST-LOCALLY"
|
|
@@ -40,8 +40,8 @@ export const REMOTE_HOST_DEFAULTS = Object.freeze({
|
|
|
40
40
|
* scripts/lib/config.mjs — impl / db / security / compliance / docs stay local.
|
|
41
41
|
*
|
|
42
42
|
* NOT the wave-role enum ("Impl-Core", "Quality", …): those are two different
|
|
43
|
-
* enums and conflating them is the documented trap (see
|
|
44
|
-
* in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
|
|
43
|
+
* enums and conflating them is the documented trap (see the MODE_BLIND_SESSION_TYPE
|
|
44
|
+
* note in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
|
|
45
45
|
* `OFFLOADABLE_WAVE_ROLES` in that same module.
|
|
46
46
|
*
|
|
47
47
|
* @type {readonly string[]}
|
|
@@ -86,6 +86,8 @@ export function validateSessionConfig(config) {
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
validateIssueBudget(config['issue-budget'], errors);
|
|
90
|
+
|
|
89
91
|
validateVaultIntegration(config['vault-integration'], errors);
|
|
90
92
|
validateVaultSync(config['vault-sync'], errors);
|
|
91
93
|
|
|
@@ -124,6 +126,83 @@ export function validateSessionConfig(config) {
|
|
|
124
126
|
return { ok: true, config, warnings };
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Validate the `issue-budget` block's cap, including the per-session-type
|
|
131
|
+
* override form (#session-shape) — `12 (feature: 6)` parses to
|
|
132
|
+
* `{ default: 12, feature: 6 }` and lands on `max-per-session-raw`, while
|
|
133
|
+
* `max-per-session` stays the resolved NUMBER every consumer reads.
|
|
134
|
+
*
|
|
135
|
+
* Mirrors `validateAgentsPerWave` in shape, with two deliberate differences:
|
|
136
|
+
* the floor is 0 (`max-per-session: 0` is a valid "no issue may be created"
|
|
137
|
+
* setting) and the override key set is free-form, because the session-type
|
|
138
|
+
* vocabulary is owned by session-start, not by this schema.
|
|
139
|
+
*
|
|
140
|
+
* @param {unknown} block — `config['issue-budget']`
|
|
141
|
+
* @param {Array<{path: string, rule: string, message: string}>} errors
|
|
142
|
+
*/
|
|
143
|
+
function validateIssueBudget(block, errors) {
|
|
144
|
+
if (block === undefined || block === null) return;
|
|
145
|
+
if (typeof block !== 'object' || Array.isArray(block)) {
|
|
146
|
+
errors.push({
|
|
147
|
+
path: 'issue-budget',
|
|
148
|
+
rule: 'object',
|
|
149
|
+
message: `issue-budget must be an object (got ${JSON.stringify(block)})`,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const resolved = block['max-per-session'];
|
|
155
|
+
if (resolved !== undefined && (!Number.isInteger(resolved) || resolved < 0)) {
|
|
156
|
+
errors.push({
|
|
157
|
+
path: 'issue-budget.max-per-session',
|
|
158
|
+
rule: 'integer>=0',
|
|
159
|
+
message: `issue-budget.max-per-session must be an integer >= 0 (got ${JSON.stringify(resolved)})`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const raw = block['max-per-session-raw'];
|
|
164
|
+
if (raw === undefined || raw === null) return;
|
|
165
|
+
|
|
166
|
+
if (Number.isInteger(raw)) {
|
|
167
|
+
if (raw < 0) {
|
|
168
|
+
errors.push({
|
|
169
|
+
path: 'issue-budget.max-per-session-raw',
|
|
170
|
+
rule: 'integer>=0',
|
|
171
|
+
message: `issue-budget.max-per-session-raw must be an integer >= 0 (got ${raw})`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
178
|
+
const defaultVal = raw['default'];
|
|
179
|
+
if (!Number.isInteger(defaultVal) || defaultVal < 0) {
|
|
180
|
+
errors.push({
|
|
181
|
+
path: 'issue-budget.max-per-session-raw.default',
|
|
182
|
+
rule: 'integer>=0',
|
|
183
|
+
message: `issue-budget.max-per-session-raw.default must be an integer >= 0 (got ${JSON.stringify(defaultVal)})`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
187
|
+
if (k === 'default') continue;
|
|
188
|
+
if (!Number.isInteger(v) || v < 0) {
|
|
189
|
+
errors.push({
|
|
190
|
+
path: `issue-budget.max-per-session-raw.${k}`,
|
|
191
|
+
rule: 'integer>=0',
|
|
192
|
+
message: `issue-budget.max-per-session-raw.${k} must be an integer >= 0 (got ${JSON.stringify(v)})`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
errors.push({
|
|
200
|
+
path: 'issue-budget.max-per-session-raw',
|
|
201
|
+
rule: 'integer-or-object',
|
|
202
|
+
message: `issue-budget.max-per-session-raw must be an integer >= 0 or an object with numeric entries (got ${JSON.stringify(raw)})`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
127
206
|
function validateAgentsPerWave(value, errors) {
|
|
128
207
|
if (Number.isInteger(value)) {
|
|
129
208
|
if (value < 2) {
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -208,7 +208,18 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
|
|
|
208
208
|
_getVal(kv, 'ecosystem-health', undefined) !== undefined
|
|
209
209
|
? _coerceBoolean(kv, 'ecosystem-health', false)
|
|
210
210
|
: (_parseEcosystemHealthBlockEnabled(mdContent) ?? false);
|
|
211
|
-
|
|
211
|
+
// discovery-on-close alone accepts `auto` (= true) and defaults to true when
|
|
212
|
+
// absent: the template recommends `auto` and the 2026-07-29 doc decision made
|
|
213
|
+
// close-time discovery the default. Other booleans stay strict (#1340).
|
|
214
|
+
// Ceiling (BV-004): a one-key tri-state kept inline; revisit (move into
|
|
215
|
+
// coercers.mjs as a shared auto-boolean coercer) when a second key accepts `auto`.
|
|
216
|
+
const discoveryOnCloseRaw = (_getVal(kv, 'discovery-on-close', 'auto')).toLowerCase();
|
|
217
|
+
if (!['true', 'false', 'auto'].includes(discoveryOnCloseRaw)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`config.mjs: invalid boolean for 'discovery-on-close': '${_getVal(kv, 'discovery-on-close', '')}' (expected true, false or auto)`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const discoveryOnClose = discoveryOnCloseRaw !== 'false';
|
|
212
223
|
const reasoningOutput = _coerceBoolean(kv, 'reasoning-output', false);
|
|
213
224
|
const groundingCheck = _coerceBoolean(kv, 'grounding-check', true);
|
|
214
225
|
const allowDestructiveOps = _coerceBoolean(kv, 'allow-destructive-ops', false);
|
|
@@ -37,6 +37,7 @@ import path from 'node:path';
|
|
|
37
37
|
import { resolvePluginRoot } from '../common.mjs';
|
|
38
38
|
import { readJsonlFile } from '../io.mjs';
|
|
39
39
|
import { readCanonicalSessions } from '../sessions-canonical.mjs';
|
|
40
|
+
import { isCoordinatorDirectHousekeeping } from '../session-schema/filters.mjs';
|
|
40
41
|
import { buildRunId, CURRENT_STANDARD_VERSION, VALID_MODEL_SOURCES } from './schema.mjs';
|
|
41
42
|
import { resolveSession, computeWindow, findPeerOverlap } from './session-resolve.mjs';
|
|
42
43
|
|
|
@@ -250,7 +251,12 @@ function scoreGateHealth(ctx) {
|
|
|
250
251
|
const totalWaves = typeof ctx.record.total_waves === 'number' ? ctx.record.total_waves : null;
|
|
251
252
|
const wavesEmpty =
|
|
252
253
|
totalWaves === 0 || !Array.isArray(ctx.record.waves) || ctx.record.waves.length === 0;
|
|
253
|
-
|
|
254
|
+
// Since the metrics-collection writer rule (#1321), a session with no
|
|
255
|
+
// dispatched waves is recorded as ONE coordinator-direct Housekeeping wave
|
|
256
|
+
// with total_waves 1 — still "no waves ran" per rubric-v1's clarification.
|
|
257
|
+
// Only that wave SHAPE counts, never session_type: a housekeeping session
|
|
258
|
+
// that ran real waves stays cannot-determine (the pre-registered formula).
|
|
259
|
+
if (wavesEmpty || isCoordinatorDirectHousekeeping(ctx.record)) {
|
|
254
260
|
return {
|
|
255
261
|
id,
|
|
256
262
|
method,
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* and the state-lock / staging-fence / session-lock blocks of session-lock.mjs).
|
|
7
7
|
* Each copy independently re-implemented the same skeleton:
|
|
8
8
|
*
|
|
9
|
-
* 1.
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4.
|
|
13
|
-
* 5.
|
|
9
|
+
* 1. exclusive acquisition guard via `linkSync(tmp, lock + '.acquire')`;
|
|
10
|
+
* 2. atomic create-or-fail via `linkSync(tmp, lock)` (POSIX mutex);
|
|
11
|
+
* 3. on EEXIST → read + parse the existing body;
|
|
12
|
+
* 4. same-host + dead-PID (or unparseable) → guarded override + WARN;
|
|
13
|
+
* 5. release the acquisition guard; live/cross-host holders wait until a deadline;
|
|
14
|
+
* 6. owner-guarded primary-lock release.
|
|
14
15
|
*
|
|
15
16
|
* This module is the single home for that skeleton. It is a near-PURE primitive:
|
|
16
17
|
* it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync), the two host
|
|
@@ -27,6 +28,20 @@
|
|
|
27
28
|
* opinion of its own — every divergence between the five copies is a knob.
|
|
28
29
|
* - Cross-host locks are NEVER auto-overridden (PSA-003 hard invariant).
|
|
29
30
|
* - Overrides always go through writeJsonAtomicSync (tmp + renameSync).
|
|
31
|
+
* - #1284 serializes acquisition and takeover: a stale observation must not
|
|
32
|
+
* replace a different process's newly acquired lock.
|
|
33
|
+
* - #1285 serializes owner-guarded release with takeover: release runs its
|
|
34
|
+
* read → owner-match → unlink under the same `${lock}.acquire` guard, so an
|
|
35
|
+
* old holder's delayed release can never delete the lock a successor took
|
|
36
|
+
* over after the old lease expired.
|
|
37
|
+
*
|
|
38
|
+
* Lease semantics (`staleCheck: 'mtime' | 'heartbeat'`): lease expiry prevents
|
|
39
|
+
* a STUCK lock; it does NOT protect the critical section. After `staleMs` the
|
|
40
|
+
* next acquirer takes over whether or not the old holder is still running, and
|
|
41
|
+
* neither mode renews the lease (`heartbeat` ages from `acquiredAt`, `mtime`
|
|
42
|
+
* from the file's last write). A `not-owner` release under a lease mode means
|
|
43
|
+
* YOUR lease expired during your critical section and a successor may have run
|
|
44
|
+
* concurrently — keep critical sections well below `staleMs`.
|
|
30
45
|
*
|
|
31
46
|
* No external dependencies — Node 20+ stdlib + io.mjs only.
|
|
32
47
|
*/
|
|
@@ -246,6 +261,25 @@ function serializeBody(body, indent) {
|
|
|
246
261
|
return JSON.stringify(body, null, indent) + '\n';
|
|
247
262
|
}
|
|
248
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Create the exclusive `${lockPath}.acquire` sibling guard that serializes every
|
|
266
|
+
* acquisition, takeover and owner-guarded release pass on `lockPath` (#1284,
|
|
267
|
+
* #1285). The caller owns the guard only when `ok` is true and must unlink it
|
|
268
|
+
* in a `finally`; an existing guard is NEVER replaced (see tryAcquireFileLock).
|
|
269
|
+
*
|
|
270
|
+
* @param {string} lockPath
|
|
271
|
+
* @param {string} tmpPrefix — tmp-file prefix; `.acquire` is appended.
|
|
272
|
+
* @returns {{ ok: true } | { ok: false, reason: 'exists' } | { ok: false, reason: 'fs-error', error: string }}
|
|
273
|
+
*/
|
|
274
|
+
function createAcquireGuard(lockPath, tmpPrefix) {
|
|
275
|
+
return createExclusive(`${lockPath}.acquire`, {
|
|
276
|
+
pid: process.pid,
|
|
277
|
+
host: os.hostname(),
|
|
278
|
+
acquiredAt: new Date().toISOString(),
|
|
279
|
+
kind: 'acquisition-guard',
|
|
280
|
+
}, { indent: 2, tmpPrefix: `${tmpPrefix}.acquire` });
|
|
281
|
+
}
|
|
282
|
+
|
|
249
283
|
// ---------------------------------------------------------------------------
|
|
250
284
|
// Exported primitive
|
|
251
285
|
// ---------------------------------------------------------------------------
|
|
@@ -258,6 +292,26 @@ function serializeBody(body, indent) {
|
|
|
258
292
|
* overridden via writeJsonAtomicSync and a WARN is emitted. A live holder or a
|
|
259
293
|
* cross-host body returns `{ acquired: false, reason: 'held' }`.
|
|
260
294
|
*
|
|
295
|
+
* Every acquisition pass owns the exclusive sibling `${lockPath}.acquire`
|
|
296
|
+
* from before create/read through any takeover. This prevents a waiter from
|
|
297
|
+
* reading an old holder, observing its exit, then replacing a newer holder.
|
|
298
|
+
* Owner-guarded releases (releaseFileLock) take the SAME guard for their
|
|
299
|
+
* read → owner-match → unlink pass (#1285), so a release can never unlink a
|
|
300
|
+
* lock that a takeover replaced after the releaser read its own body.
|
|
301
|
+
* All contenders must use this guarded implementation; legacy writers that
|
|
302
|
+
* ignore the sibling guard cannot participate safely in the same protocol.
|
|
303
|
+
*
|
|
304
|
+
* Crash-liveness tradeoff: the guard is held only for one synchronous pass
|
|
305
|
+
* (acquire or release), not for the caller's critical section. If its owner
|
|
306
|
+
* dies during a pass or cleanup fails, the guard remains: acquire attempts
|
|
307
|
+
* return `held` immediately (withFileLock's normal deadline bounds polling) and
|
|
308
|
+
* owner-guarded releases return `busy` after their bounded retry, leaving the
|
|
309
|
+
* primary lock to its stale policy. Even a dead-PID or malformed
|
|
310
|
+
* guard is NEVER stolen, because stale-guard replacement would repeat the same
|
|
311
|
+
* race. Recovery requires quiescing every process that can acquire this lock,
|
|
312
|
+
* verifying the guard is abandoned, then explicitly removing only that sibling.
|
|
313
|
+
* The primary lock retains its existing stale/host/owner protections.
|
|
314
|
+
*
|
|
261
315
|
* The `signalVanished` knob reproduces memory-proposals/store.mjs's distinct
|
|
262
316
|
* third state: when the lock file disappears between the EEXIST and the read
|
|
263
317
|
* (concurrent release race), `{ acquired: false, reason: 'vanished' }` is
|
|
@@ -284,6 +338,25 @@ function serializeBody(body, indent) {
|
|
|
284
338
|
* | { acquired: false, reason: 'held'|'vanished'|'fs-error', existing?: object|null, error?: string }}
|
|
285
339
|
*/
|
|
286
340
|
export function tryAcquireFileLock(lockPath, opts = {}) {
|
|
341
|
+
const guardPath = `${lockPath}.acquire`;
|
|
342
|
+
const guard = createAcquireGuard(lockPath, opts.tmpPrefix ?? '.file.lock');
|
|
343
|
+
if (!guard.ok) {
|
|
344
|
+
return guard.reason === 'exists'
|
|
345
|
+
? { acquired: false, reason: 'held', existing: null }
|
|
346
|
+
: { acquired: false, reason: 'fs-error', error: guard.error };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
return tryAcquireGuardedFileLock(lockPath, opts);
|
|
351
|
+
} finally {
|
|
352
|
+
// Only this pass can own/remove this guard; no acquisition path replaces
|
|
353
|
+
// it. An unlink failure deliberately leaves subsequent attempts blocked.
|
|
354
|
+
try { fs.unlinkSync(guardPath); } catch { /* fail closed; see recovery above */ }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Caller must hold the acquisition guard throughout this synchronous pass. */
|
|
359
|
+
function tryAcquireGuardedFileLock(lockPath, opts) {
|
|
287
360
|
const {
|
|
288
361
|
staleCheck = 'pid',
|
|
289
362
|
staleMs,
|
|
@@ -376,21 +449,35 @@ export function tryAcquireFileLock(lockPath, opts = {}) {
|
|
|
376
449
|
* match. This reproduces the agent-status / state-lock / staging-fence owner
|
|
377
450
|
* guard (PSA-003: never delete a lock another holder owns).
|
|
378
451
|
*
|
|
452
|
+
* Guaranteed (#1285): the owner-guarded read → owner-match → unlink runs under
|
|
453
|
+
* the same `${lockPath}.acquire` guard as acquisition and takeover, so an old
|
|
454
|
+
* holder's release can never delete a replacement's lock. Without the guard, a
|
|
455
|
+
* holder that read its own body, then paused while its lease expired and a
|
|
456
|
+
* successor took over, unlinked the successor's lock and let a third process
|
|
457
|
+
* acquire beside a live holder. Under `staleCheck: 'mtime' | 'heartbeat'`, a
|
|
458
|
+
* `not-owner` answer means your lease expired during your critical section —
|
|
459
|
+
* see the module header § Lease semantics. The guard is never stolen: when it
|
|
460
|
+
* stays taken past the budget the release returns `busy` and leaves the lock
|
|
461
|
+
* untouched for its stale policy to reclaim.
|
|
462
|
+
*
|
|
379
463
|
* With `ownerGuard: false` the file is unlinked unconditionally, ENOENT
|
|
380
464
|
* ignored — reproducing memory-proposals/store.mjs's `releaseProposalsLock`.
|
|
465
|
+
* That path takes no guard (its callers never run a takeover).
|
|
381
466
|
*
|
|
382
467
|
* @param {string} lockPath
|
|
383
468
|
* @param {object} [opts]
|
|
384
469
|
* @param {string} [opts.holder] — expected holder for the owner guard.
|
|
385
470
|
* @param {boolean} [opts.ownerGuard=true]
|
|
471
|
+
* @param {number} [opts.guardTimeoutMs=1000] — owner-guarded path only: how long
|
|
472
|
+
* to retry for the `.acquire` guard before answering `busy`.
|
|
386
473
|
* @param {(errToken: string) => void} [opts.warn] — sink for unexpected fs
|
|
387
474
|
* errors on the ownerGuard:false path. Receives the raw
|
|
388
475
|
* `err.code ?? err.message` token; the call-site formats the message.
|
|
389
476
|
* @returns {{ ok: true }
|
|
390
|
-
* | { ok: false, reason: 'not-found'|'not-owner'|'fs-error', error?: string }}
|
|
477
|
+
* | { ok: false, reason: 'not-found'|'not-owner'|'busy'|'fs-error', error?: string }}
|
|
391
478
|
*/
|
|
392
479
|
export function releaseFileLock(lockPath, opts = {}) {
|
|
393
|
-
const { holder, ownerGuard = true, warn } = opts;
|
|
480
|
+
const { holder, ownerGuard = true, warn, guardTimeoutMs } = opts;
|
|
394
481
|
|
|
395
482
|
if (ownerGuard === false) {
|
|
396
483
|
// Unconditional unlink; ENOENT ignored. Other fs errors surfaced via warn.
|
|
@@ -406,6 +493,53 @@ export function releaseFileLock(lockPath, opts = {}) {
|
|
|
406
493
|
}
|
|
407
494
|
}
|
|
408
495
|
|
|
496
|
+
// Nothing to release → answer without the guard. Taking it would create the
|
|
497
|
+
// lock's directory (createExclusive mkdirs) and contend with live acquirers
|
|
498
|
+
// for a pass that can never unlink anything. Only ENOENT short-cuts; every
|
|
499
|
+
// other stat error falls through to the guarded read, which reports it.
|
|
500
|
+
try {
|
|
501
|
+
fs.statSync(lockPath);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (err.code === 'ENOENT') return { ok: false, reason: 'not-found' };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const budget = typeof guardTimeoutMs === 'number' && guardTimeoutMs >= 0
|
|
507
|
+
? guardTimeoutMs
|
|
508
|
+
: DEFAULT_RELEASE_GUARD_MS;
|
|
509
|
+
const deadline = Date.now() + budget;
|
|
510
|
+
for (;;) {
|
|
511
|
+
const guard = createAcquireGuard(lockPath, '.file.lock');
|
|
512
|
+
if (guard.ok) break;
|
|
513
|
+
if (guard.reason === 'fs-error') return { ok: false, reason: 'fs-error', error: guard.error };
|
|
514
|
+
// Never steal the guard (same rule as acquisition): wait, then give up.
|
|
515
|
+
if (Date.now() >= deadline) return { ok: false, reason: 'busy' };
|
|
516
|
+
sleepSync(RELEASE_GUARD_POLL_MS);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
return releaseGuardedFileLock(lockPath, holder);
|
|
521
|
+
} finally {
|
|
522
|
+
// Only this pass owns the guard; an unlink failure fails closed exactly
|
|
523
|
+
// like the acquisition path (see tryAcquireFileLock § recovery).
|
|
524
|
+
try { fs.unlinkSync(`${lockPath}.acquire`); } catch { /* fail closed */ }
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Budget for an owner-guarded release to obtain the `.acquire` guard.
|
|
530
|
+
*
|
|
531
|
+
* CEILING (BV-004): a live contender holds the guard for one synchronous pass
|
|
532
|
+
* (a handful of fs syscalls), so the wait is normally sub-millisecond; the full
|
|
533
|
+
* 1000 ms is only spent on an ABANDONED guard (owner crashed mid-pass), where
|
|
534
|
+
* waiting longer cannot help. REVISIT if a `busy` release is ever observed on a
|
|
535
|
+
* host without a crash.
|
|
536
|
+
*/
|
|
537
|
+
const DEFAULT_RELEASE_GUARD_MS = 1000;
|
|
538
|
+
/** Poll cadence while a release waits for the `.acquire` guard. */
|
|
539
|
+
const RELEASE_GUARD_POLL_MS = 5;
|
|
540
|
+
|
|
541
|
+
/** Caller must hold the acquisition guard throughout this synchronous pass. */
|
|
542
|
+
function releaseGuardedFileLock(lockPath, holder) {
|
|
409
543
|
let raw;
|
|
410
544
|
try {
|
|
411
545
|
raw = fs.readFileSync(lockPath, 'utf8');
|
|
@@ -481,6 +615,11 @@ function delay(ms) {
|
|
|
481
615
|
* @param {boolean} [opts.sync=false] — true → synchronous busy-wait poll +
|
|
482
616
|
* synchronous fn (agent-status variant). false → async poll.
|
|
483
617
|
* @param {boolean} [opts.ownerGuard=true] — passed to releaseFileLock.
|
|
618
|
+
* @param {(release: ReturnType<typeof releaseFileLock>) => void} [opts.onRelease]
|
|
619
|
+
* — receives the releaseFileLock result from the finally (#1336). The
|
|
620
|
+
* only reader of `busy` / `not-owner` (a lease that expired mid-`fn`);
|
|
621
|
+
* the return value of withFileLock is unchanged. A throwing callback is
|
|
622
|
+
* swallowed so it can mask neither `value` nor an error thrown by `fn`.
|
|
484
623
|
* @param {...*} [opts.acquireOpts] — remaining keys forwarded to tryAcquireFileLock.
|
|
485
624
|
* @returns {Promise<{ ok: true, value: T }
|
|
486
625
|
* | { ok: false, reason: 'timeout'|'fs-error', error?: string, existing?: object|null }>}
|
|
@@ -492,6 +631,7 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
492
631
|
pollMs = 100,
|
|
493
632
|
sync = false,
|
|
494
633
|
ownerGuard = true,
|
|
634
|
+
onRelease,
|
|
495
635
|
...acquireOpts
|
|
496
636
|
} = opts;
|
|
497
637
|
|
|
@@ -525,6 +665,9 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
525
665
|
const value = await fn(acquired.body);
|
|
526
666
|
return { ok: true, value };
|
|
527
667
|
} finally {
|
|
528
|
-
releaseFileLock(lockPath, { holder: releaseHolder, ownerGuard });
|
|
668
|
+
const released = releaseFileLock(lockPath, { holder: releaseHolder, ownerGuard });
|
|
669
|
+
if (typeof onRelease === 'function') {
|
|
670
|
+
try { onRelease(released); } catch { /* diagnostic sink — never masks fn */ }
|
|
671
|
+
}
|
|
529
672
|
}
|
|
530
673
|
}
|