@latysh-development/monolith 0.0.0-stage → 0.1.0-alpha.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.
Files changed (291) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -2
  3. package/dist/brief/cli.d.ts +30 -0
  4. package/dist/brief/cli.d.ts.map +1 -0
  5. package/dist/brief/cli.js +85 -0
  6. package/dist/brief/collect.d.ts +17 -0
  7. package/dist/brief/collect.d.ts.map +1 -0
  8. package/dist/brief/collect.js +302 -0
  9. package/dist/brief/config.d.ts +40 -0
  10. package/dist/brief/config.d.ts.map +1 -0
  11. package/dist/brief/config.js +85 -0
  12. package/dist/brief/constants.d.ts +10 -0
  13. package/dist/brief/constants.d.ts.map +1 -0
  14. package/dist/brief/constants.js +9 -0
  15. package/dist/brief/docs.d.ts +53 -0
  16. package/dist/brief/docs.d.ts.map +1 -0
  17. package/dist/brief/docs.js +229 -0
  18. package/dist/brief/freshness.d.ts +104 -0
  19. package/dist/brief/freshness.d.ts.map +1 -0
  20. package/dist/brief/freshness.js +263 -0
  21. package/dist/brief/git-signals.d.ts +17 -0
  22. package/dist/brief/git-signals.d.ts.map +1 -0
  23. package/dist/brief/git-signals.js +101 -0
  24. package/dist/brief/index.d.ts +11 -0
  25. package/dist/brief/index.d.ts.map +1 -0
  26. package/dist/brief/index.js +5 -0
  27. package/dist/brief/reconcile.d.ts +75 -0
  28. package/dist/brief/reconcile.d.ts.map +1 -0
  29. package/dist/brief/reconcile.js +259 -0
  30. package/dist/brief/render.d.ts +60 -0
  31. package/dist/brief/render.d.ts.map +1 -0
  32. package/dist/brief/render.js +407 -0
  33. package/dist/brief/sanitize.d.ts +38 -0
  34. package/dist/brief/sanitize.d.ts.map +1 -0
  35. package/dist/brief/sanitize.js +48 -0
  36. package/dist/brief/selectors.d.ts +36 -0
  37. package/dist/brief/selectors.d.ts.map +1 -0
  38. package/dist/brief/selectors.js +75 -0
  39. package/dist/brief/story.d.ts +18 -0
  40. package/dist/brief/story.d.ts.map +1 -0
  41. package/dist/brief/story.js +59 -0
  42. package/dist/brief/types.d.ts +110 -0
  43. package/dist/brief/types.d.ts.map +1 -0
  44. package/dist/brief/types.js +11 -0
  45. package/dist/cli/index.d.ts +3 -0
  46. package/dist/cli/index.d.ts.map +1 -0
  47. package/dist/cli/index.js +166 -0
  48. package/dist/cli/init-args.d.ts +16 -0
  49. package/dist/cli/init-args.d.ts.map +1 -0
  50. package/dist/cli/init-args.js +24 -0
  51. package/dist/cli/ui-args.d.ts +23 -0
  52. package/dist/cli/ui-args.d.ts.map +1 -0
  53. package/dist/cli/ui-args.js +66 -0
  54. package/dist/cli/ui.d.ts +40 -0
  55. package/dist/cli/ui.d.ts.map +1 -0
  56. package/dist/cli/ui.js +180 -0
  57. package/dist/coordinatorium/checkpoint.d.ts +44 -0
  58. package/dist/coordinatorium/checkpoint.d.ts.map +1 -0
  59. package/dist/coordinatorium/checkpoint.js +124 -0
  60. package/dist/coordinatorium/cli.d.ts +39 -0
  61. package/dist/coordinatorium/cli.d.ts.map +1 -0
  62. package/dist/coordinatorium/cli.js +203 -0
  63. package/dist/coordinatorium/errors.d.ts +112 -0
  64. package/dist/coordinatorium/errors.d.ts.map +1 -0
  65. package/dist/coordinatorium/errors.js +145 -0
  66. package/dist/coordinatorium/index.d.ts +11 -0
  67. package/dist/coordinatorium/index.d.ts.map +1 -0
  68. package/dist/coordinatorium/index.js +5 -0
  69. package/dist/coordinatorium/lock.d.ts +187 -0
  70. package/dist/coordinatorium/lock.d.ts.map +1 -0
  71. package/dist/coordinatorium/lock.js +659 -0
  72. package/dist/coordinatorium/mission.d.ts +32 -0
  73. package/dist/coordinatorium/mission.d.ts.map +1 -0
  74. package/dist/coordinatorium/mission.js +87 -0
  75. package/dist/coordinatorium/state-keeper.d.ts +115 -0
  76. package/dist/coordinatorium/state-keeper.d.ts.map +1 -0
  77. package/dist/coordinatorium/state-keeper.js +396 -0
  78. package/dist/coordinatorium/types.d.ts +69 -0
  79. package/dist/coordinatorium/types.d.ts.map +1 -0
  80. package/dist/coordinatorium/types.js +27 -0
  81. package/dist/durable/assess.d.ts +105 -0
  82. package/dist/durable/assess.d.ts.map +1 -0
  83. package/dist/durable/assess.js +239 -0
  84. package/dist/durable/canonical.d.ts +25 -0
  85. package/dist/durable/canonical.d.ts.map +1 -0
  86. package/dist/durable/canonical.js +78 -0
  87. package/dist/durable/cli.d.ts +37 -0
  88. package/dist/durable/cli.d.ts.map +1 -0
  89. package/dist/durable/cli.js +141 -0
  90. package/dist/durable/errors.d.ts +188 -0
  91. package/dist/durable/errors.d.ts.map +1 -0
  92. package/dist/durable/errors.js +317 -0
  93. package/dist/durable/fs-store.d.ts +233 -0
  94. package/dist/durable/fs-store.d.ts.map +1 -0
  95. package/dist/durable/fs-store.js +1393 -0
  96. package/dist/durable/index.d.ts +20 -0
  97. package/dist/durable/index.d.ts.map +1 -0
  98. package/dist/durable/index.js +13 -0
  99. package/dist/durable/log.d.ts +33 -0
  100. package/dist/durable/log.d.ts.map +1 -0
  101. package/dist/durable/log.js +157 -0
  102. package/dist/durable/memory-store.d.ts +51 -0
  103. package/dist/durable/memory-store.d.ts.map +1 -0
  104. package/dist/durable/memory-store.js +302 -0
  105. package/dist/durable/projection.d.ts +35 -0
  106. package/dist/durable/projection.d.ts.map +1 -0
  107. package/dist/durable/projection.js +328 -0
  108. package/dist/durable/prune-check.d.ts +17 -0
  109. package/dist/durable/prune-check.d.ts.map +1 -0
  110. package/dist/durable/prune-check.js +45 -0
  111. package/dist/durable/register.d.ts +100 -0
  112. package/dist/durable/register.d.ts.map +1 -0
  113. package/dist/durable/register.js +532 -0
  114. package/dist/durable/retention.d.ts +49 -0
  115. package/dist/durable/retention.d.ts.map +1 -0
  116. package/dist/durable/retention.js +59 -0
  117. package/dist/durable/runtime.d.ts +497 -0
  118. package/dist/durable/runtime.d.ts.map +1 -0
  119. package/dist/durable/runtime.js +1271 -0
  120. package/dist/durable/seams.d.ts +40 -0
  121. package/dist/durable/seams.d.ts.map +1 -0
  122. package/dist/durable/seams.js +21 -0
  123. package/dist/durable/testing.d.ts +11 -0
  124. package/dist/durable/testing.d.ts.map +1 -0
  125. package/dist/durable/testing.js +25 -0
  126. package/dist/durable/types.d.ts +442 -0
  127. package/dist/durable/types.d.ts.map +1 -0
  128. package/dist/durable/types.js +75 -0
  129. package/dist/graph/builder.d.ts +11 -0
  130. package/dist/graph/builder.d.ts.map +1 -0
  131. package/dist/graph/builder.js +153 -0
  132. package/dist/graph/identity.d.ts +37 -0
  133. package/dist/graph/identity.d.ts.map +1 -0
  134. package/dist/graph/identity.js +46 -0
  135. package/dist/graph/index.d.ts +7 -0
  136. package/dist/graph/index.d.ts.map +1 -0
  137. package/dist/graph/index.js +3 -0
  138. package/dist/graph/resolver.d.ts +41 -0
  139. package/dist/graph/resolver.d.ts.map +1 -0
  140. package/dist/graph/resolver.js +36 -0
  141. package/dist/graph/types.d.ts +42 -0
  142. package/dist/graph/types.d.ts.map +1 -0
  143. package/dist/graph/types.js +1 -0
  144. package/dist/http/index.d.ts +5 -0
  145. package/dist/http/index.d.ts.map +1 -0
  146. package/dist/http/index.js +2 -0
  147. package/dist/http/server.d.ts +76 -0
  148. package/dist/http/server.d.ts.map +1 -0
  149. package/dist/http/server.js +195 -0
  150. package/dist/http/ui-assets.d.ts +22 -0
  151. package/dist/http/ui-assets.d.ts.map +1 -0
  152. package/dist/http/ui-assets.js +100 -0
  153. package/dist/index.d.ts +31 -0
  154. package/dist/index.d.ts.map +1 -0
  155. package/dist/index.js +26 -0
  156. package/dist/init/index.d.ts +12 -0
  157. package/dist/init/index.d.ts.map +1 -0
  158. package/dist/init/index.js +90 -0
  159. package/dist/init/prompts.d.ts +3 -0
  160. package/dist/init/prompts.d.ts.map +1 -0
  161. package/dist/init/prompts.js +47 -0
  162. package/dist/init/scaffold.d.ts +25 -0
  163. package/dist/init/scaffold.d.ts.map +1 -0
  164. package/dist/init/scaffold.js +204 -0
  165. package/dist/init/types.d.ts +14 -0
  166. package/dist/init/types.d.ts.map +1 -0
  167. package/dist/init/types.js +1 -0
  168. package/dist/readmodel/dashboard.d.ts +40 -0
  169. package/dist/readmodel/dashboard.d.ts.map +1 -0
  170. package/dist/readmodel/dashboard.js +236 -0
  171. package/dist/readmodel/index.d.ts +3 -0
  172. package/dist/readmodel/index.d.ts.map +1 -0
  173. package/dist/readmodel/index.js +1 -0
  174. package/dist/readmodel/types.d.ts +104 -0
  175. package/dist/readmodel/types.d.ts.map +1 -0
  176. package/dist/readmodel/types.js +15 -0
  177. package/dist/root/index.d.ts +179 -0
  178. package/dist/root/index.d.ts.map +1 -0
  179. package/dist/root/index.js +418 -0
  180. package/dist/root/safe-write.d.ts +268 -0
  181. package/dist/root/safe-write.d.ts.map +1 -0
  182. package/dist/root/safe-write.js +966 -0
  183. package/dist/root/write-artifacts.d.ts +20 -0
  184. package/dist/root/write-artifacts.d.ts.map +1 -0
  185. package/dist/root/write-artifacts.js +42 -0
  186. package/dist/scriptorium/compile.d.ts +4 -0
  187. package/dist/scriptorium/compile.d.ts.map +1 -0
  188. package/dist/scriptorium/compile.js +185 -0
  189. package/dist/scriptorium/frontmatter.d.ts +13 -0
  190. package/dist/scriptorium/frontmatter.d.ts.map +1 -0
  191. package/dist/scriptorium/frontmatter.js +90 -0
  192. package/dist/scriptorium/index.d.ts +3 -0
  193. package/dist/scriptorium/index.d.ts.map +1 -0
  194. package/dist/scriptorium/index.js +1 -0
  195. package/dist/scriptorium/markdown.d.ts +31 -0
  196. package/dist/scriptorium/markdown.d.ts.map +1 -0
  197. package/dist/scriptorium/markdown.js +88 -0
  198. package/dist/scriptorium/types.d.ts +78 -0
  199. package/dist/scriptorium/types.d.ts.map +1 -0
  200. package/dist/scriptorium/types.js +1 -0
  201. package/dist/types/enforcement.d.ts +11 -0
  202. package/dist/types/enforcement.d.ts.map +1 -0
  203. package/dist/types/enforcement.js +1 -0
  204. package/dist/types/integrity.d.ts +33 -0
  205. package/dist/types/integrity.d.ts.map +1 -0
  206. package/dist/types/integrity.js +1 -0
  207. package/dist/types/project-definition.d.ts +30 -0
  208. package/dist/types/project-definition.d.ts.map +1 -0
  209. package/dist/types/project-definition.js +1 -0
  210. package/dist/ui/assets/cosmic-glass-bg-Dwx47zPu.webp +0 -0
  211. package/dist/ui/assets/index-C4NhMaq7.js +28 -0
  212. package/dist/ui/assets/index-DMZ7ZvZY.css +1 -0
  213. package/dist/ui/index.html +13 -0
  214. package/dist/validate/index.d.ts +6 -0
  215. package/dist/validate/index.d.ts.map +1 -0
  216. package/dist/validate/index.js +214 -0
  217. package/dist/version.d.ts +2 -0
  218. package/dist/version.d.ts.map +1 -0
  219. package/dist/version.js +1 -0
  220. package/package.json +67 -4
  221. package/templates/.gitkeep +0 -0
  222. package/templates/default-instance/instruction/.cursorrules +53 -0
  223. package/templates/default-instance/instruction/.windsurfrules +53 -0
  224. package/templates/default-instance/instruction/CLAUDE.md +53 -0
  225. package/templates/default-instance/monolith/context/boot/HANDOFF.md +49 -0
  226. package/templates/default-instance/monolith/context/boot/WORK_SESSION.md +30 -0
  227. package/templates/default-instance/monolith/context/execution/current-state.md +30 -0
  228. package/templates/default-instance/monolith/context/maps/dependency-map.md +38 -0
  229. package/templates/default-instance/monolith/context/maps/project-map.md +85 -0
  230. package/templates/default-instance/monolith/context/maps/roadmap-index.md +41 -0
  231. package/templates/default-instance/monolith/context/maps/routing-map.md +147 -0
  232. package/templates/default-instance/monolith/context/maps/semantic-map.md +73 -0
  233. package/templates/default-instance/monolith/context/models/model-registry.md +37 -0
  234. package/templates/default-instance/monolith/context/models/privacy-classification.md +35 -0
  235. package/templates/default-instance/monolith/context/templates/plan-template.md +56 -0
  236. package/templates/default-instance/monolith/governance/README.md +29 -0
  237. package/templates/default-instance/monolith/governance/RULE_SCHEMA.md +58 -0
  238. package/templates/default-instance/monolith/learning/INDEX.md +48 -0
  239. package/templates/default-instance/monolith/learning/injection-map.md +49 -0
  240. package/templates/default-instance/monolith/learning/taxonomy.md +30 -0
  241. package/templates/default-instance/monolith/memory/errors/README.md +26 -0
  242. package/templates/default-instance/monolith/memory/errors/SCHEMA.md +47 -0
  243. package/templates/default-instance/monolith/memory/errors/TAXONOMY.md +27 -0
  244. package/templates/default-instance/monolith/memory/long-term/architecture-principles.md +31 -0
  245. package/templates/default-instance/monolith/memory/long-term/decisions.md +36 -0
  246. package/templates/default-instance/monolith/memory/long-term/execution-history.md +20 -0
  247. package/templates/default-instance/monolith/memory/long-term/tech-debt.md +25 -0
  248. package/templates/default-instance/monolith/memory/working/active.md +41 -0
  249. package/templates/default-instance/monolith/memory/working/governance-result.md +0 -0
  250. package/templates/default-instance/monolith/memory/working/integrity-result.md +0 -0
  251. package/templates/default-instance/monolith/memory/working/story-index.md +28 -0
  252. package/templates/default-instance/monolith/protocols/boot/boot-sequence.md +59 -0
  253. package/templates/default-instance/monolith/protocols/cognition/cognitive-modes.md +90 -0
  254. package/templates/default-instance/monolith/protocols/cognition/dry-run.md +56 -0
  255. package/templates/default-instance/monolith/protocols/cognition/execution-trace-format.md +52 -0
  256. package/templates/default-instance/monolith/protocols/cognition/semantic-planning.md +51 -0
  257. package/templates/default-instance/monolith/protocols/execution/hybrid-execution.md +37 -0
  258. package/templates/default-instance/monolith/protocols/execution/local-llm-governance.md +42 -0
  259. package/templates/default-instance/monolith/protocols/governance/command-policy.md +51 -0
  260. package/templates/default-instance/monolith/protocols/governance/core-invariants.md +40 -0
  261. package/templates/default-instance/monolith/protocols/governance/memory-governance.md +54 -0
  262. package/templates/default-instance/monolith/protocols/governance/metadata-schema.md +62 -0
  263. package/templates/default-instance/monolith/protocols/governance/skills.md +53 -0
  264. package/templates/default-instance/monolith/protocols/governance/work-session.md +46 -0
  265. package/templates/default-instance/monolith/skills/INDEX.md +28 -0
  266. package/templates/default-instance/monolith/skills/current-status/SKILL.md +47 -0
  267. package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +86 -0
  268. package/templates/default-instance/monolith/skills/global-status/SKILL.md +56 -0
  269. package/templates/default-instance/monolith/skills/self-improvement/SKILL.md +77 -0
  270. package/templates/default-instance/monolith/state.json +11 -0
  271. package/templates/default-instance/monolith/tests/INDEX.md +57 -0
  272. package/templates/default-instance/monolith/tests/scenarios/architecture-review.md +32 -0
  273. package/templates/default-instance/monolith/tests/scenarios/debug-failure.md +32 -0
  274. package/templates/default-instance/monolith/tests/scenarios/dry-run-activation.md +33 -0
  275. package/templates/default-instance/monolith/tests/scenarios/execution-trace-verification.md +31 -0
  276. package/templates/default-instance/monolith/tests/scenarios/finalize-story.md +35 -0
  277. package/templates/default-instance/monolith/tests/scenarios/implement-feature.md +36 -0
  278. package/templates/default-instance/monolith/tests/scenarios/local-llm-delegation.md +31 -0
  279. package/templates/default-instance/monolith/tests/scenarios/planning-request.md +32 -0
  280. package/templates/default-instance/monolith/tests/scenarios/sensitive-context-task.md +30 -0
  281. package/templates/default-instance/monolith/tests/scenarios/validator-verification.md +33 -0
  282. package/templates/default-instance/monolith/tests/validation/expected-behavior.md +70 -0
  283. package/templates/default-instance/monolith/tests/validators/INDEX.md +17 -0
  284. package/templates/default-instance/monolith/tests/validators/delegation-semantics.md +22 -0
  285. package/templates/default-instance/monolith/tests/validators/mode-consistency.md +25 -0
  286. package/templates/default-instance/monolith/tests/validators/privacy-routing.md +23 -0
  287. package/templates/default-instance/monolith/tests/validators/protocol-references.md +27 -0
  288. package/templates/default-instance/monolith/tests/validators/routing-integrity.md +30 -0
  289. package/templates/default-instance/projectroot/AGENTS.md +107 -0
  290. package/templates/default-instance/projectroot/monolith.config.json +32 -0
  291. package/templates/default-instance/projectroot/project.definition.mjs.template +41 -0
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Minimal cross-process file lock.
3
+ *
4
+ * Acquisition is a single `open(path, 'wx')` syscall — it either creates the
5
+ * file or fails with EEXIST, atomically, on every platform Node supports.
6
+ * That atomicity is what actually prevents two processes from both believing
7
+ * they own the lock at once; the lock file's body (a JSON owner record) exists only
8
+ * to support crash recovery.
9
+ *
10
+ * Three invariants govern every decision in this module:
11
+ *
12
+ * 1. Lock ownership is not time-based.
13
+ * The age of the lock file is never, by itself, a reason to take it.
14
+ * 2. Uncertainty never grants ownership.
15
+ * If we cannot reliably establish that the recorded owner has exited,
16
+ * the lock is treated as held — we wait, we do not recover it.
17
+ * 3. Timeout ends waiting, not ownership.
18
+ * `timeoutMs` bounds how long a *challenger* will wait to acquire the
19
+ * lock. It says nothing about how long the current owner may hold it.
20
+ *
21
+ * A fourth, narrower rule governs recovery specifically:
22
+ *
23
+ * 4. Recovering a dead owner's lock is itself serialized.
24
+ * "Read the lock, confirm its owner is dead, re-read it, and delete it
25
+ * if unchanged" is NOT one atomic operation — the re-read and the
26
+ * delete are two separate syscalls, and another process can complete
27
+ * an entire recovery (delete the stale lock, create its own) in the
28
+ * gap between them. A recovery mutex (`<lockPath>.recovery`, itself
29
+ * just another `open(..., 'wx')`-guarded file) makes "verify and then
30
+ * delete a specific stale lock" exclusive, so at most one process is
31
+ * ever in a position to delete a given dead owner's record.
32
+ *
33
+ * Known limitation: this is a local-filesystem lock built on POSIX rename/
34
+ * open semantics. It does not claim to provide distributed coordination,
35
+ * and its guarantees are not evaluated on network filesystems (NFS, SMB,
36
+ * etc.) where `open(..., 'wx')` atomicity is not always dependable.
37
+ *
38
+ * A fifth invariant governs *creating* a lock (primary or recovery mutex)
39
+ * specifically:
40
+ *
41
+ * 5. A lock is never published in a partially-written state.
42
+ * Metadata is written in full to a private staging file
43
+ * (`<lockPath>.creating.<pid>.<nonce>`, a name no other acquirer can
44
+ * ever guess or collide with; created `O_EXCL|O_NOFOLLOW` at mode `0600`
45
+ * through the shared safe-write primitive, SEC-001B.2) and only
46
+ * published — via `link()`,
47
+ * which is exactly as exclusive a create-if-absent primitive as
48
+ * `open(path, 'wx')` — once that write (and, if requested, an fsync)
49
+ * has fully succeeded. If anything fails first, the canonical path
50
+ * never existed because of this attempt; only this process's own
51
+ * uniquely-named staging file is ever touched by its own cleanup. See
52
+ * `createAndPublish` below and ADR-008 for the full argument.
53
+ */
54
+ import { writeSync, closeSync, lstatSync, unlinkSync, fchmodSync, fsyncSync, readFileSync, readlinkSync, } from 'fs';
55
+ import { basename, dirname } from 'path';
56
+ import { randomBytes } from 'crypto';
57
+ import { performance } from 'perf_hooks';
58
+ import { LockTimeoutError, LockUnsafeError, LockCreationError, ValidationError } from './errors.js';
59
+ import { SafeWriteError, createExclusive, disposeOwned, openParent, publishLink, readBoundedFileStable, boundaryPathOf, trustedDirectoryBoundary, } from '../root/index.js';
60
+ /** A well-formed record (see `LockRecord`) is under 200 bytes; 256 B is generous. */
61
+ export const MAX_LOCK_BYTES = 256;
62
+ const DEFAULT_OPTIONS = {
63
+ timeoutMs: 5000,
64
+ retryDelayMs: 25,
65
+ fsyncMetadata: false,
66
+ };
67
+ /**
68
+ * Merge caller options over the defaults. A missing or explicitly `undefined`
69
+ * value takes the default (a plain spread would let `undefined` overwrite it);
70
+ * a present value must be a finite, non-negative number. `NaN` would make the
71
+ * deadline `NaN` — every comparison against it is false, so the wait would
72
+ * never end — and `Infinity` would make it unbounded by construction.
73
+ */
74
+ function resolveOptions(options) {
75
+ const out = { ...DEFAULT_OPTIONS };
76
+ for (const key of ['timeoutMs', 'retryDelayMs']) {
77
+ const v = options[key];
78
+ if (v === undefined)
79
+ continue;
80
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
81
+ throw new ValidationError(`"${key}" must be a finite, non-negative number of milliseconds (got ${String(v)})`);
82
+ }
83
+ out[key] = v;
84
+ }
85
+ if (options.fsyncMetadata !== undefined)
86
+ out.fsyncMetadata = options.fsyncMetadata;
87
+ return out;
88
+ }
89
+ function sleep(ms) {
90
+ return new Promise((r) => setTimeout(r, ms));
91
+ }
92
+ /** Monotonic milliseconds. The deadline never reads the wall clock, so a clock
93
+ * step (NTP, manual change, VM resume) can neither extend nor cut the wait. */
94
+ const monotonicNow = () => performance.now();
95
+ const realProcOps = {
96
+ platform: process.platform,
97
+ get pid() {
98
+ return process.pid;
99
+ },
100
+ readText: (path) => readFileSync(path, 'utf8'),
101
+ readlink: (path) => readlinkSync(path),
102
+ kill: (pid, signal) => {
103
+ process.kill(pid, signal);
104
+ },
105
+ };
106
+ const NO_IDENTITY = { boot: null, pidns: null, start: null };
107
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
108
+ const PIDNS_RE = /^pid:\[[0-9]{1,20}\]$/;
109
+ const START_RE = /^[0-9]{1,20}$/;
110
+ /** State letter and start ticks from `/proc/<pid>/stat`. The command name is
111
+ * parenthesised and may itself contain spaces and parentheses, so parsing
112
+ * starts after the LAST `)`. */
113
+ function parseProcStat(text) {
114
+ const close = text.lastIndexOf(')');
115
+ if (close < 0)
116
+ return null;
117
+ const fields = text.slice(close + 2).trim().split(' ');
118
+ const state = fields[0];
119
+ const start = fields[19]; // field 22 overall; fields[0] is field 3
120
+ if (!state || state.length !== 1 || start === undefined || !START_RE.test(start))
121
+ return null;
122
+ return { state, start };
123
+ }
124
+ /** This process's identity, or all-`null` when it cannot be established
125
+ * (non-Linux, or `/proc` that does not belong to our PID namespace). */
126
+ export function captureIdentity(ops = realProcOps) {
127
+ if (ops.platform !== 'linux')
128
+ return NO_IDENTITY;
129
+ try {
130
+ // A /proc mounted from another PID namespace would describe other processes.
131
+ if (ops.readlink('/proc/self') !== String(ops.pid))
132
+ return NO_IDENTITY;
133
+ const boot = ops.readText('/proc/sys/kernel/random/boot_id').trim();
134
+ const pidns = ops.readlink('/proc/self/ns/pid');
135
+ const stat = parseProcStat(ops.readText('/proc/self/stat'));
136
+ return {
137
+ boot: UUID_RE.test(boot) ? boot : null,
138
+ pidns: PIDNS_RE.test(pidns) ? pidns : null,
139
+ start: stat ? stat.start : null,
140
+ };
141
+ }
142
+ catch {
143
+ return NO_IDENTITY;
144
+ }
145
+ }
146
+ let cachedIdentity = null;
147
+ function ownIdentity(ops) {
148
+ if (ops !== undefined && Object.keys(ops).length > 0)
149
+ return captureIdentity({ ...realProcOps, ...ops });
150
+ if (cachedIdentity === null || cachedIdentity.pid !== process.pid) {
151
+ cachedIdentity = { pid: process.pid, identity: captureIdentity() };
152
+ }
153
+ return cachedIdentity.identity;
154
+ }
155
+ const errno = (err) => err?.code;
156
+ /**
157
+ * Decide whether the owner named by `record` is alive, from verifiable
158
+ * identity rather than from `kill(pid, 0)` alone (H1, LOCK-FIX-001).
159
+ *
160
+ * A PID is only meaningful inside the PID namespace and boot that issued it, so
161
+ * `ESRCH` is NOT proof of death by itself: a process in another namespace
162
+ * sharing the filesystem would see a live owner's PID as "no such process".
163
+ * `dead` therefore requires the record's boot and PID namespace to equal ours,
164
+ * and either a different start time for that PID (it was reused) or two
165
+ * independent probes agreeing it does not exist (`/proc/<pid>` absent AND
166
+ * `ESRCH`). Everything else is `unknown`, which — like `alive` — forbids
167
+ * recovery. Rules are checked top to bottom; the first match decides. The age
168
+ * of the lock plays no part.
169
+ */
170
+ export function judgeOwner(record, ours, ops = realProcOps) {
171
+ if (ours.boot === null || ours.pidns === null)
172
+ return { verdict: 'unknown', reason: 'no-local-identity' };
173
+ if (record.boot === null || record.pidns === null)
174
+ return { verdict: 'unknown', reason: 'weak-record' };
175
+ if (record.boot !== ours.boot)
176
+ return { verdict: 'unknown', reason: 'boot-mismatch' };
177
+ if (record.pidns !== ours.pidns)
178
+ return { verdict: 'unknown', reason: 'pidns-mismatch' };
179
+ if (record.pid === ops.pid)
180
+ return { verdict: 'alive', reason: 'same-process' };
181
+ let killCode;
182
+ try {
183
+ ops.kill(record.pid, 0);
184
+ killCode = undefined;
185
+ }
186
+ catch (err) {
187
+ killCode = errno(err) ?? 'EUNKNOWN';
188
+ }
189
+ if (killCode === 'EPERM')
190
+ return { verdict: 'alive', reason: 'eperm' };
191
+ if (killCode !== undefined && killCode !== 'ESRCH')
192
+ return { verdict: 'unknown', reason: 'probe-error' };
193
+ let statText = null;
194
+ try {
195
+ statText = ops.readText(`/proc/${record.pid}/stat`);
196
+ }
197
+ catch (err) {
198
+ if (errno(err) !== 'ENOENT')
199
+ return { verdict: 'unknown', reason: 'probe-error' };
200
+ }
201
+ if (statText === null) {
202
+ // Absent from /proc: dead only if kill() agrees (ESRCH). If kill() says the
203
+ // process exists, the oracles disagree (e.g. hidepid) — not a death signal.
204
+ return killCode === 'ESRCH'
205
+ ? { verdict: 'dead', reason: 'absent-by-two-oracles' }
206
+ : { verdict: 'unknown', reason: 'oracle-disagreement' };
207
+ }
208
+ if (killCode === 'ESRCH')
209
+ return { verdict: 'unknown', reason: 'oracle-disagreement' };
210
+ const stat = parseProcStat(statText);
211
+ if (stat === null)
212
+ return { verdict: 'unknown', reason: 'probe-error' };
213
+ if (stat.state === 'Z')
214
+ return { verdict: 'unknown', reason: 'zombie' };
215
+ if (stat.state === 'X')
216
+ return { verdict: 'dead', reason: 'exited' };
217
+ if (record.start === null)
218
+ return { verdict: 'alive', reason: 'cannot-rule-out-reuse' };
219
+ return record.start !== stat.start
220
+ ? { verdict: 'dead', reason: 'pid-reused' }
221
+ : { verdict: 'alive', reason: 'start-match' };
222
+ }
223
+ const RECORD_KEYS = ['v', 'pid', 'nonce', 'boot', 'pidns', 'start'];
224
+ function makeOwnRecord(hooks = {}) {
225
+ return { pid: process.pid, nonce: randomBytes(8).toString('hex'), ...ownIdentity(hooks.procOps) };
226
+ }
227
+ /** The exact bytes written as a lock file's body. */
228
+ export function formatLockRecord(r) {
229
+ return JSON.stringify({ v: 2, pid: r.pid, nonce: r.nonce, boot: r.boot, pidns: r.pidns, start: r.start });
230
+ }
231
+ /** Content identity, not liveness. Used to detect whether a lock file has
232
+ * changed (recovered by someone else, or released) between two reads. */
233
+ function sameRecord(a, b) {
234
+ if (a === null || b === null)
235
+ return a === b;
236
+ return a.pid === b.pid && a.nonce === b.nonce && a.boot === b.boot && a.pidns === b.pidns && a.start === b.start;
237
+ }
238
+ /**
239
+ * Parse a lock file's raw content. Returns null for anything that is not
240
+ * unambiguously a well-formed record — including an empty file, a partially
241
+ * written one, garbage, or the unsupported `pid:nonce` format. A null result is
242
+ * NOT treated as "no owner"; callers must treat it as uncertain (invariant 2).
243
+ */
244
+ function parseLockRecord(raw) {
245
+ let o;
246
+ try {
247
+ o = JSON.parse(raw);
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ if (typeof o !== 'object' || o === null || Array.isArray(o))
253
+ return null;
254
+ const keys = Object.keys(o);
255
+ if (keys.length !== RECORD_KEYS.length || !RECORD_KEYS.every((k) => keys.includes(k)))
256
+ return null;
257
+ const r = o;
258
+ const optional = (v, re) => v === null ? null : typeof v === 'string' && re.test(v) ? v : undefined;
259
+ const boot = optional(r.boot, UUID_RE);
260
+ const pidns = optional(r.pidns, PIDNS_RE);
261
+ const start = optional(r.start, START_RE);
262
+ if (r.v !== 2 || !Number.isInteger(r.pid) || r.pid <= 0 || r.pid > 2 ** 31 - 1)
263
+ return null;
264
+ if (typeof r.nonce !== 'string' || !/^[0-9a-f]{16}$/.test(r.nonce))
265
+ return null;
266
+ if (boot === undefined || pidns === undefined || start === undefined)
267
+ return null;
268
+ const parsed = { pid: r.pid, nonce: r.nonce, boot, pidns, start };
269
+ // Canonical form only: `JSON.parse` silently accepts duplicate keys (last one
270
+ // wins), whitespace, reordered keys and a trailing newline. A record is
271
+ // exactly what `formatLockRecord` writes, byte for byte, or it is not a record.
272
+ if (formatLockRecord(parsed) !== raw)
273
+ return null;
274
+ return parsed;
275
+ }
276
+ function inspectLock(boundary, path) {
277
+ // Unified safe read (SEC-001B.1-R): the lock is read under the boundary its
278
+ // staging files are written under; a symlink or hardlinked lock is `structural`.
279
+ const r = readBoundedFileStable(path, 'lock file', { boundary, maxBytes: MAX_LOCK_BYTES, allowEmpty: true }, true);
280
+ if (r.ok) {
281
+ const record = 'content' in r ? parseLockRecord(r.content) : null;
282
+ if (record)
283
+ return { kind: 'valid', record };
284
+ return { kind: 'unsafe', class: 'content', fileCode: 'FILE_UNREADABLE', detail: 'lock content is empty, malformed or of an unrecognised format; automatic recovery is not performed' };
285
+ }
286
+ // Missing, or still being released/re-acquired after the retries: not
287
+ // observed. The exclusive create attempt that follows is the arbiter.
288
+ if (r.code === 'FILE_MISSING' || r.code === 'FILE_CHANGED')
289
+ return { kind: 'absent' };
290
+ return { kind: 'unsafe', class: 'structural', fileCode: r.code, detail: r.detail };
291
+ }
292
+ /** Valid record or null. `unsafe` is deliberately collapsed to null here for
293
+ * the two callers that must merely refuse to act on it (invariant 2). */
294
+ function readLockRecord(boundary, path) {
295
+ const i = inspectLock(boundary, path);
296
+ return i.kind === 'valid' ? i.record : null;
297
+ }
298
+ /** Write `buffer` to `fd` in full, looping on short writes (a single
299
+ * `writeSync` call is never guaranteed to write the whole buffer). A
300
+ * non-positive byte count from the writer is a hard error, not something
301
+ * to retry forever. `writer` defaults to the real `writeSync` and is
302
+ * overridable only for deterministic fault injection in tests. */
303
+ function writeAll(fd, buffer, writer = writeSync) {
304
+ let offset = 0;
305
+ while (offset < buffer.length) {
306
+ const bytesWritten = writer(fd, buffer, offset, buffer.length - offset);
307
+ if (!(bytesWritten > 0)) {
308
+ throw new Error(`write returned ${bytesWritten} bytes with ${buffer.length - offset} of ${buffer.length} ` +
309
+ 'bytes still unwritten');
310
+ }
311
+ offset += bytesWritten;
312
+ }
313
+ }
314
+ function stagingPath(lockPath, record) {
315
+ return `${lockPath}.creating.${record.pid}.${record.nonce}`;
316
+ }
317
+ /**
318
+ * Create `record`'s metadata as `lockPath`, safely.
319
+ *
320
+ * The metadata is written to a private staging file
321
+ * (`<lockPath>.creating.<pid>.<nonce>`) first, in full, and only
322
+ * *published* as `lockPath` — via `link()` — once that write (and, if
323
+ * `fsyncMetadata` is set, an fsync) has completely succeeded. `link()` fails
324
+ * with `EEXIST` if `lockPath` already exists, exactly like the
325
+ * `open(path, 'wx')` it replaces as the publish step, so it is exactly as
326
+ * exclusive a primitive — the canonical path still never has two
327
+ * simultaneous "creators" — but unlike writing straight into `lockPath`, a
328
+ * failure partway through can never leave the canonical path itself in an
329
+ * empty/partial state, because the canonical path was never touched by this
330
+ * attempt in the first place.
331
+ *
332
+ * `rename()` is deliberately not used for publication: an ordinary rename
333
+ * silently replaces an existing destination, which is exactly the
334
+ * exclusivity `open(path, 'wx')` is relied on for everywhere else in this
335
+ * module. Only `link()`'s EEXIST-on-existing-destination behavior
336
+ * preserves that.
337
+ *
338
+ * On any failure, only this process's own staging file is ever removed — and
339
+ * only while its recorded identity (`dev/ino`) still matches. `lockPath`
340
+ * itself is never touched by a failed creator, so a failed/paused creator can
341
+ * never delete a valid lock a different, concurrently-succeeding acquirer
342
+ * published in the meantime.
343
+ *
344
+ * If the publish step ends in a state that cannot be classified (`UNKNOWN`:
345
+ * the link may or may not have happened), the lock is confirmed ONLY by its
346
+ * ownership record — `lockPath` holding exactly this attempt's owner record.
347
+ * Otherwise the attempt fails with `publication: 'UNKNOWN'`; nothing is
348
+ * retried and the staging file is left in place.
349
+ */
350
+ async function createAndPublish(parent, lockPath, record, fsyncMetadata, testHooks) {
351
+ const stagingName = basename(stagingPath(lockPath, record));
352
+ const lockName = basename(lockPath);
353
+ const metadata = Buffer.from(formatLockRecord(record), 'utf8');
354
+ const base = { lockPath, pid: record.pid, nonce: record.nonce };
355
+ let owned;
356
+ try {
357
+ owned = createExclusive(parent, stagingName, 0o600, { ops: testHooks.fsOps });
358
+ }
359
+ catch (err) {
360
+ // The staging name is unique per (pid, nonce); reaching here means we
361
+ // could not even create our own private file. Normally nothing was
362
+ // created; `cleanedUp` is false only if a file exists whose identity we
363
+ // could not confirm.
364
+ const created = err instanceof SafeWriteError && err.tmp !== undefined;
365
+ throw new LockCreationError({
366
+ ...base,
367
+ phase: 'staging-open',
368
+ cleanedUp: !created,
369
+ cause: err instanceof SafeWriteError ? (err.cause ?? err) : err,
370
+ });
371
+ }
372
+ const { fd, token } = owned;
373
+ const fail = async (phase, cause) => {
374
+ await testHooks.beforeStagingCleanupOnFailure?.();
375
+ const res = disposeOwned(parent, token, { ops: testHooks.fsOps });
376
+ throw new LockCreationError({ ...base, phase, cleanedUp: res === 'removed' || res === 'absent', cause });
377
+ };
378
+ try {
379
+ fchmodSync(fd, 0o600); // independent of the umask
380
+ writeAll(fd, metadata, testHooks.writeMetadata);
381
+ }
382
+ catch (err) {
383
+ closeSync(fd);
384
+ return fail('metadata-write', err);
385
+ }
386
+ if (fsyncMetadata) {
387
+ try {
388
+ fsyncSync(fd);
389
+ }
390
+ catch (err) {
391
+ closeSync(fd);
392
+ return fail('metadata-fsync', err);
393
+ }
394
+ }
395
+ closeSync(fd);
396
+ let result;
397
+ try {
398
+ result = publishLink(parent, token, lockName, { ops: testHooks.fsOps });
399
+ }
400
+ catch (err) {
401
+ if (!(err instanceof SafeWriteError))
402
+ throw err;
403
+ if (err.publication === 'UNKNOWN') {
404
+ const seen = readLockRecord(boundaryPathOf(parent), lockPath);
405
+ if (seen !== null && sameRecord(seen, record)) {
406
+ // The record at the lock path is exactly ours: the link did happen.
407
+ disposeOwned(parent, token, { ops: testHooks.fsOps });
408
+ return 'published';
409
+ }
410
+ throw new LockCreationError({
411
+ ...base,
412
+ phase: 'publish-link',
413
+ cleanedUp: false,
414
+ publication: 'UNKNOWN',
415
+ cause: err.cause ?? err,
416
+ });
417
+ }
418
+ // NOT_PUBLISHED: the staging file was removed if the publish step failed
419
+ // (`err.tmp.removed`); an error raised before it (e.g. the parent changed)
420
+ // leaves it in place and is reported as `cleanedUp: false`.
421
+ throw new LockCreationError({
422
+ ...base,
423
+ phase: 'publish-link',
424
+ cleanedUp: err.tmp?.removed ?? false,
425
+ cause: err.cause ?? err,
426
+ });
427
+ }
428
+ // Lost race: only our own staging file is ever removed. Published:
429
+ // lockPath and staging are now two links to the same metadata; drop our
430
+ // temporary name, the canonical one remains. A failed removal is harmless.
431
+ disposeOwned(parent, token, { ops: testHooks.fsOps });
432
+ return result.outcome;
433
+ }
434
+ /** Delete `path` only if its current content is still, byte-for-byte, the
435
+ * record we believe we own. Never clears a lock that has changed or is
436
+ * already gone. This is used both for releasing a held primary lock and
437
+ * for releasing the internal recovery mutex. */
438
+ function releaseIfOwned(boundary, path, record) {
439
+ const current = readLockRecord(boundary, path);
440
+ if (sameRecord(current, record)) {
441
+ try {
442
+ unlinkSync(path);
443
+ }
444
+ catch {
445
+ // already gone — fine
446
+ }
447
+ }
448
+ }
449
+ function recoveryMutexPath(lockPath) {
450
+ return `${lockPath}.recovery`;
451
+ }
452
+ /** `dev:ino` of what is at `path`, `null` if nothing is, `undefined` if that
453
+ * cannot be told (an error other than ENOENT). */
454
+ function fileIdentity(path) {
455
+ try {
456
+ const st = lstatSync(path, { bigint: true });
457
+ return `${st.dev}:${st.ino}`;
458
+ }
459
+ catch (err) {
460
+ return errno(err) === 'ENOENT' ? null : undefined;
461
+ }
462
+ }
463
+ /**
464
+ * Attempt to recover a lock whose recorded owner was just confirmed dead.
465
+ *
466
+ * The read-confirm-delete sequence for a *specific* stale record is made
467
+ * exclusive by a recovery mutex (`<lockPath>.recovery`, itself just another
468
+ * `wx`-guarded file): a challenger must hold it for the entire re-verify
469
+ * step through the actual `unlink`. Because `open(path, 'wx')` is atomic,
470
+ * at most one challenger ever holds the recovery mutex for a given lock at
471
+ * a time — so at most one challenger is ever in a position to delete a
472
+ * given dead owner's record. A second challenger either fails to get the
473
+ * mutex (returns 'mutex-busy' — it does not block, it just backs off and
474
+ * lets the caller's normal wait/retry loop handle it) or, if it gets the
475
+ * mutex only after the first challenger has already finished, finds the
476
+ * lock file no longer matches what it originally observed and aborts
477
+ * without touching anything ('aborted'). Neither case ever deletes a lock
478
+ * that belongs to a new owner — see ADR-008 for the full argument.
479
+ *
480
+ * The recovery mutex itself is NEVER taken from another process based on
481
+ * age or liveness: if its holder crashes mid-recovery, the mutex simply
482
+ * stays held, and every future recovery attempt for this lock reports
483
+ * 'mutex-busy' until a human removes it by hand. This is a deliberate
484
+ * safety-over-availability trade-off (no recursive stale-mutex recovery)
485
+ * — it never corrupts state.json and never causes double ownership; it
486
+ * only means automatic crash recovery for *this* lock is unavailable
487
+ * until that's cleaned up.
488
+ */
489
+ async function recoverDeadOwner(parent, lockPath, observed, fsyncMetadata, hooks) {
490
+ await hooks.beforeRecoveryAttempt?.();
491
+ const mutexPath = recoveryMutexPath(lockPath);
492
+ const mutexRecord = makeOwnRecord(hooks);
493
+ // The recovery mutex is created through the same safe staging/publish
494
+ // primitive as the primary lock — its semantics (never recovered by
495
+ // liveness or age, see module doc) are unchanged; only how it is
496
+ // physically created is shared code now.
497
+ if ((await createAndPublish(parent, mutexPath, mutexRecord, fsyncMetadata, hooks)) === 'lost-race') {
498
+ return { outcome: 'mutex-busy' };
499
+ }
500
+ try {
501
+ // Re-verify from scratch now that we exclusively hold the recovery
502
+ // mutex — time has passed since the caller's own read, and this is
503
+ // the check that actually matters: nothing but the mutex holder can
504
+ // delete this specific stale record from here on.
505
+ const current = readLockRecord(boundaryPathOf(parent), lockPath);
506
+ if (current === null || !sameRecord(current, observed)) {
507
+ return { outcome: 'aborted', reason: 'lock changed or vanished while acquiring the recovery mutex' };
508
+ }
509
+ const judged = judgeOwner(current, ownIdentity(hooks.procOps), { ...realProcOps, ...hooks.procOps });
510
+ if (judged.verdict !== 'dead') {
511
+ return { outcome: 'aborted', reason: `owner no longer provably dead (${judged.verdict}: ${judged.reason})` };
512
+ }
513
+ const before = fileIdentity(lockPath);
514
+ await hooks.beforeStaleUnlink?.();
515
+ try {
516
+ (hooks.unlinkLock ?? unlinkSync)(lockPath);
517
+ }
518
+ catch (err) {
519
+ // Already gone is fine. Any other failure is NOT recovery: report it so
520
+ // the caller backs off within its deadline instead of pretending progress.
521
+ if (errno(err) !== 'ENOENT')
522
+ return { outcome: 'unlink-failed', cause: err };
523
+ }
524
+ // A successful syscall is not proof that the lock is gone (some filesystems
525
+ // and fault layers report success without effect). Recovery is declared only
526
+ // from observation: the path is absent, or it now holds a different file.
527
+ const after = fileIdentity(lockPath);
528
+ if (after === undefined) {
529
+ return { outcome: 'unlink-failed', cause: new Error('could not observe the lock path after unlink') };
530
+ }
531
+ if (after !== null && after === before) {
532
+ return { outcome: 'unlink-failed', cause: new Error('unlink reported success but the same lock file is still present') };
533
+ }
534
+ return { outcome: 'recovered' };
535
+ }
536
+ finally {
537
+ releaseIfOwned(boundaryPathOf(parent), mutexPath, mutexRecord);
538
+ }
539
+ }
540
+ /** Upper bound of the delay between attempts, as a multiple of `retryDelayMs`. */
541
+ const BACKOFF_CAP_FACTOR = 8;
542
+ /** Consecutive iterations that may skip the wait after a verified recovery or a
543
+ * changed observation. Beyond this a pause is mandatory, so a filesystem that
544
+ * keeps "recovering" the same lock cannot turn the loop into a busy spin. */
545
+ const MAX_PROGRESS_WITHOUT_SLEEP = 3;
546
+ function describeFailure(err) {
547
+ const code = errno(err);
548
+ const msg = err instanceof Error ? err.message : String(err);
549
+ return code !== undefined && !msg.includes(code) ? `${code}: ${msg}` : msg;
550
+ }
551
+ /**
552
+ * Acquire `lockPath` exclusively. Waits up to `timeoutMs` for the current
553
+ * owner (if any) to release it, attempting liveness-gated, mutex-serialized
554
+ * recovery only when the owner is provably dead (`judgeOwner`). Returns a
555
+ * release function. `testHooks` exists solely to let tests force specific
556
+ * interleavings deterministically — production callers must never pass it.
557
+ *
558
+ * One monotonic deadline governs the whole call and is checked at the top of
559
+ * EVERY iteration, and the only `continue` sits at the tail after all
560
+ * bookkeeping, so no branch (recovery progress, aborted recovery, a failing
561
+ * `unlink`) can skip the check or the wait. Waits are a bounded exponential backoff with jitter in `[d/2, d]`,
562
+ * never longer than the time remaining. After a recovery the lock path is
563
+ * observed again rather than assumed free; at most
564
+ * `MAX_PROGRESS_WITHOUT_SLEEP` iterations in a row may run without waiting.
565
+ *
566
+ * The deadline bounds the waiting loop and the number of retries. It does not
567
+ * bound a single synchronous filesystem call that hangs inside the kernel
568
+ * (e.g. a stuck mount): no timer can run while it blocks.
569
+ */
570
+ export async function acquireLock(lockPath, options = {}, testHooks = {}, parent = openParent(trustedDirectoryBoundary(dirname(lockPath)), dirname(lockPath))) {
571
+ // `parent` is the already-verified directory that holds the lock. State
572
+ // keeper passes the one it opened under the caller's write scope. When it
573
+ // is omitted (internal/test callers) the lock's directory itself is the
574
+ // trust boundary (trusted-directory semantics, ADR-012).
575
+ const opts = resolveOptions(options);
576
+ const now = testHooks.now ?? monotonicNow;
577
+ const random = testHooks.random ?? Math.random;
578
+ const wait = testHooks.sleep ?? sleep;
579
+ const started = now();
580
+ const deadline = started + opts.timeoutMs;
581
+ const own = makeOwnRecord(testHooks);
582
+ const ours = ownIdentity(testHooks.procOps);
583
+ const probeOps = { ...realProcOps, ...testHooks.procOps };
584
+ const baseDelay = Math.max(1, opts.retryDelayMs);
585
+ const maxDelay = baseDelay * BACKOFF_CAP_FACTOR;
586
+ let attempts = 0;
587
+ let delay = baseDelay;
588
+ let freeRuns = 0;
589
+ let lastKey = '';
590
+ let observation = 'lock is held';
591
+ let lastFailure;
592
+ let lastFailureText = '';
593
+ for (;;) {
594
+ if (attempts > 0 && now() >= deadline) {
595
+ const failure = lastFailureText ? `; last failure: ${lastFailureText}` : '';
596
+ throw new LockTimeoutError(`Timed out after ${opts.timeoutMs}ms (${attempts} attempts) waiting for lock at "${lockPath}" (${observation}${failure})`, { lastFailure });
597
+ }
598
+ attempts++;
599
+ if ((await createAndPublish(parent, lockPath, own, opts.fsyncMetadata, testHooks)) === 'published') {
600
+ return () => releaseIfOwned(boundaryPathOf(parent), lockPath, own);
601
+ }
602
+ // 'lost-race': the canonical lock already exists — observe it below.
603
+ const inspection = inspectLock(boundaryPathOf(parent), lockPath);
604
+ if (inspection.kind === 'unsafe' && inspection.class === 'structural') {
605
+ // A FIFO / oversize / unreadable lock can never become a valid one:
606
+ // refuse with a diagnostic instead of waiting out the timeout. The
607
+ // file is left exactly as found and the lock is not acquired.
608
+ throw new LockUnsafeError(lockPath, inspection.fileCode, inspection.detail);
609
+ }
610
+ // True only when this iteration verifiably changed the situation (a dead
611
+ // owner's lock was removed and observed gone); only then may the next
612
+ // iteration start without waiting.
613
+ let progressed = false;
614
+ // What we are waiting on, so the backoff restarts when it changes.
615
+ let key = inspection.kind;
616
+ if (inspection.kind === 'unsafe') {
617
+ observation = `${inspection.detail}; the file was left untouched`;
618
+ }
619
+ else if (inspection.kind === 'valid') {
620
+ key = `${inspection.record.pid}:${inspection.record.nonce}`;
621
+ const judged = judgeOwner(inspection.record, ours, probeOps);
622
+ observation = `owner pid ${inspection.record.pid} is ${judged.verdict} (${judged.reason})`;
623
+ if (judged.verdict === 'dead') {
624
+ const result = await recoverDeadOwner(parent, lockPath, inspection.record, opts.fsyncMetadata, testHooks);
625
+ if (result.outcome === 'recovered') {
626
+ progressed = true;
627
+ lastFailure = undefined;
628
+ lastFailureText = '';
629
+ }
630
+ else if (result.outcome === 'unlink-failed') {
631
+ lastFailure = result.cause;
632
+ lastFailureText = `could not remove the stale lock of dead owner pid ${inspection.record.pid} (${describeFailure(result.cause)})`;
633
+ }
634
+ else if (result.outcome === 'aborted') {
635
+ observation += `; recovery aborted: ${result.reason}`;
636
+ }
637
+ else {
638
+ observation += '; another process holds the recovery mutex';
639
+ }
640
+ }
641
+ // 'alive' or 'unknown': invariant 2 — do not recover.
642
+ }
643
+ // inspection absent: the file vanished — try creating it again after the
644
+ // same waiting/backoff discipline as a confirmed-busy lock.
645
+ if (key !== lastKey) {
646
+ delay = baseDelay;
647
+ lastKey = key;
648
+ }
649
+ if (progressed && freeRuns < MAX_PROGRESS_WITHOUT_SLEEP) {
650
+ freeRuns++;
651
+ continue; // the deadline check at the top still runs
652
+ }
653
+ freeRuns = 0;
654
+ const remaining = Math.max(0, deadline - now());
655
+ const jittered = delay / 2 + random() * (delay / 2);
656
+ await wait(Math.min(jittered, remaining));
657
+ delay = Math.min(delay * 2, maxDelay);
658
+ }
659
+ }