@workit-dev/core 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,1299 @@
1
+ // Host resources for heavy checks (issue #282, slice 1): a machine-wide lease
2
+ // serializes heavy `workit check` runs across every agent and worktree, and on
3
+ // Linux with a delegated user systemd each heavy run gets its own
4
+ // `systemd-run --user --scope` with MemoryMax and CPUQuota, so parallel agents
5
+ // cannot push the desktop session into systemd-oomd.
6
+ //
7
+ // The lease is a set of slot files, `<state>/leases/slot-<n>.json`, created
8
+ // with O_EXCL: holding one is holding the lease. A slot is held while its
9
+ // workit process runs or, after workit is killed, while the check's process
10
+ // group or scope still has processes. It is stale, and reclaimed by the next
11
+ // acquirer under a short mkdir guard, once those are gone, after a reboot, or
12
+ // (where the holder cannot be identified) past its age bound.
13
+ //
14
+ // Settings: built-in defaults (derived from total RAM and CPUs), then
15
+ // `resources` in the matching workspaces.json entry (the machine owner's
16
+ // word), then `resources` in .workit/checks.json, which can only tighten them.
17
+ // Only the CLI imports this module; hooks never do.
18
+ import { randomUUID } from "node:crypto";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { tailLines, type CheckRun } from "../checks";
23
+ import { stateDir } from "./heavy";
24
+ import { processStartOf, type ProcessStartDeps } from "./store-lock";
25
+
26
+ export { stateDir };
27
+ export {
28
+ probeScope,
29
+ scopePrefix,
30
+ scopeSupport,
31
+ type ScopeProbe,
32
+ type ScopeSupport,
33
+ } from "./heavy-scope";
34
+
35
+ /** Named checks that take the lease when nothing is configured. */
36
+ export const DEFAULT_HEAVY_CHECKS = ["test", "build", "packaging"];
37
+ /**
38
+ * Named checks that never take the lease unless the machine lists them heavy:
39
+ * they finish in seconds, so they must not queue behind a 10-minute suite (#508).
40
+ */
41
+ export const DEFAULT_LIGHT_CHECKS = ["lint", "typecheck", "format"];
42
+ const DEFAULT_SLOTS = 1;
43
+ /** A queued heavy check waits its turn this long by default (#508). */
44
+ const DEFAULT_WAIT_S = 1800;
45
+ /** A holder past this hold time is shown `stuck?` (#608). */
46
+ export const DEFAULT_MAX_HOLD_MS = 15 * 60_000;
47
+ /** A holder whose check printed nothing and used no CPU for this long is shown `stuck?`. */
48
+ export const SILENT_MS = 5 * 60_000;
49
+ /** Past this many times its max hold, a silent holder is reclaimed by the next acquirer. */
50
+ const AUTO_REAP_FACTOR = 3;
51
+ const DEFAULT_MEMORY_SHARE = 0.4;
52
+ const DEFAULT_CPU_SHARE = 0.7;
53
+ const MAX_SLOTS = 64;
54
+ /** A slot file nobody could parse is a writer mid-create; after this it is debris. */
55
+ const UNREADABLE_TTL_MS = 30_000;
56
+ /** A reclaim guard lives for microseconds; one older than this was abandoned. */
57
+ const GUARD_TTL_MS = 30_000;
58
+
59
+ export type ResourceSettings = {
60
+ /** How many heavy checks may run at once on this machine. */
61
+ slots: number;
62
+ /** Named checks that are heavy. */
63
+ heavy: string[];
64
+ /** Named checks that never take the lease (disjoint from `heavy`). */
65
+ light: string[];
66
+ /** systemd MemoryMax for a heavy check's scope, in bytes. */
67
+ memoryMax: string;
68
+ /** systemd CPUQuota for a heavy check's scope (percent of one CPU). */
69
+ cpuQuota: string;
70
+ /** How long a heavy check waits for the lease before giving up. */
71
+ waitMs: number;
72
+ /** How long a check may hold a slot before it is shown `stuck?` (#608); unset is DEFAULT_MAX_HOLD_MS. */
73
+ maxHoldMs?: number;
74
+ /** False turns the systemd scope off (the lease still applies). */
75
+ scope: boolean;
76
+ };
77
+
78
+ export type HostShape = { totalMemBytes: number; cpus: number };
79
+
80
+ export const hostShape = (): HostShape => ({
81
+ totalMemBytes: os.totalmem(),
82
+ cpus: Math.max(1, os.availableParallelism?.() ?? os.cpus().length),
83
+ });
84
+
85
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
86
+ typeof value === "object" && value !== null && !Array.isArray(value);
87
+
88
+ const SIZE = /^(\d+(?:\.\d+)?)([KMGT]?)$/iu;
89
+ const PERCENT = /^(\d+(?:\.\d+)?)%$/u;
90
+ const UNITS: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
91
+
92
+ /** "40%" of total RAM, or a size ("8G", "8589934592"), as MemoryMax bytes. */
93
+ function memoryValue(value: unknown, host: HostShape): number | Error {
94
+ const text = typeof value === "number" ? String(value) : typeof value === "string" ? value : "";
95
+ const percent = PERCENT.exec(text.trim());
96
+ if (percent) {
97
+ const share = Number(percent[1]);
98
+ if (share <= 0 || share > 100) return new Error(`memoryMax ${text} must be 1-100%`);
99
+ return Math.floor((host.totalMemBytes * share) / 100);
100
+ }
101
+ const size = SIZE.exec(text.trim());
102
+ if (size && Number(size[1]) > 0)
103
+ return Math.floor(Number(size[1]) * UNITS[size[2].toUpperCase()]);
104
+ return new Error(`memoryMax must be a percent of RAM ("40%") or a size ("8G"), got ${text}`);
105
+ }
106
+
107
+ /** "70%" of the whole machine, or a CPU count (4 → 400), as CPUQuota percent of one CPU. */
108
+ function cpuValue(value: unknown, host: HostShape): number | Error {
109
+ if (typeof value === "number" && value > 0) return Math.round(value * 100);
110
+ const percent = typeof value === "string" ? PERCENT.exec(value.trim()) : null;
111
+ if (percent) {
112
+ const share = Number(percent[1]);
113
+ if (share <= 0 || share > 100) return new Error(`cpuQuota ${percent[0]} must be 1-100%`);
114
+ return Math.max(1, Math.round(host.cpus * share));
115
+ }
116
+ return new Error(
117
+ `cpuQuota must be a percent of the machine ("70%") or a CPU count, got ${String(value)}`,
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Where a `resources` block comes from. `machine` (the user's workspaces.json)
123
+ * sets anything. `repo` (the repo's .workit/checks.json) can only tighten: it
124
+ * adds heavy checks, lowers memoryMax and cpuQuota, and sets its own wait; it
125
+ * cannot raise the machine's slots or turn the scope off.
126
+ */
127
+ export type ResourceSource = { label: string; trust: "machine" | "repo"; value: unknown };
128
+
129
+ /**
130
+ * Merge `resources` blocks over the defaults, machine sources before repo
131
+ * ones. Lenient: an invalid or refused field is reported and the earlier
132
+ * value kept, so a typo never turns the lease off.
133
+ */
134
+ export function resolveResourceSettings(
135
+ sources: ResourceSource[],
136
+ host: HostShape = hostShape(),
137
+ ): { settings: ResourceSettings; warnings: string[] } {
138
+ let memory = Math.floor(host.totalMemBytes * DEFAULT_MEMORY_SHARE);
139
+ let cpu = Math.max(1, Math.round(host.cpus * DEFAULT_CPU_SHARE * 100));
140
+ const settings: ResourceSettings = {
141
+ slots: DEFAULT_SLOTS,
142
+ heavy: [...DEFAULT_HEAVY_CHECKS],
143
+ light: [...DEFAULT_LIGHT_CHECKS],
144
+ memoryMax: "",
145
+ cpuQuota: "",
146
+ waitMs: DEFAULT_WAIT_S * 1000,
147
+ scope: true,
148
+ };
149
+ const warnings: string[] = [];
150
+ const ordered = [
151
+ ...sources.filter((source) => source.trust === "machine"),
152
+ ...sources.filter((source) => source.trust === "repo"),
153
+ ];
154
+ for (const { label, trust, value } of ordered) {
155
+ if (value === undefined || value === null) continue;
156
+ if (!isRecord(value)) {
157
+ warnings.push(`${label}: resources must be an object`);
158
+ continue;
159
+ }
160
+ const repo = trust === "repo";
161
+ const warn = (field: string, message: string) =>
162
+ warnings.push(`${label}: ${field}: ${message}`);
163
+ const machineOnly = (field: string) =>
164
+ warn(field, "is machine-wide; set it in your workspaces.json, not in the repo");
165
+ if (value.slots !== undefined) {
166
+ const slots = value.slots;
167
+ if (repo) machineOnly("slots");
168
+ else if (
169
+ typeof slots === "number" &&
170
+ Number.isInteger(slots) &&
171
+ slots >= 1 &&
172
+ slots <= MAX_SLOTS
173
+ )
174
+ settings.slots = slots;
175
+ else warn("slots", `must be an integer 1-${MAX_SLOTS}`);
176
+ }
177
+ if (value.scope !== undefined) {
178
+ if (repo) machineOnly("scope");
179
+ else if (typeof value.scope === "boolean") settings.scope = value.scope;
180
+ else warn("scope", "must be true or false");
181
+ }
182
+ if (value.heavy !== undefined) {
183
+ const heavy = isRecord(value.heavy) ? Object.keys(value.heavy) : value.heavy;
184
+ if (!Array.isArray(heavy) || !heavy.every((item) => typeof item === "string"))
185
+ warn("heavy", "must be a list of check names");
186
+ // A repo adds heavy checks; it never makes the machine's heavy ones light.
187
+ else {
188
+ settings.heavy = repo ? [...new Set([...settings.heavy, ...heavy])] : heavy;
189
+ settings.light = settings.light.filter((name) => !heavy.includes(name));
190
+ }
191
+ }
192
+ if (value.light !== undefined) {
193
+ const light = value.light;
194
+ // Exempting a check from the lease loosens it: the machine owner's word only.
195
+ if (repo) machineOnly("light");
196
+ else if (!Array.isArray(light) || !light.every((item) => typeof item === "string"))
197
+ warn("light", "must be a list of check names");
198
+ else {
199
+ settings.light = light;
200
+ settings.heavy = settings.heavy.filter((name) => !light.includes(name));
201
+ }
202
+ }
203
+ if (value.memoryMax !== undefined) {
204
+ const next = memoryValue(value.memoryMax, host);
205
+ if (next instanceof Error) warn("memoryMax", next.message);
206
+ else if (repo && next > memory) warn("memoryMax", "a repo can only lower it");
207
+ else memory = next;
208
+ }
209
+ if (value.cpuQuota !== undefined) {
210
+ const next = cpuValue(value.cpuQuota, host);
211
+ if (next instanceof Error) warn("cpuQuota", next.message);
212
+ else if (repo && next > cpu) warn("cpuQuota", "a repo can only lower it");
213
+ else cpu = next;
214
+ }
215
+ if (value.wait !== undefined) {
216
+ const wait = durationValue(value.wait);
217
+ if (wait !== null) settings.waitMs = wait;
218
+ else warn("wait", 'must be a number of seconds or a duration ("30m")');
219
+ }
220
+ if (value.maxHold !== undefined) {
221
+ const hold = durationValue(value.maxHold);
222
+ if (hold === null || hold <= 0)
223
+ warn("maxHold", 'must be a positive number of seconds or a duration ("15m")');
224
+ else if (repo && hold > (settings.maxHoldMs ?? DEFAULT_MAX_HOLD_MS))
225
+ warn("maxHold", "a repo can only lower it");
226
+ else settings.maxHoldMs = hold;
227
+ }
228
+ }
229
+ // More slots run more heavy checks at once, each up to memoryMax: say when together they can take all RAM.
230
+ const share = Math.round((memory / host.totalMemBytes) * 100);
231
+ if (settings.slots > 1 && settings.slots * memory > host.totalMemBytes)
232
+ warnings.push(
233
+ `slots: ${settings.slots} heavy checks at ${share}% of RAM each can use ${settings.slots * share}% of it; lower memoryMax to about ${Math.floor(80 / settings.slots)}% or use fewer slots`,
234
+ );
235
+ settings.memoryMax = String(memory);
236
+ settings.cpuQuota = `${cpu}%`;
237
+ return { settings, warnings };
238
+ }
239
+
240
+ /** Seconds (a number) or a duration ("30m") as ms; null when it is neither. */
241
+ const durationValue = (value: unknown): number | null =>
242
+ typeof value === "number"
243
+ ? value >= 0
244
+ ? value * 1000
245
+ : null
246
+ : typeof value === "string"
247
+ ? parseWait(value)
248
+ : null;
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // the machine lease
252
+
253
+ export const leaseDir = (env: NodeJS.ProcessEnv): string => path.join(stateDir(env), "leases");
254
+
255
+ export type LeaseRecord = {
256
+ id: string;
257
+ /** The workit process that took the lease. */
258
+ pid: number;
259
+ /** The process start time (processStart), so a recycled pid does not keep a lease alive. */
260
+ processStart: string | null;
261
+ /** The kernel boot id (Linux). */
262
+ bootId: string | null;
263
+ /** Boot time in ms, where there is no boot id (macOS, Windows). */
264
+ bootTime?: number | null;
265
+ /** The check's process group: it outlives a SIGKILLed workit. */
266
+ pgid?: number | null;
267
+ /**
268
+ * The group leader's start time, so a recycled pgid does not keep a lease
269
+ * alive. Null when it could not be read (Windows); absent on older leases,
270
+ * whose group is trusted only within the age bound.
271
+ */
272
+ pgidStart?: string | null;
273
+ /** Past this age a lease whose holder cannot be identified (no processStart, no group identity) is stale. */
274
+ maxAgeMs?: number | null;
275
+ check: string;
276
+ cwd: string;
277
+ session: string | null;
278
+ startedAt: string;
279
+ /** The check's systemd scope unit; it holds the lease while it has processes. */
280
+ scope: string | null;
281
+ /** Last time the check printed or used CPU (#608); absent on older leases. */
282
+ heartbeatAt?: string | null;
283
+ /** The holder's max hold time; absent on older leases (the reader's own setting applies). */
284
+ maxHoldMs?: number | null;
285
+ /** The process that started workit (a queued ticket): its waiter leaves when it ends. */
286
+ ownerPid?: number | null;
287
+ ownerStart?: string | null;
288
+ /** The waiter honours a `workit lock cancel` marker (older ones get SIGTERM). */
289
+ cancellable?: boolean;
290
+ };
291
+
292
+ const readText = (file: string): string | null => {
293
+ try {
294
+ return fs.readFileSync(file, "utf8");
295
+ } catch {
296
+ return null;
297
+ }
298
+ };
299
+
300
+ /** The kernel boot id (Linux), so a lease from before a reboot is stale. */
301
+ export const bootId = (): string | null =>
302
+ readText("/proc/sys/kernel/random/boot_id")?.trim() || null;
303
+
304
+ /** When the machine booted (ms), for platforms without a boot id. */
305
+ const bootTime = (): number => Math.round((Date.now() - os.uptime() * 1000) / 1000) * 1000;
306
+ /** Clock steps and uptime rounding move the computed boot time; a reboot moves it far more. */
307
+ const BOOT_SLACK_MS = 120_000;
308
+
309
+ /** The fields of /proc/<pid>/stat after the comm, so a comm with spaces cannot shift them. */
310
+ const statFields = (pid: number): string[] | null => {
311
+ const stat = readText(`/proc/${pid}/stat`);
312
+ return stat ? stat.slice(stat.lastIndexOf(")") + 2).split(" ") : null;
313
+ };
314
+
315
+ /**
316
+ * Start time of `pid`, so a recycled pid does not keep a lease alive: /proc
317
+ * on Linux, `ps -o lstart=` on macOS (store-lock.ts), null elsewhere.
318
+ */
319
+ export const processStart = (pid: number, deps?: ProcessStartDeps): string | null => {
320
+ if (deps) return processStartOf(pid, deps);
321
+ // A waiter polls the holder every tick; on macOS each probe spawns `ps`. A
322
+ // live pid's start time cannot change, so a short real-time cache is exact
323
+ // enough (a pid reused within it keeps its lease that long at most).
324
+ const now = Date.now();
325
+ const hit = START_CACHE.get(pid);
326
+ if (hit && now - hit.at < START_CACHE_MS) return hit.start;
327
+ const start = processStartOf(pid);
328
+ if (START_CACHE.size > 256) START_CACHE.clear();
329
+ START_CACHE.set(pid, { start, at: now });
330
+ return start;
331
+ };
332
+ const START_CACHE_MS = 2_000;
333
+ const START_CACHE = new Map<number, { start: string | null; at: number }>();
334
+
335
+ const pidAlive = (pid: number): boolean => {
336
+ try {
337
+ process.kill(pid, 0);
338
+ return true;
339
+ } catch (error) {
340
+ return (error as NodeJS.ErrnoException).code === "EPERM";
341
+ }
342
+ };
343
+
344
+ /** Does process group `pgid` still have members? (Windows: is the process alive.) */
345
+ const groupAlive = (pgid: number): boolean =>
346
+ process.platform === "win32" ? pidAlive(pgid) : pidAlive(-pgid);
347
+
348
+ /** This process's group (Linux, from /proc); null where there is no cheap read. */
349
+ const ownGroup = (): number | null => {
350
+ if (process.platform !== "linux") return null;
351
+ const pgrp = Number(statFields(process.pid)?.[2]);
352
+ return Number.isInteger(pgrp) && pgrp > 0 ? pgrp : null;
353
+ };
354
+
355
+ const SCOPE_UNIT = /^[\w.@-]+\.scope$/u;
356
+
357
+ /** Does the user scope `unit` still hold processes? */
358
+ function scopeBusy(unit: string): boolean {
359
+ if (process.platform !== "linux" || !SCOPE_UNIT.test(unit)) return false;
360
+ const uid = process.getuid?.() ?? 0;
361
+ const manager = `/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service`;
362
+ return [`${manager}/app.slice/${unit}`, `${manager}/${unit}`].some(
363
+ (dir) => (readText(`${dir}/cgroup.procs`) ?? "").trim() !== "",
364
+ );
365
+ }
366
+
367
+ /**
368
+ * Is `pid` this process or one of its ancestors? Walks the ppid chain on
369
+ * Linux; elsewhere there is no cheap chain, so it answers true.
370
+ */
371
+ function isAncestor(pid: number): boolean {
372
+ if (process.platform !== "linux") return true;
373
+ let current = process.pid;
374
+ for (let step = 0; step < 128 && current > 1; step += 1) {
375
+ if (current === pid) return true;
376
+ const ppid = Number(statFields(current)?.[1]);
377
+ if (!Number.isInteger(ppid) || ppid <= 0) return false;
378
+ current = ppid;
379
+ }
380
+ return current === pid;
381
+ }
382
+
383
+ export const parseLease = (text: string | null): LeaseRecord | null => {
384
+ if (!text) return null;
385
+ try {
386
+ const value: unknown = JSON.parse(text);
387
+ return isRecord(value) && typeof value.id === "string" && typeof value.pid === "number"
388
+ ? (value as LeaseRecord)
389
+ : null;
390
+ } catch {
391
+ return null;
392
+ }
393
+ };
394
+
395
+ export type LeaseProbe = {
396
+ alive?: (pid: number) => boolean;
397
+ boot?: string | null;
398
+ bootTime?: number | null;
399
+ start?: (pid: number) => string | null;
400
+ group?: (pgid: number) => boolean;
401
+ scope?: (unit: string) => boolean;
402
+ now?: number;
403
+ };
404
+
405
+ /**
406
+ * The lease's check group: `same` while it runs under the leader recorded with
407
+ * it, `recycled` when another process now leads that pgid, `unproven` when it
408
+ * has members but its leader is gone or was never recorded, `none` otherwise.
409
+ */
410
+ function groupState(
411
+ lease: LeaseRecord,
412
+ probe: LeaseProbe,
413
+ ): "same" | "recycled" | "unproven" | "none" {
414
+ const pgid = lease.pgid;
415
+ if (typeof pgid !== "number" || pgid <= 0 || !(probe.group ?? groupAlive)(pgid)) return "none";
416
+ if (typeof lease.pgidStart !== "string") return "unproven";
417
+ const leader = (probe.start ?? processStart)(pgid);
418
+ return leader === lease.pgidStart ? "same" : leader ? "recycled" : "unproven";
419
+ }
420
+
421
+ /**
422
+ * Why `lease` no longer holds anything, or null while it does. It holds while
423
+ * its workit process runs, or, once that is gone (a SIGKILL), while the
424
+ * check's process group (led by the recorded leader) or scope still has
425
+ * processes. A lease from before a reboot is stale either way; so is one past
426
+ * its age bound whose holder cannot be identified (no process start, or a
427
+ * group whose leader cannot be proven). A lease from an older workit records
428
+ * no leader, so its age bound applies as soon as its workit is unidentified.
429
+ */
430
+ export function staleReason(lease: LeaseRecord, probe: LeaseProbe = {}): string | null {
431
+ const boot = probe.boot === undefined ? bootId() : probe.boot;
432
+ if (lease.bootId && boot) {
433
+ if (lease.bootId !== boot) return "taken before a reboot";
434
+ } else if (typeof lease.bootTime === "number") {
435
+ const now = probe.bootTime === undefined ? bootTime() : probe.bootTime;
436
+ if (now !== null && Math.abs(now - lease.bootTime) > BOOT_SLACK_MS)
437
+ return "taken before a reboot";
438
+ }
439
+ const start = lease.processStart ? (probe.start ?? processStart)(lease.pid) : null;
440
+ const age = (probe.now ?? Date.now()) - Date.parse(lease.startedAt);
441
+ const bound =
442
+ typeof lease.maxAgeMs === "number" && age > lease.maxAgeMs
443
+ ? `older than its ${Math.round(lease.maxAgeMs / 1000)} s bound`
444
+ : null;
445
+ const legacy = lease.pgidStart === undefined;
446
+ if (!start && bound && (legacy || !lease.processStart)) return bound;
447
+ const holder = !(probe.alive ?? pidAlive)(lease.pid)
448
+ ? `holder pid ${lease.pid} is gone`
449
+ : start && start !== lease.processStart
450
+ ? `pid ${lease.pid} now belongs to another process`
451
+ : null;
452
+ // A live pid whose start cannot be read now is not proven to be the holder.
453
+ if (!holder) return start ? null : bound;
454
+ // workit is gone, but the check it started may still run.
455
+ const group = groupState(lease, probe);
456
+ if (group === "same") return null;
457
+ if (group === "unproven") return legacy ? null : bound;
458
+ if (lease.scope && (probe.scope ?? scopeBusy)(lease.scope)) return start ? null : bound;
459
+ return group === "recycled"
460
+ ? `${holder}; process group ${lease.pgid} now belongs to another process`
461
+ : holder;
462
+ }
463
+
464
+ /**
465
+ * Why a holder looks stuck (#608), or null: it has held its slot past its max
466
+ * hold time, or its check printed nothing and used no CPU for SILENT_MS. A
467
+ * lease from an older workit has no heartbeat; only its hold time counts.
468
+ */
469
+ export function stuckReason(
470
+ lease: LeaseRecord,
471
+ maxHoldMs: number,
472
+ now = Date.now(),
473
+ ): string | null {
474
+ const limit = typeof lease.maxHoldMs === "number" ? lease.maxHoldMs : maxHoldMs;
475
+ const age = now - Date.parse(lease.startedAt);
476
+ const beat = lease.heartbeatAt ? now - Date.parse(lease.heartbeatAt) : null;
477
+ if (age > limit) return `held ${formatWait(age)}, past its ${formatWait(limit)} limit`;
478
+ if (beat !== null && beat > SILENT_MS) return `no heartbeat for ${formatWait(beat)}`;
479
+ return null;
480
+ }
481
+
482
+ /**
483
+ * Why the next acquirer reclaims a live but stuck holder, or null: held past
484
+ * AUTO_REAP_FACTOR times its max hold and silent for at least its max hold
485
+ * (no heartbeat at all counts as silent). A busy long run is never reclaimed.
486
+ */
487
+ export function autoReapReason(
488
+ lease: LeaseRecord,
489
+ maxHoldMs: number,
490
+ now = Date.now(),
491
+ ): string | null {
492
+ const limit = typeof lease.maxHoldMs === "number" ? lease.maxHoldMs : maxHoldMs;
493
+ const age = now - Date.parse(lease.startedAt);
494
+ const beat = now - Date.parse(lease.heartbeatAt ?? lease.startedAt);
495
+ return age > AUTO_REAP_FACTOR * limit && beat > limit
496
+ ? `stuck: held ${formatWait(age)}, silent for ${formatWait(beat)}, past ${AUTO_REAP_FACTOR}x its ${formatWait(limit)} limit`
497
+ : null;
498
+ }
499
+
500
+ export type LeaseHolder = Pick<LeaseRecord, "pid" | "check" | "cwd" | "session" | "startedAt"> & {
501
+ /** Why the holder looks stuck (stuckReason); absent or null when it does not. */
502
+ stuck?: string | null;
503
+ };
504
+
505
+ const holderOf = (lease: LeaseRecord): LeaseHolder => ({
506
+ pid: lease.pid,
507
+ check: lease.check,
508
+ cwd: lease.cwd,
509
+ session: lease.session,
510
+ startedAt: lease.startedAt,
511
+ });
512
+
513
+ /** Why a queued ticket's owner (the process that started its workit) is gone, or null. */
514
+ export function ownerGone(record: LeaseRecord, probe: LeaseProbe = {}): string | null {
515
+ const owner = record.ownerPid;
516
+ if (typeof owner !== "number" || owner <= 1) return null;
517
+ if (!(probe.alive ?? pidAlive)(owner)) return `owner pid ${owner} is gone`;
518
+ const start = record.ownerStart ? (probe.start ?? processStart)(owner) : null;
519
+ return start && start !== record.ownerStart
520
+ ? `owner pid ${owner} now belongs to another process`
521
+ : null;
522
+ }
523
+
524
+ /** Why a queued ticket no longer waits: its waiter or the waiter's owner is gone. */
525
+ export const ticketStale = (record: LeaseRecord, probe: LeaseProbe = {}): string | null =>
526
+ staleReason(record, probe) ?? ownerGone(record, probe);
527
+
528
+ /** Remove a stale slot under a mkdir guard, re-checking it inside so a fresh holder is never removed. */
529
+ export function reclaim(
530
+ file: string,
531
+ stale: (lease: LeaseRecord | null, ageMs: number) => boolean,
532
+ ) {
533
+ const guard = `${file}.reclaim`;
534
+ try {
535
+ fs.mkdirSync(guard);
536
+ } catch {
537
+ try {
538
+ if (Date.now() - fs.statSync(guard).mtimeMs > GUARD_TTL_MS)
539
+ fs.rmSync(guard, { force: true, recursive: true });
540
+ } catch {
541
+ // Gone already.
542
+ }
543
+ return false;
544
+ }
545
+ try {
546
+ let ageMs = 0;
547
+ try {
548
+ ageMs = Date.now() - fs.statSync(file).mtimeMs;
549
+ } catch {
550
+ return true;
551
+ }
552
+ if (!stale(parseLease(readText(file)), ageMs)) return false;
553
+ fs.rmSync(file, { force: true });
554
+ return true;
555
+ } finally {
556
+ fs.rmSync(guard, { force: true, recursive: true });
557
+ }
558
+ }
559
+
560
+ export type HeldLease = {
561
+ record: LeaseRecord;
562
+ slot: number;
563
+ file: string;
564
+ /** Holders seen while waiting (empty when the lease was free). */
565
+ waitedOn: LeaseHolder[];
566
+ waitedMs: number;
567
+ /** Stale leases reclaimed on the way, with the reason. */
568
+ reclaimed: Reclaimed[];
569
+ release: () => void;
570
+ };
571
+
572
+ /** A slot reclaimed from a gone holder, or (`reaped`) from a live one past its auto-reap bound. */
573
+ export type Reclaimed = LeaseHolder & { reason: string; reaped?: boolean };
574
+
575
+ export type AcquireOptions = {
576
+ dir: string;
577
+ slots: number;
578
+ check: string;
579
+ cwd: string;
580
+ session: string | null;
581
+ waitMs: number;
582
+ pollMs?: number;
583
+ /** The age bound recorded on the lease (see LeaseRecord.maxAgeMs). */
584
+ maxAgeMs?: number;
585
+ /** The max hold time applied to older leases that carry none (#608); recorded on the lease unless `holdMs` is set. */
586
+ maxHoldMs?: number;
587
+ /** This check's own max hold time, recorded on its lease (heavy-eta holdLimit: from its measured runs). */
588
+ holdMs?: number;
589
+ /** The process that started this waiter (default: the parent); null records none. */
590
+ ownerPid?: number | null;
591
+ /** Called once, when the first attempt finds every slot held. */
592
+ onWait?: (holders: LeaseHolder[]) => void;
593
+ /** Called when the wait starts and every `statusEveryMs` after (default 30 s). */
594
+ onStatus?: (status: QueueStatus) => void;
595
+ statusEveryMs?: number;
596
+ /**
597
+ * Called once, when the wait first reaches its limit: the ms to keep
598
+ * waiting, in the same place in the queue (0 gives up as `timeout`).
599
+ */
600
+ onLimit?: (status: QueueStatus) => number;
601
+ /** Overrides for tests. */
602
+ probe?: LeaseProbe;
603
+ pid?: number;
604
+ clock?: Clock;
605
+ };
606
+
607
+ /** Where a waiter stands: 1 is next in line. */
608
+ export type QueueStatus = {
609
+ position: number;
610
+ /** Live waiters ahead of this one. */
611
+ ahead: number;
612
+ /** Those waiters, in serving order (for a wait estimate). */
613
+ aheadOf: LeaseHolder[];
614
+ /** The live holders of the slots. */
615
+ holders: LeaseHolder[];
616
+ waitedMs: number;
617
+ /** The first report of this wait (whatever time has already passed). */
618
+ first: boolean;
619
+ };
620
+
621
+ export type Clock = { now: () => number; sleep: (ms: number) => Promise<void> };
622
+
623
+ export type AcquireResult =
624
+ | { ok: true; lease: HeldLease }
625
+ | {
626
+ ok: false;
627
+ reason: "timeout";
628
+ holders: LeaseHolder[];
629
+ waitedMs: number;
630
+ /** Where this waiter stood when it gave up. */
631
+ position: number;
632
+ /** The wait limit it reached first, when `onLimit` re-queued it once. */
633
+ requeuedAfterMs: number | null;
634
+ }
635
+ | {
636
+ ok: false;
637
+ reason: "cancelled";
638
+ /** Who cancelled it: `workit lock cancel`'s note, or the owner that ended. */
639
+ by: string;
640
+ waitedMs: number;
641
+ }
642
+ | { ok: false; reason: "unavailable"; error: string };
643
+
644
+ /** One pass over the slots: the slot taken, or the live holders. */
645
+ function tryOnce(
646
+ options: AcquireOptions,
647
+ record: LeaseRecord,
648
+ reclaimed: Reclaimed[],
649
+ ): { slot: number; file: string } | { holders: LeaseHolder[] } {
650
+ const holders: LeaseHolder[] = [];
651
+ const text = `${JSON.stringify(record)}\n`;
652
+ for (let slot = 0; slot < options.slots; slot += 1) {
653
+ const file = path.join(options.dir, `slot-${slot}.json`);
654
+ for (let attempt = 0; attempt < 2; attempt += 1) {
655
+ try {
656
+ const fd = fs.openSync(file, "wx", 0o600);
657
+ try {
658
+ fs.writeSync(fd, text);
659
+ } finally {
660
+ fs.closeSync(fd);
661
+ }
662
+ return { slot, file };
663
+ } catch (error) {
664
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
665
+ }
666
+ const lease = parseLease(readText(file));
667
+ let reason: string | null = null;
668
+ let stuck = false;
669
+ const removed = reclaim(file, (current, ageMs) => {
670
+ reason = current
671
+ ? staleReason(current, options.probe)
672
+ : ageMs > UNREADABLE_TTL_MS
673
+ ? "unreadable"
674
+ : null;
675
+ if (current && reason === null) {
676
+ reason = autoReapReason(
677
+ current,
678
+ options.maxHoldMs ?? DEFAULT_MAX_HOLD_MS,
679
+ options.probe?.now,
680
+ );
681
+ stuck = reason !== null;
682
+ }
683
+ return reason !== null;
684
+ });
685
+ if (removed) {
686
+ if (lease && reason)
687
+ reclaimed.push({ ...holderOf(lease), reason, ...(stuck ? { reaped: true } : {}) });
688
+ continue;
689
+ }
690
+ if (lease) holders.push(holderOf(lease));
691
+ break;
692
+ }
693
+ }
694
+ return { holders };
695
+ }
696
+
697
+ const realClock: Clock = {
698
+ now: () => Date.now(),
699
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
700
+ };
701
+
702
+ /** Remove `file` only while it still holds lease `id`: a reclaimed and retaken slot is someone else's. */
703
+ function removeOwn(file: string, id: string): void {
704
+ if (parseLease(readText(file))?.id === id) fs.rmSync(file, { force: true });
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // the queue (issues #508, #608)
709
+ //
710
+ // A waiter writes a ticket, `<leases>/queue/<ms>-<ns>-<id>.json` (a lease
711
+ // record naming its process and the process that started it), and takes a
712
+ // slot only while fewer live waiters stand ahead of it than there are free
713
+ // slots. "Ahead" is round-robin per session: a session's n-th waiter (counting
714
+ // the slots it already holds) goes after every other session's earlier ones,
715
+ // arrival order breaking ties, so one session's chain of jobs cannot starve
716
+ // another's single check. A ticket whose waiter or owner died is stale
717
+ // (ticketStale), and the next reader removes it under the same mkdir guard.
718
+ // `workit lock cancel` drops a ticket and leaves a `<ticket>.cancel` marker
719
+ // its waiter reads. An older CLI that does not queue still races for a free
720
+ // slot; it can jump the queue, never break it.
721
+
722
+ export const queueDir = (dir: string): string => path.join(dir, "queue");
723
+
724
+ const TICKET = /^\d{15}-\d{16}-[\w-]+\.json$/u;
725
+
726
+ /** The marker `workit lock cancel` leaves for a ticket's waiter. */
727
+ export const cancelMarker = (ticket: string): string => `${ticket}.cancel`;
728
+
729
+ /** A ticket name that sorts in arrival order. */
730
+ const ticketName = (now: number, id: string): string =>
731
+ `${String(now).padStart(15, "0")}-${String(process.hrtime.bigint() % 10n ** 16n).padStart(16, "0")}-${id}.json`;
732
+
733
+ export type QueueEntry = {
734
+ file: string;
735
+ holder: LeaseHolder;
736
+ id: string | null;
737
+ stale: string | null;
738
+ /** The full ticket, for `workit lock cancel`. */
739
+ record?: LeaseRecord;
740
+ };
741
+
742
+ /** The queue's tickets in arrival order. Read-only: nothing is removed. */
743
+ export function leaseQueue(dir: string, probe: LeaseProbe = {}): QueueEntry[] {
744
+ let names: string[];
745
+ try {
746
+ names = fs.readdirSync(queueDir(dir));
747
+ } catch {
748
+ return [];
749
+ }
750
+ const entries: QueueEntry[] = [];
751
+ for (const name of names.filter((item) => item.endsWith(".json")).toSorted()) {
752
+ const file = path.join(queueDir(dir), name);
753
+ const record = parseLease(readText(file));
754
+ if (!record) continue;
755
+ entries.push({
756
+ file,
757
+ holder: holderOf(record),
758
+ id: record.id,
759
+ stale: ticketStale(record, probe),
760
+ record,
761
+ });
762
+ }
763
+ return entries;
764
+ }
765
+
766
+ const sessionKey = (holder: Pick<LeaseHolder, "session" | "pid">): string =>
767
+ holder.session ?? `pid:${holder.pid}`;
768
+
769
+ /** A slot handed out: whose session, and when (ms). */
770
+ export type Grant = { key: string; at: number };
771
+
772
+ const GRANTS_FILE = "grants.json";
773
+ /** Grants older than this no longer weigh on anyone's turn. */
774
+ const GRANT_TTL_MS = 2 * 3_600_000;
775
+ const MAX_GRANTS = 256;
776
+
777
+ /** Recent grants (best effort: unreadable means none). */
778
+ export function readGrants(dir: string, now = Date.now()): Grant[] {
779
+ try {
780
+ const value: unknown = JSON.parse(fs.readFileSync(path.join(dir, GRANTS_FILE), "utf8"));
781
+ const list = isRecord(value) && Array.isArray(value.grants) ? value.grants : [];
782
+ return list.filter(
783
+ (item): item is Grant =>
784
+ isRecord(item) &&
785
+ typeof item.key === "string" &&
786
+ typeof item.at === "number" &&
787
+ now - item.at < GRANT_TTL_MS,
788
+ );
789
+ } catch {
790
+ return [];
791
+ }
792
+ }
793
+
794
+ /** Note a grant for the round-robin. A lost race drops one note, never a slot. */
795
+ function recordGrant(dir: string, holder: LeaseHolder, at: number): void {
796
+ try {
797
+ const grants = [...readGrants(dir, at), { key: sessionKey(holder), at }].slice(-MAX_GRANTS);
798
+ const file = path.join(dir, GRANTS_FILE);
799
+ const temp = `${file}.${process.pid}.tmp`;
800
+ fs.writeFileSync(temp, `${JSON.stringify({ grants })}\n`, { mode: 0o600 });
801
+ fs.renameSync(temp, file);
802
+ } catch {
803
+ // Fairness is best effort.
804
+ }
805
+ }
806
+
807
+ /**
808
+ * `waiting` (live, in arrival order) in serving order: round-robin per
809
+ * session. A waiter's round counts its session's waiters ahead of it, the
810
+ * slots its session held when it queued (`holders`), and the slots its
811
+ * session was granted since (`grants`); arrival order breaks ties.
812
+ */
813
+ export function fairOrder<T extends { holder: LeaseHolder }>(
814
+ waiting: T[],
815
+ holders: LeaseHolder[],
816
+ grants: Grant[] = [],
817
+ ): T[] {
818
+ const ahead = new Map<string, number>();
819
+ return waiting
820
+ .map((entry, arrival) => {
821
+ const key = sessionKey(entry.holder);
822
+ const queuedAt = Date.parse(entry.holder.startedAt);
823
+ const before = ahead.get(key) ?? 0;
824
+ ahead.set(key, before + 1);
825
+ const held = holders.filter(
826
+ (holder) => sessionKey(holder) === key && Date.parse(holder.startedAt) < queuedAt,
827
+ ).length;
828
+ const granted = grants.filter((grant) => grant.key === key && grant.at >= queuedAt).length;
829
+ return { entry, round: before + held + granted, arrival };
830
+ })
831
+ .toSorted((a, b) => a.round - b.round || a.arrival - b.arrival)
832
+ .map(({ entry }) => entry);
833
+ }
834
+
835
+ /** The live tickets served before `own` (a ticket name, or "~" before queueing), removing stale ones on the way. */
836
+ function waitersAhead(
837
+ options: AcquireOptions,
838
+ own: string,
839
+ self: LeaseHolder,
840
+ holders: LeaseHolder[],
841
+ ): LeaseHolder[] {
842
+ const live: Array<{ name: string; holder: LeaseHolder }> = [];
843
+ for (const entry of leaseQueue(options.dir, options.probe)) {
844
+ const name = path.basename(entry.file);
845
+ if (!TICKET.test(name) || name === own) continue;
846
+ if (
847
+ entry.stale &&
848
+ reclaim(entry.file, (current) => !current || ticketStale(current, options.probe) !== null)
849
+ )
850
+ continue;
851
+ live.push({ name, holder: entry.holder });
852
+ }
853
+ const order = fairOrder(
854
+ [...live, { name: own, holder: self }].toSorted((a, b) => (a.name < b.name ? -1 : 1)),
855
+ holders,
856
+ readGrants(options.dir, options.probe?.now),
857
+ );
858
+ return order
859
+ .slice(
860
+ 0,
861
+ order.findIndex((entry) => entry.name === own),
862
+ )
863
+ .map((entry) => entry.holder);
864
+ }
865
+
866
+ /** Live holders of the slots, read without reclaiming; one past its auto-reap bound does not count. */
867
+ function liveHolders(options: AcquireOptions): LeaseHolder[] {
868
+ const maxHold = options.maxHoldMs ?? DEFAULT_MAX_HOLD_MS;
869
+ return leaseSlots(options.dir, options.slots, options.probe, maxHold).flatMap((slot) => {
870
+ if (!slot.holder || slot.stale) return [];
871
+ const record = parseLease(readText(path.join(options.dir, `slot-${slot.slot}.json`)));
872
+ return record && autoReapReason(record, maxHold, options.probe?.now) ? [] : [slot.holder];
873
+ });
874
+ }
875
+
876
+ function writeTicket(file: string, record: LeaseRecord): void {
877
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
878
+ fs.writeFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
879
+ }
880
+
881
+ /** "90", "90s", "30m", "1.5h" as ms; null when it is none of those. */
882
+ export function parseWait(text: string): number | null {
883
+ const match = /^(\d+(?:\.\d+)?)([smh]?)$/u.exec(text.trim());
884
+ if (!match) return null;
885
+ const unit = { "": 1000, s: 1000, m: 60_000, h: 3_600_000 }[match[2]] ?? 1000;
886
+ return Math.round(Number(match[1]) * unit);
887
+ }
888
+
889
+ /** "12m 5s" or "40s". */
890
+ export function formatWait(ms: number): string {
891
+ const seconds = Math.max(0, Math.round(ms / 1000));
892
+ if (seconds < 60) return `${seconds}s`;
893
+ const minutes = Math.floor(seconds / 60);
894
+ return seconds % 60 ? `${minutes}m ${seconds % 60}s` : `${minutes}m`;
895
+ }
896
+
897
+ /** The note a cancel marker carries, or null when there is none. */
898
+ function cancelledBy(ticket: string): string | null {
899
+ const text = readText(cancelMarker(ticket));
900
+ if (text === null) return null;
901
+ try {
902
+ const value: unknown = JSON.parse(text);
903
+ if (isRecord(value) && typeof value.by === "string") return value.by;
904
+ } catch {
905
+ // A marker is a marker, whatever it says.
906
+ }
907
+ return "workit lock cancel";
908
+ }
909
+
910
+ /**
911
+ * Take one of `slots` machine-wide lease slots. A slot free and nobody queued
912
+ * ahead: it is taken at once. Otherwise the caller queues (a ticket in
913
+ * `queue/`) and polls every `pollMs`, round-robin per session, up to
914
+ * `waitMs`, which `onLimit` may extend once without losing its place;
915
+ * `onStatus` reports its place now and then. A `workit lock cancel`
916
+ * marker, or the end of the process that started this waiter, ends the wait
917
+ * as `cancelled`. The returned lease is released by `release()`, and on
918
+ * process exit as a backstop.
919
+ */
920
+ export async function acquireLease(options: AcquireOptions): Promise<AcquireResult> {
921
+ const clock = options.clock ?? realClock;
922
+ const pid = options.pid ?? process.pid;
923
+ const boot = bootId();
924
+ const record: LeaseRecord = {
925
+ id: randomUUID(),
926
+ pid,
927
+ processStart: processStart(pid),
928
+ bootId: boot,
929
+ bootTime: boot ? null : bootTime(),
930
+ pgid: null,
931
+ pgidStart: null,
932
+ maxAgeMs: options.maxAgeMs ?? null,
933
+ check: options.check,
934
+ cwd: options.cwd,
935
+ session: options.session,
936
+ startedAt: new Date(clock.now()).toISOString(),
937
+ scope: null,
938
+ heartbeatAt: null,
939
+ maxHoldMs: options.holdMs ?? options.maxHoldMs ?? null,
940
+ };
941
+ try {
942
+ fs.mkdirSync(options.dir, { recursive: true, mode: 0o700 });
943
+ } catch (error) {
944
+ return { ok: false, reason: "unavailable", error: (error as Error).message };
945
+ }
946
+ const started = clock.now();
947
+ const reclaimed: Reclaimed[] = [];
948
+ let waitedOn: LeaseHolder[] = [];
949
+ const name = ticketName(started, record.id);
950
+ const ticket = path.join(queueDir(options.dir), name);
951
+ const owner = options.ownerPid === undefined ? process.ppid : options.ownerPid;
952
+ // A ticket outlives a waiter that never finds the slot it would take on
953
+ // platforms without a process start time; its age bound is the wait.
954
+ const ticketRecord: LeaseRecord = {
955
+ ...record,
956
+ maxAgeMs: options.waitMs + 60_000,
957
+ ownerPid: typeof owner === "number" && owner > 1 ? owner : null,
958
+ ownerStart: typeof owner === "number" && owner > 1 ? processStart(owner) : null,
959
+ cancellable: true,
960
+ };
961
+ const self = holderOf(record);
962
+ let queued = false;
963
+ // Ctrl-C or SIGTERM while queued: give up the place, then exit as the signal would.
964
+ const onSignal = (signal: NodeJS.Signals) => {
965
+ leave();
966
+ process.exit(signal === "SIGINT" ? 130 : 143);
967
+ };
968
+ const leave = () => {
969
+ process.off("exit", leave);
970
+ process.off("SIGINT", onSignal);
971
+ process.off("SIGTERM", onSignal);
972
+ if (queued) {
973
+ removeOwn(ticket, record.id);
974
+ fs.rmSync(cancelMarker(ticket), { force: true });
975
+ }
976
+ queued = false;
977
+ };
978
+ let nextStatus = 0;
979
+ let limitMs = options.waitMs;
980
+ let requeuedAfterMs: number | null = null;
981
+ try {
982
+ for (;;) {
983
+ const waited = clock.now() - started;
984
+ if (queued) {
985
+ const by = cancelledBy(ticket) ?? ownerGone(ticketRecord, options.probe);
986
+ if (by) return { ok: false, reason: "cancelled", by, waitedMs: waited };
987
+ }
988
+ const before = liveHolders(options);
989
+ const aheadOf = waitersAhead(options, queued ? name : "~", self, before);
990
+ const ahead = aheadOf.length;
991
+ const free = options.slots - before.length;
992
+ if (ahead < free) {
993
+ const now = new Date(clock.now()).toISOString();
994
+ record.startedAt = now;
995
+ record.heartbeatAt = now;
996
+ const outcome = tryOnce(options, record, reclaimed);
997
+ if ("slot" in outcome) {
998
+ recordGrant(options.dir, self, clock.now());
999
+ const waitedMs = queued ? waited : 0;
1000
+ leave();
1001
+ const { file, slot } = outcome;
1002
+ let released = false;
1003
+ const release = () => {
1004
+ if (released) return;
1005
+ released = true;
1006
+ process.off("exit", release);
1007
+ removeOwn(file, record.id);
1008
+ };
1009
+ process.on("exit", release);
1010
+ return {
1011
+ ok: true,
1012
+ lease: {
1013
+ record,
1014
+ slot,
1015
+ file,
1016
+ waitedOn,
1017
+ waitedMs,
1018
+ reclaimed,
1019
+ release,
1020
+ },
1021
+ };
1022
+ }
1023
+ }
1024
+ const holders = liveHolders(options);
1025
+ if (!waitedOn.length) {
1026
+ waitedOn = holders;
1027
+ options.onWait?.(holders);
1028
+ }
1029
+ if (waited >= limitMs) {
1030
+ const more =
1031
+ requeuedAfterMs === null
1032
+ ? (options.onLimit?.({
1033
+ position: ahead + 1,
1034
+ ahead,
1035
+ aheadOf,
1036
+ holders,
1037
+ waitedMs: waited,
1038
+ first: false,
1039
+ }) ?? 0)
1040
+ : 0;
1041
+ if (!(more > 0))
1042
+ return {
1043
+ ok: false,
1044
+ reason: "timeout",
1045
+ holders,
1046
+ waitedMs: waited,
1047
+ position: ahead + 1,
1048
+ requeuedAfterMs,
1049
+ };
1050
+ // Re-queued once in the same place: the ticket's age bound follows the new limit.
1051
+ requeuedAfterMs = waited;
1052
+ limitMs = waited + more;
1053
+ ticketRecord.maxAgeMs = limitMs + 60_000;
1054
+ if (queued && fs.existsSync(ticket)) writeTicket(ticket, ticketRecord);
1055
+ }
1056
+ if (!queued) {
1057
+ // Handlers first, then one event-loop turn so Bun has armed them, then
1058
+ // the ticket: a signal that lands as the ticket appears must find a
1059
+ // handler, not the default action that leaves the ticket behind.
1060
+ process.on("exit", leave);
1061
+ process.on("SIGINT", onSignal);
1062
+ process.on("SIGTERM", onSignal);
1063
+ await new Promise<void>((resolve) => setImmediate(resolve));
1064
+ writeTicket(ticket, ticketRecord);
1065
+ queued = true;
1066
+ } else if (!fs.existsSync(ticket) && !fs.existsSync(cancelMarker(ticket))) {
1067
+ // Wrongly reclaimed (a clock step past its age bound): stand in the same place again.
1068
+ writeTicket(ticket, ticketRecord);
1069
+ }
1070
+ if (waited >= nextStatus) {
1071
+ options.onStatus?.({
1072
+ position: ahead + 1,
1073
+ ahead,
1074
+ aheadOf,
1075
+ holders,
1076
+ waitedMs: waited,
1077
+ first: nextStatus === 0,
1078
+ });
1079
+ nextStatus = waited + (options.statusEveryMs ?? 30_000);
1080
+ }
1081
+ await clock.sleep(Math.min(options.pollMs ?? 2000, Math.max(1, limitMs - waited)));
1082
+ }
1083
+ } catch (error) {
1084
+ return {
1085
+ ok: false,
1086
+ reason: "unavailable",
1087
+ error: (error as Error).message,
1088
+ };
1089
+ } finally {
1090
+ leave();
1091
+ }
1092
+ }
1093
+
1094
+ /**
1095
+ * Record the check's scope unit or process group on a held lease, so the lease
1096
+ * stays held while they run, even if workit itself is killed. Never writes
1097
+ * over a slot that is no longer this lease's.
1098
+ */
1099
+ export function updateLease(
1100
+ lease: HeldLease,
1101
+ patch: Pick<Partial<LeaseRecord>, "scope" | "pgid" | "pgidStart" | "heartbeatAt">,
1102
+ ) {
1103
+ Object.assign(lease.record, patch);
1104
+ try {
1105
+ if (parseLease(readText(lease.file))?.id !== lease.record.id) return;
1106
+ const temp = `${lease.file}.${process.pid}.tmp`;
1107
+ fs.writeFileSync(temp, `${JSON.stringify(lease.record)}\n`, { mode: 0o600 });
1108
+ fs.renameSync(temp, lease.file);
1109
+ } catch {
1110
+ // The lease still holds; only the note is missing.
1111
+ }
1112
+ }
1113
+
1114
+ /**
1115
+ * Record the check's process group on a held lease, with its leader's start
1116
+ * time read fresh (not cached), so a later reader can tell a recycled pgid.
1117
+ */
1118
+ export const recordGroup = (lease: HeldLease, pgid: number) =>
1119
+ updateLease(lease, { pgid, pgidStart: processStartOf(pgid) });
1120
+
1121
+ /**
1122
+ * The lease an outer heavy check passed down as WORKIT_LEASE, when it is held
1123
+ * in `dir` by this process or one of its ancestors, or by the check group
1124
+ * this process runs in (its leader proven): the ancestor chain breaks once the
1125
+ * outer workit dies or a daemon re-parents to init. A nested heavy check
1126
+ * reuses it instead of queueing behind its own parent; a leaked or forged id
1127
+ * from an unrelated process is ignored.
1128
+ */
1129
+ export function heldLease(
1130
+ dir: string,
1131
+ id: string | undefined,
1132
+ options: {
1133
+ probe?: LeaseProbe;
1134
+ ancestor?: (pid: number) => boolean;
1135
+ /** This process's group; null where it cannot be read (Windows, macOS). */
1136
+ group?: () => number | null;
1137
+ } = {},
1138
+ ): LeaseRecord | null {
1139
+ if (!id || !/^[0-9a-f-]{36}$/u.test(id)) return null;
1140
+ let names: string[];
1141
+ try {
1142
+ names = fs.readdirSync(dir);
1143
+ } catch {
1144
+ return null;
1145
+ }
1146
+ for (const name of names) {
1147
+ if (!/^slot-\d+\.json$/u.test(name)) continue;
1148
+ const lease = parseLease(readText(path.join(dir, name)));
1149
+ if (
1150
+ lease?.id === id &&
1151
+ !staleReason(lease, options.probe) &&
1152
+ ((options.ancestor ?? isAncestor)(lease.pid) || inLeaseGroup(lease, options))
1153
+ )
1154
+ return lease;
1155
+ }
1156
+ return null;
1157
+ }
1158
+
1159
+ /** Does this process run in the lease's check group, under the leader recorded with it? */
1160
+ function inLeaseGroup(
1161
+ lease: LeaseRecord,
1162
+ options: { probe?: LeaseProbe; group?: () => number | null },
1163
+ ): boolean {
1164
+ const own = (options.group ?? ownGroup)();
1165
+ return own !== null && own === lease.pgid && groupState(lease, options.probe ?? {}) === "same";
1166
+ }
1167
+ /** "test, pid 123, 1m 40s, /repo/wt" for a waiting or timeout message; a stuck holder says so. */
1168
+ export function describeHolder(holder: LeaseHolder, now = Date.now()): string {
1169
+ const age = formatWait(now - Date.parse(holder.startedAt));
1170
+ return `${holder.check}, pid ${holder.pid}${holder.session ? `, session ${holder.session}` : ""}, ${age}, ${holder.cwd}${holder.stuck ? `, stuck? (${holder.stuck})` : ""}`;
1171
+ }
1172
+
1173
+ /** systemd-run's own failure before it ran the command (bus, unit, property). */
1174
+ const SCOPE_START_FAILURE =
1175
+ /^Failed to (?:connect to bus|create bus connection|start transient scope unit|parse \S+?=)/u;
1176
+
1177
+ type Sinks = { onStdout: (text: string) => void; onStderr: (text: string) => void };
1178
+
1179
+ /**
1180
+ * Run a command in its scope, and plainly when systemd-run itself failed
1181
+ * before the command ran (one "Failed to …" line, non-zero exit). The scoped
1182
+ * attempt's first stderr line is held back until it is known not to be that
1183
+ * failure, so the caller never sees systemd-run's line on a fallback.
1184
+ * `fallback` is that line when the command ran unscoped.
1185
+ */
1186
+ export async function runScoped(
1187
+ run: (wrapper: readonly string[] | null, sinks: Sinks) => Promise<CheckRun>,
1188
+ wrapper: readonly string[] | null,
1189
+ sinks: Sinks,
1190
+ ): Promise<{ result: CheckRun; fallback: string | null }> {
1191
+ if (!wrapper) return { result: await run(null, sinks), fallback: null };
1192
+ let state = "deciding" as "deciding" | "passing" | "held";
1193
+ let firstLine = "";
1194
+ const held: Array<[keyof Sinks, string]> = [];
1195
+ const flush = () => {
1196
+ state = "passing";
1197
+ for (const [sink, text] of held.splice(0)) sinks[sink](text);
1198
+ };
1199
+ const result = await run(wrapper, {
1200
+ onStdout: (text) => {
1201
+ if (state === "passing") return sinks.onStdout(text);
1202
+ held.push(["onStdout", text]);
1203
+ // systemd-run writes only to stderr: stdout means the command runs.
1204
+ if (state === "deciding") flush();
1205
+ },
1206
+ onStderr: (text) => {
1207
+ if (state === "passing") return sinks.onStderr(text);
1208
+ held.push(["onStderr", text]);
1209
+ if (state === "held") return;
1210
+ firstLine += text;
1211
+ const newline = firstLine.indexOf("\n");
1212
+ if (newline < 0 && firstLine.length < 4096) return;
1213
+ if (SCOPE_START_FAILURE.test(firstLine.slice(0, newline < 0 ? undefined : newline)))
1214
+ state = "held";
1215
+ else flush();
1216
+ },
1217
+ });
1218
+ const lines = tailLines(result.log, 2);
1219
+ if (
1220
+ state !== "passing" &&
1221
+ result.exitCode !== 0 &&
1222
+ lines.length === 1 &&
1223
+ SCOPE_START_FAILURE.test(lines[0])
1224
+ )
1225
+ return { result: await run(null, sinks), fallback: lines[0] };
1226
+ flush();
1227
+ return { result, fallback: null };
1228
+ }
1229
+
1230
+ /** Notice markers older than this are pruned. */
1231
+ const NOTICE_TTL_MS = 7 * 86_400_000;
1232
+
1233
+ /**
1234
+ * Should the "no scope" notice print? Once per session: a marker under the
1235
+ * state dir records that this session was told. Markers older than a week
1236
+ * are pruned when a new one is written.
1237
+ */
1238
+ export function firstNotice(
1239
+ env: NodeJS.ProcessEnv,
1240
+ session: string | null,
1241
+ key: string,
1242
+ now = Date.now(),
1243
+ ): boolean {
1244
+ const id = (session ?? `ppid-${process.ppid}`).replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 120);
1245
+ const dir = path.join(stateDir(env), "notices");
1246
+ try {
1247
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1248
+ fs.writeFileSync(path.join(dir, `${key}-${id}`), `${new Date(now).toISOString()}\n`, {
1249
+ flag: "wx",
1250
+ mode: 0o600,
1251
+ });
1252
+ } catch (error) {
1253
+ return (error as NodeJS.ErrnoException).code !== "EEXIST";
1254
+ }
1255
+ try {
1256
+ for (const name of fs.readdirSync(dir)) {
1257
+ const file = path.join(dir, name);
1258
+ if (now - fs.statSync(file).mtimeMs > NOTICE_TTL_MS) fs.rmSync(file, { force: true });
1259
+ }
1260
+ } catch {
1261
+ // Pruning is best effort.
1262
+ }
1263
+ return true;
1264
+ }
1265
+
1266
+ export type SlotState = {
1267
+ slot: number;
1268
+ /** The lease in this slot, or null when it is free. */
1269
+ holder: LeaseHolder | null;
1270
+ /** Why the lease in it no longer holds (the next acquirer reclaims it). */
1271
+ stale: string | null;
1272
+ /** Why its live holder looks stuck (`workit lock reap` frees it), or null. */
1273
+ stuck: string | null;
1274
+ };
1275
+
1276
+ /**
1277
+ * What each of `slots` lease slots holds right now. Read-only: nothing is
1278
+ * reclaimed. `maxHoldMs` judges `stuck` for leases that do not carry their own.
1279
+ */
1280
+ export function leaseSlots(
1281
+ dir: string,
1282
+ slots: number,
1283
+ probe: LeaseProbe = {},
1284
+ maxHoldMs = DEFAULT_MAX_HOLD_MS,
1285
+ ): SlotState[] {
1286
+ const states: SlotState[] = [];
1287
+ for (let slot = 0; slot < slots; slot += 1) {
1288
+ const lease = parseLease(readText(path.join(dir, `slot-${slot}.json`)));
1289
+ const stale = lease ? staleReason(lease, probe) : null;
1290
+ const stuck = lease && !stale ? stuckReason(lease, maxHoldMs, probe.now) : null;
1291
+ states.push({
1292
+ slot,
1293
+ holder: lease ? { ...holderOf(lease), ...(stuck ? { stuck } : {}) } : null,
1294
+ stale,
1295
+ stuck,
1296
+ });
1297
+ }
1298
+ return states;
1299
+ }