segreant 0.0.0-stage → 0.2.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 (349) hide show
  1. package/LICENSE +116 -0
  2. package/LICENSING.md +62 -0
  3. package/README.md +315 -2
  4. package/baselines/lift-baselines.json +22 -0
  5. package/bin/publication-lock.mjs +748 -0
  6. package/bin/runtime-snapshot.mjs +266 -0
  7. package/bin/segreant-statusline.mjs +20 -0
  8. package/bin/segreant.mjs +96 -0
  9. package/dist/alerts/detect.js +245 -0
  10. package/dist/alerts/notify.js +66 -0
  11. package/dist/alloc/apply.js +0 -0
  12. package/dist/alloc/epistemic.js +154 -0
  13. package/dist/alloc/exact.js +714 -0
  14. package/dist/alloc/rules.js +0 -0
  15. package/dist/billing/countermodels.js +123 -0
  16. package/dist/billing/epistemic.js +636 -0
  17. package/dist/billing/importer.js +75 -0
  18. package/dist/billing/mapping.js +239 -0
  19. package/dist/billing/openaiCosts.js +352 -0
  20. package/dist/billing/openaiCostsCoverage.js +108 -0
  21. package/dist/billing/readiness.js +68 -0
  22. package/dist/billing/reconcile.js +414 -0
  23. package/dist/billing/scope.js +68 -0
  24. package/dist/billing/types.js +180 -0
  25. package/dist/budget/allocate.js +100 -0
  26. package/dist/budget/capDecision.js +457 -0
  27. package/dist/budget/controlLock.js +71 -0
  28. package/dist/budget/enforceability.js +39 -0
  29. package/dist/budget/guard.js +313 -0
  30. package/dist/budget/onlineControl.js +404 -0
  31. package/dist/budget/recommend.js +153 -0
  32. package/dist/capital.js +220 -0
  33. package/dist/causal/assignment.js +179 -0
  34. package/dist/causal/design.js +161 -0
  35. package/dist/causal/epistemic.js +428 -0
  36. package/dist/causal/estimand.js +64 -0
  37. package/dist/causal/estimate.js +237 -0
  38. package/dist/causal/family.js +61 -0
  39. package/dist/causal/identity.js +18 -0
  40. package/dist/causal/inference-ledger.js +400 -0
  41. package/dist/causal/ledger.js +201 -0
  42. package/dist/causal/measurement.js +150 -0
  43. package/dist/causal/ope.js +297 -0
  44. package/dist/causal/precision.js +206 -0
  45. package/dist/causal/producer.js +774 -0
  46. package/dist/causal/protocol.js +962 -0
  47. package/dist/causal/qualification.js +260 -0
  48. package/dist/causal/records.js +416 -0
  49. package/dist/causal/registry.js +58 -0
  50. package/dist/causal/sequential.js +679 -0
  51. package/dist/causal/transport.js +138 -0
  52. package/dist/causal/types.js +10 -0
  53. package/dist/cli/allocCmd.js +340 -0
  54. package/dist/cli/askModelPrices.js +59 -0
  55. package/dist/cli/backupCmd.js +100 -0
  56. package/dist/cli/basisLines.js +45 -0
  57. package/dist/cli/billingCmd.js +1007 -0
  58. package/dist/cli/capitalCmd.js +65 -0
  59. package/dist/cli/causalCmd.js +340 -0
  60. package/dist/cli/connectCmd.js +466 -0
  61. package/dist/cli/controlCmd.js +341 -0
  62. package/dist/cli/diagnosticsCmd.js +41 -0
  63. package/dist/cli/economicCmd.js +384 -0
  64. package/dist/cli/egressCmd.js +248 -0
  65. package/dist/cli/evidenceCmd.js +157 -0
  66. package/dist/cli/featuresCmd.js +53 -0
  67. package/dist/cli/flags.js +128 -0
  68. package/dist/cli/homeCmd.js +117 -0
  69. package/dist/cli/importCmd.js +657 -0
  70. package/dist/cli/launchCmd.js +90 -0
  71. package/dist/cli/marketCmd.js +178 -0
  72. package/dist/cli/opsCmd.js +335 -0
  73. package/dist/cli/outcomeCmd.js +120 -0
  74. package/dist/cli/packCmd.js +172 -0
  75. package/dist/cli/planCmd.js +181 -0
  76. package/dist/cli/pluginCmd.js +145 -0
  77. package/dist/cli/pricingDeclareCmd.js +178 -0
  78. package/dist/cli/quotaCmd.js +190 -0
  79. package/dist/cli/retention.js +28 -0
  80. package/dist/cli/runCmd.js +591 -0
  81. package/dist/cli/showCmd.js +500 -0
  82. package/dist/cli/teamCmd.js +760 -0
  83. package/dist/cli/ui.js +128 -0
  84. package/dist/cli/valueCmd.js +1001 -0
  85. package/dist/cli.js +628 -0
  86. package/dist/config.js +677 -0
  87. package/dist/connect/antigravity.js +310 -0
  88. package/dist/connect/claudeCode.js +218 -0
  89. package/dist/connect/codex.js +320 -0
  90. package/dist/connect/connectors.js +285 -0
  91. package/dist/connect/importShared.js +283 -0
  92. package/dist/connect/opencode.js +214 -0
  93. package/dist/connect/protobuf.js +126 -0
  94. package/dist/connect/registry.js +46 -0
  95. package/dist/connect/repoRoot.js +112 -0
  96. package/dist/cost/basis.js +168 -0
  97. package/dist/cost/coverage.js +53 -0
  98. package/dist/cost/declarations.js +37 -0
  99. package/dist/cost/exactPricing.js +132 -0
  100. package/dist/cost/firstSight.js +98 -0
  101. package/dist/cost/overrides.js +230 -0
  102. package/dist/cost/pricing.js +997 -0
  103. package/dist/cost/reprice.js +130 -0
  104. package/dist/dashboard/claim-support.js +271 -0
  105. package/dist/dashboard/contracts.js +211 -0
  106. package/dist/dashboard/routes.js +2004 -0
  107. package/dist/dashboard/server.js +76 -0
  108. package/dist/dashboard/sessions.js +345 -0
  109. package/dist/dashboard/settings.js +109 -0
  110. package/dist/dashboard/shared-types.js +6 -0
  111. package/dist/dashboard/static.js +111 -0
  112. package/dist/dashboard/value-cache.js +105 -0
  113. package/dist/dashboard/web/app/components/claimInspector.js +138 -0
  114. package/dist/dashboard/web/app/components/drawer.js +284 -0
  115. package/dist/dashboard/web/app/components/ruleFields.js +144 -0
  116. package/dist/dashboard/web/app/components/spine.js +163 -0
  117. package/dist/dashboard/web/app/components/workflow.js +82 -0
  118. package/dist/dashboard/web/app/core/actions.js +1021 -0
  119. package/dist/dashboard/web/app/core/api.js +164 -0
  120. package/dist/dashboard/web/app/core/chain.js +40 -0
  121. package/dist/dashboard/web/app/core/claimLayers.js +392 -0
  122. package/dist/dashboard/web/app/core/claimTypes.js +222 -0
  123. package/dist/dashboard/web/app/core/dom.js +138 -0
  124. package/dist/dashboard/web/app/core/fmt.js +159 -0
  125. package/dist/dashboard/web/app/core/focus.js +10 -0
  126. package/dist/dashboard/web/app/core/generated-contract.js +211 -0
  127. package/dist/dashboard/web/app/core/generated-payload-contract.js +4579 -0
  128. package/dist/dashboard/web/app/core/generated-types.js +8 -0
  129. package/dist/dashboard/web/app/core/griffin.js +10 -0
  130. package/dist/dashboard/web/app/core/jobs.js +12 -0
  131. package/dist/dashboard/web/app/core/period.js +35 -0
  132. package/dist/dashboard/web/app/core/periodWords.js +10 -0
  133. package/dist/dashboard/web/app/core/periods.js +32 -0
  134. package/dist/dashboard/web/app/core/pricingActions.js +264 -0
  135. package/dist/dashboard/web/app/core/privacyText.js +78 -0
  136. package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
  137. package/dist/dashboard/web/app/core/registry.js +337 -0
  138. package/dist/dashboard/web/app/core/signal.js +140 -0
  139. package/dist/dashboard/web/app/core/slug.js +9 -0
  140. package/dist/dashboard/web/app/core/theme.js +48 -0
  141. package/dist/dashboard/web/app/core/view.js +69 -0
  142. package/dist/dashboard/web/app/main.js +362 -0
  143. package/dist/dashboard/web/app/views/allocation.js +278 -0
  144. package/dist/dashboard/web/app/views/control.js +249 -0
  145. package/dist/dashboard/web/app/views/data.js +136 -0
  146. package/dist/dashboard/web/app/views/evidence.js +553 -0
  147. package/dist/dashboard/web/app/views/privacy.js +13 -0
  148. package/dist/dashboard/web/app/views/spend.js +143 -0
  149. package/dist/dashboard/web/app/views/statement.js +189 -0
  150. package/dist/dashboard/web/app/views/system.js +80 -0
  151. package/dist/dashboard/web/app/views/value.js +534 -0
  152. package/dist/dashboard/web/app/views/workbench.js +312 -0
  153. package/dist/dashboard/web/classic.html +2151 -0
  154. package/dist/dashboard/web/index.html +51 -0
  155. package/dist/dashboard/web/styles/app.css +1336 -0
  156. package/dist/decision/assurance.js +425 -0
  157. package/dist/decision/control.js +336 -0
  158. package/dist/decision/countermodels.js +118 -0
  159. package/dist/decision/engine.js +382 -0
  160. package/dist/decision/epistemic.js +546 -0
  161. package/dist/demo/seed.js +741 -0
  162. package/dist/diagnostics.js +236 -0
  163. package/dist/economics/attribution.js +201 -0
  164. package/dist/economics/close.js +133 -0
  165. package/dist/economics/corrections.js +120 -0
  166. package/dist/economics/epistemic.js +164 -0
  167. package/dist/economics/events.js +294 -0
  168. package/dist/economics/fx.js +245 -0
  169. package/dist/economics/ledger.js +1516 -0
  170. package/dist/economics/money.js +177 -0
  171. package/dist/economics/rate.js +331 -0
  172. package/dist/economics/request.js +89 -0
  173. package/dist/economics/serialization.js +99 -0
  174. package/dist/egress/policy.js +192 -0
  175. package/dist/egress/proxyHealth.js +36 -0
  176. package/dist/egress/receipts.js +871 -0
  177. package/dist/egress/ruleValidation.js +127 -0
  178. package/dist/egress/transport.js +395 -0
  179. package/dist/epistemic/abstract.js +432 -0
  180. package/dist/epistemic/admissibility.js +213 -0
  181. package/dist/epistemic/assumption.js +134 -0
  182. package/dist/epistemic/claim-uses.js +202 -0
  183. package/dist/epistemic/claim.js +264 -0
  184. package/dist/epistemic/countermodel.js +278 -0
  185. package/dist/epistemic/dag.js +421 -0
  186. package/dist/epistemic/derivation.js +391 -0
  187. package/dist/epistemic/evidence.js +254 -0
  188. package/dist/epistemic/grain.js +82 -0
  189. package/dist/epistemic/issuance-map.js +260 -0
  190. package/dist/epistemic/ledger.js +1489 -0
  191. package/dist/epistemic/obligation.js +149 -0
  192. package/dist/epistemic/preservation.js +178 -0
  193. package/dist/epistemic/profile.js +61 -0
  194. package/dist/epistemic/revocation.js +65 -0
  195. package/dist/epistemic/scope.js +66 -0
  196. package/dist/epistemic/serialization.js +171 -0
  197. package/dist/epistemic/state.js +46 -0
  198. package/dist/epistemic/time.js +37 -0
  199. package/dist/epistemic/ux.js +104 -0
  200. package/dist/epistemic/witness.js +176 -0
  201. package/dist/export/allocationCsv.js +70 -0
  202. package/dist/export/billingCsv.js +36 -0
  203. package/dist/export/csv.js +52 -0
  204. package/dist/export/economic.js +105 -0
  205. package/dist/export/focusBilling.js +66 -0
  206. package/dist/export/statementCsv.js +104 -0
  207. package/dist/git/completeness.js +78 -0
  208. package/dist/git/contribution.js +598 -0
  209. package/dist/git/correlate.js +286 -0
  210. package/dist/git/quality.js +467 -0
  211. package/dist/git/repoScope.js +207 -0
  212. package/dist/git/survivalStore.js +186 -0
  213. package/dist/githubActionsEvidence.js +234 -0
  214. package/dist/guide.js +159 -0
  215. package/dist/judge/call.js +161 -0
  216. package/dist/judge/orchestrate.js +127 -0
  217. package/dist/judge/payload.js +47 -0
  218. package/dist/judge/tier.js +107 -0
  219. package/dist/judge/transcript.js +324 -0
  220. package/dist/market/consensus.js +181 -0
  221. package/dist/market/market.js +323 -0
  222. package/dist/market/refresh.js +119 -0
  223. package/dist/market/sources.js +236 -0
  224. package/dist/market/zip.js +98 -0
  225. package/dist/measurement/completeness.js +125 -0
  226. package/dist/measurement/model.js +113 -0
  227. package/dist/measurement/registry.js +166 -0
  228. package/dist/measurement/surrogate.js +325 -0
  229. package/dist/outcomes/contract.js +107 -0
  230. package/dist/outcomes/registry.js +259 -0
  231. package/dist/outcomes/work-unit.js +42 -0
  232. package/dist/pack/canonical.js +63 -0
  233. package/dist/pack/export.js +120 -0
  234. package/dist/pack/index.js +5 -0
  235. package/dist/pack/manifest.js +77 -0
  236. package/dist/pack/signature.js +69 -0
  237. package/dist/pack/types.js +455 -0
  238. package/dist/pack/verifier.js +275 -0
  239. package/dist/plans/detect.js +147 -0
  240. package/dist/plugins/contract.js +497 -0
  241. package/dist/plugins/host.js +333 -0
  242. package/dist/plugins/index.js +4 -0
  243. package/dist/plugins/intake.js +137 -0
  244. package/dist/plugins/invocation.js +158 -0
  245. package/dist/plugins/isolation.js +203 -0
  246. package/dist/proxy/server.js +891 -0
  247. package/dist/proxy/stream-proposals.js +249 -0
  248. package/dist/proxy/usage.js +213 -0
  249. package/dist/quota/limits.js +138 -0
  250. package/dist/quota/statusline.js +68 -0
  251. package/dist/quota/view.js +122 -0
  252. package/dist/research/complexity/calibration.js +110 -0
  253. package/dist/research/complexity/lab.js +134 -0
  254. package/dist/research/complexity/profile.js +78 -0
  255. package/dist/research/economics/budgetRisk.js +100 -0
  256. package/dist/research/economics/capital.js +101 -0
  257. package/dist/research/economics/decisionLedger.js +103 -0
  258. package/dist/research/economics/decomposition.js +106 -0
  259. package/dist/research/economics/execution.js +42 -0
  260. package/dist/research/economics/frontier.js +113 -0
  261. package/dist/research/economics/metricSafety.js +74 -0
  262. package/dist/research/economics/offPolicy.js +120 -0
  263. package/dist/research/economics/promotion.js +85 -0
  264. package/dist/scan/knownApps.js +113 -0
  265. package/dist/scan/scan.js +220 -0
  266. package/dist/store/allocation.js +543 -0
  267. package/dist/store/backup.js +368 -0
  268. package/dist/store/billing.js +919 -0
  269. package/dist/store/causal.js +2547 -0
  270. package/dist/store/causalLineage.js +912 -0
  271. package/dist/store/causalProducer.js +462 -0
  272. package/dist/store/db.js +2726 -0
  273. package/dist/store/economicReadModel.js +270 -0
  274. package/dist/store/ope.js +67 -0
  275. package/dist/store/realization.js +379 -0
  276. package/dist/store/rows.js +52 -0
  277. package/dist/store/schema.js +2218 -0
  278. package/dist/store/scopeEvidence.js +67 -0
  279. package/dist/team/ledger-evidence.js +82 -0
  280. package/dist/team/rollup.js +496 -0
  281. package/dist/team/standardize.js +115 -0
  282. package/dist/util/json.js +24 -0
  283. package/dist/util/pool.js +19 -0
  284. package/dist/util/quiet.js +18 -0
  285. package/dist/util/resource-limits.js +113 -0
  286. package/dist/util/statements.js +61 -0
  287. package/dist/util/time.js +7 -0
  288. package/dist/util/transaction.js +68 -0
  289. package/dist/value/anytime.js +106 -0
  290. package/dist/value/characterization.js +77 -0
  291. package/dist/value/cohort.js +212 -0
  292. package/dist/value/drift.js +211 -0
  293. package/dist/value/epistemic.js +448 -0
  294. package/dist/value/frontier.js +513 -0
  295. package/dist/value/gates.js +276 -0
  296. package/dist/value/instrumentationSensitivity.js +76 -0
  297. package/dist/value/lenses.js +398 -0
  298. package/dist/value/lift.js +206 -0
  299. package/dist/value/liftBaseline.js +355 -0
  300. package/dist/value/liftEfficiency.js +68 -0
  301. package/dist/value/marginal.js +154 -0
  302. package/dist/value/proposals.js +263 -0
  303. package/dist/value/realization.js +1159 -0
  304. package/dist/value/receipt.js +222 -0
  305. package/dist/value/receiptReconciliation.js +117 -0
  306. package/dist/value/reliability.js +101 -0
  307. package/dist/value/report.js +338 -0
  308. package/dist/value/selfReported.js +224 -0
  309. package/dist/value/sourceDepth.js +25 -0
  310. package/dist/value/taskType.js +46 -0
  311. package/dist/value/timeReclaimed.js +90 -0
  312. package/dist/value/usage.js +281 -0
  313. package/dist/version.js +20 -0
  314. package/docs/ALLOCATION.md +263 -0
  315. package/docs/ARCHITECTURE.md +452 -0
  316. package/docs/BILLING-EVIDENCE-IMPORT.md +274 -0
  317. package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +220 -0
  318. package/docs/CAUSAL-EVIDENCE-PROTOCOL.md +295 -0
  319. package/docs/CAUSAL-PRODUCER-CONTRACT.md +128 -0
  320. package/docs/CLI-JSON-CONTRACT.md +38 -0
  321. package/docs/COMPATIBILITY.md +64 -0
  322. package/docs/DATA-BOUNDARIES.md +300 -0
  323. package/docs/EVIDENCE-PROVENANCE.md +40 -0
  324. package/docs/FAQ.md +275 -0
  325. package/docs/FOCUS-COMPATIBILITY.md +15 -0
  326. package/docs/GETTING-STARTED.md +236 -0
  327. package/docs/GUIDE.md +830 -0
  328. package/docs/INTEGRATIONS.md +233 -0
  329. package/docs/MARKET-SOURCES.md +142 -0
  330. package/docs/METHODOLOGY.md +166 -0
  331. package/docs/NEUTRALITY.md +95 -0
  332. package/docs/PRIVACY.md +51 -0
  333. package/docs/PROVIDER-RECONCILIATION.md +260 -0
  334. package/docs/README.md +52 -0
  335. package/docs/REAL-PROVIDER-RECONCILIATION-RUNBOOK.md +44 -0
  336. package/docs/RELEASE-GATE.md +682 -0
  337. package/docs/RELEASE-PROCESS.md +68 -0
  338. package/docs/RELIABILITY-PERFORMANCE.md +149 -0
  339. package/docs/RETURN-ON-INTELLIGENCE.md +808 -0
  340. package/docs/SUPPORT.md +37 -0
  341. package/docs/THE-STANDARD.md +235 -0
  342. package/docs/THREAT-MODEL.md +114 -0
  343. package/market/consensus-fits.json +1 -0
  344. package/market/snapshot.json +1 -0
  345. package/package.json +79 -4
  346. package/pricing/models.json +1400 -0
  347. package/standalone/segreantpack-verifier.d.mts +61 -0
  348. package/standalone/segreantpack-verifier.mjs +715 -0
  349. package/web/assets/brand/mark-256.png +0 -0
@@ -0,0 +1,748 @@
1
+ // Shared cross-process publication gate for the build and the supported CLI
2
+ // launcher. This module intentionally has no side effects on import: the
3
+ // package launcher can use it before resolving any generated runtime module.
4
+ import { randomUUID } from 'node:crypto';
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ renameSync,
11
+ rmSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ // A full cross-platform test/build fan-out can legitimately queue several
18
+ // publication attempts behind slow filesystem/module-loader work. Keep the
19
+ // wait finite for a genuinely wedged live owner, but do not turn that queue into
20
+ // a false build failure at the old two-minute boundary.
21
+ export const LOCK_WAIT_MS = 300_000;
22
+ /**
23
+ * How long an owner-less runtime-snapshot tree is left before it is reaped.
24
+ *
25
+ * This constant is now the SNAPSHOT reaper's, not the lock's — see
26
+ * `bin/runtime-snapshot.mjs`. The two once shared it because they share a
27
+ * shape (owner-bearing objects judged by PID liveness, owner-less ones by age),
28
+ * but not a cost: nothing waits on a snapshot tree, so a generous grace there
29
+ * only leaves a directory on disk a while longer. Waiting is exactly what the
30
+ * lock does, which is why it needed its own, much shorter budget.
31
+ */
32
+ export const LOCK_STALE_MS = 10 * 60_000;
33
+ /**
34
+ * How long a lock carrying no owner token in any form is treated as live.
35
+ *
36
+ * Reaching that state means a creator died inside a one-syscall window, so the
37
+ * grace only has to exceed that. It must stay well under `LOCK_WAIT_MS`: at ten
38
+ * minutes it exceeded the wait, and a single such directory wedged every
39
+ * contender until each one timed out.
40
+ */
41
+ export const OWNERLESS_LOCK_STALE_MS = 10_000;
42
+ /**
43
+ * How long release keeps trying the atomic hand-back before removing the lock
44
+ * in place.
45
+ *
46
+ * It only has to outlast transient interference — an open handle, a competing
47
+ * reader mid-scan. It must stay far below `LOCK_WAIT_MS`, because everything
48
+ * queued behind this lock is waiting for exactly this budget to expire.
49
+ */
50
+ export const RELEASE_BUDGET_MS = 15_000;
51
+ export const LOCK_POLL_MS = 25;
52
+ export const RENAME_RETRY_MS = 5_000;
53
+ /**
54
+ * How long a canonical-path filesystem error is tolerated before it is reported
55
+ * as itself.
56
+ *
57
+ * Contention clears in milliseconds; a full disk or a read-only tree never does,
58
+ * and must be reported as what it is rather than as a wait timeout naming the
59
+ * wrong problem. The budget only has to exceed a plausible run of interference
60
+ * while staying far below `LOCK_WAIT_MS`, so it is generous on purpose.
61
+ */
62
+ export const PATH_CONTENTION_MS = 10_000;
63
+ export const OWNER_FILE = 'owner.json';
64
+ export const OWNER_QUARANTINE_FILE = '.owner-quarantine.json';
65
+ export const LOCK_QUARANTINE_PREFIX = '.segreant-build.lock.quarantine-';
66
+
67
+ const waitCell = new Int32Array(new SharedArrayBuffer(4));
68
+
69
+ function sleep(milliseconds) {
70
+ Atomics.wait(waitCell, 0, 0, milliseconds);
71
+ }
72
+
73
+ function removeQuarantine(path) {
74
+ try {
75
+ // Windows antivirus/indexer handles can briefly keep a just-renamed file
76
+ // open. Retry the generated quarantine cleanup so a successful publication
77
+ // does not leave a misleading lock-generation directory in the repository.
78
+ rmSync(path, { recursive: true, force: true, maxRetries: 20, retryDelay: LOCK_POLL_MS });
79
+ } catch {
80
+ // The canonical lock has already been moved away. Preserve an uncertain
81
+ // quarantine rather than risking a path-based delete of a new generation;
82
+ // the next acquisition can safely inspect/reclaim it by owner token.
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Shared with the runtime-snapshot reaper: both subsystems decide whether a
88
+ * recorded owner is gone, and the EPERM case below is exactly the distinction
89
+ * that must not be reimplemented differently in two places.
90
+ */
91
+ export function processIsAlive(pid) {
92
+ if (!Number.isInteger(pid) || pid <= 0) return false;
93
+ try {
94
+ process.kill(pid, 0);
95
+ return true;
96
+ } catch (error) {
97
+ // EPERM means the process exists but cannot be signalled. Any other error
98
+ // (most importantly ESRCH) means that the owner is gone.
99
+ return error?.code === 'EPERM';
100
+ }
101
+ }
102
+
103
+ function readOwnerFile(path) {
104
+ try {
105
+ const owner = JSON.parse(readFileSync(path, 'utf8'));
106
+ if (!owner || typeof owner !== 'object') return null;
107
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0) return null;
108
+ if (typeof owner.token !== 'string' || owner.token.length === 0) return null;
109
+ return { pid: owner.pid, token: owner.token };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The token-bearing temp names a creator may have left behind.
117
+ *
118
+ * Shared by `inspectLock`, which reads them as a recoverable identity, and by
119
+ * the release abandon path, which removes them. Those two must agree on what
120
+ * counts as a record: a name one of them recognises and the other does not is
121
+ * exactly how a lock becomes unrecoverable while looking clean.
122
+ */
123
+ function ownerTempNames(lockPath) {
124
+ try {
125
+ return readdirSync(lockPath, { withFileTypes: true })
126
+ .filter((entry) => entry.isFile() && /^\.owner-[0-9a-f-]+\.tmp$/i.test(entry.name))
127
+ .map((entry) => entry.name)
128
+ .sort();
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ function ownerQuarantineNames(lockPath) {
135
+ try {
136
+ return readdirSync(lockPath, { withFileTypes: true })
137
+ .filter((entry) => entry.isFile() && (
138
+ entry.name === OWNER_QUARANTINE_FILE || entry.name.startsWith('.owner-quarantine-')
139
+ ))
140
+ .map((entry) => entry.name);
141
+ } catch {
142
+ return [];
143
+ }
144
+ }
145
+
146
+ function inspectLock(buildLock) {
147
+ let lockStat;
148
+ try {
149
+ lockStat = statSync(buildLock);
150
+ } catch {
151
+ return null;
152
+ }
153
+
154
+ const ownerPath = join(buildLock, OWNER_FILE);
155
+ const owner = readOwnerFile(ownerPath);
156
+ if (owner) return { lockStat, owner, ownerPath };
157
+
158
+ // The creator writes a complete JSON owner record to a token-specific temp
159
+ // name before the same-directory rename to owner.json. If that creator is
160
+ // interrupted in the tiny interval between those operations, the lock is
161
+ // still recoverable: retain the token-bearing temp path as the identity and
162
+ // run the normal PID-aware quarantine flow. Treating it as an unknown,
163
+ // owner-less lock would impose the full stale age even when its process is
164
+ // already dead, which can wedge every queued build/reader for minutes.
165
+ for (const name of ownerTempNames(buildLock)) {
166
+ const temporaryOwner = readOwnerFile(join(buildLock, name));
167
+ if (temporaryOwner) return { lockStat, owner: temporaryOwner, ownerPath: join(buildLock, name) };
168
+ }
169
+
170
+ // A reclaimer may have atomically moved the owner record aside and then
171
+ // crashed. Keep that token-bearing record as the lock identity so a later
172
+ // reclaimer cannot mistake the partially recovered directory for a new one.
173
+ for (const name of ownerQuarantineNames(buildLock)) {
174
+ const quarantinedOwner = readOwnerFile(join(buildLock, name));
175
+ if (quarantinedOwner) {
176
+ return { lockStat, owner: quarantinedOwner, ownerPath: join(buildLock, name) };
177
+ }
178
+ }
179
+
180
+ return { lockStat, owner: null, ownerPath: null };
181
+ }
182
+
183
+ /**
184
+ * Tokens this process is holding RIGHT NOW, as opposed to has ever minted.
185
+ *
186
+ * `ownedByToken` proves a generation is ours. This proves we are still standing
187
+ * in it, and the difference is the whole of D-085: a record naming our PID
188
+ * under a token that is not in here was written by a call that has already
189
+ * returned, so it is an orphan we may reclaim; one under a token that IS in
190
+ * here is a lock we hold, and reclaiming it would hand the same lock to two
191
+ * holders at once.
192
+ */
193
+ const heldTokens = new Set();
194
+
195
+ /**
196
+ * Can whoever owns this generation still do anything with it?
197
+ *
198
+ * ONE QUESTION, ONE ANSWER, ASKED FROM TWO PLACES. The acquire path and the
199
+ * quarantine reaper both need it, and answering it as `processIsAlive` alone
200
+ * gets our own released generations wrong in both: a token this process has
201
+ * already handed back names nobody who will ever come back for it, however
202
+ * alive the PID is. Leaving the reaper on the older answer would repeat D-078
203
+ * exactly — a rule corrected in one place and left standing in the helper next
204
+ * door.
205
+ *
206
+ * For another process, liveness is all we can observe. For ourselves we know
207
+ * something stronger and cheaper: whether we are still standing in it.
208
+ */
209
+ function ownerCanStillAct(snapshot) {
210
+ if (!snapshot?.owner) return false;
211
+ if (snapshot.owner.pid === process.pid) return heldTokens.has(snapshot.owner.token);
212
+ return processIsAlive(snapshot.owner.pid);
213
+ }
214
+
215
+ function lockIsStale(buildLock, snapshot = inspectLock(buildLock)) {
216
+ if (!snapshot) return true;
217
+ if (snapshot.owner) return !ownerCanStillAct(snapshot);
218
+
219
+ // The timer governs ONE case: a directory carrying no token at all, in any
220
+ // form — no owner.json, no `.owner-<token>.tmp`, no quarantined record.
221
+ // `inspectLock` recovers a token from all three, so reaching here means the
222
+ // creator died between `mkdir` and its first write. That window is a single
223
+ // syscall wide, and `LOCK_STALE_MS` (ten minutes) was calibrated for a
224
+ // different question: it is longer than `LOCK_WAIT_MS`, so one such directory
225
+ // parked every contender until they each timed out. An owner-bearing lock is
226
+ // still judged by whether its process is alive, which is the case that
227
+ // legitimately lasts minutes.
228
+ return Date.now() - snapshot.lockStat.mtimeMs > OWNERLESS_LOCK_STALE_MS;
229
+ }
230
+
231
+ /**
232
+ * Is this lock one we created and failed to publish?
233
+ *
234
+ * The token is the proof. A directory carrying OUR token is ours whatever state
235
+ * it is in, and reclaiming it is sound; a directory carrying no token, or
236
+ * someone else's, is not ours to touch. The old cleanup acted on the ABSENCE of
237
+ * a token, which is the one thing that proves nothing.
238
+ */
239
+ function ownedByToken(snapshot, token) {
240
+ return snapshot?.owner?.token === token;
241
+ }
242
+
243
+ /**
244
+ * Is this lock owned by the process asking?
245
+ *
246
+ * PID rather than token, deliberately. `lockIsStale` judges an owner by whether
247
+ * its process is alive, which for our own PID is permanently true — so a lock
248
+ * naming us is the one lock in the world this process must never wait for,
249
+ * whatever generation it belongs to.
250
+ *
251
+ * A record naming our PID was written either by this process or by a dead one
252
+ * whose PID we inherited, and reclaiming is correct under both readings: in the
253
+ * first it is our own orphan, in the second its owner is demonstrably gone.
254
+ * `heldTokens` separates out the only case where it is not.
255
+ */
256
+ function ownedByThisProcess(snapshot) {
257
+ return snapshot?.owner?.pid === process.pid && Boolean(snapshot.ownerPath);
258
+ }
259
+
260
+ /**
261
+ * May this quarantined generation be collected?
262
+ *
263
+ * NOT `lockIsStale`, WHICH ANSWERS A DIFFERENT QUESTION. That one answers "may
264
+ * I take this canonical lock?", and only half of its answer transfers here. The
265
+ * owner half does: an owner who cannot still act is gone wherever its directory
266
+ * sits, and `ownerCanStillAct` stays the single answer to that so the reaper
267
+ * and the acquire path cannot drift apart — the D-078 lesson.
268
+ *
269
+ * The owner-LESS half does not transfer. `lockIsStale` gives an owner-less
270
+ * directory `OWNERLESS_LOCK_STALE_MS` for the reason its own comment states:
271
+ * the creator may have died "between `mkdir` and its first write", so the timer
272
+ * protects a live process about to write its record into a directory it has
273
+ * just made. No such process can exist at a quarantine pathname. A quarantine
274
+ * is only ever created by RENAMING an existing directory aside; nothing is ever
275
+ * `mkdir`ed there, and no creator will ever come back for a name it does not
276
+ * know. An owner-less quarantine is therefore garbage the instant it exists,
277
+ * and the timer protected nobody while delaying its collection.
278
+ *
279
+ * That delay was a real failure, not a tidiness point. `quarantineKnownLock`
280
+ * renames whatever is at the canonical path at the instant it acts, which need
281
+ * not be the generation it inspected — a contender can quarantine and remove
282
+ * that generation while a third process `mkdir`s a fresh empty one. The
283
+ * mismatch is detected and restoration attempted; when the canonical path has
284
+ * already been re-claimed, restoration loses the race D-092 recorded, and an
285
+ * EMPTY quarantine is left behind. Run `33760552077` failed on Ubuntu and
286
+ * Windows both because a sweeping acquisition could not collect it for ten
287
+ * seconds.
288
+ *
289
+ * Racing a quarantine still in progress is safe under this rule. The two paths
290
+ * that create one from an owner-bearing directory (`quarantineKnownLock`,
291
+ * `releaseOwnedGeneration`) move the token-bearing record INTO the directory
292
+ * first, so what they are working on always carries a record and is judged by
293
+ * liveness. The one path that quarantines an owner-less directory
294
+ * (`quarantineUnknownLock`) is itself about to delete it, and `removeQuarantine`
295
+ * swallows the loss.
296
+ */
297
+ function quarantineIsCollectable(snapshot) {
298
+ if (!snapshot) return true;
299
+ if (snapshot.owner) return !ownerCanStillAct(snapshot);
300
+ return true;
301
+ }
302
+
303
+ function reapOrphanQuarantines(root) {
304
+ let entries;
305
+ try {
306
+ entries = readdirSync(root, { withFileTypes: true });
307
+ } catch {
308
+ return;
309
+ }
310
+ for (const entry of entries) {
311
+ if (!entry.isDirectory() || !entry.name.startsWith(LOCK_QUARANTINE_PREFIX)) continue;
312
+ const path = join(root, entry.name);
313
+ if (quarantineIsCollectable(inspectLock(path))) removeQuarantine(path);
314
+ }
315
+ }
316
+
317
+ function sameOwner(left, right) {
318
+ return left?.pid === right?.pid && left?.token === right?.token;
319
+ }
320
+
321
+ /**
322
+ * Claim `source` as `target` by rename. Answers whether the claim succeeded.
323
+ *
324
+ * THE ERRNO LIST LIVED HERE TOO. D-072 removed an enumeration of platform error
325
+ * codes from the acquire loop on the grounds that the list is a property of
326
+ * whichever kernel the job runs on, and the next platform adds an entry. It did
327
+ * not remove this one, and macOS supplied the entry: renaming inside a
328
+ * directory a contender has just unlinked answers `EINVAL`, which was not on
329
+ * the list, so it was rethrown — out of `releasePublicationLock`, out of
330
+ * `bin/segreant.mjs`, killing a CLI that had merely lost a race.
331
+ *
332
+ * The position argument is the same one. This function answers ONE question —
333
+ * did I manage to claim this by rename? — and every caller handles `false` by
334
+ * carrying on. There is no failure here that means "you claimed it", so no
335
+ * failure needs to be fatal. `ENOENT` and `EEXIST` are still distinguished, and
336
+ * not because of what they are called: both mean another process has already
337
+ * settled this claim, so retrying cannot change the answer. Everything else is
338
+ * the path being momentarily unusable, which is what the budget is for.
339
+ *
340
+ * A permanently broken filesystem now returns `false` after `RENAME_RETRY_MS`
341
+ * rather than throwing its exact errno. That is a real loss of diagnostic
342
+ * precision, and it is the right trade: acquisition still surfaces a persistent
343
+ * fault through `PATH_CONTENTION_MS`, and no lock helper should be able to kill
344
+ * the launcher over an errno nobody enumerated.
345
+ */
346
+ function renameForQuarantine(source, target) {
347
+ const started = Date.now();
348
+ while (true) {
349
+ try {
350
+ renameSync(source, target);
351
+ return true;
352
+ } catch (error) {
353
+ const code = error?.code;
354
+ if (code === 'ENOENT' || code === 'EEXIST') return false;
355
+ if (Date.now() - started >= RENAME_RETRY_MS) return false;
356
+ sleep(LOCK_POLL_MS);
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Put a quarantined generation back, if the canonical path is still free.
363
+ *
364
+ * THE ERRNO LIST WAS STILL HERE, AND LINUX SUPPLIED THE NEXT ENTRY. The comment
365
+ * on `renameForQuarantine` above records D-072 removing an enumeration of
366
+ * platform error codes "on the grounds that the list is a property of whichever
367
+ * kernel the job runs on, and the next platform adds an entry", and notes that
368
+ * it did not remove this one. This function kept
369
+ * `['ENOENT','EACCES','EBUSY','EPERM','EEXIST']` and rethrew everything else.
370
+ * Renaming a directory onto an existing NON-EMPTY directory answers `EEXIST` on
371
+ * Windows and `ENOTEMPTY` on Linux — POSIX permits either — so the list was
372
+ * complete on the platform it was written on and wrong on the one CI runs.
373
+ * Exact-head run `33730517441` killed a worker inside `acquirePublicationLock`
374
+ * with a raw `ENOTEMPTY` for losing a race it is designed to lose.
375
+ *
376
+ * THE POSITION, NOT THE ENTRY. Adding `ENOTEMPTY` would repeat the mistake with
377
+ * one more name. The reason no failure here may be fatal is structural: once
378
+ * `renameForQuarantine` moved the lock aside, THE CANONICAL PATH IS ABSENT and
379
+ * any contender may claim it immediately. So restoration is best-effort by
380
+ * construction — a failure means somebody else got there first, which is the
381
+ * ordinary outcome of a lost race and never a reason to kill a build. Both
382
+ * callers already ignore the answer and return `false` regardless; there is no
383
+ * failure here that means "you restored it".
384
+ *
385
+ * WHAT IS PRESERVED. A failed restore still never deletes the quarantine to make
386
+ * the error disappear. The generation is left intact for `reapOrphanQuarantines`,
387
+ * which collects it once its owner is demonstrably dead — by owner token, never
388
+ * by pathname.
389
+ */
390
+ function restoreQuarantinedLock(buildLock, quarantine) {
391
+ try {
392
+ renameSync(quarantine, buildLock);
393
+ return true;
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+
399
+ function quarantineUnknownLock(root, buildLock) {
400
+ const quarantine = join(root, `${LOCK_QUARANTINE_PREFIX}${process.pid}-${randomUUID()}`);
401
+ if (!renameForQuarantine(buildLock, quarantine)) return false;
402
+
403
+ // Unknown means owner.json was absent or malformed. If a valid owner appears
404
+ // in the atomically quarantined object, this was a newer lock generation and
405
+ // it is not ours to delete. Restore it to the canonical path when possible.
406
+ const owner = readOwnerFile(join(quarantine, OWNER_FILE));
407
+ if (owner || ownerQuarantineNames(quarantine).some((name) => readOwnerFile(join(quarantine, name)))) {
408
+ restoreQuarantinedLock(buildLock, quarantine);
409
+ return false;
410
+ }
411
+
412
+ removeQuarantine(quarantine);
413
+ return true;
414
+ }
415
+
416
+ function quarantineKnownLock(root, buildLock, snapshot) {
417
+ if (!snapshot.owner || !snapshot.ownerPath) return false;
418
+
419
+ // First quarantine the token-bearing owner record while the canonical lock
420
+ // directory remains in place. That closes the stale-check -> removal window:
421
+ // no other process can acquire the directory until this owner identity has
422
+ // been claimed by an atomic same-directory rename.
423
+ const ownerName = OWNER_QUARANTINE_FILE;
424
+ const ownerQuarantine = join(buildLock, ownerName);
425
+ if (snapshot.ownerPath !== ownerQuarantine) {
426
+ // One fixed quarantine name makes the owner-record claim itself exclusive:
427
+ // competing stale recoverers observe the same claimed generation and wait
428
+ // instead of moving one another's temporary records around.
429
+ if (existsSync(ownerQuarantine) || !renameForQuarantine(snapshot.ownerPath, ownerQuarantine)) return false;
430
+ }
431
+ const movedOwner = readOwnerFile(ownerQuarantine);
432
+ if (!sameOwner(movedOwner, snapshot.owner)) {
433
+ try { renameSync(ownerQuarantine, snapshot.ownerPath); } catch { /* leave it for stale recovery */ }
434
+ return false;
435
+ }
436
+
437
+ // Now move the whole claimed lock to a unique sibling. Publication
438
+ // contenders can create a fresh canonical lock after this point, but they
439
+ // can never be affected by cleanup of this quarantined generation.
440
+ const quarantine = join(root, `${LOCK_QUARANTINE_PREFIX}${process.pid}-${randomUUID()}`);
441
+ if (!renameForQuarantine(buildLock, quarantine)) return false;
442
+ const quarantinedOwner = readOwnerFile(join(quarantine, ownerName));
443
+ if (!sameOwner(quarantinedOwner, snapshot.owner)) {
444
+ restoreQuarantinedLock(buildLock, quarantine);
445
+ return false;
446
+ }
447
+
448
+ removeQuarantine(quarantine);
449
+ return true;
450
+ }
451
+
452
+ function quarantineStaleLock(root, buildLock, snapshot) {
453
+ return snapshot?.owner
454
+ ? quarantineKnownLock(root, buildLock, snapshot)
455
+ : quarantineUnknownLock(root, buildLock);
456
+ }
457
+
458
+ /**
459
+ * Hand back a lock this process proved it owns.
460
+ *
461
+ * THE INVARIANT, WHICH THE OLD SHAPE VIOLATED FOUR WAYS.
462
+ *
463
+ * A process that cannot prove it owns the lock must never act as though it
464
+ * does, and a process that HAS proved ownership must not return while
465
+ * leaving that lock permanently held.
466
+ *
467
+ * Release used to be a straight line of atomic steps, each of which returned on
468
+ * failure. Three of those returns left the canonical directory in place still
469
+ * carrying OUR token — and `inspectLock` reads that record as an owner while
470
+ * `lockIsStale` clears it as live, because the PID it names is this process and
471
+ * this process is still running. No contender can ever recover such a lock:
472
+ * they wait out `LOCK_WAIT_MS` and report `timed out waiting for another Segreant
473
+ * build` about a build that moved on minutes earlier.
474
+ *
475
+ * D-077 fixed ONE of those four returns — the directory move — and did not ask
476
+ * about its siblings. That is the same mistake D-078 found in the acquire path
477
+ * (an errno list removed in one place and left in the helper next door), which
478
+ * makes it the defect CLASS rather than an unlucky line. So the give-up
479
+ * decision is no longer per-step at all: the loop below re-establishes whether
480
+ * the lock is still ours and keeps trying, and the only way out is that the
481
+ * lock is gone, or demonstrably not ours, or removed by us.
482
+ *
483
+ * THE TERMINAL FALLBACK IS SOUND ONLY BECAUSE OF THE TOKEN. Removing the
484
+ * directory by pathname is exactly what the atomic dance exists to avoid, and
485
+ * it is safe here for one reason: `ownedByToken` has just re-read the lock and
486
+ * found OUR token in it. That is the same proof every other step relies on. The
487
+ * quarantine rename only ever bought speed — freeing the canonical path sooner
488
+ * — and losing speed is not a reason to keep a lock forever.
489
+ */
490
+ function releasePublicationLock(root, buildLock, token) {
491
+ // Dropped FIRST, and on every exit below by virtue of being dropped here: a
492
+ // token left in the set after release would make a later acquisition treat a
493
+ // genuine orphan of ours as a lock we are standing in, and refuse instead of
494
+ // reclaiming. Release is the moment we stop standing in it, whatever the
495
+ // filesystem then does.
496
+ heldTokens.delete(token);
497
+
498
+ const deadline = Date.now() + RELEASE_BUDGET_MS;
499
+ while (true) {
500
+ const snapshot = inspectLock(buildLock);
501
+ // Gone, or a newer generation someone else owns. We hold nothing, and
502
+ // touching it would be the pathname-based cleanup D-072 removed.
503
+ if (!ownedByToken(snapshot, token) || !snapshot.ownerPath) return;
504
+
505
+ if (releaseOwnedGeneration(root, buildLock, token, snapshot)) return;
506
+
507
+ if (Date.now() >= deadline) {
508
+ abandonOwnedGeneration(buildLock, token);
509
+ return;
510
+ }
511
+ sleep(LOCK_POLL_MS);
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Take back a canonical lock that names this process, at any cost.
517
+ *
518
+ * The acquire-side twin of `releasePublicationLock`, and it exists for the same
519
+ * reason: waiting is not one of the outcomes. `abandonOwnedGeneration` is the
520
+ * same terminal step, licensed by the same proof — the owner record names our
521
+ * PID, and while we are alive no contender can have taken this generation,
522
+ * because a contender only touches a live lock it does not own after judging
523
+ * the owner dead.
524
+ *
525
+ * The atomic route is tried first and given the same budget release gets, so a
526
+ * rename losing to momentary contention is retried rather than escalated. Past
527
+ * it, the directory goes. Degrading the canonical path to owner-less — which
528
+ * `OWNERLESS_LOCK_STALE_MS` clears in ten seconds — is a real outcome. Waiting
529
+ * five minutes for ourselves is not an outcome at all.
530
+ */
531
+ function reclaimOwnGeneration(root, buildLock, snapshot, ownedSince) {
532
+ if (quarantineKnownLock(root, buildLock, snapshot)) return true;
533
+ if (Date.now() - ownedSince < RELEASE_BUDGET_MS) return false;
534
+ abandonOwnedGeneration(buildLock, snapshot.owner.token);
535
+ return true;
536
+ }
537
+
538
+ /**
539
+ * Last resort: make this generation recoverable by someone else, at any cost
540
+ * short of touching a generation that is not ours.
541
+ *
542
+ * Reached only after `ownedByToken` has just re-read the lock and found OUR
543
+ * token in it. That proof is what licenses acting by pathname here: the token
544
+ * is a UUID minted in this process, so the directory is demonstrably our
545
+ * generation, and while this process is alive no other can have taken it — a
546
+ * contender only touches a live lock it does not own by first judging the owner
547
+ * dead, and `processIsAlive` says otherwise about us.
548
+ *
549
+ * Two attempts, because the first one can be swallowed:
550
+ *
551
+ * 1. Remove the directory. Best outcome — the canonical path is free at once.
552
+ * 2. If it is still ours, unlink every token-bearing record inside it. What
553
+ * makes a lock look HELD is the record, not the directory: without one,
554
+ * `inspectLock` reports an owner-less lock and `lockIsStale` clears it on
555
+ * the `OWNERLESS_LOCK_STALE_MS` timer.
556
+ *
557
+ * Step 2 is the reason this is a function rather than one call. `rmSync` is not
558
+ * atomic and `removeQuarantine` swallows its failures by design, so a partial
559
+ * removal that leaves the owner record behind would put us straight back into
560
+ * the permanently-held state this whole path exists to prevent. Degrading to
561
+ * "recoverable in ten seconds" is a real outcome; returning while holding it
562
+ * forever is not an outcome at all.
563
+ */
564
+ function abandonOwnedGeneration(buildLock, token) {
565
+ removeQuarantine(buildLock);
566
+ if (!ownedByToken(inspectLock(buildLock), token)) return;
567
+
568
+ for (const name of [OWNER_FILE, ...ownerQuarantineNames(buildLock), ...ownerTempNames(buildLock)]) {
569
+ try {
570
+ rmSync(join(buildLock, name), { force: true, maxRetries: 20, retryDelay: LOCK_POLL_MS });
571
+ } catch {
572
+ // Nothing further is available. The next acquisition inspects it again.
573
+ }
574
+ }
575
+ }
576
+
577
+ /**
578
+ * One attempt at the atomic hand-back. Answers whether the lock is now gone.
579
+ *
580
+ * Every `false` here means "could not finish this time", never "give up" — the
581
+ * caller decides that, against the invariant, and it is the caller that owns
582
+ * the fallback. Keeping the steps atomic still matters: a pathname read
583
+ * followed by recursive deletion would race a lock that changed underneath us,
584
+ * and the fallback is only reached after that race has been re-checked.
585
+ */
586
+ function releaseOwnedGeneration(root, buildLock, token, snapshot) {
587
+ const ownerName = OWNER_QUARANTINE_FILE;
588
+ const ownerQuarantine = join(buildLock, ownerName);
589
+ if (snapshot.ownerPath !== ownerQuarantine && !renameForQuarantine(snapshot.ownerPath, ownerQuarantine)) {
590
+ return false;
591
+ }
592
+ const movedOwner = readOwnerFile(ownerQuarantine);
593
+ if (!movedOwner || movedOwner.token !== token) {
594
+ // The record changed under us, so this generation may not be ours after
595
+ // all. Put it back and let the caller re-establish ownership from scratch.
596
+ try { renameSync(ownerQuarantine, snapshot.ownerPath); } catch { /* the next lap re-reads it */ }
597
+ return false;
598
+ }
599
+
600
+ const quarantine = join(root, `${LOCK_QUARANTINE_PREFIX}release-${process.pid}-${randomUUID()}`);
601
+ if (!renameForQuarantine(buildLock, quarantine)) return false;
602
+
603
+ const quarantinedOwner = readOwnerFile(join(quarantine, ownerName));
604
+ if (!quarantinedOwner || quarantinedOwner.token !== token) {
605
+ restoreQuarantinedLock(buildLock, quarantine);
606
+ return false;
607
+ }
608
+ removeQuarantine(quarantine);
609
+ return true;
610
+ }
611
+
612
+ /**
613
+ * Acquire the exclusive build/reader gate rooted beside the package.
614
+ *
615
+ * Both the publisher and bin/segreant.mjs use this exact mkdir protocol. A
616
+ * reader therefore cannot observe the check-vs-acquire window where a build
617
+ * starts publication immediately after a reader sees an absent lock. Stale
618
+ * recovery is token- and PID-aware and never falls back to path-based deletion.
619
+ */
620
+ export function acquirePublicationLock(root) {
621
+ reapOrphanQuarantines(root);
622
+ const buildLock = join(root, '.segreant-build.lock');
623
+ const waitStarted = Date.now();
624
+ const token = randomUUID();
625
+ // First moment the canonical lock was seen carrying THIS process's PID under
626
+ // a token we are not holding. Reset whenever it is not, so the budget bounds
627
+ // a consecutive run rather than a total.
628
+ let ownedSince = null;
629
+ // First moment the canonical path answered with a filesystem error that is
630
+ // not EEXIST. Reset on any other outcome, so the budget below bounds a
631
+ // CONSECUTIVE run of them rather than their total over a long wait.
632
+ let unusableSince = null;
633
+
634
+ while (true) {
635
+ let created = false;
636
+ try {
637
+ mkdirSync(buildLock);
638
+ created = true;
639
+
640
+ // Publish the owner record through a same-directory rename so a waiter
641
+ // never mistakes a half-written JSON file for an actionable owner.
642
+ const ownerTemp = join(buildLock, `.owner-${token}.tmp`);
643
+ writeFileSync(ownerTemp, JSON.stringify({ pid: process.pid, token }), 'utf8');
644
+ renameSync(ownerTemp, join(buildLock, OWNER_FILE));
645
+
646
+ heldTokens.add(token);
647
+ return () => releasePublicationLock(root, buildLock, token);
648
+ } catch (error) {
649
+ // CONTENTION ON THE CANONICAL PATH IS NOT ACQUISITION FAILING. IT IS
650
+ // ACQUISITION NOT HAVING HAPPENED YET.
651
+ //
652
+ // `EEXIST` was the only contention this loop recognised, and it is one
653
+ // face of the condition among several. The lock is made in two steps —
654
+ // `mkdir` the directory, then write an owner record into it — and between
655
+ // them it carries no owner, which is indistinguishable from one an
656
+ // interrupted process abandoned. A contender that removes it there leaves
657
+ // the creator's write failing, and what that failure is CALLED depends on
658
+ // the platform and on exactly when the call lands:
659
+ //
660
+ // Windows EEXIST from mkdir while it is there, EPERM from mkdir while
661
+ // it sits in pending-delete, ENOENT from the write once it is
662
+ // gone, EPERM from the rename if it goes mid-publish.
663
+ // macOS EINVAL from the write into an unlinked directory.
664
+ //
665
+ // CI showed ENOENT (run `33502986214`), then EINVAL on a different runner
666
+ // (run `33505785655`) once the first three were handled. Enumerating
667
+ // errnos is the wrong shape of fix: the list is a property of the kernel
668
+ // this happens to run on, and the next platform adds another entry.
669
+ //
670
+ // The structural fact does not vary. THE OWNER RECORD IS WHAT MAKES THE
671
+ // LOCK OURS, so if we created the directory and failed to publish that
672
+ // record, we hold nothing — whatever the failure was called. There is no
673
+ // error in that position that means "you hold the lock". So the branch
674
+ // asks where it failed, not what the error was, and a persistent failure
675
+ // is separated from a transient one by whether it clears.
676
+ const code = error?.code;
677
+
678
+ if (created) {
679
+ // Clean up ONLY what carries our token. The original code quarantined
680
+ // `buildLock` by pathname, which is unsound here: our owner record never
681
+ // landed, so a pathname says nothing about whose directory it now is,
682
+ // and removing it takes a fresh lock from another process inside ITS own
683
+ // publish window. Removing the cleanup entirely was worse in a different
684
+ // way — a half-written `.owner-<token>.tmp` of OUR OWN is recovered by
685
+ // `inspectLock` as a live owner whose PID is this very process, so the
686
+ // next lap sees a lock owned by someone alive and waits for itself until
687
+ // `LOCK_WAIT_MS`. CI deadlocked eight contenders that way.
688
+ //
689
+ // The token settles it. A directory carrying our token is ours whatever
690
+ // state it is in; one carrying another token, or none, is not ours to
691
+ // touch. `quarantineKnownLock` re-verifies the record after moving it,
692
+ // so a generation that changed under us is restored rather than removed.
693
+ const snapshot = inspectLock(buildLock);
694
+ if (ownedByToken(snapshot, token)) quarantineKnownLock(root, buildLock, snapshot);
695
+
696
+ unusableSince ??= Date.now();
697
+ if (Date.now() - unusableSince > PATH_CONTENTION_MS) throw error;
698
+ } else if (code === 'EEXIST') {
699
+ // Someone holds it, or is publishing it. This is the ordinary wait and
700
+ // must not be bounded by the contention budget: a legitimately held lock
701
+ // can outlast it by minutes, and `LOCK_WAIT_MS` is its bound.
702
+ unusableSince = null;
703
+ const snapshot = inspectLock(buildLock);
704
+ // A LOCK NAMING THIS PROCESS IS NEVER SOMETHING TO WAIT FOR.
705
+ //
706
+ // This test used to be `ownedByToken`, and the token is minted per CALL
707
+ // — so a generation left behind by an EARLIER call of this same process
708
+ // carried a token this call had never heard of. Different token, live
709
+ // PID: not ours to reclaim by that test, not stale by `lockIsStale`,
710
+ // and therefore waited on until `LOCK_WAIT_MS`. CI run `33630894290`
711
+ // killed a contender at the harness window with every other worker long
712
+ // since exited, which leaves exactly one process alive to have owned it.
713
+ //
714
+ // The actor is the right granularity, not the generation. What made
715
+ // waiting futile was never the token — it was that the PID we are
716
+ // waiting on is our own, and `processIsAlive` will keep saying yes for
717
+ // as long as we are the one asking.
718
+ if (ownedByThisProcess(snapshot)) {
719
+ if (heldTokens.has(snapshot.owner.token)) {
720
+ // Not an orphan: we are standing in this lock. Reclaiming would
721
+ // hand one lock to two holders, and waiting is a deadlock with a
722
+ // five-minute fuse that then blames another build for it.
723
+ throw new Error('publication lock is already held by this process; release it before acquiring again');
724
+ }
725
+ ownedSince ??= Date.now();
726
+ if (reclaimOwnGeneration(root, buildLock, snapshot, ownedSince)) continue;
727
+ } else {
728
+ ownedSince = null;
729
+ if (lockIsStale(buildLock, snapshot) && quarantineStaleLock(root, buildLock, snapshot)) continue;
730
+ }
731
+ } else if (code === 'ENOENT') {
732
+ // `mkdir` cannot answer ENOENT because of anything happening at the lock
733
+ // path itself; the parent directory is missing. Nothing to wait for.
734
+ throw error;
735
+ } else {
736
+ // The path is momentarily unusable — most often a directory another
737
+ // process is deleting. Absorb it while it clears.
738
+ unusableSince ??= Date.now();
739
+ if (Date.now() - unusableSince > PATH_CONTENTION_MS) throw error;
740
+ }
741
+
742
+ if (Date.now() - waitStarted >= LOCK_WAIT_MS) {
743
+ throw new Error(`timed out waiting for another Segreant build (${LOCK_WAIT_MS}ms)`);
744
+ }
745
+ sleep(LOCK_POLL_MS);
746
+ }
747
+ }
748
+ }