harnery 0.45.2 → 0.46.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 (216) hide show
  1. package/dist/commander.d.ts +13 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commands/admission.d.ts.map +1 -1
  4. package/dist/commands/admission.js +214 -27
  5. package/dist/commands/agents.d.ts +4 -0
  6. package/dist/commands/agents.d.ts.map +1 -1
  7. package/dist/commands/agents.js +102 -25
  8. package/dist/commands/browse.d.ts +3 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +172 -8
  11. package/dist/commands/fetch.js +1 -0
  12. package/dist/commands/ledger-v3.d.ts.map +1 -1
  13. package/dist/commands/ledger-v3.js +8 -2
  14. package/dist/commands/presence.d.ts.map +1 -1
  15. package/dist/commands/presence.js +40 -0
  16. package/dist/commands/qa-run.d.ts.map +1 -1
  17. package/dist/commands/qa-run.js +4 -3
  18. package/dist/commands/review-pack.d.ts.map +1 -1
  19. package/dist/commands/review-pack.js +33 -6
  20. package/dist/core/agents/index.d.ts +1 -0
  21. package/dist/core/agents/index.d.ts.map +1 -1
  22. package/dist/core/agents/index.js +1 -0
  23. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  24. package/dist/core/agents/live-authority-v3.js +11 -4
  25. package/dist/core/agents/render/session-context.d.ts +0 -6
  26. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  27. package/dist/core/agents/render/session-context.js +1 -6
  28. package/dist/core/agents/session-events.d.ts.map +1 -1
  29. package/dist/core/agents/session-events.js +12 -4
  30. package/dist/core/agents/state/heartbeat-reader.d.ts +3 -0
  31. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  32. package/dist/core/agents/state/live-coordination-view.d.ts +3 -1
  33. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  34. package/dist/core/agents/state/live-coordination-view.js +6 -2
  35. package/dist/core/agents/state/parent-reference-contract.d.ts +16 -0
  36. package/dist/core/agents/state/parent-reference-contract.d.ts.map +1 -0
  37. package/dist/core/agents/state/parent-reference-contract.js +1 -0
  38. package/dist/core/agents/state/parent-reference.d.ts +18 -0
  39. package/dist/core/agents/state/parent-reference.d.ts.map +1 -0
  40. package/dist/core/agents/state/parent-reference.js +119 -0
  41. package/dist/core/config.d.ts +39 -0
  42. package/dist/core/config.d.ts.map +1 -1
  43. package/dist/core/config.js +63 -0
  44. package/dist/core/diagnostics/path-identity.d.ts +1 -1
  45. package/dist/core/diagnostics/path-identity.d.ts.map +1 -1
  46. package/dist/core/diagnostics/path-identity.js +51 -6
  47. package/dist/core/events/v3/canonical.js +43 -14
  48. package/dist/core/events/v3/index.d.ts +1 -1
  49. package/dist/core/events/v3/index.d.ts.map +1 -1
  50. package/dist/core/events/v3/index.js +1 -1
  51. package/dist/core/events/v3/producers/recorder.d.ts +34 -0
  52. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  53. package/dist/core/events/v3/producers/recorder.js +117 -13
  54. package/dist/core/events/v3/reader.d.ts.map +1 -1
  55. package/dist/core/events/v3/reader.js +53 -13
  56. package/dist/core/events/v3/validate.d.ts.map +1 -1
  57. package/dist/core/events/v3/validate.js +36 -1
  58. package/dist/core/hooks/adapter/native-parent.d.ts +23 -0
  59. package/dist/core/hooks/adapter/native-parent.d.ts.map +1 -0
  60. package/dist/core/hooks/adapter/native-parent.js +91 -0
  61. package/dist/core/hooks/cli.js +79 -4
  62. package/dist/core/hooks/resolve/owner.d.ts +0 -11
  63. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  64. package/dist/core/hooks/resolve/owner.js +0 -23
  65. package/dist/core/hooks/shell-admission.d.ts +84 -0
  66. package/dist/core/hooks/shell-admission.d.ts.map +1 -0
  67. package/dist/core/hooks/shell-admission.js +622 -0
  68. package/dist/core/hooks/shell-commit-tree.d.ts +2 -0
  69. package/dist/core/hooks/shell-commit-tree.d.ts.map +1 -0
  70. package/dist/core/hooks/shell-commit-tree.js +104 -0
  71. package/dist/core/inbox/contract.d.ts +12 -0
  72. package/dist/core/inbox/contract.d.ts.map +1 -1
  73. package/dist/core/inbox/contract.js +16 -0
  74. package/dist/core/inbox/peer-messages.d.ts +41 -16
  75. package/dist/core/inbox/peer-messages.d.ts.map +1 -1
  76. package/dist/core/inbox/peer-messages.js +372 -15
  77. package/dist/core/inbox/service.d.ts +8 -1
  78. package/dist/core/inbox/service.d.ts.map +1 -1
  79. package/dist/core/inbox/service.js +83 -3
  80. package/dist/core/inbox/targets.d.ts +17 -3
  81. package/dist/core/inbox/targets.d.ts.map +1 -1
  82. package/dist/core/inbox/targets.js +77 -2
  83. package/dist/core/presence/git.d.ts +18 -12
  84. package/dist/core/presence/git.d.ts.map +1 -1
  85. package/dist/core/presence/git.js +32 -38
  86. package/dist/core/presence/index.d.ts +2 -2
  87. package/dist/core/presence/index.d.ts.map +1 -1
  88. package/dist/core/presence/index.js +8 -6
  89. package/dist/core/presence/mail.d.ts +133 -0
  90. package/dist/core/presence/mail.d.ts.map +1 -0
  91. package/dist/core/presence/mail.js +464 -0
  92. package/dist/core/semantic/contract.d.ts +5 -5
  93. package/dist/core/storage/modes.d.ts +4 -0
  94. package/dist/core/storage/modes.d.ts.map +1 -1
  95. package/dist/core/storage/modes.js +4 -0
  96. package/dist/core/storage/segments.d.ts.map +1 -1
  97. package/dist/core/storage/segments.js +5 -4
  98. package/dist/lib/admission-history.d.ts +96 -0
  99. package/dist/lib/admission-history.d.ts.map +1 -0
  100. package/dist/lib/admission-history.js +196 -0
  101. package/dist/lib/admission.d.ts +56 -7
  102. package/dist/lib/admission.d.ts.map +1 -1
  103. package/dist/lib/admission.js +161 -19
  104. package/dist/lib/browser/client.d.ts +18 -0
  105. package/dist/lib/browser/client.d.ts.map +1 -1
  106. package/dist/lib/browser/client.js +100 -12
  107. package/dist/lib/browser/content-checks.d.ts +3 -1
  108. package/dist/lib/browser/content-checks.d.ts.map +1 -1
  109. package/dist/lib/browser/content-checks.js +10 -2
  110. package/dist/lib/browser/critique.d.ts +32 -0
  111. package/dist/lib/browser/critique.d.ts.map +1 -1
  112. package/dist/lib/browser/critique.js +72 -3
  113. package/dist/lib/browser/geometry.d.ts +2 -0
  114. package/dist/lib/browser/geometry.d.ts.map +1 -1
  115. package/dist/lib/browser/geometry.js +61 -15
  116. package/dist/lib/browser/index.d.ts +1 -1
  117. package/dist/lib/browser/index.d.ts.map +1 -1
  118. package/dist/lib/browser/index.js +1 -1
  119. package/dist/lib/browser/page-review-contracts.d.ts +6 -1
  120. package/dist/lib/browser/page-review-contracts.d.ts.map +1 -1
  121. package/dist/lib/browser/page-review-contracts.js +6 -1
  122. package/dist/lib/browser/page-review-judge.d.ts +3 -1
  123. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  124. package/dist/lib/browser/page-review-judge.js +41 -11
  125. package/dist/lib/browser/page-review-pack.d.ts +3 -0
  126. package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
  127. package/dist/lib/browser/page-review-pack.js +5 -2
  128. package/dist/lib/browser/qa-run-contracts.d.ts +11 -0
  129. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  130. package/dist/lib/browser/qa-run-contracts.js +11 -4
  131. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  132. package/dist/lib/browser/qa-run.js +73 -16
  133. package/dist/lib/browser/runts.d.ts +4 -2
  134. package/dist/lib/browser/runts.d.ts.map +1 -1
  135. package/dist/lib/browser/runts.js +84 -33
  136. package/dist/lib/browser/windows-chrome.d.ts +14 -0
  137. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  138. package/dist/lib/browser/windows-chrome.js +42 -0
  139. package/dist/lib/browser/windows-login-sync.d.ts +110 -0
  140. package/dist/lib/browser/windows-login-sync.d.ts.map +1 -0
  141. package/dist/lib/browser/windows-login-sync.js +171 -0
  142. package/dist/lib/cookies/client.d.ts +30 -5
  143. package/dist/lib/cookies/client.d.ts.map +1 -1
  144. package/dist/lib/cookies/client.js +55 -10
  145. package/dist/lib/cookies/index.d.ts +1 -1
  146. package/dist/lib/cookies/index.d.ts.map +1 -1
  147. package/dist/lib/cookies/index.js +1 -1
  148. package/dist/lib/docs-metadata-sync.d.ts.map +1 -1
  149. package/dist/lib/docs-metadata-sync.js +41 -2
  150. package/dist/lib/durable-job.d.ts +8 -0
  151. package/dist/lib/durable-job.d.ts.map +1 -1
  152. package/dist/lib/durable-job.js +10 -0
  153. package/dist/lib/http/client.d.ts +9 -8
  154. package/dist/lib/http/client.d.ts.map +1 -1
  155. package/dist/lib/http/client.js +44 -2
  156. package/dist/lib/instructions/templates.js +4 -4
  157. package/package.json +2 -2
  158. package/src/commander.ts +13 -0
  159. package/src/commands/admission.ts +272 -29
  160. package/src/commands/agents.ts +132 -27
  161. package/src/commands/browse.ts +208 -6
  162. package/src/commands/fetch.ts +1 -0
  163. package/src/commands/ledger-v3.ts +8 -2
  164. package/src/commands/presence.ts +49 -0
  165. package/src/commands/qa-run.ts +4 -3
  166. package/src/commands/review-pack.ts +41 -5
  167. package/src/core/agents/index.ts +6 -0
  168. package/src/core/agents/live-authority-v3.ts +11 -4
  169. package/src/core/agents/render/session-context.ts +1 -7
  170. package/src/core/agents/session-events.ts +18 -3
  171. package/src/core/agents/state/heartbeat-reader.ts +3 -0
  172. package/src/core/agents/state/live-coordination-view.ts +13 -6
  173. package/src/core/agents/state/parent-reference-contract.ts +14 -0
  174. package/src/core/agents/state/parent-reference.ts +179 -0
  175. package/src/core/config.ts +85 -0
  176. package/src/core/diagnostics/path-identity.ts +52 -6
  177. package/src/core/events/v3/canonical.ts +46 -13
  178. package/src/core/events/v3/index.ts +3 -0
  179. package/src/core/events/v3/producers/recorder.ts +151 -13
  180. package/src/core/events/v3/reader.ts +55 -11
  181. package/src/core/events/v3/validate.ts +42 -1
  182. package/src/core/hooks/adapter/native-parent.ts +109 -0
  183. package/src/core/hooks/cli.ts +83 -4
  184. package/src/core/hooks/resolve/owner.ts +0 -23
  185. package/src/core/hooks/shell-admission.ts +634 -0
  186. package/src/core/hooks/shell-commit-tree.ts +102 -0
  187. package/src/core/inbox/contract.ts +18 -0
  188. package/src/core/inbox/peer-messages.ts +501 -15
  189. package/src/core/inbox/service.ts +108 -3
  190. package/src/core/inbox/targets.ts +94 -3
  191. package/src/core/presence/git.ts +45 -37
  192. package/src/core/presence/index.ts +16 -8
  193. package/src/core/presence/mail.ts +620 -0
  194. package/src/core/storage/modes.ts +6 -0
  195. package/src/core/storage/segments.ts +5 -4
  196. package/src/lib/admission-history.ts +267 -0
  197. package/src/lib/admission.ts +217 -19
  198. package/src/lib/browser/client.ts +147 -35
  199. package/src/lib/browser/content-checks.ts +14 -3
  200. package/src/lib/browser/critique.ts +113 -3
  201. package/src/lib/browser/geometry.ts +63 -14
  202. package/src/lib/browser/index.ts +5 -0
  203. package/src/lib/browser/page-review-contracts.ts +6 -1
  204. package/src/lib/browser/page-review-judge.ts +58 -13
  205. package/src/lib/browser/page-review-pack.ts +13 -2
  206. package/src/lib/browser/qa-run-contracts.ts +30 -4
  207. package/src/lib/browser/qa-run.ts +78 -17
  208. package/src/lib/browser/runts.ts +85 -37
  209. package/src/lib/browser/windows-chrome.ts +50 -0
  210. package/src/lib/browser/windows-login-sync.ts +275 -0
  211. package/src/lib/cookies/client.ts +62 -10
  212. package/src/lib/cookies/index.ts +3 -0
  213. package/src/lib/docs-metadata-sync.ts +44 -2
  214. package/src/lib/durable-job.ts +15 -0
  215. package/src/lib/http/client.ts +63 -1
  216. package/src/lib/instructions/templates.ts +4 -4
@@ -0,0 +1,267 @@
1
+ // Append-only history of admission slot use. The queue files in admission.ts
2
+ // describe only the present: a waiter or holder disappears the moment it is
3
+ // admitted, released, or pruned. This module keeps one JSON line per finished
4
+ // ticket so a later reader can answer how long work queued, behind what, and
5
+ // how long each holder kept its slot.
6
+ //
7
+ // Layout: <history dir>/<resource>.jsonl, rotated to <resource>.1.jsonl once
8
+ // it passes HISTORY_ROTATE_BYTES. Each line is a complete record appended in a
9
+ // single write, so concurrent writers on one machine never interleave.
10
+ //
11
+ // Toolkit tier: this module must not import src/core (layering check).
12
+
13
+ import {
14
+ appendFileSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ renameSync,
19
+ statSync,
20
+ } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, join } from "node:path";
23
+ import { coordEnv } from "./env.ts";
24
+
25
+ export const ADMISSION_HISTORY_SCHEMA_VERSION = 1;
26
+
27
+ /** Rotate a resource's history once it passes this size; one older file is kept. */
28
+ const HISTORY_ROTATE_BYTES = 8 * 1024 * 1024;
29
+
30
+ /**
31
+ * How a ticket ended.
32
+ * - `released`: the holder ran and gave the slot back.
33
+ * - `timeout`: no slot freed before the waiter's deadline.
34
+ * - `abandoned`: the process died while waiting or holding; a peer pruned it.
35
+ * - `expired`: the entry outlived the queue TTL and a peer pruned it.
36
+ */
37
+ export type AdmissionHistoryOutcome = "released" | "timeout" | "abandoned" | "expired";
38
+
39
+ export interface AdmissionHistoryRecord {
40
+ v: typeof ADMISSION_HISTORY_SCHEMA_VERSION;
41
+ resource: string;
42
+ label: string;
43
+ pid: number;
44
+ /** Working directory of the process that queued, when known. */
45
+ cwd?: string;
46
+ capacity?: number;
47
+ outcome: AdmissionHistoryOutcome;
48
+ enqueued_at: string;
49
+ acquired_at?: string;
50
+ /** When the slot was released, the wait gave up, or a peer pruned the entry. */
51
+ ended_at: string;
52
+ /** Time spent in the queue. For an entry pruned while waiting, an upper bound. */
53
+ wait_ms: number;
54
+ /** Time the slot was held. For an entry pruned while holding, an upper bound. */
55
+ hold_ms?: number;
56
+ /** Labels of the holders when this ticket first had to wait. */
57
+ waited_behind?: string[];
58
+ /** Time spent deferring for memory pressure before joining the queue. */
59
+ pressure_wait_ms?: number;
60
+ exit_code?: number | null;
61
+ signal?: string | null;
62
+ }
63
+
64
+ /** Outcome details a caller can attach when it releases its slot. */
65
+ export interface AdmissionReleaseResult {
66
+ exitCode?: number | null;
67
+ signal?: string | null;
68
+ }
69
+
70
+ /**
71
+ * Persistent machine-wide history directory used for the default admission
72
+ * queue root. The queues live in tmpdir and correctly vanish at reboot; the
73
+ * history is evidence and must survive one.
74
+ */
75
+ export function defaultAdmissionHistoryDir(): string {
76
+ const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
77
+ return join(stateHome, "harnery", "admission-history");
78
+ }
79
+
80
+ /** Directory name of a machine-wide queue root (see admissionBaseDir). */
81
+ const SHARED_QUEUE_DIRNAME = "harnery-admission";
82
+
83
+ /**
84
+ * Where history for a given queue root is written, or null when disabled.
85
+ * HARNERY_ADMISSION_HISTORY=off disables recording; HARNERY_ADMISSION_HISTORY_DIR
86
+ * overrides the location. A machine-wide queue root, named `harnery-admission`
87
+ * under any temp directory, records to the persistent state directory. Any
88
+ * other root (an isolated test queue, for example) records beside itself as
89
+ * `<root>-history`, so isolated queues never write into the
90
+ * machine-wide history and the history directory is never listed as a resource.
91
+ */
92
+ export function resolveAdmissionHistoryDir(queueDir: string): string | null {
93
+ const toggle = coordEnv("ADMISSION_HISTORY")?.trim().toLowerCase();
94
+ if (toggle === "off" || toggle === "0" || toggle === "false") return null;
95
+ const override = coordEnv("ADMISSION_HISTORY_DIR");
96
+ if (override) return override;
97
+ return basename(queueDir) === SHARED_QUEUE_DIRNAME
98
+ ? defaultAdmissionHistoryDir()
99
+ : `${queueDir}-history`;
100
+ }
101
+
102
+ function historyFile(dir: string, resource: string, generation = 0): string {
103
+ return join(dir, generation === 0 ? `${resource}.jsonl` : `${resource}.${generation}.jsonl`);
104
+ }
105
+
106
+ /**
107
+ * Append one record. History is diagnostic: a write failure never fails the
108
+ * caller's admission, so errors are swallowed.
109
+ */
110
+ export function appendAdmissionHistory(dir: string | null, record: AdmissionHistoryRecord): void {
111
+ if (!dir) return;
112
+ try {
113
+ mkdirSync(dir, { recursive: true });
114
+ const path = historyFile(dir, record.resource);
115
+ try {
116
+ if (statSync(path).size > HISTORY_ROTATE_BYTES) {
117
+ renameSync(path, historyFile(dir, record.resource, 1));
118
+ }
119
+ } catch {
120
+ // no file yet, or a peer rotated it first
121
+ }
122
+ appendFileSync(path, `${JSON.stringify(record)}\n`);
123
+ } catch {
124
+ // diagnostic only
125
+ }
126
+ }
127
+
128
+ /** Resources with recorded history in a history directory. */
129
+ export function listAdmissionHistoryResources(dir: string): string[] {
130
+ try {
131
+ const names = new Set<string>();
132
+ for (const name of readdirSync(dir)) {
133
+ const match = /^(.+?)(?:\.1)?\.jsonl$/.exec(name);
134
+ if (match?.[1]) names.add(match[1]);
135
+ }
136
+ return [...names].sort();
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ function parseRecord(line: string): AdmissionHistoryRecord | undefined {
143
+ try {
144
+ const parsed: unknown = JSON.parse(line);
145
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
146
+ const record = parsed as Partial<AdmissionHistoryRecord>;
147
+ if (
148
+ typeof record.resource !== "string" ||
149
+ typeof record.outcome !== "string" ||
150
+ typeof record.enqueued_at !== "string" ||
151
+ typeof record.ended_at !== "string" ||
152
+ typeof record.wait_ms !== "number"
153
+ ) {
154
+ return undefined;
155
+ }
156
+ return record as AdmissionHistoryRecord;
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ export interface AdmissionHistoryQuery {
163
+ resource?: string;
164
+ /** Keep records that ended at or after this epoch-ms. */
165
+ sinceMs?: number;
166
+ /** Keep records whose label contains this text (case-insensitive). */
167
+ labelContains?: string;
168
+ }
169
+
170
+ /** Read history records, oldest first, across the current and rotated files. */
171
+ export function readAdmissionHistory(
172
+ dir: string | null,
173
+ query: AdmissionHistoryQuery = {},
174
+ ): AdmissionHistoryRecord[] {
175
+ if (!dir) return [];
176
+ const resources =
177
+ query.resource !== undefined ? [query.resource] : listAdmissionHistoryResources(dir);
178
+ const needle = query.labelContains?.toLowerCase();
179
+ const records: AdmissionHistoryRecord[] = [];
180
+ for (const resource of resources) {
181
+ for (const path of [historyFile(dir, resource, 1), historyFile(dir, resource)]) {
182
+ let text: string;
183
+ try {
184
+ text = readFileSync(path, "utf8");
185
+ } catch {
186
+ continue;
187
+ }
188
+ for (const line of text.split("\n")) {
189
+ if (!line) continue;
190
+ const record = parseRecord(line);
191
+ if (!record) continue;
192
+ if (query.sinceMs !== undefined && Date.parse(record.ended_at) < query.sinceMs) continue;
193
+ if (needle && !record.label.toLowerCase().includes(needle)) continue;
194
+ records.push(record);
195
+ }
196
+ }
197
+ }
198
+ return records.sort((a, b) => Date.parse(a.ended_at) - Date.parse(b.ended_at));
199
+ }
200
+
201
+ export interface AdmissionHistoryGroup {
202
+ resource: string;
203
+ label: string;
204
+ count: number;
205
+ /** Records that waited at least one second for a slot. */
206
+ queued: number;
207
+ wait_ms: { p50: number; p90: number; max: number; total: number };
208
+ /** Hold statistics over released records only. */
209
+ hold_ms: { p50: number; p90: number; max: number } | null;
210
+ outcomes: Partial<Record<AdmissionHistoryOutcome, number>>;
211
+ }
212
+
213
+ function percentile(sorted: number[], p: number): number {
214
+ if (sorted.length === 0) return 0;
215
+ return sorted[Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1)] ?? 0;
216
+ }
217
+
218
+ const QUEUED_THRESHOLD_MS = 1_000;
219
+
220
+ /**
221
+ * Group records by resource and label, most total wait first, so the work
222
+ * that spends the longest queueing leads the report.
223
+ */
224
+ export function summarizeAdmissionHistory(
225
+ records: AdmissionHistoryRecord[],
226
+ ): AdmissionHistoryGroup[] {
227
+ const groups = new Map<string, AdmissionHistoryRecord[]>();
228
+ for (const record of records) {
229
+ const key = `${record.resource}\u0000${record.label}`;
230
+ const bucket = groups.get(key);
231
+ if (bucket) bucket.push(record);
232
+ else groups.set(key, [record]);
233
+ }
234
+ const summaries: AdmissionHistoryGroup[] = [];
235
+ for (const bucket of groups.values()) {
236
+ const first = bucket[0] as AdmissionHistoryRecord;
237
+ const waits = bucket.map((record) => record.wait_ms).sort((a, b) => a - b);
238
+ const holds = bucket
239
+ .filter((record) => record.outcome === "released" && typeof record.hold_ms === "number")
240
+ .map((record) => record.hold_ms as number)
241
+ .sort((a, b) => a - b);
242
+ const outcomes: Partial<Record<AdmissionHistoryOutcome, number>> = {};
243
+ for (const record of bucket) outcomes[record.outcome] = (outcomes[record.outcome] ?? 0) + 1;
244
+ summaries.push({
245
+ resource: first.resource,
246
+ label: first.label,
247
+ count: bucket.length,
248
+ queued: waits.filter((wait) => wait >= QUEUED_THRESHOLD_MS).length,
249
+ wait_ms: {
250
+ p50: percentile(waits, 0.5),
251
+ p90: percentile(waits, 0.9),
252
+ max: waits[waits.length - 1] ?? 0,
253
+ total: waits.reduce((sum, wait) => sum + wait, 0),
254
+ },
255
+ hold_ms:
256
+ holds.length > 0
257
+ ? {
258
+ p50: percentile(holds, 0.5),
259
+ p90: percentile(holds, 0.9),
260
+ max: holds[holds.length - 1] ?? 0,
261
+ }
262
+ : null,
263
+ outcomes,
264
+ });
265
+ }
266
+ return summaries.sort((a, b) => b.wait_ms.total - a.wait_ms.total);
267
+ }
@@ -18,6 +18,9 @@
18
18
  // file; process death leaves a dead-PID file for the next participant to
19
19
  // prune.
20
20
  //
21
+ // Every ticket that ends (released, timed out, or pruned after its process
22
+ // died) appends one record to the admission history (admission-history.ts).
23
+ //
21
24
  // Toolkit tier: this module must not import src/core (layering check).
22
25
 
23
26
  import { randomUUID } from "node:crypto";
@@ -31,8 +34,17 @@ import {
31
34
  } from "node:fs";
32
35
  import { tmpdir } from "node:os";
33
36
  import { join } from "node:path";
37
+ import {
38
+ ADMISSION_HISTORY_SCHEMA_VERSION,
39
+ type AdmissionHistoryOutcome,
40
+ type AdmissionReleaseResult,
41
+ appendAdmissionHistory,
42
+ resolveAdmissionHistoryDir,
43
+ } from "./admission-history.ts";
34
44
  import { coordEnv } from "./env.ts";
35
45
 
46
+ export type { AdmissionReleaseResult } from "./admission-history.ts";
47
+
36
48
  export interface AdmissionConfig {
37
49
  /** Base directory holding one subdirectory per resource. */
38
50
  dir: string;
@@ -44,6 +56,11 @@ export interface AdmissionConfig {
44
56
  pollMs?: number;
45
57
  /** Entries older than this are pruned regardless of PID (default 6h). */
46
58
  ttlMs?: number;
59
+ /**
60
+ * Directory for the slot-use history. Omitted: resolved from the queue
61
+ * directory (see resolveAdmissionHistoryDir). null: record nothing.
62
+ */
63
+ historyDir?: string | null;
47
64
  }
48
65
 
49
66
  export interface AdmissionEntry {
@@ -52,6 +69,12 @@ export interface AdmissionEntry {
52
69
  created_at: string;
53
70
  /** Set on holders once admitted. */
54
71
  acquired_at?: string;
72
+ /** Working directory of the queueing process. */
73
+ cwd?: string;
74
+ /** Capacity the queueing process asked for. */
75
+ capacity?: number;
76
+ /** Set on holders that had to wait: the holder labels they first waited behind. */
77
+ waited_behind?: string[];
55
78
  }
56
79
 
57
80
  export interface AdmissionWaitInfo {
@@ -67,6 +90,8 @@ export interface AdmissionAcquireOptions {
67
90
  timeoutMs?: number;
68
91
  /** Progress callback, invoked at most once per poll while waiting. */
69
92
  onWait?: (info: AdmissionWaitInfo) => void;
93
+ /** Time the caller already spent deferring for memory pressure, for the history. */
94
+ pressureWaitMs?: number;
70
95
  }
71
96
 
72
97
  export interface AdmissionHandle {
@@ -74,8 +99,11 @@ export interface AdmissionHandle {
74
99
  entry: string;
75
100
  /** Milliseconds spent waiting in the queue. */
76
101
  waitedMs: number;
77
- /** Give the slot back. Safe to call more than once. */
78
- release: () => void;
102
+ /**
103
+ * Give the slot back and record the slot use in the history. Safe to call
104
+ * more than once; only the first call records.
105
+ */
106
+ release: (result?: AdmissionReleaseResult) => void;
79
107
  }
80
108
 
81
109
  export class AdmissionTimeoutError extends Error {
@@ -143,7 +171,13 @@ interface LiveEntry {
143
171
  /** List live entries in lexical (= enqueue) order, pruning dead-PID, expired,
144
172
  * and torn files as a side effect. Every participant prunes, so a crashed
145
173
  * holder's slot frees as soon as anyone else looks. */
146
- function listLive(dir: string, ttlMs: number): LiveEntry[] {
174
+ type PruneReason = Extract<AdmissionHistoryOutcome, "abandoned" | "expired">;
175
+
176
+ function listLive(
177
+ dir: string,
178
+ ttlMs: number,
179
+ onPrune?: (entry: AdmissionEntry, reason: PruneReason) => void,
180
+ ): LiveEntry[] {
147
181
  let names: string[];
148
182
  try {
149
183
  names = readdirSync(dir)
@@ -167,6 +201,15 @@ function listLive(dir: string, ttlMs: number): LiveEntry[] {
167
201
  label: typeof record.label === "string" ? record.label : "",
168
202
  created_at: record.created_at,
169
203
  ...(typeof record.acquired_at === "string" ? { acquired_at: record.acquired_at } : {}),
204
+ ...(typeof record.cwd === "string" ? { cwd: record.cwd } : {}),
205
+ ...(typeof record.capacity === "number" ? { capacity: record.capacity } : {}),
206
+ ...(Array.isArray(record.waited_behind)
207
+ ? {
208
+ waited_behind: record.waited_behind.filter(
209
+ (label): label is string => typeof label === "string",
210
+ ),
211
+ }
212
+ : {}),
170
213
  };
171
214
  }
172
215
  }
@@ -188,12 +231,16 @@ function listLive(dir: string, ttlMs: number): LiveEntry[] {
188
231
  continue;
189
232
  }
190
233
  const age = now - Date.parse(entry.created_at);
191
- if (!pidAlive(entry.pid) || Number.isNaN(age) || age > ttlMs) {
234
+ const dead = !pidAlive(entry.pid);
235
+ if (dead || Number.isNaN(age) || age > ttlMs) {
236
+ let pruned = false;
192
237
  try {
193
238
  unlinkSync(path);
239
+ pruned = true;
194
240
  } catch {
195
- // a peer pruned it first
241
+ // a peer pruned it first, and records it
196
242
  }
243
+ if (pruned) onPrune?.(entry, dead ? "abandoned" : "expired");
197
244
  continue;
198
245
  }
199
246
  live.push({ name, entry });
@@ -201,6 +248,42 @@ function listLive(dir: string, ttlMs: number): LiveEntry[] {
201
248
  return live;
202
249
  }
203
250
 
251
+ function historyDirFor(config: Pick<AdmissionConfig, "dir" | "historyDir">): string | null {
252
+ return config.historyDir !== undefined
253
+ ? config.historyDir
254
+ : resolveAdmissionHistoryDir(config.dir);
255
+ }
256
+
257
+ /** Record an entry a participant pruned because its process died or it expired. */
258
+ function recordPruned(
259
+ historyDir: string | null,
260
+ resource: string,
261
+ entry: AdmissionEntry,
262
+ reason: PruneReason,
263
+ ): void {
264
+ if (!historyDir) return;
265
+ const now = Date.now();
266
+ const enqueuedMs = Date.parse(entry.created_at);
267
+ const acquiredMs = entry.acquired_at ? Date.parse(entry.acquired_at) : Number.NaN;
268
+ appendAdmissionHistory(historyDir, {
269
+ v: ADMISSION_HISTORY_SCHEMA_VERSION,
270
+ resource,
271
+ label: entry.label,
272
+ pid: entry.pid,
273
+ ...(entry.cwd ? { cwd: entry.cwd } : {}),
274
+ ...(entry.capacity !== undefined ? { capacity: entry.capacity } : {}),
275
+ outcome: reason,
276
+ enqueued_at: entry.created_at,
277
+ ...(entry.acquired_at ? { acquired_at: entry.acquired_at } : {}),
278
+ ended_at: new Date(now).toISOString(),
279
+ wait_ms: Number.isNaN(enqueuedMs)
280
+ ? 0
281
+ : Math.max(0, (Number.isNaN(acquiredMs) ? now : acquiredMs) - enqueuedMs),
282
+ ...(Number.isNaN(acquiredMs) ? {} : { hold_ms: Math.max(0, now - acquiredMs) }),
283
+ ...(entry.waited_behind?.length ? { waited_behind: entry.waited_behind } : {}),
284
+ });
285
+ }
286
+
204
287
  function sleep(ms: number): Promise<void> {
205
288
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
206
289
  }
@@ -224,11 +307,18 @@ export async function acquireAdmission(
224
307
  const tickets = ticketsDir(config);
225
308
  const held = heldDir(config);
226
309
 
310
+ const historyDir = historyDirFor(config);
311
+ const onPrune = (entry: AdmissionEntry, reason: PruneReason): void =>
312
+ recordPruned(historyDir, config.resource, entry, reason);
227
313
  const myEntry: AdmissionEntry = {
228
314
  pid: process.pid,
229
315
  label: options.label,
230
316
  created_at: new Date().toISOString(),
317
+ cwd: process.cwd(),
318
+ capacity: config.capacity,
231
319
  };
320
+ let waitedBehind: string[] | undefined;
321
+ const pressureWait = options.pressureWaitMs ? { pressure_wait_ms: options.pressureWaitMs } : {};
232
322
  const myName = `${String(Date.now()).padStart(13, "0")}-${process.pid}-${randomUUID().slice(0, 8)}.json`;
233
323
  writeEntry(tickets, myName, myEntry);
234
324
 
@@ -236,8 +326,8 @@ export async function acquireAdmission(
236
326
  const deadline = startedWaiting + timeoutMs;
237
327
  try {
238
328
  while (true) {
239
- const liveHeld = listLive(held, ttlMs);
240
- const liveTickets = listLive(tickets, ttlMs);
329
+ const liveHeld = listLive(held, ttlMs, onPrune);
330
+ const liveTickets = listLive(tickets, ttlMs, onPrune);
241
331
  const myIndex = liveTickets.findIndex((ticket) => ticket.name === myName);
242
332
  if (myIndex === -1) {
243
333
  // Pruned by a peer (should not happen while we are alive) or lost to
@@ -253,7 +343,7 @@ export async function acquireAdmission(
253
343
  // between two waiters' listings. Lexical order (= enqueue order)
254
344
  // decides who stays; the loser rejoins the queue with its original
255
345
  // priority because the filename keeps its enqueue timestamp.
256
- const heldNow = listLive(held, ttlMs);
346
+ const heldNow = listLive(held, ttlMs, onPrune);
257
347
  if (heldNow.length > config.capacity) {
258
348
  const keepers = new Set(
259
349
  heldNow.slice(0, config.capacity).map((holderEntry) => holderEntry.name),
@@ -264,12 +354,14 @@ export async function acquireAdmission(
264
354
  continue;
265
355
  }
266
356
  }
267
- writeEntry(held, myName, { ...myEntry, acquired_at: new Date().toISOString() });
357
+ const acquiredAt = new Date();
358
+ writeEntry(held, myName, { ...myEntry, acquired_at: acquiredAt.toISOString() });
359
+ const waitedMs = acquiredAt.getTime() - startedWaiting;
268
360
  let released = false;
269
361
  return {
270
362
  entry: myName,
271
- waitedMs: Date.now() - startedWaiting,
272
- release: () => {
363
+ waitedMs,
364
+ release: (result?: AdmissionReleaseResult) => {
273
365
  if (released) return;
274
366
  released = true;
275
367
  try {
@@ -277,14 +369,55 @@ export async function acquireAdmission(
277
369
  } catch {
278
370
  // already pruned
279
371
  }
372
+ const endedAt = new Date();
373
+ appendAdmissionHistory(historyDir, {
374
+ v: ADMISSION_HISTORY_SCHEMA_VERSION,
375
+ resource: config.resource,
376
+ label: myEntry.label,
377
+ pid: myEntry.pid,
378
+ ...(myEntry.cwd ? { cwd: myEntry.cwd } : {}),
379
+ capacity: config.capacity,
380
+ outcome: "released",
381
+ enqueued_at: myEntry.created_at,
382
+ acquired_at: acquiredAt.toISOString(),
383
+ ended_at: endedAt.toISOString(),
384
+ wait_ms: waitedMs,
385
+ hold_ms: endedAt.getTime() - acquiredAt.getTime(),
386
+ ...(waitedBehind?.length ? { waited_behind: waitedBehind } : {}),
387
+ ...pressureWait,
388
+ ...(result?.exitCode !== undefined ? { exit_code: result.exitCode } : {}),
389
+ ...(result?.signal !== undefined ? { signal: result.signal } : {}),
390
+ });
280
391
  },
281
392
  };
282
393
  }
394
+ if (waitedBehind === undefined) {
395
+ // Persist who this ticket waits behind, so the record survives even
396
+ // if this process dies in the queue and a peer prunes the ticket.
397
+ waitedBehind = liveHeld.map((holderEntry) => holderEntry.entry.label);
398
+ myEntry.waited_behind = waitedBehind;
399
+ writeEntry(tickets, myName, myEntry);
400
+ }
283
401
  options.onWait?.({
284
402
  position: myIndex + 1,
285
403
  holders: liveHeld.map((holderEntry) => holderEntry.entry),
286
404
  });
287
405
  if (Date.now() >= deadline) {
406
+ const endedAt = new Date();
407
+ appendAdmissionHistory(historyDir, {
408
+ v: ADMISSION_HISTORY_SCHEMA_VERSION,
409
+ resource: config.resource,
410
+ label: myEntry.label,
411
+ pid: myEntry.pid,
412
+ ...(myEntry.cwd ? { cwd: myEntry.cwd } : {}),
413
+ capacity: config.capacity,
414
+ outcome: "timeout",
415
+ enqueued_at: myEntry.created_at,
416
+ ended_at: endedAt.toISOString(),
417
+ wait_ms: endedAt.getTime() - startedWaiting,
418
+ ...(waitedBehind?.length ? { waited_behind: waitedBehind } : {}),
419
+ ...pressureWait,
420
+ });
288
421
  throw new AdmissionTimeoutError(
289
422
  config.resource,
290
423
  timeoutMs,
@@ -313,25 +446,90 @@ export interface AdmissionStatus {
313
446
 
314
447
  /** Snapshot the queue for one resource, pruning dead entries on the way. */
315
448
  export function admissionStatus(
316
- config: Pick<AdmissionConfig, "dir" | "resource" | "ttlMs">,
449
+ config: Pick<AdmissionConfig, "dir" | "resource" | "ttlMs" | "historyDir">,
317
450
  ): AdmissionStatus {
318
451
  const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
319
452
  const full: AdmissionConfig = { ...config, capacity: 1 };
453
+ const historyDir = historyDirFor(config);
454
+ const onPrune = (entry: AdmissionEntry, reason: PruneReason): void =>
455
+ recordPruned(historyDir, config.resource, entry, reason);
320
456
  return {
321
457
  resource: config.resource,
322
- holders: listLive(heldDir(full), ttlMs).map((holderEntry) => holderEntry.entry),
323
- waiters: listLive(ticketsDir(full), ttlMs).map((ticket) => ticket.entry),
458
+ holders: listLive(heldDir(full), ttlMs, onPrune).map((holderEntry) => holderEntry.entry),
459
+ waiters: listLive(ticketsDir(full), ttlMs, onPrune).map((ticket) => ticket.entry),
324
460
  };
325
461
  }
326
462
 
327
463
  /**
328
- * Base directory for admission queue state. tmpdir clears on reboot, which
329
- * is correct — stale queue state must not outlive the boot. Override with
330
- * HARNERY_ADMISSION_DIR. Host CLIs and harn commands must share this helper
331
- * so they join the same machine-wide queues.
464
+ * Base directory for admission queue state. The temp directory clears on
465
+ * reboot, which is correct: stale queue state must not outlive the boot.
466
+ * Override with HARNERY_ADMISSION_DIR. Host CLIs and harn commands must share
467
+ * this helper so they join the same machine-wide queues.
468
+ *
469
+ * On POSIX the default is the literal /tmp, not os.tmpdir(): agent sandboxes
470
+ * often set a private TMPDIR per session, and a queue under it would admit
471
+ * every session on its own.
332
472
  */
333
473
  export function admissionBaseDir(): string {
334
- return coordEnv("ADMISSION_DIR") ?? join(tmpdir(), "harnery-admission");
474
+ return (
475
+ coordEnv("ADMISSION_DIR") ??
476
+ (process.platform === "win32" ? join(tmpdir(), "harnery-admission") : "/tmp/harnery-admission")
477
+ );
478
+ }
479
+
480
+ /**
481
+ * Linux memory pressure: the `full avg10` figure from /proc/pressure/memory,
482
+ * the share of the last ten seconds in which every runnable task stalled on
483
+ * memory. Undefined where pressure stall information is unavailable.
484
+ */
485
+ export function memoryPressureFullAvg10(): number | undefined {
486
+ try {
487
+ const line = readFileSync("/proc/pressure/memory", "utf8")
488
+ .split("\n")
489
+ .find((candidate) => candidate.startsWith("full"));
490
+ const match = line ? /avg10=([0-9.]+)/.exec(line) : null;
491
+ return match?.[1] !== undefined ? Number(match[1]) : undefined;
492
+ } catch {
493
+ return undefined;
494
+ }
495
+ }
496
+
497
+ export interface PressureWaitOptions {
498
+ /** Defer while `full avg10` is above this value. */
499
+ threshold: number;
500
+ /** Give up deferring after this long and let the caller start anyway. */
501
+ maxWaitMs: number;
502
+ pollMs?: number;
503
+ /** Pressure reader; tests inject one. */
504
+ read?: () => number | undefined;
505
+ /** Called once when deferral starts and once if it gives up. */
506
+ onWait?: (event: { pressure: number; waitedMs: number; gaveUp: boolean }) => void;
507
+ }
508
+
509
+ /**
510
+ * Wait, holding no slot, while the machine is already stalling on memory, so
511
+ * a heavy job does not start into a storm. Never blocks forever: after
512
+ * maxWaitMs it returns and the caller starts anyway. Returns the time waited.
513
+ */
514
+ export async function waitForMemoryPressure(options: PressureWaitOptions): Promise<number> {
515
+ const read = options.read ?? memoryPressureFullAvg10;
516
+ const pollMs = options.pollMs ?? 5_000;
517
+ const started = Date.now();
518
+ let announced = false;
519
+ while (true) {
520
+ const pressure = read();
521
+ if (pressure === undefined || pressure <= options.threshold) return Date.now() - started;
522
+ const waitedMs = Date.now() - started;
523
+ if (waitedMs + pollMs > options.maxWaitMs) {
524
+ options.onWait?.({ pressure, waitedMs, gaveUp: true });
525
+ return waitedMs;
526
+ }
527
+ if (!announced) {
528
+ announced = true;
529
+ options.onWait?.({ pressure, waitedMs, gaveUp: false });
530
+ }
531
+ await sleep(pollMs);
532
+ }
335
533
  }
336
534
 
337
535
  /** Resources present under an admission base directory. */