@event4u/agent-config 14.6.0 → 14.7.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/CHANGELOG.md +260 -7
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +264 -10
- package/dist/agent-src/commands/analyze.md +1 -1
- package/dist/agent-src/commands/roadmap/process-full.md +116 -30
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +135 -104
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +3 -0
- package/dist/agent-src/rules/fix-what-you-see.md +108 -0
- package/dist/agent-src/rules/no-roadmap-references.md +0 -5
- package/dist/agent-src/rules/recurring-criticism.md +110 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
- package/dist/agent-src/rules/rule-type-governance.md +0 -2
- package/dist/agent-src/rules/self-repair-loop.md +17 -0
- package/dist/agent-src/rules/skill-quality.md +0 -2
- package/dist/agent-src/rules/source-confidentiality.md +37 -7
- package/dist/agent-src/scripts/archival_sweep.ts +117 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
- package/dist/agent-src/scripts/gate_budget.ts +282 -0
- package/dist/agent-src/scripts/gate_execute.ts +72 -31
- package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
- package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
- package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
- package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
- package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
- package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
- package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
- package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
- package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
- package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
- package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
- package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
- package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
- package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
- package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
- package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
- package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
- package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +45 -2
- package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/dist/cli/registry.js +1 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
- package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
- package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
- package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
- package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
- package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
- package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
- package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
- package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-JZ4NR2TW.js} +338 -0
- package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
- package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
- package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
- package/dist/cli-delegate/cmd_conformance.js +459 -709
- package/dist/cli-delegate/cmd_doctor.js +6 -6
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +7 -6
- package/dist/cli-delegate/cmd_handoff.js +6 -6
- package/dist/cli-delegate/cmd_packs_active.js +1 -1
- package/dist/cli-delegate/cmd_preflight.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +7 -6
- package/dist/cli-delegate/cmd_session_recycle.js +840 -30
- package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +7 -6
- package/dist/cli-delegate/cmd_uninstall.js +9 -7
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +7 -6
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +68 -25
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +2901 -2053
- package/dist/install/claudePathsPlan.js +187 -0
- package/dist/install/claudePathsPlan.js.map +1 -0
- package/dist/install/hostLayerFingerprint.js +136 -0
- package/dist/install/hostLayerFingerprint.js.map +1 -0
- package/dist/install/install.mjs +553 -425
- package/dist/install/partitionEligibility.js +276 -0
- package/dist/install/partitionEligibility.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1744 -1191
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_src.js +41 -0
- package/dist/scripts/_lib/agent_src.js.map +1 -1
- package/dist/scripts/_lib/install_layout.js +83 -0
- package/dist/scripts/_lib/install_layout.js.map +1 -0
- package/dist/scripts/_lib/installed_lock.js +444 -0
- package/dist/scripts/_lib/installed_lock.js.map +1 -0
- package/dist/scripts/condense.js +13 -164
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +4 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +12 -6
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
- package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
- package/docs/guidelines/agent-infra/memory-access.md +17 -0
- package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archival_sweep.ts +117 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
- package/src/agent-src/scripts/gate_budget.ts +282 -0
- package/src/agent-src/scripts/gate_execute.ts +72 -31
- package/src/agent-src/scripts/roadmap_gates.ts +74 -3
- package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
- package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/src/config/agent-settings.template.yml +27 -2
- package/src/config/ci-local-parity.yml +18 -0
- package/src/config/estate-count-budget.json +339 -3
- package/src/config/gate-violation-baselines.json +41 -41
- package/src/config/rule-activation-census.json +14 -6
- package/src/config/rule-stub-ceilings.json +19 -6
- package/src/install/claudePathsPlan.ts +196 -0
- package/src/install/hostLayerFingerprint.ts +145 -0
- package/src/install/partitionEligibility.ts +325 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
- package/src/scripts/_dispatch.bash +6 -1
- package/src/scripts/_lib/ac_heading.ts +30 -0
- package/src/scripts/_lib/agent_src.ts +42 -0
- package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
- package/src/scripts/_lib/envelope_grounding.ts +38 -8
- package/src/scripts/_lib/installed_lock.ts +50 -5
- package/src/scripts/_lib/md_section.ts +129 -0
- package/src/scripts/_lib/scope_guard.sh +26 -3
- package/src/scripts/_lib/self_repair.ts +19 -0
- package/src/scripts/_lib/self_repair_class_b.ts +171 -0
- package/src/scripts/ai_council/cli_help.ts +55 -0
- package/src/scripts/before_complete_hook.ts +153 -28
- package/src/scripts/bench_ab_v2_complexity.ts +27 -7
- package/src/scripts/check_references.ts +60 -0
- package/src/scripts/check_release_published.ts +32 -11
- package/src/scripts/check_single_delivery.ts +59 -15
- package/src/scripts/ci_settle.ts +170 -0
- package/src/scripts/condense.ts +20 -170
- package/src/scripts/council_cli.ts +3 -3
- package/src/scripts/dispatch_r2_reviewer.ts +6 -14
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +77 -14
- package/src/scripts/hooks/block_no_verify.ts +61 -10
- package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
- package/src/scripts/hooks/run_continuation_hook.ts +8 -5
- package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
- package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
- package/src/scripts/hooks/state_io.ts +782 -20
- package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
- package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
- package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
- package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
- package/src/scripts/install.ts +16 -11
- package/src/scripts/language_mirror_hook.ts +561 -25
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_plan_risk_register.ts +4 -3
- package/src/scripts/lint_skill_router_head.ts +4 -4
- package/src/scripts/mcp_server/tools.ts +2 -2
- package/src/scripts/new_roadmap.ts +136 -0
- package/src/scripts/pack_mcp_content.ts +20 -5
- package/src/scripts/release.ts +37 -38
- package/src/scripts/report_layer_overlap.ts +6 -0
- package/src/scripts/self_repair_hook.ts +37 -5
- package/src/scripts/session_register_hook.ts +11 -0
- package/src/scripts/skill_usage_collect.ts +11 -1
- package/src/scripts/skill_usage_report.ts +278 -8
- package/src/scripts/sync_pr_branch.ts +7 -0
- package/src/server/schemas/settings.ts +8 -0
- package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
- package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
- /package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* locks, writes, releases, closes. No long-lived file handles.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
36
37
|
import fs from "node:fs";
|
|
37
38
|
import path from "node:path";
|
|
38
39
|
|
|
@@ -42,6 +43,14 @@ export const REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY";
|
|
|
42
43
|
|
|
43
44
|
export const FEEDBACK_DIRNAME = ".dispatcher";
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Outcome of one `update_json_under_lock` call.
|
|
48
|
+
*
|
|
49
|
+
* A string union rather than an enum so it survives the JSON/CLI boundaries this
|
|
50
|
+
* tree crosses, and so a `switch` over it is exhaustively checked.
|
|
51
|
+
*/
|
|
52
|
+
export type LockedUpdateResult = "written" | "skipped" | "failed";
|
|
53
|
+
|
|
45
54
|
/**
|
|
46
55
|
* True when the caller signalled read-only fixture replay.
|
|
47
56
|
*
|
|
@@ -57,6 +66,62 @@ function _lock_path(state_dir: string): string {
|
|
|
57
66
|
return path.join(state_dir, LOCK_BASENAME);
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Lock path for ONE state file, used only by `update_json_under_lock`.
|
|
71
|
+
*
|
|
72
|
+
* ## Why this exists, and why it is narrow
|
|
73
|
+
*
|
|
74
|
+
* `_lock_path` above is DIRECTORY-keyed and always was — that part is not new,
|
|
75
|
+
* and `docs/contracts/hook-architecture-v1.md` § Concurrency mandates it with a
|
|
76
|
+
* stated rationale: "serialising state writes across concerns is cheaper than
|
|
77
|
+
* per-file locks, and concerns already run sequentially within one dispatcher
|
|
78
|
+
* invocation." That rationale is about CONCERNS inside one invocation, and it
|
|
79
|
+
* still holds for them.
|
|
80
|
+
*
|
|
81
|
+
* What changed is what the directory holds. Before the per-session split it
|
|
82
|
+
* held one file, so a directory lock WAS a file lock. After it, the directory
|
|
83
|
+
* holds N per-session files — and concurrent SESSIONS are not "concerns within
|
|
84
|
+
* one dispatcher invocation", so the contract's rationale does not reach them.
|
|
85
|
+
* A directory lock there re-serialises exactly the sessions the split exists to
|
|
86
|
+
* decouple.
|
|
87
|
+
*
|
|
88
|
+
* ## Measured, not assumed
|
|
89
|
+
*
|
|
90
|
+
* The prior basis was "probably unmeasurable at millisecond writes", which the
|
|
91
|
+
* roadmap named as a guess and required to be measured before any choice. It
|
|
92
|
+
* was measured (4 and 8 concurrent processes, 60 read-modify-writes each, each
|
|
93
|
+
* to its OWN per-session file, macOS/APFS):
|
|
94
|
+
*
|
|
95
|
+
* - 4 workers: slowest worker 68 ms with the shared directory lock vs 27 ms
|
|
96
|
+
* with no shared lock — ~1.1 ms vs ~0.45 ms per write.
|
|
97
|
+
* - 8 workers: slowest worker 138–267 ms vs 83–95 ms across runs.
|
|
98
|
+
*
|
|
99
|
+
* So the guess was wrong in direction (it IS measurable, and it grows with the
|
|
100
|
+
* number of concurrent sessions) and roughly right in magnitude (sub-millisecond
|
|
101
|
+
* to a few milliseconds per write). The decisive reading is not the absolute
|
|
102
|
+
* number: writes to DISTINCT files under the shared directory lock came out at
|
|
103
|
+
* or above writes to the SAME file, i.e. the directory lock was paying the full
|
|
104
|
+
* cost of mutual exclusion for writes that require none.
|
|
105
|
+
*
|
|
106
|
+
* ## Scope of the change
|
|
107
|
+
*
|
|
108
|
+
* Only `update_json_under_lock` uses this. `_atomic_write_text` — the path the
|
|
109
|
+
* contract describes, the one concerns share — keeps the directory lock
|
|
110
|
+
* untouched. Two sessions writing different per-session files now take
|
|
111
|
+
* different locks and do not block each other; two writers to the SAME file
|
|
112
|
+
* still take the same lock, so the four-process mutual-exclusion test is
|
|
113
|
+
* unaffected.
|
|
114
|
+
*
|
|
115
|
+
* The sentinel and its companion sit next to the state file as
|
|
116
|
+
* `<digest>.json.lock` / `.lock.held`. Neither ends in `.json`, so
|
|
117
|
+
* `prune_stale_session_states` skips both by its existing filter; that pruner
|
|
118
|
+
* now removes them alongside the state file it prunes, so this does not trade a
|
|
119
|
+
* lock for unbounded sentinel growth.
|
|
120
|
+
*/
|
|
121
|
+
function _target_lock_path(target_path: string): string {
|
|
122
|
+
return `${target_path}.lock`;
|
|
123
|
+
}
|
|
124
|
+
|
|
60
125
|
// Python json.dumps(payload, indent=2) byte-for-byte: indent of 2 spaces,
|
|
61
126
|
// ", " collapses to ",\n<indent>" with separators (",", ": "), and
|
|
62
127
|
// ensure_ascii=True (default) escapes every non-ASCII codepoint as \uXXXX.
|
|
@@ -203,16 +268,153 @@ export function atomic_write_text(target: string, text: string): void {
|
|
|
203
268
|
_atomic_write_text(targetPath, text);
|
|
204
269
|
}
|
|
205
270
|
|
|
206
|
-
|
|
271
|
+
/**
|
|
272
|
+
* ── The guarantee this lock rests on, and where it holds ─────────────────────
|
|
273
|
+
*
|
|
274
|
+
* PRIMITIVE: `fs.openSync(path, "wx")` — `O_CREAT | O_EXCL`. Create-or-fail is
|
|
275
|
+
* the exclusion; the loser gets `EEXIST`.
|
|
276
|
+
*
|
|
277
|
+
* PLATFORM AND VERSION the guarantee is claimed on: **local filesystems on the
|
|
278
|
+
* platforms this package supports** — macOS/APFS and Linux/ext4|btrfs|xfs, on
|
|
279
|
+
* Node >= 20.11.0 (`package.json` `engines`). Native Windows is explicitly not
|
|
280
|
+
* a first-class target (`docs/installation.md` § Windows names WSL2 or Git
|
|
281
|
+
* Bash), so no claim is made for it. Verified locally on Darwin 24.6.0 arm64.
|
|
282
|
+
*
|
|
283
|
+
* ## The NFS claim, and why it is not acted on as it was reported
|
|
284
|
+
*
|
|
285
|
+
* A peer reported `openSync(path, "wx")` as "broken on NFS per `open(2)`" and
|
|
286
|
+
* proposed `mkdirSync` instead. That report is not usable as stated, in both
|
|
287
|
+
* directions:
|
|
288
|
+
*
|
|
289
|
+
* - The LOCAL BSD `open(2)` says the opposite. Read on Darwin 24.6.0: "If
|
|
290
|
+
* O_EXCL is set with O_CREAT and the file already exists, open() returns an
|
|
291
|
+
* error. **This may be used to implement a simple exclusive-access locking
|
|
292
|
+
* mechanism.**" That is a recommendation of this exact use.
|
|
293
|
+
* - The warning is real but Linux-specific and VERSION-BOUND, not categorical.
|
|
294
|
+
* The Linux `open(2)` NOTES scope it to old NFS — NFSv2, which lacks an
|
|
295
|
+
* atomic create-exclusive operation, so `O_EXCL` there is emulated and
|
|
296
|
+
* racy. NFSv3 carries an atomic exclusive-create and NFSv4 likewise. So the
|
|
297
|
+
* honest form of the claim is "unreliable on NFSv2", not "broken on NFS".
|
|
298
|
+
*
|
|
299
|
+
* ## Is NFS even in scope? No — and that is why nothing is rewritten
|
|
300
|
+
*
|
|
301
|
+
* This state tree lives INSIDE the project directory
|
|
302
|
+
* (`<project>/agents/runtime/state/`, gitignored per
|
|
303
|
+
* `docs/contracts/agents-layout.md`). Its filesystem is whatever the developer's
|
|
304
|
+
* checkout sits on. A checkout on an NFSv2 mount is not a configuration this
|
|
305
|
+
* package supports, tests, or has ever observed; NFSv2 has been superseded for
|
|
306
|
+
* roughly three decades.
|
|
307
|
+
*
|
|
308
|
+
* So `mkdirSync` is NOT adopted. Both primitives are atomic create-or-fail on
|
|
309
|
+
* every filesystem in scope, so the swap would buy nothing here while trading a
|
|
310
|
+
* primitive the local manpage recommends for one chosen on a mis-stated premise.
|
|
311
|
+
* Note also that `mkdirSync` would not even be a clean win on the premise as
|
|
312
|
+
* given: the same Linux NOTES that qualify `O_EXCL` are about the absence of an
|
|
313
|
+
* atomic create-exclusive RPC, and directory creation over NFSv2 inherits the
|
|
314
|
+
* same retransmission ambiguity.
|
|
315
|
+
*
|
|
316
|
+
* WHAT WOULD CHANGE THIS: an actual report of this state tree on a network
|
|
317
|
+
* mount, or a supported-platform claim that includes one. At that point the
|
|
318
|
+
* fix is not a primitive swap by reputation — it is a measurement on that
|
|
319
|
+
* mount, because the staleness reclaim above (mtime-based) also depends on
|
|
320
|
+
* clock and attribute-cache behaviour that a network filesystem changes.
|
|
321
|
+
*/
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* How long a companion must be untouched before it counts as ABANDONED.
|
|
325
|
+
*
|
|
326
|
+
* Matches `language_mirror_hook.LOCK_STALE_MS` — a convention match, not a
|
|
327
|
+
* measurement, and stated as such. What matters is that it is much larger than
|
|
328
|
+
* any real critical section here (a read, a mutate and a rename), so a live
|
|
329
|
+
* holder is never mistaken for a dead one.
|
|
330
|
+
*/
|
|
331
|
+
export const LOCK_STALE_MS = 30_000;
|
|
332
|
+
|
|
333
|
+
/** How long a BLOCKING acquire waits before giving up. Unchanged value. */
|
|
334
|
+
export const LOCK_ACQUIRE_DEADLINE_MS = 5_000;
|
|
335
|
+
|
|
336
|
+
/** One spin's sleep. Small enough that the deadline is a real bound. */
|
|
337
|
+
const LOCK_SPIN_SLEEP_MS = 2;
|
|
338
|
+
|
|
339
|
+
/** Real sleep without async — `Atomics.wait` on a throwaway buffer. */
|
|
340
|
+
function _sleep(ms: number): void {
|
|
341
|
+
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
342
|
+
Atomics.wait(sab, 0, 0, ms);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Take the cross-process lock for `lock_path`, or report that it is held.
|
|
347
|
+
*
|
|
348
|
+
* Node has no `flock`, so exclusion comes from an `O_EXCL` companion
|
|
349
|
+
* (`<lock>.held`) removed on release; the visible sentinel is kept.
|
|
350
|
+
*
|
|
351
|
+
* ## What was wrong before, precisely
|
|
352
|
+
*
|
|
353
|
+
* The reclaim branch was commented `// Stale companion — reclaim it` and
|
|
354
|
+
* examined the companion in NO way: no mtime, no age, no owner. It fired on
|
|
355
|
+
* `Date.now() - start > deadlineMs` — a property of how long THIS caller had
|
|
356
|
+
* waited, not of the companion's health. So it was a patience check wearing the
|
|
357
|
+
* name of a staleness check, and a caller that merely waited long enough
|
|
358
|
+
* deleted a perfectly live peer's lock.
|
|
359
|
+
*
|
|
360
|
+
* Two consequences followed, and the second is the worse one:
|
|
361
|
+
*
|
|
362
|
+
* - `start` was never reset before `continue`, and the deadline test sat
|
|
363
|
+
* BEFORE the sleep. So the first timeout made the sleep permanently
|
|
364
|
+
* unreachable: every later iteration took the reclaim branch and looped
|
|
365
|
+
* with no pause at all. A pauseless spin was the end state of any call that
|
|
366
|
+
* touched the deadline once.
|
|
367
|
+
* - Two callers both past their deadline evicted each other's companion
|
|
368
|
+
* without bound — each one's "reclaim" destroyed the exclusion the other
|
|
369
|
+
* had just acquired, so neither held a lock while both believed they did.
|
|
370
|
+
*
|
|
371
|
+
* ## What holds now
|
|
372
|
+
*
|
|
373
|
+
* The companion's AGE decides reclamation, and nothing else. A fresh companion
|
|
374
|
+
* is never removed, by any caller, at any wait duration — a blocking caller
|
|
375
|
+
* that reaches its deadline reports failure instead of evicting. Reclamation
|
|
376
|
+
* requires `LOCK_STALE_MS` of inactivity, which only an abandoned lock reaches,
|
|
377
|
+
* so mutual eviction is gone rather than merely rarer.
|
|
378
|
+
*
|
|
379
|
+
* Every retry path either sleeps or has just made real progress (the companion
|
|
380
|
+
* vanished, or a genuinely abandoned one was removed), so no path reaches a
|
|
381
|
+
* sleepless retry loop.
|
|
382
|
+
*
|
|
383
|
+
* ## `blocking: false` — the hot-path acquire
|
|
384
|
+
*
|
|
385
|
+
* `post_tool_use` runs on every tool call and must never wait. A non-blocking
|
|
386
|
+
* caller returns on the first `EEXIST` with no spin at all, so it cannot cross
|
|
387
|
+
* a deadline — which makes the reclaim branch UNREACHABLE for it rather than
|
|
388
|
+
* merely rarer. That is why the roadmap treats the non-blocking acquire as
|
|
389
|
+
* subsuming the reclaim fix rather than complementing it; the reclaim fix is
|
|
390
|
+
* still needed for the blocking callers, which is why both landed.
|
|
391
|
+
*
|
|
392
|
+
* An abandoned companion is still reclaimed on the non-blocking path: skipping
|
|
393
|
+
* that would let one crashed process wedge the hot path for 30 seconds, which
|
|
394
|
+
* is the wedge the never-block contract exists to prevent.
|
|
395
|
+
*
|
|
396
|
+
* Returns the sentinel fd, or `null` when the lock is held by a live peer.
|
|
397
|
+
* `null` is NOT a failure to handle generically — the two callers below take
|
|
398
|
+
* deliberately opposite actions on it, for reasons stated at each.
|
|
399
|
+
*/
|
|
400
|
+
function _acquire_lock(
|
|
401
|
+
lock_path: string,
|
|
402
|
+
options: { blocking?: boolean } = {},
|
|
403
|
+
): number | null {
|
|
404
|
+
const blocking = options.blocking ?? true;
|
|
207
405
|
// Open (and keep) the sentinel lock file with O_CREAT | O_RDWR — never
|
|
208
406
|
// truncated, just an fd, exactly like the Python fcntl path. The file
|
|
209
407
|
// stays on disk alongside the target after release.
|
|
210
408
|
const fd = fs.openSync(lock_path, "a", 0o644);
|
|
211
|
-
// Cross-process mutual exclusion: Node has no flock, so we spin on an
|
|
212
|
-
// O_EXCL companion (`.held`) with a bounded deadline. The companion is
|
|
213
|
-
// removed on release; the visible `.dispatcher.lock` sentinel is kept.
|
|
214
409
|
const held = lock_path + ".held";
|
|
215
|
-
const
|
|
410
|
+
const give_up = (): null => {
|
|
411
|
+
try {
|
|
412
|
+
fs.closeSync(fd);
|
|
413
|
+
} catch {
|
|
414
|
+
/* ignore */
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
};
|
|
216
418
|
const start = Date.now();
|
|
217
419
|
for (;;) {
|
|
218
420
|
try {
|
|
@@ -221,22 +423,44 @@ function _acquire_lock(lock_path: string): number {
|
|
|
221
423
|
return fd;
|
|
222
424
|
} catch (err) {
|
|
223
425
|
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
224
|
-
// Unexpected error
|
|
225
|
-
// than wedge the never-block hook
|
|
426
|
+
// Unexpected error (a read-only mount, a vanished parent) — proceed
|
|
427
|
+
// without the companion guard rather than wedge the never-block hook
|
|
428
|
+
// contract. Pre-existing behaviour, deliberately retained: the failure
|
|
429
|
+
// mode here is a lost guard, and the alternative is a lost write.
|
|
226
430
|
return fd;
|
|
227
431
|
}
|
|
228
|
-
|
|
229
|
-
|
|
432
|
+
// EEXIST: somebody holds it. Decide from the COMPANION, never from how
|
|
433
|
+
// long we have waited.
|
|
434
|
+
let age_ms: number;
|
|
435
|
+
try {
|
|
436
|
+
age_ms = Date.now() - fs.statSync(held).mtimeMs;
|
|
437
|
+
} catch {
|
|
438
|
+
// Vanished between the open and the stat — the holder just released.
|
|
439
|
+
// Retry at once: this is real progress, not a spin.
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (age_ms >= LOCK_STALE_MS) {
|
|
443
|
+
// A genuine staleness check: the companion itself has been untouched
|
|
444
|
+
// for longer than any live critical section here can last. Removing it
|
|
445
|
+
// is progress, so the immediate retry is bounded.
|
|
230
446
|
try {
|
|
231
447
|
fs.rmSync(held, { force: true });
|
|
232
448
|
} catch {
|
|
233
|
-
/*
|
|
449
|
+
/* a peer reclaimed it first — the retry below sees the new one */
|
|
234
450
|
}
|
|
235
451
|
continue;
|
|
236
452
|
}
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
453
|
+
// A LIVE peer holds it.
|
|
454
|
+
if (!blocking) {
|
|
455
|
+
return give_up();
|
|
456
|
+
}
|
|
457
|
+
if (Date.now() - start > LOCK_ACQUIRE_DEADLINE_MS) {
|
|
458
|
+
// Waited long enough. Report failure — never evict a fresh companion,
|
|
459
|
+
// which is exactly the eviction that made two impatient callers destroy
|
|
460
|
+
// each other's exclusion.
|
|
461
|
+
return give_up();
|
|
462
|
+
}
|
|
463
|
+
_sleep(LOCK_SPIN_SLEEP_MS);
|
|
240
464
|
}
|
|
241
465
|
}
|
|
242
466
|
}
|
|
@@ -257,13 +481,54 @@ function _release_lock(fd: number, lock_path: string): void {
|
|
|
257
481
|
}
|
|
258
482
|
}
|
|
259
483
|
|
|
260
|
-
|
|
484
|
+
/**
|
|
485
|
+
* Publish `text` at `target` atomically. CALLER MUST HOLD THE LOCK.
|
|
486
|
+
*
|
|
487
|
+
* Split out of `_atomic_write_text` so a read-modify-write can hold ONE lock
|
|
488
|
+
* across all three steps. The `O_EXCL` companion in `_acquire_lock` is not
|
|
489
|
+
* reentrant: nesting an acquire inside a held lock spins to the 5-second
|
|
490
|
+
* deadline, reclaims its own companion as stale, and proceeds — so it would
|
|
491
|
+
* "work" while costing five seconds per call and silently dropping the mutual
|
|
492
|
+
* exclusion it was there for. That is why this is a separate function rather
|
|
493
|
+
* than a nested call.
|
|
494
|
+
*/
|
|
495
|
+
function _publish_text_locked(target: string, text: string): void {
|
|
261
496
|
const tmp = target + `.tmp.${process.pid}`;
|
|
497
|
+
fs.writeFileSync(tmp, text, { encoding: "utf-8" });
|
|
498
|
+
fs.renameSync(tmp, target);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* A BLIND WRITE is the lesser evil here, and only here.
|
|
503
|
+
*
|
|
504
|
+
* `_acquire_lock` can now report that a live peer holds the lock. This writer
|
|
505
|
+
* publishes anyway, because its unit of work is a whole-file replacement: the
|
|
506
|
+
* publish is a `rename`, so the worst case under contention is last-writer-wins
|
|
507
|
+
* on a complete, well-formed file — never a torn one. Refusing instead would
|
|
508
|
+
* turn a contended write into a LOST write, and every caller here
|
|
509
|
+
* (`atomic_write_json`, the dispatcher's feedback files) treats a lost write as
|
|
510
|
+
* the more serious failure.
|
|
511
|
+
*
|
|
512
|
+
* `update_json_under_lock` takes the OPPOSITE decision on the same signal, and
|
|
513
|
+
* the asymmetry is the point: a read-modify-write without exclusion produces
|
|
514
|
+
* the lost UPDATE that function exists to prevent, so there it fails closed.
|
|
515
|
+
* Pairing the two decisions in one place rather than picking one globally is
|
|
516
|
+
* what keeps each caller's real failure mode addressed.
|
|
517
|
+
*
|
|
518
|
+
* This also preserves the pre-existing behaviour exactly. The old code reached
|
|
519
|
+
* the same outcome by DELETING the peer's companion and proceeding, which wrote
|
|
520
|
+
* the file and destroyed the peer's exclusion. This writes the file and leaves
|
|
521
|
+
* the peer's lock intact.
|
|
522
|
+
*/
|
|
523
|
+
function _atomic_write_text(target: string, text: string): void {
|
|
262
524
|
const lock_path = _lock_path(path.dirname(target));
|
|
263
525
|
const fd = _acquire_lock(lock_path);
|
|
526
|
+
if (fd === null) {
|
|
527
|
+
_publish_text_locked(target, text);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
264
530
|
try {
|
|
265
|
-
|
|
266
|
-
fs.renameSync(tmp, target);
|
|
531
|
+
_publish_text_locked(target, text);
|
|
267
532
|
} finally {
|
|
268
533
|
_release_lock(fd, lock_path);
|
|
269
534
|
}
|
|
@@ -281,13 +546,510 @@ function _atomic_write_text(target: string, text: string): void {
|
|
|
281
546
|
* multiple concern signals into a single platform-native code; the
|
|
282
547
|
* feedback dir surfaces the per-concern detail to humans and
|
|
283
548
|
* `task hooks-status` without re-routing control flow.
|
|
549
|
+
*
|
|
550
|
+
* **Why a composite name and not `session_state_file`'s pure digest.** The two
|
|
551
|
+
* serve different readers. A state file is machine-only, so a bare digest costs
|
|
552
|
+
* nothing. This directory is the HUMAN surface named in the paragraph above,
|
|
553
|
+
* and `hooks_doctor` puts its path in the `last_feedback` column — a bare
|
|
554
|
+
* digest there would replace the one operator-legible token in the output with
|
|
555
|
+
* 12 hex characters. The composite keeps the label for the reader and takes the
|
|
556
|
+
* distinctness from the digest, which is where distinctness actually comes from.
|
|
557
|
+
*
|
|
558
|
+
* **No migration.** Nothing parses the directory name back into a session id
|
|
559
|
+
* and nothing prunes this tree: `hooks_doctor._latest_feedback` enumerates
|
|
560
|
+
* whatever directories exist and picks per concern by mtime. Pre-existing
|
|
561
|
+
* old-scheme directories therefore stay readable and simply stop being written
|
|
562
|
+
* to. Verified against that function rather than assumed.
|
|
563
|
+
*
|
|
564
|
+
* **The id-less bucket is retained, deliberately.** An empty id still maps to
|
|
565
|
+
* one shared `unknown-session.<digest of "">` directory, and
|
|
566
|
+
* `has_stable_session_id` is NOT applied here. That helper exists so a caller
|
|
567
|
+
* can decline to persist; this caller cannot — the dispatcher must put its
|
|
568
|
+
* per-concern detail somewhere or the feedback surface silently empties for any
|
|
569
|
+
* host that exports no id. What merges in that bucket is a per-concern VIEW,
|
|
570
|
+
* recoverable from the next run, not a pin whose loss is unrecoverable. That
|
|
571
|
+
* asymmetry is the whole reason the two functions differ, and it is stated
|
|
572
|
+
* rather than left as an apparent oversight.
|
|
284
573
|
*/
|
|
285
574
|
export function feedback_dir(state_root: string, session_id: string): string {
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
575
|
+
// Defence-in-depth: refuse path traversal in session_id. This half is
|
|
576
|
+
// UNCHANGED — it is what keeps the result a single segment under
|
|
577
|
+
// `.dispatcher`, and `dispatcher_feedback_traversal.test.ts` pins that
|
|
578
|
+
// property directly rather than pinning the literal it produces.
|
|
579
|
+
const label = (session_id || "unknown-session")
|
|
289
580
|
.replace(/\//g, "_")
|
|
290
581
|
.replace(/\\/g, "_")
|
|
291
582
|
.replace(/\.\./g, "_");
|
|
292
|
-
|
|
583
|
+
// The DIGEST is what makes two ids two directories. The sanitiser alone
|
|
584
|
+
// could not: it maps `a/b` and `a_b` onto one name, which is the exact shape
|
|
585
|
+
// `session_state_file` replaced for the same reason — see § Per-session
|
|
586
|
+
// concern state, which named this function as a known instance and deferred
|
|
587
|
+
// it on scope grounds. This is that deferral being paid.
|
|
588
|
+
//
|
|
589
|
+
// Keyed on the FULL id, never on `label`, so the sanitiser's collisions
|
|
590
|
+
// cannot survive into the digest.
|
|
591
|
+
const digest = createHash("sha256").update(session_id, "utf8").digest("hex").slice(0, 12);
|
|
592
|
+
return path.join(state_root, FEEDBACK_DIRNAME, `${label}.${digest}`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* ── Per-session concern state ────────────────────────────────────────────────
|
|
597
|
+
*
|
|
598
|
+
* WHY THIS LIVES HERE, and why it is shared rather than copied.
|
|
599
|
+
*
|
|
600
|
+
* A concern that keeps ONE state file per project root shares that file with
|
|
601
|
+
* every concurrent session — and in this repo's worktree workflow
|
|
602
|
+
* `CLAUDE_PROJECT_DIR` resolves to the PARENT checkout, so "one per project
|
|
603
|
+
* root" means one across every worktree at once. Two failures follow, and they
|
|
604
|
+
* are opposites, which is why neither is obviously a bug from inside one
|
|
605
|
+
* session:
|
|
606
|
+
*
|
|
607
|
+
* - A WRONG READ. Session B reads a fact session A recorded. Measured
|
|
608
|
+
* 2026-08-20 (session 15b9ac52): a terse German "1" read a neighbouring
|
|
609
|
+
* English session's language pin and injected
|
|
610
|
+
* `Reply language for this turn: English.` into a German conversation.
|
|
611
|
+
*
|
|
612
|
+
* - A LOST WRITE. Both sessions write through `atomic_write_json`
|
|
613
|
+
* (write + rename), so the later rename discards the earlier state
|
|
614
|
+
* entirely.
|
|
615
|
+
*
|
|
616
|
+
* A "session boundary reset" — noticing a foreign `session_id` INSIDE the file
|
|
617
|
+
* and clearing the counters — is the guard that looks like a fix and is not.
|
|
618
|
+
* It is written for SEQUENTIAL sessions (A ends, B starts). Under CONCURRENT
|
|
619
|
+
* sessions it becomes the damage: each read sees the other's id, resets, and
|
|
620
|
+
* writes, so the two sessions erase each other's evidence in a loop. The AI
|
|
621
|
+
* council said the same thing about `_ownsPin` in the language hook (round 2,
|
|
622
|
+
* BLOCKER 1): a guard catches the collision only AFTER the fact, and cannot
|
|
623
|
+
* stop the other session's file from being replaced.
|
|
624
|
+
*
|
|
625
|
+
* The property that actually holds comes from the DIGEST, not from the
|
|
626
|
+
* directory layout — stating it the other way round is how that defect survived
|
|
627
|
+
* a round of review. Two distinct ids cannot address one file because
|
|
628
|
+
* sha256 does not collide in practice, and a sanitiser DOES: the first cut of
|
|
629
|
+
* this keyed on a character replacement, under which `a/b` and `a_b` were one
|
|
630
|
+
* file.
|
|
631
|
+
*
|
|
632
|
+
* `feedback_dir` above carried the same sanitiser shape (`a/b` and `a_b` → one
|
|
633
|
+
* directory) and this paragraph used to defer it, on the grounds that changing
|
|
634
|
+
* the dispatcher feedback layout needed its own change with its own
|
|
635
|
+
* `hooks-status` verification. That change has now happened: `feedback_dir`
|
|
636
|
+
* takes its distinctness from a digest of the full id and keeps a legible
|
|
637
|
+
* label as a prefix, for the reader-asymmetry reason stated at that function.
|
|
638
|
+
* The deferral text is replaced rather than left standing — a comment that
|
|
639
|
+
* describes a defect the code no longer has reads as a live known-issue, and
|
|
640
|
+
* the next reader spends the same investigation twice.
|
|
641
|
+
*
|
|
642
|
+
* One difference between the two remains, and it is intentional rather than
|
|
643
|
+
* residual: `feedback_dir` still gives every id-less invocation one shared
|
|
644
|
+
* bucket, because its caller cannot decline to write. The reason is at that
|
|
645
|
+
* function; the short version is that a merged VIEW is recoverable and a
|
|
646
|
+
* merged PIN is not.
|
|
647
|
+
*/
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Is there a stable identity to key state on at all?
|
|
651
|
+
*
|
|
652
|
+
* An empty or whitespace id is NOT a bucket to share. Sanitising it into a
|
|
653
|
+
* literal like `unknown-session` puts every id-less invocation in one file with
|
|
654
|
+
* no secondary defense left, which is the original defect restored in the one
|
|
655
|
+
* case that had no guard (language hook, council round 2, BLOCKER 2). There is
|
|
656
|
+
* no sound local way to tell two id-less sessions apart — a fresh UUID per
|
|
657
|
+
* invocation would give one session a new file per hook call, destroying
|
|
658
|
+
* continuity rather than providing it — so the honest answer is for callers to
|
|
659
|
+
* run stateless: derive what this turn needs from this turn's input, persist
|
|
660
|
+
* nothing.
|
|
661
|
+
*/
|
|
662
|
+
export function has_stable_session_id(session_id: unknown): boolean {
|
|
663
|
+
return typeof session_id === "string" && session_id.trim() !== "";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Path of one session's state file inside `dir`, keyed on a digest of the FULL id.
|
|
668
|
+
*
|
|
669
|
+
* The digest also removes the filename-length failure mode for unusually long
|
|
670
|
+
* ids. Callers keep an in-file `session_id` as a cheap integrity check — it now
|
|
671
|
+
* guards against a stale or hand-copied file rather than against a collision.
|
|
672
|
+
*/
|
|
673
|
+
export function session_state_file(dir: string, session_id: string): string {
|
|
674
|
+
const digest = createHash("sha256").update(session_id, "utf8").digest("hex").slice(0, 32);
|
|
675
|
+
return path.join(dir, `${digest}.json`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Put a claimed state file back at its live path, without ever overwriting.
|
|
680
|
+
*
|
|
681
|
+
* THE CRITICAL SECTION of the pruner. Council round 3, both seats independently:
|
|
682
|
+
* the previous `existsSync(full)` then `renameSync(tomb, full)` was a TOCTOU
|
|
683
|
+
* window — the owner could create `full` between the two calls, and POSIX
|
|
684
|
+
* `rename` then replaced that fresh file with the pruner's older copy. Losing a
|
|
685
|
+
* live pin to the component whose whole job is to protect it.
|
|
686
|
+
*
|
|
687
|
+
* `link` closes it because it is atomic and refuses to clobber: it either
|
|
688
|
+
* creates the live name (nothing was there — restoring is right) or fails
|
|
689
|
+
* `EEXIST` (the owner won the race — its file is newer than ours by
|
|
690
|
+
* construction, and dropping our copy is right). There is no third outcome and
|
|
691
|
+
* no window between deciding and acting, which is the difference from the code
|
|
692
|
+
* this replaces.
|
|
693
|
+
*
|
|
694
|
+
* `rm` afterwards removes only the tombstone NAME; on the success path the
|
|
695
|
+
* content stays reachable through the live path it is now also linked to.
|
|
696
|
+
*
|
|
697
|
+
* Exported for its own tests. What those tests can and cannot establish is
|
|
698
|
+
* stated at them: the difference from the code this replaces is visible ONLY
|
|
699
|
+
* under real concurrency, so they pin the post-condition (a restore never
|
|
700
|
+
* changes an existing live file) rather than claiming to stage the race.
|
|
701
|
+
*
|
|
702
|
+
* FILESYSTEMS WITHOUT HARD LINKS fall back to the racy sequence, deliberately.
|
|
703
|
+
* The peer-review round asked for this before accepting `link` as the fix, and
|
|
704
|
+
* it was the one condition of the three not already met: `linkSync` is the
|
|
705
|
+
* tree's first, `engines` only pins Node >= 20.11, and `installation.md` names
|
|
706
|
+
* Windows — so a share, a FUSE mount, or a restricted container can answer
|
|
707
|
+
* `EPERM` / `ENOSYS` / `EXDEV` / `EOPNOTSUPP` / `EMLINK`. Throwing there would
|
|
708
|
+
* be caught by the pruner's outer handler and leave the tombstone behind, which
|
|
709
|
+
* loses a FRESH pin outright. The fallback narrows the window to the same one
|
|
710
|
+
* this fix removes elsewhere — strictly better than a guaranteed loss, and the
|
|
711
|
+
* only honest option where the atomic primitive is unavailable.
|
|
712
|
+
*/
|
|
713
|
+
export function restore_claimed_state(
|
|
714
|
+
tomb: string,
|
|
715
|
+
live: string,
|
|
716
|
+
/** Injectable so the no-hard-link fallback is reachable without one. */
|
|
717
|
+
link: (from: string, to: string) => void = fs.linkSync,
|
|
718
|
+
): void {
|
|
719
|
+
try {
|
|
720
|
+
link(tomb, live);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
723
|
+
// EEXIST → the owner wrote a newer file; ENOENT → a peer pruner already
|
|
724
|
+
// resolved this tombstone. Both mean the live path is already correct.
|
|
725
|
+
if (code !== "EEXIST" && code !== "ENOENT") {
|
|
726
|
+
if (!NO_HARD_LINK_CODES.has(code ?? "")) throw err;
|
|
727
|
+
// Degraded path: check-then-act, the race this fix closes where `link`
|
|
728
|
+
// works. Kept narrow — nothing else is available here.
|
|
729
|
+
if (!fs.existsSync(live)) {
|
|
730
|
+
fs.renameSync(tomb, live);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
fs.rmSync(tomb, { force: true });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Errors that mean "this filesystem cannot hard-link", not "the link failed". */
|
|
739
|
+
const NO_HARD_LINK_CODES = new Set(["EPERM", "ENOSYS", "EXDEV", "EOPNOTSUPP", "EMLINK"]);
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Drop session states in `dir` untouched for longer than `retention_days`.
|
|
743
|
+
*
|
|
744
|
+
* One file per session is unbounded growth otherwise. Call this on a
|
|
745
|
+
* once-per-turn path (a prompt or session event), never per tool call.
|
|
746
|
+
*
|
|
747
|
+
* `mtime_of` is injectable for the same reason the language hook injects it:
|
|
748
|
+
* the claim-then-revalidate branch below only runs when the candidate check and
|
|
749
|
+
* the post-claim check DISAGREE, and that disagreement is a race no
|
|
750
|
+
* cutoff-only test can stage. A test that cannot enter the branch would pass
|
|
751
|
+
* against an implementation that does not have it.
|
|
752
|
+
*/
|
|
753
|
+
export function prune_stale_session_states(
|
|
754
|
+
dir: string,
|
|
755
|
+
now_ms: number,
|
|
756
|
+
retention_days: number,
|
|
757
|
+
mtime_of: (target: string) => number = (target) => fs.statSync(target).mtimeMs,
|
|
758
|
+
): number {
|
|
759
|
+
if (is_replay_mode()) return 0;
|
|
760
|
+
const cutoff = now_ms - retention_days * 24 * 60 * 60 * 1000;
|
|
761
|
+
let entries: string[];
|
|
762
|
+
try {
|
|
763
|
+
entries = fs.readdirSync(dir);
|
|
764
|
+
} catch {
|
|
765
|
+
return 0; // no directory yet
|
|
766
|
+
}
|
|
767
|
+
let removed = 0;
|
|
768
|
+
let claim = 0;
|
|
769
|
+
|
|
770
|
+
// Tombstones a crashed pruner left behind. Council round 3, both seats: a
|
|
771
|
+
// crash between the claim rename and the restore stranded the file under a
|
|
772
|
+
// name nothing reads (it no longer ends in `.json`) and nothing prunes — and
|
|
773
|
+
// when the candidate had been refreshed under the pruner, that tombstone held
|
|
774
|
+
// the CURRENT state while the live path was gone for good.
|
|
775
|
+
//
|
|
776
|
+
// No grace period, deliberately: `rename` preserves mtime, so a tombstone
|
|
777
|
+
// carries the age of its CONTENT, not of the claim. That is the property this
|
|
778
|
+
// needs. A tombstone another pruner is holding right now is judged on exactly
|
|
779
|
+
// the same evidence its holder will use, and both orders end at the same
|
|
780
|
+
// place — the loser's `link` hits EEXIST or its `rm` is a no-op.
|
|
781
|
+
//
|
|
782
|
+
// WHY THIS IS NOT THE STALE-BREAKER DEFECT that a sibling lock primitive hit
|
|
783
|
+
// in round 4 (two breakers each judge one fixed-name lock stale; the second
|
|
784
|
+
// deletes the first one FRESH lock and both believe they hold it). The
|
|
785
|
+
// question is worth answering here rather than leaving to a reviewer, because
|
|
786
|
+
// the shapes look alike: both delete an object another live process is using.
|
|
787
|
+
//
|
|
788
|
+
// Two properties separate them, and neither is incidental. The tombstone name
|
|
789
|
+
// carries `pid` and a per-run counter, so no peer can ever create the name
|
|
790
|
+
// this run holds — there is no second claimant to confuse, which is exactly
|
|
791
|
+
// what a fixed name provides. And the delete/restore decision reads the
|
|
792
|
+
// CONTENT mtime, which `rename` does not touch, so a peer resolving this
|
|
793
|
+
// tombstone reaches the same verdict its holder would; the holder then finds
|
|
794
|
+
// ENOENT and skips. The lock case has neither: one name, and a decision made
|
|
795
|
+
// on how long THIS caller waited rather than on a property of the object.
|
|
796
|
+
//
|
|
797
|
+
// What this does NOT claim: that a peer never touches a live tombstone. It
|
|
798
|
+
// does, and that is intended — the alternative (waiting out a holder that may
|
|
799
|
+
// have crashed) is the leak this pass exists to close.
|
|
800
|
+
for (const name of entries) {
|
|
801
|
+
const orphan = /^(.+\.json)\.\d+\.\d+\.tomb$/.exec(name);
|
|
802
|
+
const live_name = orphan?.[1];
|
|
803
|
+
if (live_name === undefined) continue;
|
|
804
|
+
const tomb = path.join(dir, name);
|
|
805
|
+
const live = path.join(dir, live_name);
|
|
806
|
+
try {
|
|
807
|
+
if (mtime_of(tomb) >= cutoff) {
|
|
808
|
+
restore_claimed_state(tomb, live);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
fs.rmSync(tomb, { force: true });
|
|
812
|
+
removed += 1;
|
|
813
|
+
} catch {
|
|
814
|
+
// Vanished under us, or a peer pruner got there first. Costs disk, never
|
|
815
|
+
// state.
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
for (const name of entries) {
|
|
820
|
+
if (!name.endsWith(".json")) continue;
|
|
821
|
+
const full = path.join(dir, name);
|
|
822
|
+
try {
|
|
823
|
+
if (mtime_of(full) >= cutoff) continue;
|
|
824
|
+
|
|
825
|
+
// `stat` then `rm` is a race that can delete a FRESH file: the pruner
|
|
826
|
+
// observes a stale mtime, the owning session resumes and atomically
|
|
827
|
+
// replaces that pathname, and the delete then removes the NEW state —
|
|
828
|
+
// losing the pin of an actively resumed session (language hook, council
|
|
829
|
+
// round 2, BLOCKER 3). Writing before pruning only stops the current
|
|
830
|
+
// invocation from eating its own file; it does nothing about a
|
|
831
|
+
// concurrent one.
|
|
832
|
+
//
|
|
833
|
+
// Claim-then-revalidate instead: rename the candidate OUT of the live
|
|
834
|
+
// path first, so any writer arriving afterwards simply creates the path
|
|
835
|
+
// anew and is unaffected. Then re-stat what was actually claimed — a
|
|
836
|
+
// writer that landed between the stat and the rename means the claimed
|
|
837
|
+
// file is fresh, and it goes back (or is dropped, if the writer has since
|
|
838
|
+
// written a newer file at the live path).
|
|
839
|
+
claim += 1;
|
|
840
|
+
const tomb = `${full}.${process.pid}.${claim}.tomb`;
|
|
841
|
+
fs.renameSync(full, tomb);
|
|
842
|
+
if (mtime_of(tomb) >= cutoff) {
|
|
843
|
+
restore_claimed_state(tomb, full);
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
fs.rmSync(tomb, { force: true });
|
|
847
|
+
// The per-file lock sentinel belongs to the state file, so it is pruned
|
|
848
|
+
// WITH it. Without this, `update_json_under_lock`'s file-keyed lock
|
|
849
|
+
// (see `_target_lock_path`) would leave one sentinel per retired session
|
|
850
|
+
// forever: neither name ends in `.json`, so the filter above skips both
|
|
851
|
+
// and nothing else walks this directory. Trading a serialised write path
|
|
852
|
+
// for an unbounded file count is not a fix.
|
|
853
|
+
//
|
|
854
|
+
// Safe to remove unconditionally at this point: the state file is gone,
|
|
855
|
+
// so no live writer can be holding a lock for it — and a writer that
|
|
856
|
+
// arrives afterwards creates both anew.
|
|
857
|
+
for (const suffix of [".lock", ".lock.held"]) {
|
|
858
|
+
try {
|
|
859
|
+
fs.rmSync(`${full}${suffix}`, { force: true });
|
|
860
|
+
} catch {
|
|
861
|
+
/* never a reason to fail a prune */
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
removed += 1;
|
|
865
|
+
} catch {
|
|
866
|
+
// A file that vanished under us, an unreadable stat, or a rename another
|
|
867
|
+
// pruner won — skip it. A failed prune costs disk, never a turn, and
|
|
868
|
+
// never another session's state.
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return removed;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Remove a pre-split single state file once the per-session layout owns the tree.
|
|
876
|
+
*
|
|
877
|
+
* NOT a migration: a new session has no state to inherit anyway, so there is
|
|
878
|
+
* nothing in the old file worth carrying across — and carrying it across would
|
|
879
|
+
* reintroduce exactly the cross-session read the split closed, since that file
|
|
880
|
+
* is the shared one. Leaving it in place leaves dead state that looks live to
|
|
881
|
+
* anyone reading the directory.
|
|
882
|
+
*
|
|
883
|
+
* MIXED-VERSION EXPOSURE, measured rather than assumed. A cross-model review
|
|
884
|
+
* (2026-08-20) flagged this as destroying the live state of an older bundle that
|
|
885
|
+
* is still writing the single file — and the callers' own comments claimed
|
|
886
|
+
* exactly that scenario, which is what made the finding land. Both were written
|
|
887
|
+
* without checking, so here is the check: the shipped hook command resolves the
|
|
888
|
+
* dispatcher as `$CLAUDE_PROJECT_DIR/node_modules/@event4u/agent-config/dist/hooks/dispatch.js`,
|
|
889
|
+
* falling back to `$CLAUDE_PROJECT_DIR/dist/hooks/dispatch.js`. `CLAUDE_PROJECT_DIR`
|
|
890
|
+
* is the PARENT checkout even in a worktree session, so every concurrent session
|
|
891
|
+
* under one project — worktrees included — executes ONE bundle and writes ONE
|
|
892
|
+
* state tree. There is no steady state in which two versions run side by side,
|
|
893
|
+
* which is why the deletion is safe in the case the review had in mind.
|
|
894
|
+
*
|
|
895
|
+
* What DOES remain is a narrow window, and it is real: a hook process that
|
|
896
|
+
* already loaded the previous bundle and is still running when the file is
|
|
897
|
+
* replaced. It writes by the old layout, a concurrent new-bundle session deletes
|
|
898
|
+
* that file, and one turn's evidence is lost. Bounded to a turn, self-healing on
|
|
899
|
+
* the next write, and confined to the moment of a rebuild or `npm install`. It is
|
|
900
|
+
* NOT bounded by anything in this function — that is a property of the deployment
|
|
901
|
+
* shape, so if the shape changes (a per-worktree `node_modules`, a long-lived
|
|
902
|
+
* hook daemon) this deletion needs a version marker or a grace period.
|
|
903
|
+
*/
|
|
904
|
+
export function prune_legacy_state_file(target: string): void {
|
|
905
|
+
if (is_replay_mode()) return;
|
|
906
|
+
try {
|
|
907
|
+
if (fs.statSync(target).isFile()) {
|
|
908
|
+
fs.rmSync(target, { force: true });
|
|
909
|
+
}
|
|
910
|
+
} catch {
|
|
911
|
+
// Absent or unreadable — nothing to prune.
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Does a loaded session-state object belong to `session_id`?
|
|
917
|
+
*
|
|
918
|
+
* THE DIGEST PATH IS NOT THE WHOLE GUARANTEE, which is the correction this
|
|
919
|
+
* function exists to carry. `session_state_file` says callers "keep an in-file
|
|
920
|
+
* `session_id` as a cheap integrity check" — and a cross-model review
|
|
921
|
+
* (2026-08-20, both seats, blocking) found the two `turn-end-gate` readers
|
|
922
|
+
* consuming state without ever making that check, so the sentence described a
|
|
923
|
+
* discipline no consumer followed. A file that arrives at the wrong digest path
|
|
924
|
+
* by a copy, a restore, a hand-edit, or a buggy writer was accepted whole; the
|
|
925
|
+
* sharp case is a foreign `ci_last: {settled: true}`, which vouches for a CI run
|
|
926
|
+
* this session never made and lets a premature completion claim through a
|
|
927
|
+
* BLOCKING gate.
|
|
928
|
+
*
|
|
929
|
+
* EXACT match, and an absent owner is foreign. There is no compatibility window
|
|
930
|
+
* to honour: the digest layout has never shipped for either concern, so no
|
|
931
|
+
* deployed version ever wrote an ownerless hashed file — a permissive branch
|
|
932
|
+
* would be dead code that weakens the guarantee its own doc comment states.
|
|
933
|
+
* (The language hook's `_ownsPin` learned this in its round 3 and now delegates
|
|
934
|
+
* here.)
|
|
935
|
+
*/
|
|
936
|
+
export function owns_session_state(state: unknown, session_id: string): boolean {
|
|
937
|
+
if (typeof state !== "object" || state === null || Array.isArray(state)) return false;
|
|
938
|
+
return (state as Record<string, unknown>)["session_id"] === session_id;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Read a JSON state file, mutate it, and write it back — the WHOLE sequence
|
|
943
|
+
* under one lock.
|
|
944
|
+
*
|
|
945
|
+
* WHY THIS EXISTS. `atomic_write_json` makes the *publish* atomic; it does not
|
|
946
|
+
* make load → update → publish atomic. A cross-model review (2026-08-20, both
|
|
947
|
+
* seats) named the consequence, and it is not hypothetical on a host that runs
|
|
948
|
+
* tool calls in parallel: two `post_tool_use` invocations for the SAME session
|
|
949
|
+
* both load counter 149, both compute 150, both publish 150, and one event is
|
|
950
|
+
* gone. Per-session filenames fix cross-session reads and do nothing for this —
|
|
951
|
+
* the two failures are independent, and fixing the first made the second easier
|
|
952
|
+
* to mistake for solved.
|
|
953
|
+
*
|
|
954
|
+
* The worse shape is a lost FIELD rather than a lost increment: a snapshot
|
|
955
|
+
* loaded at the start of a hook run, spread into a write at the end
|
|
956
|
+
* (`{...previous, one_field: x}`), silently republishes every OTHER field as it
|
|
957
|
+
* looked at load time — so a newer value written in between is reverted, not
|
|
958
|
+
* merely missed.
|
|
959
|
+
*
|
|
960
|
+
* `mutate` receives the state as loaded INSIDE the lock and returns what to
|
|
961
|
+
* write. Returning `null` writes nothing, so a caller that decides against the
|
|
962
|
+
* write after seeing fresh state does not have to publish a no-op.
|
|
963
|
+
*
|
|
964
|
+
* ## Three states, not two
|
|
965
|
+
*
|
|
966
|
+
* This returned a BOOLEAN and reported `true` for two different outcomes: the
|
|
967
|
+
* write landed, and the mutator deliberately declined. That collapse is exactly
|
|
968
|
+
* what a fail-closed caller must not have. The reset-then-emit pattern is the
|
|
969
|
+
* worked case: a reset that did not land leaves a counter at its threshold, so
|
|
970
|
+
* emitting anyway re-fires on every subsequent call — which is why such callers
|
|
971
|
+
* stay silent unless they know the write landed. Under the boolean they could
|
|
972
|
+
* not distinguish "declined, nothing needed to land" from "landed", and the
|
|
973
|
+
* only way to express a decline was a flag captured in the mutator's closure —
|
|
974
|
+
* state smuggled out of a callback, invisible in the signature, and unenforced.
|
|
975
|
+
*
|
|
976
|
+
* - `written` — the mutator returned a value and it is on disk.
|
|
977
|
+
* - `skipped` — the mutator returned `null`. Nothing was written and nothing
|
|
978
|
+
* went wrong. A caller whose emit depends on a write having landed treats
|
|
979
|
+
* this as "do not emit"; a caller that only needed the state examined
|
|
980
|
+
* treats it as success. Both readings are now expressible.
|
|
981
|
+
* - `failed` — replay mode, or the directory, the lock, the read or the
|
|
982
|
+
* publish went wrong. Never distinguishable from `skipped` before.
|
|
983
|
+
*
|
|
984
|
+
* `failed` is deliberately NOT split further (no `replay` state, no `busy`
|
|
985
|
+
* state): every caller that has one treats replay, contention and a genuine
|
|
986
|
+
* write failure identically — stay silent — and a state nobody branches on is a
|
|
987
|
+
* state that goes stale. Under `AGENT_CONFIG_REPLAY=1` this reports `failed`
|
|
988
|
+
* without touching the tree, the same no-op contract as the writers above.
|
|
989
|
+
*
|
|
990
|
+
* ## `blocking: false` — for hot paths that must never wait
|
|
991
|
+
*
|
|
992
|
+
* Default `true`, i.e. the pre-existing behaviour. Pass `false` from a caller
|
|
993
|
+
* that runs on every tool call: it declines on the first contended attempt with
|
|
994
|
+
* no spin, returning `failed`. The trade is explicit — a wait becomes a skip,
|
|
995
|
+
* and for a counter that is a missed increment rather than a late one. Take it
|
|
996
|
+
* only where the missed increment is recoverable by construction (a re-emit
|
|
997
|
+
* distance counter is; a verification witness may not be).
|
|
998
|
+
*
|
|
999
|
+
* **Migration hazard, stated because the compiler does not catch it.** The old
|
|
1000
|
+
* shape was tested with `if (!result)`. Every value of the new union is a
|
|
1001
|
+
* non-empty string and therefore truthy, so a missed call site does not fail to
|
|
1002
|
+
* compile — it silently stops detecting failure. There were two production call
|
|
1003
|
+
* sites at the time of the change and both were converted; a new caller must
|
|
1004
|
+
* compare against a member of the union, never coerce to a boolean.
|
|
1005
|
+
*/
|
|
1006
|
+
export function update_json_under_lock<T>(
|
|
1007
|
+
target: string,
|
|
1008
|
+
mutate: (loaded: Partial<T>) => T | null,
|
|
1009
|
+
options: { indent?: number; blocking?: boolean } = {},
|
|
1010
|
+
): LockedUpdateResult {
|
|
1011
|
+
if (is_replay_mode()) return "failed";
|
|
1012
|
+
const targetPath = path.resolve(target);
|
|
1013
|
+
const state_dir = path.dirname(targetPath);
|
|
1014
|
+
try {
|
|
1015
|
+
fs.mkdirSync(state_dir, { recursive: true });
|
|
1016
|
+
} catch {
|
|
1017
|
+
return "failed";
|
|
1018
|
+
}
|
|
1019
|
+
// FILE-keyed, not directory-keyed — see `_target_lock_path` for the measured
|
|
1020
|
+
// reason and for why `_atomic_write_text` deliberately keeps the shared one.
|
|
1021
|
+
const lock_path = _target_lock_path(targetPath);
|
|
1022
|
+
let fd: number | null;
|
|
1023
|
+
try {
|
|
1024
|
+
fd = _acquire_lock(lock_path, { blocking: options.blocking ?? true });
|
|
1025
|
+
} catch {
|
|
1026
|
+
return "failed";
|
|
1027
|
+
}
|
|
1028
|
+
// FAIL CLOSED, unlike `_atomic_write_text`. A read-modify-write without
|
|
1029
|
+
// exclusion is the lost-update this function exists to prevent, so a held
|
|
1030
|
+
// lock is reported rather than worked around. Reported as `failed` and not as
|
|
1031
|
+
// a fourth `busy` state on purpose: every fail-closed caller takes the same
|
|
1032
|
+
// action on both (stay silent), and a state nobody branches on goes stale.
|
|
1033
|
+
if (fd === null) return "failed";
|
|
1034
|
+
try {
|
|
1035
|
+
let loaded: Partial<T> = {};
|
|
1036
|
+
try {
|
|
1037
|
+
const raw = fs.readFileSync(targetPath, "utf-8");
|
|
1038
|
+
const decoded: unknown = JSON.parse(raw);
|
|
1039
|
+
if (typeof decoded === "object" && decoded !== null && !Array.isArray(decoded)) {
|
|
1040
|
+
loaded = decoded as Partial<T>;
|
|
1041
|
+
}
|
|
1042
|
+
} catch {
|
|
1043
|
+
// Absent, unreadable, or malformed — the mutator decides what an empty
|
|
1044
|
+
// state means. Never a reason to abandon the write.
|
|
1045
|
+
}
|
|
1046
|
+
const next = mutate(loaded);
|
|
1047
|
+
if (next === null) return "skipped"; // deliberate no-write, not a failure
|
|
1048
|
+
_publish_text_locked(targetPath, _py_json_dumps(next, options.indent ?? 2) + "\n");
|
|
1049
|
+
return "written";
|
|
1050
|
+
} catch {
|
|
1051
|
+
return "failed";
|
|
1052
|
+
} finally {
|
|
1053
|
+
_release_lock(fd, lock_path);
|
|
1054
|
+
}
|
|
293
1055
|
}
|