weir-harness 1.2.2 → 2.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 (89) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.file-lease-badge.js +307 -0
  3. package/lib/client.js +20 -30
  4. package/lib/client.settings-page.js +10 -34
  5. package/package.json +2 -2
  6. package/src/capabilities/store/checked-contract.d.ts +2 -2
  7. package/src/capabilities/store/fs-adapter.js +2 -3
  8. package/src/context-guard/index.js +2 -2
  9. package/src/delegate/supervision-mount.js +5 -5
  10. package/src/delegate/tool.js +2 -10
  11. package/src/file-lease/client.js +1066 -0
  12. package/src/file-lease/index.js +30 -0
  13. package/src/file-lease/lineage.js +207 -0
  14. package/src/file-lease/node-contracts.d.ts +42 -0
  15. package/src/file-lease/plugin.js +789 -0
  16. package/src/file-lease/singleton.js +155 -0
  17. package/src/file-lease/spawn.js +62 -0
  18. package/src/file-lease/tools.js +459 -0
  19. package/src/file-lease/view.js +144 -0
  20. package/src/hashline-edit/index.js +76 -42
  21. package/src/hashline-edit/write-tool.js +90 -0
  22. package/src/intent-gate/matcher.js +27 -0
  23. package/src/lease-broker/entry.js +98 -0
  24. package/src/lease-broker/node-contracts.d.ts +63 -0
  25. package/src/lease-broker/protocol.js +236 -0
  26. package/src/lease-broker/server.js +399 -0
  27. package/src/lease-broker/state.js +425 -0
  28. package/src/lsp/admin.js +24 -13
  29. package/src/lsp/index.js +5 -2
  30. package/src/lsp/registry.js +83 -17
  31. package/src/lsp/tools.js +137 -27
  32. package/src/notify/commands.js +15 -2
  33. package/src/notify/index.js +48 -10
  34. package/src/notify/web-channel.js +26 -3
  35. package/src/process/index.js +11 -0
  36. package/src/settings/index.js +2 -44
  37. package/src/settings/sections.js +29 -28
  38. package/src/shared/audit.js +6 -1
  39. package/src/todo-driver/index.js +1 -6
  40. package/src/worktree/cards.js +6 -21
  41. package/src/worktree/lane-core.js +1 -19
  42. package/src/worktree/lane-workflows.js +11 -32
  43. package/lib/client.edit-lock-maintenance.js +0 -592
  44. package/lib/client.edit-lock-panel.js +0 -405
  45. package/src/edit-lock/adapter.js +0 -57
  46. package/src/edit-lock/admin-ledger.js +0 -149
  47. package/src/edit-lock/admin-recovery.js +0 -151
  48. package/src/edit-lock/admission.js +0 -82
  49. package/src/edit-lock/binding-attestation.js +0 -29
  50. package/src/edit-lock/binding-guidance.js +0 -72
  51. package/src/edit-lock/binding-machine.js +0 -312
  52. package/src/edit-lock/call-context.js +0 -65
  53. package/src/edit-lock/derived-facts.js +0 -67
  54. package/src/edit-lock/domain.js +0 -165
  55. package/src/edit-lock/domains.js +0 -170
  56. package/src/edit-lock/host.js +0 -149
  57. package/src/edit-lock/incarnations.js +0 -150
  58. package/src/edit-lock/index.js +0 -1303
  59. package/src/edit-lock/inspect.js +0 -219
  60. package/src/edit-lock/lifecycle.js +0 -579
  61. package/src/edit-lock/limits.js +0 -61
  62. package/src/edit-lock/maintenance.js +0 -310
  63. package/src/edit-lock/manager.js +0 -875
  64. package/src/edit-lock/negotiation.js +0 -132
  65. package/src/edit-lock/operation-history.js +0 -214
  66. package/src/edit-lock/peer-client.js +0 -86
  67. package/src/edit-lock/peer-transport.js +0 -76
  68. package/src/edit-lock/peer.js +0 -87
  69. package/src/edit-lock/publisher.js +0 -191
  70. package/src/edit-lock/read-authority.js +0 -101
  71. package/src/edit-lock/recovery.js +0 -139
  72. package/src/edit-lock/remote.js +0 -362
  73. package/src/edit-lock/request-binding.js +0 -42
  74. package/src/edit-lock/request-data.js +0 -40
  75. package/src/edit-lock/reservation.js +0 -301
  76. package/src/edit-lock/reserved-runtime.js +0 -45
  77. package/src/edit-lock/resource-identity.js +0 -130
  78. package/src/edit-lock/runtime.js +0 -145
  79. package/src/edit-lock/settle.js +0 -144
  80. package/src/edit-lock/snapshot.js +0 -175
  81. package/src/edit-lock/stale-sweep.js +0 -120
  82. package/src/edit-lock/state.js +0 -462
  83. package/src/edit-lock/store.js +0 -327
  84. package/src/edit-lock/tool-scope.js +0 -29
  85. package/src/edit-lock/view.js +0 -170
  86. package/src/edit-lock/write-scopes.js +0 -48
  87. package/src/edit-lock/write-tool.js +0 -45
  88. package/src/shared/delegate-edit-lifecycle.js +0 -36
  89. package/src/worktree/authority.js +0 -56
@@ -0,0 +1,1066 @@
1
+ // The client half of the edit-lock lease protocol (design D6, D7, D8).
2
+ //
3
+ // The daemon in ../lease-broker/ is the ONLY arbiter: every verdict about who
4
+ // may write a file arrives over this socket, and nothing here re-derives one.
5
+ // What lives in this file:
6
+ //
7
+ // - one lazily established connection per client, one `hello` handshake per
8
+ // connection, one `reqId` per request (replies are matched by that id and by
9
+ // nothing else);
10
+ // - the in-memory lease ledger (`path -> {session, lineage, holdUntil}`). It is
11
+ // used for exactly two things — knowing what a turn boundary must release and
12
+ // what a reconnect must replay. It NEVER decides a conflict (D6): the moment
13
+ // this file starts reasoning about ownership it becomes a second authority,
14
+ // which is the failure mode this whole design exists to delete;
15
+ // - degrade-and-proceed (D7). An unhealthy arbiter never blocks a write; a real
16
+ // `held` conflict always does. The protocol's closed error vocabulary is what
17
+ // keeps those two apart — no heuristics, no "is it probably fine".
18
+ //
19
+ // Deliberately absent: killing or restarting the daemon. The daemon is a cache
20
+ // with an idle window and a stale-socket recovery of its own, and a client that
21
+ // permanently gives up after one transport failure would turn a transient blip
22
+ // into a session-long loss of arbitration. Every later call re-tries the
23
+ // connection (and the spawn); only the audit is rate limited.
24
+ //
25
+ // Every external dependency is injected so a unit test can drive the state
26
+ // machine without a socket, a clock, or a child process.
27
+
28
+ import { isAbsolute } from 'node:path'
29
+ import { createConnection } from 'node:net'
30
+ import { PROTOCOL } from '../lease-broker/protocol.js'
31
+ import { defaultSocketPath } from '../lease-broker/server.js'
32
+ import { spawnDaemon as defaultSpawnDaemon } from './spawn.js'
33
+
34
+ /** Per-request deadline. A slow arbiter is an unhealthy arbiter (D7). */
35
+ const DEFAULT_TIMEOUT_MS = 2_000
36
+
37
+ /** Pause between connection attempts while a spawned daemon comes up. */
38
+ const DEFAULT_CONNECT_RETRY_MS = 50
39
+
40
+ /** Total budget for "spawn, then wait for the socket" before degrading. */
41
+ const DEFAULT_CONNECT_RETRY_BUDGET_MS = 1_500
42
+
43
+ /** Retention bound in minutes, mirrored from the protocol for replay math. */
44
+ const HOLD_MAX_MINUTES = 60
45
+
46
+ /** Infrastructure failure: connection, handshake, timeout, write, closed. */
47
+ export const TRANSPORT_CODE = 'transport'
48
+
49
+ /** Real conflict: someone else holds the path. Never degraded (D7). */
50
+ const CONFLICT_CODE = 'held'
51
+
52
+ /** Endpoint-absent errors: nobody is listening yet, so spawning may help. */
53
+ const ABSENT_ENDPOINT_CODES = new Set(['ENOENT', 'ECONNREFUSED'])
54
+
55
+ /**
56
+ * Degraded-mode audit throttle: at most one record per process per window (D7).
57
+ * Exported because the operator-facing contract names the number.
58
+ */
59
+ export const DEGRADED_AUDIT_WINDOW_MS = 60_000
60
+
61
+ /**
62
+ * @typedef {Error & {
63
+ * code: string,
64
+ * conflicts?: any[],
65
+ * paths?: string[],
66
+ * maxMinutes?: number,
67
+ * cause?: unknown,
68
+ * }} LeaseFailure
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} LeaseRecord
73
+ * @property {string} session the session that last touched the lease (release scope)
74
+ * @property {string} lineage the opaque `owner.id` this client acquired under
75
+ * @property {number | null} holdUntil retention deadline in epoch ms, `null` when unretained
76
+ */
77
+
78
+ /**
79
+ * The transport contract the state machine speaks. `connect()` may resolve a
80
+ * connection asynchronously and must reject with the underlying error (so
81
+ * `ENOENT`/`ECONNREFUSED` can be recognised as "daemon not up yet").
82
+ *
83
+ * @typedef {object} LeaseConnection
84
+ * @property {(text: string) => void} write may throw; every throw is a transport failure
85
+ * @property {(handler: (chunk: string) => void) => void} onData newline-delimited frames
86
+ * @property {(handler: () => void) => void} onClose called once when the connection is gone
87
+ * @property {() => void} close idempotent
88
+ */
89
+
90
+ /**
91
+ * @typedef {object} LeaseClientOptions
92
+ * @property {string} [socketPath] endpoint address; defaults to the daemon's own `defaultSocketPath({protocol})`
93
+ * @property {number} [protocol] protocol major sent in the handshake
94
+ * @property {number} [timeoutMs] per-request deadline
95
+ * @property {() => number} [now] injected clock (epoch ms)
96
+ * @property {(socketPath: string) => LeaseConnection | Promise<LeaseConnection>} [connect] dial the endpoint
97
+ * @property {(() => unknown) | null} [spawnDaemon] called at most once per connection attempt; `null` disables spawning
98
+ * @property {(kind: string, detail: Record<string, any>) => void} [audit] audit sink (`kind` is `'degraded'`)
99
+ * @property {number} [connectRetryMs] pause between connection attempts after a spawn
100
+ * @property {number} [connectRetryBudgetMs] total budget for connect attempts after a spawn
101
+ * @property {number} [degradedAuditWindowMs] throttle window for the degraded audit record
102
+ */
103
+
104
+ /**
105
+ * @param {string} code
106
+ * @param {string} message
107
+ * @param {{ conflicts?: any[], paths?: string[], maxMinutes?: number, cause?: unknown }} [detail]
108
+ * @returns {LeaseFailure}
109
+ */
110
+ function leaseError(code, message, detail = {}) {
111
+ /** @type {LeaseFailure} */
112
+ const error = /** @type {LeaseFailure} */ (new Error(message))
113
+ error.code = code
114
+ if (Array.isArray(detail.conflicts)) error.conflicts = detail.conflicts
115
+ if (Array.isArray(detail.paths)) error.paths = detail.paths
116
+ if (typeof detail.maxMinutes === 'number') error.maxMinutes = detail.maxMinutes
117
+ if (detail.cause !== undefined) error.cause = detail.cause
118
+ return error
119
+ }
120
+
121
+ /**
122
+ * @param {string} message
123
+ * @param {unknown} [cause]
124
+ * @returns {LeaseFailure}
125
+ */
126
+ function transportError(message, cause) {
127
+ return leaseError(TRANSPORT_CODE, message, { cause })
128
+ }
129
+
130
+ /**
131
+ * True for a real conflict — the only refusal that may stop a write.
132
+ * @param {unknown} error
133
+ * @returns {boolean}
134
+ */
135
+ export function isLeaseConflict(error) {
136
+ return !!error && typeof error === 'object' && /** @type {any} */ (error).code === CONFLICT_CODE
137
+ }
138
+
139
+ /**
140
+ * @param {unknown} error
141
+ * @returns {string}
142
+ */
143
+ function errorCode(error) {
144
+ const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
145
+ return typeof code === 'string' ? code : 'internal'
146
+ }
147
+
148
+ /**
149
+ * @param {number} ms
150
+ * @returns {Promise<void>}
151
+ */
152
+ function delay(ms) {
153
+ return new Promise((resolve) => {
154
+ setTimeout(resolve, ms)
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Default dialer: a plain newline-JSON socket from `node:net`.
160
+ * @param {string} socketPath
161
+ * @returns {Promise<LeaseConnection>}
162
+ */
163
+ function defaultConnect(socketPath) {
164
+ return new Promise((resolve, reject) => {
165
+ const socket = createConnection({ path: socketPath })
166
+ socket.setEncoding('utf8')
167
+ let connected = false
168
+ let settled = false
169
+ socket.once('connect', () => {
170
+ connected = true
171
+ settled = true
172
+ socket.setNoDelay(true)
173
+ // A lease connection is process state (design D11), not a pending task: it
174
+ // must never keep an otherwise-finished Node process alive. Without this
175
+ // an idle client pins the event loop, so a headless CLI turn that ended
176
+ // normally only dies when its caller times out (observed as a hung
177
+ // integration boot), and a graceful host shutdown would wait forever.
178
+ // `unref` is part of Node's net.Socket but not modelled by the hand-written
179
+ // node contracts this bundle compiles against (`types: []`), and the
180
+ // connection is an injectable stub in tests — hence the guarded call.
181
+ const timer = /** @type {{ unref?: () => void }} */ (/** @type {unknown} */ (socket))
182
+ if (typeof timer.unref === 'function') timer.unref()
183
+ resolve(wrapSocket(socket))
184
+ })
185
+ socket.once('error', (error) => {
186
+ // Pre-connect: the dial failed and the caller decides whether to spawn and
187
+ // re-try. Post-connect: the wrapper's own listener turns it into a close.
188
+ if (connected || settled) return
189
+ settled = true
190
+ try {
191
+ socket.destroy()
192
+ } catch {
193
+ // Already gone.
194
+ }
195
+ reject(error)
196
+ })
197
+ socket.on('close', () => {
198
+ if (connected || settled) return
199
+ settled = true
200
+ reject(leaseError(TRANSPORT_CODE, `lease broker closed the connection during dial to ${socketPath}`))
201
+ })
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Narrow a Node socket to the `LeaseConnection` contract, making the close
207
+ * notification exactly-once (Node may emit both `error` and `close`).
208
+ * @param {import('node:net').Socket} socket
209
+ * @returns {LeaseConnection}
210
+ */
211
+ function wrapSocket(socket) {
212
+ let released = false
213
+ /** @type {(() => void)[]} */
214
+ const listeners = []
215
+ const notify = () => {
216
+ if (released) return
217
+ released = true
218
+ for (const listener of listeners.splice(0)) {
219
+ try {
220
+ listener()
221
+ } catch {
222
+ // A transport listener must never break the client's own bookkeeping.
223
+ }
224
+ }
225
+ }
226
+ socket.on('error', () => {
227
+ try {
228
+ socket.destroy()
229
+ } catch {
230
+ // Already gone.
231
+ }
232
+ notify()
233
+ })
234
+ socket.on('close', notify)
235
+ return {
236
+ write(text) {
237
+ socket.write(text)
238
+ },
239
+ onData(handler) {
240
+ socket.on('data', handler)
241
+ },
242
+ onClose(handler) {
243
+ if (released) {
244
+ // Registered too late: report asynchronously so the caller's state
245
+ // machine cannot re-enter mid-registration.
246
+ queueMicrotask(() => {
247
+ try {
248
+ handler()
249
+ } catch {
250
+ // Same rule as notify().
251
+ }
252
+ })
253
+ return
254
+ }
255
+ listeners.push(handler)
256
+ },
257
+ close() {
258
+ notify()
259
+ try {
260
+ socket.destroy()
261
+ } catch {
262
+ // Already gone.
263
+ }
264
+ },
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Create one lease client.
270
+ *
271
+ * A process shares ONE client (design D8/D11): one connection carries every
272
+ * lease of this process, so two preset mounts must never each create their own.
273
+ * The factory exists so tests and the wiring can build independent instances.
274
+ *
275
+ * @param {LeaseClientOptions} [options]
276
+ */
277
+ export function createLeaseClient({
278
+ socketPath,
279
+ protocol = PROTOCOL,
280
+ timeoutMs = DEFAULT_TIMEOUT_MS,
281
+ now = Date.now,
282
+ connect = defaultConnect,
283
+ spawnDaemon = defaultSpawnDaemon,
284
+ audit = () => {},
285
+ connectRetryMs = DEFAULT_CONNECT_RETRY_MS,
286
+ connectRetryBudgetMs = DEFAULT_CONNECT_RETRY_BUDGET_MS,
287
+ degradedAuditWindowMs = DEGRADED_AUDIT_WINDOW_MS,
288
+ } = {}) {
289
+ const endpoint = typeof socketPath === 'string' && socketPath.length > 0 ? socketPath : defaultSocketPath({ protocol })
290
+
291
+ /** @type {LeaseConnection | null} */
292
+ let connection = null
293
+ let greeted = false
294
+ /** @type {Promise<LeaseConnection> | null} */
295
+ let connecting = null
296
+ let closed = false
297
+ /** @type {number | undefined} */
298
+ let startedAt
299
+ /** A connection reset means the daemon may have restarted with an empty table (D6). */
300
+ let replayPending = false
301
+ /** A new incarnation was detected; the next request (or `replayHolds`) replays first. */
302
+ let replayQueued = false
303
+ /** @type {Promise<{ replayed: number, dropped: number }> | null} */
304
+ let replaying = null
305
+ /** @type {number | null} */
306
+ let lastDegradedAt = null
307
+ /** @type {string | undefined} */
308
+ let lastScope
309
+
310
+ /**
311
+ * The replay ledger. `path -> {session, lineage, holdUntil}` — nothing more.
312
+ * In particular there is no `owner`-decides-anything field here, by design.
313
+ * @type {Map<string, LeaseRecord>}
314
+ */
315
+ const ledger = new Map()
316
+ /** `lineage -> label`, presentation only; the broker never compares it. @type {Map<string, string>} */
317
+ const labels = new Map()
318
+ /** @type {Set<(event: any) => void>} */
319
+ const eventHandlers = new Set()
320
+ /**
321
+ * @type {Map<string, {
322
+ * resolve: (reply: any) => void,
323
+ * reject: (error: any) => void,
324
+ * timer: ReturnType<typeof setTimeout>,
325
+ * }>}
326
+ */
327
+ const waiters = new Map()
328
+ let requestSeq = 0
329
+
330
+ // ---------------------------------------------------------------------
331
+ // Transport: connection lifecycle, framing, correlation
332
+ // ---------------------------------------------------------------------
333
+
334
+ /** @returns {string} a correlation token unique to this client */
335
+ function nextReqId() {
336
+ requestSeq += 1
337
+ return `c${requestSeq}`
338
+ }
339
+
340
+ /**
341
+ * @param {LeaseConnection} target
342
+ * @param {Record<string, any>} frame
343
+ * @returns {Promise<any>}
344
+ */
345
+ function sendFrame(target, frame) {
346
+ return new Promise((resolve, reject) => {
347
+ const reqId = nextReqId()
348
+ const timer = setTimeout(() => {
349
+ waiters.delete(reqId)
350
+ reject(transportError(`lease broker did not answer ${String(frame.op)} within ${timeoutMs}ms`))
351
+ }, timeoutMs)
352
+ waiters.set(reqId, { resolve, reject, timer })
353
+ try {
354
+ // `reqId` is written last so a caller-supplied payload cannot collide.
355
+ target.write(`${JSON.stringify({ ...frame, reqId })}\n`)
356
+ } catch (error) {
357
+ clearTimeout(timer)
358
+ waiters.delete(reqId)
359
+ reject(transportError(`lease broker write failed at ${endpoint}`, error))
360
+ }
361
+ })
362
+ }
363
+
364
+ /**
365
+ * @param {LeaseConnection} target
366
+ * @param {string} op
367
+ * @param {Record<string, any>} [payload]
368
+ * @returns {Promise<any>}
369
+ */
370
+ async function exchange(target, op, payload = {}) {
371
+ const reply = await sendFrame(target, { op, ...payload })
372
+ if (reply !== null && typeof reply === 'object' && reply.ok === true) return reply
373
+ const detail = reply !== null && typeof reply === 'object' && reply.error !== null && typeof reply.error === 'object' ? reply.error : {}
374
+ const code = typeof detail.code === 'string' ? detail.code : 'internal'
375
+ throw leaseError(code, `lease broker refused ${op}: ${code}`, detail)
376
+ }
377
+
378
+ /** @param {any} frame */
379
+ function dispatchEvent(frame) {
380
+ for (const handler of [...eventHandlers]) {
381
+ try {
382
+ handler(frame)
383
+ } catch {
384
+ // A listener is a consumer; it must never be able to break the transport.
385
+ }
386
+ }
387
+ }
388
+
389
+ /** @param {string} line */
390
+ function handleFrame(line) {
391
+ /** @type {any} */
392
+ let frame
393
+ try {
394
+ frame = JSON.parse(line)
395
+ } catch {
396
+ // An undecodable line cannot be matched to anything; the waiter's own
397
+ // deadline is what reports it.
398
+ return
399
+ }
400
+ if (frame === null || typeof frame !== 'object') return
401
+ if (typeof frame.event === 'string') {
402
+ dispatchEvent(frame)
403
+ return
404
+ }
405
+ const reqId = typeof frame.reqId === 'string' ? frame.reqId : undefined
406
+ if (reqId === undefined) return
407
+ const waiter = waiters.get(reqId)
408
+ // Correlation is exact: a reply nobody is waiting for is dropped. The
409
+ // alternative — handing it to the next waiter in line — would let one
410
+ // request satisfy another and silently mis-report ownership.
411
+ if (waiter === undefined) return
412
+ waiters.delete(reqId)
413
+ clearTimeout(waiter.timer)
414
+ waiter.resolve(frame)
415
+ }
416
+
417
+ /**
418
+ * @param {any} error
419
+ */
420
+ function failWaiters(error) {
421
+ for (const [reqId, waiter] of [...waiters]) {
422
+ waiters.delete(reqId)
423
+ clearTimeout(waiter.timer)
424
+ waiter.reject(error)
425
+ }
426
+ }
427
+
428
+ /** @param {LeaseConnection} target */
429
+ function handleDisconnect(target) {
430
+ if (connection !== target) return
431
+ connection = null
432
+ greeted = false
433
+ // D6: a reset connection means the daemon may have died and come back with
434
+ // an empty table, so retained leases have to be replayed on the next dial.
435
+ if (ledger.size > 0) replayPending = true
436
+ failWaiters(transportError('lease broker connection closed'))
437
+ }
438
+
439
+ /**
440
+ * @param {LeaseConnection} target
441
+ */
442
+ function dropConnection(target) {
443
+ if (connection === target) {
444
+ connection = null
445
+ greeted = false
446
+ }
447
+ try {
448
+ target.close()
449
+ } catch {
450
+ // Already gone.
451
+ }
452
+ }
453
+
454
+ /** @returns {Promise<{ connection: LeaseConnection | null, error: any }>} */
455
+ async function attemptConnect() {
456
+ try {
457
+ return { connection: await connect(endpoint), error: null }
458
+ } catch (error) {
459
+ return { connection: null, error }
460
+ }
461
+ }
462
+
463
+ /** @param {unknown} error @returns {boolean} */
464
+ function isAbsentEndpoint(error) {
465
+ const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
466
+ return typeof code === 'string' && ABSENT_ENDPOINT_CODES.has(code)
467
+ }
468
+
469
+ function spawnOnce() {
470
+ if (typeof spawnDaemon !== 'function') return
471
+ try {
472
+ spawnDaemon()
473
+ } catch {
474
+ // Spawning is best effort — a `null` daemon degrades, it never refuses.
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Dial the daemon, spawning it once when the endpoint is simply not there.
480
+ *
481
+ * Spawning is per attempt, not per process: after a failure this client must
482
+ * be able to start the daemon again on the next call, because "the daemon is
483
+ * gone" is a transient condition, not a decision to stop arbitrating.
484
+ *
485
+ * @returns {Promise<LeaseConnection>}
486
+ */
487
+ async function openConnection() {
488
+ const first = await attemptConnect()
489
+ if (first.connection !== null) return first.connection
490
+ if (!isAbsentEndpoint(first.error)) {
491
+ throw transportError(`lease broker is unreachable at ${endpoint}`, first.error)
492
+ }
493
+ spawnOnce()
494
+ const deadline = now() + connectRetryBudgetMs
495
+ // A frozen or absent clock must not turn this into an endless loop: the
496
+ // attempt count is what actually bounds the wait.
497
+ const maxAttempts = Math.max(1, Math.ceil(connectRetryBudgetMs / Math.max(1, connectRetryMs))) + 2
498
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
499
+ if (now() >= deadline) break
500
+ await delay(connectRetryMs)
501
+ const retry = await attemptConnect()
502
+ if (retry.connection !== null) return retry.connection
503
+ if (!isAbsentEndpoint(retry.error)) {
504
+ throw transportError(`lease broker is unreachable at ${endpoint}`, retry.error)
505
+ }
506
+ }
507
+ throw transportError(`lease broker did not come up at ${endpoint} within ${connectRetryBudgetMs}ms`)
508
+ }
509
+
510
+ /**
511
+ * Establish the connection and complete the handshake, replaying retained
512
+ * leases when the daemon turns out to be a different incarnation (D6).
513
+ * @returns {Promise<LeaseConnection>}
514
+ */
515
+ async function establish() {
516
+ const fresh = await openConnection()
517
+ const previousStartedAt = startedAt
518
+ connection = fresh
519
+ greeted = false
520
+ let buffered = ''
521
+ fresh.onData((chunk) => {
522
+ buffered += chunk
523
+ let index = buffered.indexOf('\n')
524
+ while (index >= 0) {
525
+ const line = buffered.slice(0, index)
526
+ buffered = buffered.slice(index + 1)
527
+ if (line.trim() !== '') handleFrame(line)
528
+ index = buffered.indexOf('\n')
529
+ }
530
+ })
531
+ fresh.onClose(() => handleDisconnect(fresh))
532
+ /** @type {any} */
533
+ let hello
534
+ try {
535
+ hello = await sendFrame(fresh, { op: 'hello', protocol })
536
+ } catch (error) {
537
+ dropConnection(fresh)
538
+ throw transportError(`lease broker handshake failed at ${endpoint}`, error)
539
+ }
540
+ if (hello.ok !== true) {
541
+ // A protocol mismatch is infrastructure, not a conflict: the write goes
542
+ // through and the audit records why (D7). Nobody is asked to fix anything.
543
+ const code = typeof hello.error?.code === 'string' ? hello.error.code : 'unknown'
544
+ dropConnection(fresh)
545
+ throw transportError(`lease broker refused the handshake (${code})`)
546
+ }
547
+ greeted = true
548
+ const reported = typeof hello.startedAt === 'number' ? hello.startedAt : previousStartedAt
549
+ startedAt = reported
550
+ const restarted = replayPending || (previousStartedAt !== undefined && previousStartedAt !== reported)
551
+ replayPending = false
552
+ // Arm the replay instead of running it here: the connection is usable, but
553
+ // replaying from inside `establish` would re-enter the request path. The next
554
+ // request flushes it (or `replayHolds()` does, without replaying twice).
555
+ if (restarted && ledger.size > 0) replayQueued = true
556
+ return fresh
557
+ }
558
+
559
+ /** @returns {Promise<LeaseConnection>} */
560
+ function ensureConnection() {
561
+ if (closed) return Promise.reject(transportError('lease client is closed'))
562
+ if (connection !== null && greeted) return Promise.resolve(connection)
563
+ if (connecting !== null) return connecting
564
+ /** @type {Promise<LeaseConnection>} */
565
+ const attempt = establish().finally(() => {
566
+ if (connecting === attempt) connecting = null
567
+ })
568
+ connecting = attempt
569
+ return attempt
570
+ }
571
+
572
+ /**
573
+ * Send one protocol request on a live connection.
574
+ * @param {string} op
575
+ * @param {Record<string, any>} [payload]
576
+ * @returns {Promise<any>}
577
+ */
578
+ async function request(op, payload = {}) {
579
+ if (typeof op !== 'string' || op.length === 0) throw new TypeError('lease request requires an operation name')
580
+ const target = await ensureConnection()
581
+ if (replayQueued) {
582
+ // Flush before the caller's own frame so an observe-after-reconnect call
583
+ // reports the world the replay has already restored.
584
+ replayQueued = false
585
+ await runReplay()
586
+ }
587
+ return await exchange(target, op, payload)
588
+ }
589
+
590
+ // ---------------------------------------------------------------------
591
+ // Ledger: what we hold, what a turn boundary releases, what a reconnect replays
592
+ // ---------------------------------------------------------------------
593
+
594
+ /**
595
+ * @param {any} owner
596
+ * @returns {{ id: string, session: string, label: string }}
597
+ */
598
+ function normalizeOwner(owner) {
599
+ const rawId = typeof owner === 'string' ? owner : owner?.id
600
+ const id = typeof rawId === 'string' && rawId.length > 0 ? rawId : undefined
601
+ if (id === undefined) throw new TypeError('lease owner requires a non-empty id (the lineage)')
602
+ const session = typeof owner?.session === 'string' && owner.session.length > 0 ? owner.session : id
603
+ const label = typeof owner?.label === 'string' && owner.label.length > 0 ? owner.label : session
604
+ return { id, session, label }
605
+ }
606
+
607
+ /**
608
+ * Normalise and validate the path batch. A relative path is a wiring bug, and
609
+ * the broker would only answer `invalid-path`, which this client would have to
610
+ * degrade — so it is refused loudly here instead of silently losing
611
+ * arbitration for every write of that tool.
612
+ * @param {unknown} paths
613
+ * @returns {string[]}
614
+ */
615
+ function normalizePaths(paths) {
616
+ if (!Array.isArray(paths)) throw new TypeError('lease paths must be an array')
617
+ /** @type {string[]} */
618
+ const out = []
619
+ for (const path of paths) {
620
+ if (typeof path !== 'string' || path.length === 0) throw new TypeError('lease paths must be non-empty strings')
621
+ if (path.includes('\u0000')) throw new TypeError('lease paths may not contain NUL bytes')
622
+ if (!isAbsolute(path)) throw new TypeError(`lease paths must be absolute: ${path}`)
623
+ if (!out.includes(path)) out.push(path)
624
+ }
625
+ return out
626
+ }
627
+
628
+ /** @param {unknown} scope @returns {string} */
629
+ function requireScope(scope) {
630
+ if (typeof scope !== 'string' || scope.length === 0) {
631
+ throw new TypeError('lease scope must be a non-empty string (the project root)')
632
+ }
633
+ return scope
634
+ }
635
+
636
+ /**
637
+ * @param {string} lineage
638
+ * @param {string} session
639
+ * @returns {{ id: string, session: string, label: string }}
640
+ */
641
+ function ownerOf(lineage, session) {
642
+ return { id: lineage, session, label: labels.get(lineage) ?? session }
643
+ }
644
+
645
+ /**
646
+ * The owner to speak as for paths this client already holds. `hold` extends
647
+ * ownership, so an owner can only be inferred from something already owned; a
648
+ * batch we hold nothing of is refused locally as `not-owned` (a closed-vocabulary
649
+ * code) instead of sending a frame the broker would reject anyway.
650
+ * @param {string[]} paths
651
+ * @returns {{ id: string, session: string, label: string }}
652
+ */
653
+ function ownerForPaths(paths) {
654
+ for (const path of paths) {
655
+ const record = ledger.get(path)
656
+ if (record !== undefined) return ownerOf(record.lineage, record.session)
657
+ }
658
+ throw leaseError('not-owned', 'this client holds none of the requested paths, so there is no owner to extend', {
659
+ paths,
660
+ })
661
+ }
662
+
663
+ /**
664
+ * Record a granted batch. Only the fields the ledger exists for are kept: who
665
+ * to release as, and whether a retention must be replayed.
666
+ * @param {any[]} granted
667
+ * @param {{ id: string, session: string, label: string }} owner
668
+ * @param {string} scope
669
+ */
670
+ function remember(granted, owner, scope) {
671
+ lastScope = scope
672
+ labels.set(owner.id, owner.label)
673
+ for (const lease of granted) {
674
+ const path = typeof lease?.path === 'string' ? lease.path : undefined
675
+ if (path === undefined) continue
676
+ ledger.set(path, {
677
+ session: owner.session,
678
+ lineage: owner.id,
679
+ holdUntil: typeof lease.holdUntil === 'number' ? lease.holdUntil : null,
680
+ })
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Record a successful retention.
686
+ * @param {any} reply
687
+ * @param {{ id: string, session: string, label: string }} owner
688
+ */
689
+ function applyHold(reply, owner) {
690
+ const held = Array.isArray(reply?.held) ? reply.held : []
691
+ labels.set(owner.id, owner.label)
692
+ for (const entry of held) {
693
+ const path = typeof entry?.path === 'string' ? entry.path : undefined
694
+ if (path === undefined) continue
695
+ const holdUntil = typeof entry.holdUntil === 'number' ? entry.holdUntil : null
696
+ ledger.set(path, { session: owner.session, lineage: owner.id, holdUntil })
697
+ }
698
+ }
699
+
700
+ // ---------------------------------------------------------------------
701
+ // Arbitration-facing operations
702
+ // ---------------------------------------------------------------------
703
+
704
+ /**
705
+ * Acquire a batch up front (the explicit `edit_lock_acquire` form of what every
706
+ * managed write does implicitly).
707
+ * @param {any} owner
708
+ * @param {string[]} paths
709
+ * @param {string} scope
710
+ * @returns {Promise<any>}
711
+ */
712
+ async function acquire(owner, paths, scope) {
713
+ const holder = normalizeOwner(owner)
714
+ const list = normalizePaths(paths)
715
+ const leaseScope = requireScope(scope)
716
+ const reply = await request('acquire', { owner: holder, scope: leaseScope, paths: list })
717
+ if (Array.isArray(reply.granted)) remember(reply.granted, holder, leaseScope)
718
+ return reply
719
+ }
720
+
721
+ /**
722
+ * Attempt the batch again on a brand-new connection (the single retry D7
723
+ * allows). Returns `false` when the retry failed too; a real conflict is
724
+ * rethrown, because conflicts are never degraded.
725
+ * @param {{ id: string, session: string, label: string }} owner
726
+ * @param {string[]} paths
727
+ * @param {string} scope
728
+ * @returns {Promise<boolean>}
729
+ */
730
+ async function retryAcquire(owner, paths, scope) {
731
+ const stale = connection
732
+ if (stale !== null) dropConnection(stale)
733
+ try {
734
+ await acquire(owner, paths, scope)
735
+ return true
736
+ } catch (error) {
737
+ if (isLeaseConflict(error)) throw error
738
+ const broken = connection
739
+ if (broken !== null) dropConnection(broken)
740
+ return false
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Acquire the batch, or run the write anyway (D7).
746
+ *
747
+ * One retry on a fresh connection at most; when that fails too, a single audit
748
+ * record explains the silent loss of mutual exclusion and `fn` proceeds with no
749
+ * lease. A real conflict is rethrown on every path — including out of the retry.
750
+ *
751
+ * @param {{ id: string, session: string, label: string }} owner
752
+ * @param {string[]} paths
753
+ * @param {string} scope
754
+ * @returns {Promise<void>}
755
+ */
756
+ async function acquireOrDegrade(owner, paths, scope) {
757
+ try {
758
+ await acquire(owner, paths, scope)
759
+ return
760
+ } catch (error) {
761
+ if (isLeaseConflict(error)) throw error
762
+ if (await retryAcquire(owner, paths, scope)) return
763
+ auditDegraded({
764
+ code: errorCode(error),
765
+ session: owner.session,
766
+ lineage: owner.id,
767
+ scope,
768
+ paths: paths.length,
769
+ })
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Hold the leases for `owner` for the duration of `fn` (design D2): the leases
775
+ * are acquired once and NOT released in a `finally` — ownership lasts until the
776
+ * turn ends (`releaseSession`) or the process does (`releaseAll`/connection
777
+ * close). `fn` runs exactly once on every path that is not a real conflict.
778
+ *
779
+ * @template T
780
+ * @param {any} owner `{id, session, label}` (or a bare lineage id)
781
+ * @param {string[]} paths absolute paths
782
+ * @param {string} scope opaque project root, used only for `status` filtering
783
+ * @param {() => T | Promise<T>} fn the managed operation
784
+ * @returns {Promise<T>}
785
+ */
786
+ async function withLeases(owner, paths, scope, fn) {
787
+ if (typeof fn !== 'function') throw new TypeError('withLeases requires a callback')
788
+ const holder = normalizeOwner(owner)
789
+ const list = normalizePaths(paths)
790
+ const leaseScope = requireScope(scope)
791
+ if (list.length > 0) await acquireOrDegrade(holder, list, leaseScope)
792
+ return await fn()
793
+ }
794
+
795
+ /**
796
+ * Extend the leases for `paths` (only ever later, ≤ 60 minutes; the broker
797
+ * owns that rule). The owner is inferred from the ledger when omitted, which is
798
+ * the only legitimate case: `hold` applies to something already held.
799
+ * @param {string[]} paths
800
+ * @param {number} minutes
801
+ * @param {any} [owner]
802
+ * @returns {Promise<any>}
803
+ */
804
+ async function hold(paths, minutes, owner) {
805
+ const list = normalizePaths(paths)
806
+ const holder = owner === undefined ? ownerForPaths(list) : normalizeOwner(owner)
807
+ const reply = await request('hold', { owner: holder, paths: list, minutes })
808
+ applyHold(reply, holder)
809
+ return reply
810
+ }
811
+
812
+ /**
813
+ * Read-only projection. Filters stack; only the protocol's three filter keys
814
+ * are forwarded (the frame grammar rejects extras).
815
+ * @param {{ scope?: string, session?: string, path?: string }} [filter]
816
+ * @returns {Promise<any>}
817
+ */
818
+ function status(filter = {}) {
819
+ /** @type {Record<string, string>} */
820
+ const payload = {}
821
+ if (typeof filter?.scope === 'string' && filter.scope.length > 0) payload.scope = filter.scope
822
+ if (typeof filter?.session === 'string' && filter.session.length > 0) payload.session = filter.session
823
+ if (typeof filter?.path === 'string' && filter.path.length > 0) payload.path = filter.path
824
+ return request('status', payload)
825
+ }
826
+
827
+ /**
828
+ * Read-only connection health for the status surface (design D13).
829
+ *
830
+ * `ok` means the handshake stands right now, so a `status` read reaches the
831
+ * arbiter; `degraded` means this process HAS been arbitrated before but the
832
+ * connection is gone at this instant (the next write would degrade and
833
+ * proceed, D7); `unavailable` means it was never reached in this process.
834
+ *
835
+ * Reporting only: it opens nothing, closes nothing and retries nothing, so a
836
+ * reader can never change an arbitration verdict by looking.
837
+ *
838
+ * @returns {'ok' | 'degraded' | 'unavailable'}
839
+ */
840
+ function connectionState() {
841
+ if (closed) return 'unavailable'
842
+ if (connection !== null && greeted) return 'ok'
843
+ // `startedAt` is set by the first successful handshake and never cleared:
844
+ // its presence is exactly "this process once had an arbiter".
845
+ return startedAt === undefined ? 'unavailable' : 'degraded'
846
+ }
847
+
848
+ /**
849
+ * Ask the holder of `path` to hand it over (negotiation, D4).
850
+ * @param {string} path
851
+ * @param {any} from the requesting owner
852
+ * @returns {Promise<any>}
853
+ */
854
+ function requestTransfer(path, from) {
855
+ if (typeof path !== 'string' || path.length === 0) throw new TypeError('requestTransfer requires a path')
856
+ return request('request', { from: normalizeOwner(from), path })
857
+ }
858
+
859
+ /**
860
+ * Answer a pending transfer request as the holder.
861
+ * @param {{ requestId: string, owner: any, decision: 'release' | 'keep' }} input
862
+ * @returns {Promise<any>}
863
+ */
864
+ function reply(input) {
865
+ const requestId = input?.requestId
866
+ if (typeof requestId !== 'string' || requestId.length === 0) throw new TypeError('reply requires a requestId')
867
+ const decision = input?.decision
868
+ if (decision !== 'release' && decision !== 'keep') throw new TypeError("reply decision must be 'release' or 'keep'")
869
+ return request('reply', { owner: normalizeOwner(input?.owner), requestId, decision })
870
+ }
871
+
872
+ // ---------------------------------------------------------------------
873
+ // Release and replay
874
+ // ---------------------------------------------------------------------
875
+
876
+ /**
877
+ * Release everything this session holds EXCEPT retentions (design D10): the
878
+ * `turn/end` rule. A failure is swallowed — turn-boundary release is an
879
+ * optimisation, and the daemon's disconnect rule plus the TTL already cover it.
880
+ * @param {string} session
881
+ * @returns {Promise<{ released: string[] }>}
882
+ */
883
+ async function releaseSession(session) {
884
+ return await releaseMatching((record) => record.session === session && record.holdUntil === null)
885
+ }
886
+
887
+ /**
888
+ * Release everything this session holds, retentions included (design D10): the
889
+ * `agent/disposed` rule — a disposed agent never comes back.
890
+ * @param {string} session
891
+ * @returns {Promise<{ released: string[] }>}
892
+ */
893
+ async function releaseAll(session) {
894
+ return await releaseMatching((record) => record.session === session)
895
+ }
896
+
897
+ /**
898
+ * @param {(record: LeaseRecord) => boolean} match
899
+ * @returns {Promise<{ released: string[] }>}
900
+ */
901
+ async function releaseMatching(match) {
902
+ /** @type {Map<string, { session: string, paths: string[] }>} */
903
+ const groups = new Map()
904
+ for (const [path, record] of [...ledger]) {
905
+ if (!match(record)) continue
906
+ // The intent is "stop holding this", and a failed frame cannot undo the
907
+ // intent: the entry goes, whatever the transport does next.
908
+ ledger.delete(path)
909
+ const group = groups.get(record.lineage) ?? { session: record.session, paths: [] }
910
+ group.session = record.session
911
+ group.paths.push(path)
912
+ groups.set(record.lineage, group)
913
+ }
914
+ /** @type {string[]} */
915
+ const released = []
916
+ for (const [lineage, group] of groups) {
917
+ try {
918
+ const response = await request('release', { owner: ownerOf(lineage, group.session), paths: group.paths })
919
+ if (Array.isArray(response.released)) released.push(...response.released)
920
+ } catch {
921
+ // See above: never throw at a turn boundary, never keep state to retry.
922
+ }
923
+ }
924
+ return { released }
925
+ }
926
+
927
+ /**
928
+ * Replay every still-valid retention (`holdUntil > now`) so a daemon restart
929
+ * does not silently drop a hold. An entry somebody else now owns is removed
930
+ * without an error: the next write sees an ordinary conflict (D6).
931
+ * @returns {Promise<{ replayed: number, dropped: number }>}
932
+ */
933
+ async function replayHolds() {
934
+ await ensureConnection()
935
+ // An explicit replay satisfies a queued one: never send the same retention
936
+ // twice just because a reconnect happened to arm it.
937
+ replayQueued = false
938
+ return await runReplay()
939
+ }
940
+
941
+ /**
942
+ * @returns {Promise<{ replayed: number, dropped: number }>}
943
+ */
944
+ function runReplay() {
945
+ if (replaying !== null) return replaying
946
+ /** @type {Promise<{ replayed: number, dropped: number }>} */
947
+ const attempt = replayOnce().finally(() => {
948
+ if (replaying === attempt) replaying = null
949
+ })
950
+ replaying = attempt
951
+ return attempt
952
+ }
953
+
954
+ /**
955
+ * @returns {Promise<{ replayed: number, dropped: number }>}
956
+ */
957
+ async function replayOnce() {
958
+ let replayed = 0
959
+ let dropped = 0
960
+ const at = now()
961
+ for (const [path, record] of [...ledger]) {
962
+ if (record.holdUntil === null || record.holdUntil <= at) continue
963
+ const owner = ownerOf(record.lineage, record.session)
964
+ const scope = lastScope
965
+ try {
966
+ // Re-acquire first: after a daemon restart the table is empty, so a bare
967
+ // `hold` would be `not-owned`. The pair is idempotent by protocol.
968
+ await acquire(owner, [path], requireScope(scope))
969
+ const response = await request('hold', { owner, paths: [path], minutes: holdMinutes(record.holdUntil - at) })
970
+ applyHold(response, owner)
971
+ replayed += 1
972
+ } catch (error) {
973
+ if (errorCode(error) === TRANSPORT_CODE) {
974
+ // The connection died again: keep the rest for the next reconnect
975
+ // rather than forgetting a retention nobody has seen fail.
976
+ break
977
+ }
978
+ ledger.delete(path)
979
+ dropped += 1
980
+ }
981
+ }
982
+ return { replayed, dropped }
983
+ }
984
+
985
+ /**
986
+ * Remaining retention as `hold` minutes, clamped to the protocol's closed
987
+ * interval `(0, 60]` and rounded UP so a replay never shortens a hold.
988
+ * @param {number} remainingMs
989
+ * @returns {number}
990
+ */
991
+ function holdMinutes(remainingMs) {
992
+ const minutes = Math.ceil((remainingMs / 60_000) * 1000) / 1000
993
+ return Math.min(HOLD_MAX_MINUTES, Math.max(0.001, minutes))
994
+ }
995
+
996
+ // ---------------------------------------------------------------------
997
+ // Degrade accounting, push events, shutdown
998
+ // ---------------------------------------------------------------------
999
+
1000
+ /**
1001
+ * Record one degraded write. Rate limited per process (D7) so a broken daemon
1002
+ * cannot flood the audit log, and never allowed to throw.
1003
+ * @param {Record<string, any>} detail
1004
+ */
1005
+ function auditDegraded(detail) {
1006
+ const at = now()
1007
+ if (lastDegradedAt !== null && at - lastDegradedAt < degradedAuditWindowMs) return
1008
+ lastDegradedAt = at
1009
+ try {
1010
+ audit('degraded', { ...detail, at })
1011
+ } catch {
1012
+ // Audit is log-only; it must never break a write.
1013
+ }
1014
+ }
1015
+
1016
+ /**
1017
+ * Subscribe to broker-pushed `request` / `settled` events.
1018
+ * @param {(event: any) => void} handler
1019
+ * @returns {() => void} unsubscribe
1020
+ */
1021
+ function onEvent(handler) {
1022
+ if (typeof handler !== 'function') throw new TypeError('onEvent requires a handler')
1023
+ eventHandlers.add(handler)
1024
+ return () => {
1025
+ eventHandlers.delete(handler)
1026
+ }
1027
+ }
1028
+
1029
+ /**
1030
+ * Close the connection. The daemon keeps running: it is shared with every other
1031
+ * client on this machine, and its own idle window is what ends it.
1032
+ */
1033
+ function close() {
1034
+ if (closed) return
1035
+ closed = true
1036
+ const target = connection
1037
+ connection = null
1038
+ greeted = false
1039
+ ledger.clear()
1040
+ failWaiters(transportError('lease client closed'))
1041
+ if (target !== null) {
1042
+ try {
1043
+ target.close()
1044
+ } catch {
1045
+ // Already gone.
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ return Object.freeze({
1051
+ endpoint,
1052
+ request,
1053
+ acquire,
1054
+ withLeases,
1055
+ hold,
1056
+ status,
1057
+ connectionState,
1058
+ requestTransfer,
1059
+ reply,
1060
+ releaseSession,
1061
+ releaseAll,
1062
+ replayHolds,
1063
+ onEvent,
1064
+ close,
1065
+ })
1066
+ }