weir-harness 1.2.3 → 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 (81) 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/lease-broker/entry.js +98 -0
  23. package/src/lease-broker/node-contracts.d.ts +63 -0
  24. package/src/lease-broker/protocol.js +236 -0
  25. package/src/lease-broker/server.js +399 -0
  26. package/src/lease-broker/state.js +425 -0
  27. package/src/lsp/tools.js +137 -27
  28. package/src/settings/index.js +2 -44
  29. package/src/settings/sections.js +2 -24
  30. package/src/shared/audit.js +6 -1
  31. package/src/todo-driver/index.js +1 -6
  32. package/src/worktree/cards.js +6 -21
  33. package/src/worktree/lane-core.js +1 -19
  34. package/src/worktree/lane-workflows.js +11 -32
  35. package/lib/client.edit-lock-maintenance.js +0 -592
  36. package/lib/client.edit-lock-panel.js +0 -405
  37. package/src/edit-lock/adapter.js +0 -57
  38. package/src/edit-lock/admin-ledger.js +0 -149
  39. package/src/edit-lock/admin-recovery.js +0 -151
  40. package/src/edit-lock/admission.js +0 -82
  41. package/src/edit-lock/binding-attestation.js +0 -29
  42. package/src/edit-lock/binding-guidance.js +0 -72
  43. package/src/edit-lock/binding-machine.js +0 -312
  44. package/src/edit-lock/call-context.js +0 -65
  45. package/src/edit-lock/derived-facts.js +0 -67
  46. package/src/edit-lock/domain.js +0 -165
  47. package/src/edit-lock/domains.js +0 -170
  48. package/src/edit-lock/host.js +0 -149
  49. package/src/edit-lock/incarnations.js +0 -150
  50. package/src/edit-lock/index.js +0 -1303
  51. package/src/edit-lock/inspect.js +0 -219
  52. package/src/edit-lock/lifecycle.js +0 -579
  53. package/src/edit-lock/limits.js +0 -61
  54. package/src/edit-lock/maintenance.js +0 -310
  55. package/src/edit-lock/manager.js +0 -875
  56. package/src/edit-lock/negotiation.js +0 -132
  57. package/src/edit-lock/operation-history.js +0 -214
  58. package/src/edit-lock/peer-client.js +0 -86
  59. package/src/edit-lock/peer-transport.js +0 -76
  60. package/src/edit-lock/peer.js +0 -87
  61. package/src/edit-lock/publisher.js +0 -191
  62. package/src/edit-lock/read-authority.js +0 -101
  63. package/src/edit-lock/recovery.js +0 -139
  64. package/src/edit-lock/remote.js +0 -362
  65. package/src/edit-lock/request-binding.js +0 -42
  66. package/src/edit-lock/request-data.js +0 -40
  67. package/src/edit-lock/reservation.js +0 -301
  68. package/src/edit-lock/reserved-runtime.js +0 -45
  69. package/src/edit-lock/resource-identity.js +0 -130
  70. package/src/edit-lock/runtime.js +0 -145
  71. package/src/edit-lock/settle.js +0 -144
  72. package/src/edit-lock/snapshot.js +0 -175
  73. package/src/edit-lock/stale-sweep.js +0 -120
  74. package/src/edit-lock/state.js +0 -462
  75. package/src/edit-lock/store.js +0 -327
  76. package/src/edit-lock/tool-scope.js +0 -29
  77. package/src/edit-lock/view.js +0 -170
  78. package/src/edit-lock/write-scopes.js +0 -48
  79. package/src/edit-lock/write-tool.js +0 -45
  80. package/src/shared/delegate-edit-lifecycle.js +0 -36
  81. package/src/worktree/authority.js +0 -56
@@ -0,0 +1,425 @@
1
+ // In-memory arbitration kernel behind `weir-editlockd`.
2
+ //
3
+ // Pure state machine: no IO, no timers, no real clock (the clock is injected),
4
+ // no DSH/cordis/@deepseek-ai imports. Everything the kernel knows lives in two
5
+ // maps and is thrown away when the process dies — that is the whole point of
6
+ // the design (design D6): `kill` equals "every lease expired at once", and no
7
+ // question about who owns what survives the process.
8
+ //
9
+ // Two states only (design D3): a path is `held` by exactly one connection, or
10
+ // it is free. A transfer request is a mailbox entry with a bounded window, not
11
+ // a third ownership state.
12
+ //
13
+ // Multiplicity rules that are easy to get wrong and are therefore stated once
14
+ // here and enforced in one place:
15
+ //
16
+ // - Identity is `owner.id` (the client's lineage). `session` and `label` are
17
+ // display/delivery hints and may change; an acquisition from the SAME
18
+ // `owner.id` on the SAME connection is the same owner, so it adopts and
19
+ // refreshes instead of conflicting. The same `owner.id` through a DIFFERENT
20
+ // connection is a different owner for arbitration purposes (spec: "Same
21
+ // owner identifier from another connection").
22
+ // - Validity is `max(expiresAt, holdUntil ?? 0)`. Once `now() >=` that instant
23
+ // the path is free for anyone, without negotiation and without notification.
24
+ // - `release` judges identity only (a lapsed row still belongs to its owner, so
25
+ // releasing it is `released`, keeping the call idempotent), while `hold`
26
+ // additionally requires the row to still be valid — you cannot extend
27
+ // ownership that already lapsed, you must acquire again first.
28
+
29
+ /**
30
+ * @typedef {{ id: string, session: string, label: string }} Owner
31
+ * @typedef {{ path: string, connectionId: string, owner: Owner, scope: string, acquiredAt: number, expiresAt: number, holdUntil: number | null }} Lease
32
+ * @typedef {{ path: string, holder: Owner, expiresAt: number, holdUntil: number | null }} Conflict
33
+ * @typedef {{ path: string, owner: Owner, scope: string, acquiredAt: number, expiresAt: number, holdUntil: number | null }} LeaseView
34
+ * @typedef {{ requestId: string, path: string, from: Owner, connectionId: string, holderConnectionId: string, holderId: string, expiresAt: number }} PendingRequest
35
+ * @typedef {{ event: 'request', requestId: string, path: string, from: Owner, expiresAt: number }} RequestEvent
36
+ * @typedef {{ event: 'settled', requestId: string, path: string, state: 'transferred' | 'declined' | 'expired' }} SettledEvent
37
+ * @typedef {{ connectionId: string, owner: Owner, scope: string, paths: string[], ttlMs?: number }} AcquireInput
38
+ * @typedef {{ connectionId: string, owner: Owner, paths: string[] }} ReleaseInput
39
+ * @typedef {{ connectionId: string, owner: Owner, paths: string[], minutes: number }} HoldInput
40
+ * @typedef {{ connectionId: string, from: Owner, path: string }} RequestInput
41
+ * @typedef {{ connectionId: string, owner: Owner, requestId: string, decision: 'release' | 'keep' }} ReplyInput
42
+ * @typedef {{ scope?: string, session?: string, path?: string }} StatusFilter
43
+ * @typedef {{ ok: true, granted: LeaseView[] } | { ok: false, code: 'held', conflicts: Conflict[] }} AcquireResult
44
+ * @typedef {{ ok: true, released: string[], notOwned: string[] }} ReleaseResult
45
+ * @typedef {{ ok: true, held: { path: string, expiresAt: number, holdUntil: number | null }[] } | { ok: false, code: 'limit', maxMinutes: number } | { ok: false, code: 'not-owned', paths: string[] } | { ok: false, code: 'bad-request' }} HoldResult
46
+ * @typedef {{ ok: true, leases: LeaseView[] }} StatusResult
47
+ * @typedef {{ ok: true, state: 'pending', requestId: string, holder: Owner, expiresAt: number } | { ok: false, code: 'free' | 'self' }} RequestResult
48
+ * @typedef {{ ok: true, state: 'transferred' | 'declined' } | { ok: false, code: 'expired' | 'stale' | 'not-holder' }} ReplyResult
49
+ */
50
+
51
+ /**
52
+ * @param {Lease} lease
53
+ * @returns {LeaseView}
54
+ */
55
+ function view(lease) {
56
+ return {
57
+ path: lease.path,
58
+ owner: { ...lease.owner },
59
+ scope: lease.scope,
60
+ acquiredAt: lease.acquiredAt,
61
+ expiresAt: lease.expiresAt,
62
+ holdUntil: lease.holdUntil,
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Create one arbitration kernel.
68
+ *
69
+ * @param {object} [options]
70
+ * @param {() => number} [options.now] injected clock (epoch ms)
71
+ * @param {number} [options.ttlMs] default lease time-to-live
72
+ * @param {number} [options.requestWindowMs] bounded negotiation window
73
+ * @param {number} [options.holdMaxMinutes] retention bound in minutes
74
+ * @param {(connectionId: string, event: RequestEvent | SettledEvent) => void} [options.onEvent] event sink; a missing connection or a throwing sink never affects arbitration
75
+ */
76
+ export function createBroker({
77
+ now = Date.now,
78
+ ttlMs = 120_000,
79
+ requestWindowMs = 120_000,
80
+ holdMaxMinutes = 60,
81
+ onEvent = () => {},
82
+ } = {}) {
83
+ /** @type {Map<string, Lease>} */
84
+ const leases = new Map()
85
+ /** @type {Map<string, PendingRequest>} */
86
+ const pending = new Map()
87
+ /** @type {Map<string, string>} requester+path → requestId, at most one per pair */
88
+ const pendingKeys = new Map()
89
+ /** @type {Set<string>} */
90
+ const connections = new Set()
91
+ let requestSeq = 0
92
+
93
+ /** @param {Lease} lease */
94
+ function validUntil(lease) {
95
+ return Math.max(lease.expiresAt, lease.holdUntil ?? 0)
96
+ }
97
+
98
+ /** @param {Lease} lease */
99
+ function isValid(lease) {
100
+ return now() < validUntil(lease)
101
+ }
102
+
103
+ /** @param {string} path @param {string} fromId */
104
+ function requestKey(path, fromId) {
105
+ return `${fromId}\u0000${path}`
106
+ }
107
+
108
+ /** Drop a mailbox entry and its (requester, path) index slot. @param {string} requestId */
109
+ function dropRequest(requestId) {
110
+ const record = pending.get(requestId)
111
+ if (!record) return
112
+ pending.delete(requestId)
113
+ const key = requestKey(record.path, record.from.id)
114
+ if (pendingKeys.get(key) === requestId) pendingKeys.delete(key)
115
+ }
116
+
117
+ /**
118
+ * Deliver one event. Delivery is best effort by contract: an unknown
119
+ * connection is a silent drop and a throwing sink cannot break arbitration.
120
+ * @param {string} connectionId
121
+ * @param {RequestEvent | SettledEvent} event
122
+ */
123
+ function deliver(connectionId, event) {
124
+ if (!connections.has(connectionId)) return
125
+ try {
126
+ onEvent(connectionId, event)
127
+ } catch {
128
+ // The sink is a transport; it must never be able to corrupt the table.
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Announce the end of a request to the requester.
134
+ * @param {PendingRequest} record
135
+ * @param {'transferred' | 'declined' | 'expired'} state
136
+ */
137
+ function settle(record, state) {
138
+ deliver(record.connectionId, { event: 'settled', requestId: record.requestId, path: record.path, state })
139
+ }
140
+
141
+ /** @param {string[]} paths */
142
+ function normalize(paths) {
143
+ return [...new Set(paths)].sort()
144
+ }
145
+
146
+ /** Register a connection. @param {string} connectionId */
147
+ function connect(connectionId) {
148
+ connections.add(connectionId)
149
+ }
150
+
151
+ /**
152
+ * Forget a connection: every lease it holds is released immediately (no
153
+ * expiry wait), and every mailbox entry it takes part in is dropped. A
154
+ * request whose holder went away settles as `expired` for a requester that is
155
+ * still connected; a request owned by the departing connection simply dies
156
+ * with it.
157
+ * @param {string} connectionId
158
+ */
159
+ function disconnect(connectionId) {
160
+ connections.delete(connectionId)
161
+ for (const [path, lease] of [...leases]) {
162
+ if (lease.connectionId === connectionId) leases.delete(path)
163
+ }
164
+ for (const [requestId, record] of [...pending]) {
165
+ if (record.connectionId === connectionId) {
166
+ dropRequest(requestId)
167
+ continue
168
+ }
169
+ if (record.holderConnectionId === connectionId) {
170
+ dropRequest(requestId)
171
+ settle(record, 'expired')
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Acquire a whole path set atomically. Paths are evaluated in sorted order so
178
+ * two overlapping requests cannot deadlock and the reply is deterministic.
179
+ * One live foreign holder refuses the entire batch — nothing is granted.
180
+ * @param {AcquireInput} input
181
+ * @returns {AcquireResult}
182
+ */
183
+ function acquire({ connectionId, owner, scope, paths, ttlMs: requestTtlMs }) {
184
+ const at = now()
185
+ const ttl = requestTtlMs !== undefined ? requestTtlMs : ttlMs
186
+ /** @type {Conflict[]} */
187
+ const conflicts = []
188
+ /** @type {string[]} */
189
+ const fresh = []
190
+ /** @type {Lease[]} */
191
+ const adopt = []
192
+ for (const path of normalize(paths)) {
193
+ const lease = leases.get(path)
194
+ if (!lease || !isValid(lease)) {
195
+ fresh.push(path)
196
+ continue
197
+ }
198
+ if (lease.owner.id === owner.id && lease.connectionId === connectionId) {
199
+ adopt.push(lease)
200
+ continue
201
+ }
202
+ conflicts.push({ path, holder: { ...lease.owner }, expiresAt: lease.expiresAt, holdUntil: lease.holdUntil })
203
+ }
204
+ if (conflicts.length > 0) return { ok: false, code: 'held', conflicts }
205
+ /** @type {LeaseView[]} */
206
+ const granted = []
207
+ for (const lease of adopt) {
208
+ // Same owner, same connection: adopt. `acquiredAt` and `holdUntil` are
209
+ // preserved (only the sliding validity moves); session/label/scope are
210
+ // refreshed because this is the latest touch of that lineage.
211
+ lease.owner = { ...owner }
212
+ lease.scope = scope
213
+ lease.expiresAt = at + ttl
214
+ granted.push(view(lease))
215
+ }
216
+ for (const path of fresh) {
217
+ /** @type {Lease} */
218
+ const lease = {
219
+ path,
220
+ connectionId,
221
+ owner: { ...owner },
222
+ scope,
223
+ acquiredAt: at,
224
+ expiresAt: at + ttl,
225
+ holdUntil: null,
226
+ }
227
+ leases.set(path, lease)
228
+ granted.push(view(lease))
229
+ }
230
+ granted.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
231
+ return { ok: true, granted }
232
+ }
233
+
234
+ /**
235
+ * Release whatever this owner still holds in the set. `notOwned` is NOT an
236
+ * error — after a broker restart every path is simply free, and the client's
237
+ * release list is its own business. Repeating a release is therefore a no-op
238
+ * that reports `notOwned`.
239
+ * @param {ReleaseInput} input
240
+ * @returns {ReleaseResult}
241
+ */
242
+ function release({ connectionId, owner, paths }) {
243
+ /** @type {string[]} */
244
+ const released = []
245
+ /** @type {string[]} */
246
+ const notOwned = []
247
+ for (const path of normalize(paths)) {
248
+ const lease = leases.get(path)
249
+ if (lease && lease.connectionId === connectionId && lease.owner.id === owner.id) {
250
+ leases.delete(path)
251
+ released.push(path)
252
+ } else {
253
+ notOwned.push(path)
254
+ }
255
+ }
256
+ return { ok: true, released, notOwned }
257
+ }
258
+
259
+ /**
260
+ * Extend validity to a wall-clock deadline. All-or-nothing: one path this
261
+ * owner does not (still) hold refuses the whole batch. The bound is a product
262
+ * constant, never a budget: reaching it has no consequence other than the
263
+ * refusal itself.
264
+ * @param {HoldInput} input
265
+ * @returns {HoldResult}
266
+ */
267
+ function hold({ connectionId, owner, paths, minutes }) {
268
+ if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return { ok: false, code: 'bad-request' }
269
+ if (minutes > holdMaxMinutes) return { ok: false, code: 'limit', maxMinutes: holdMaxMinutes }
270
+ const at = now()
271
+ const targets = normalize(paths)
272
+ /** @type {string[]} */
273
+ const missing = []
274
+ for (const path of targets) {
275
+ const lease = leases.get(path)
276
+ if (!lease || lease.connectionId !== connectionId || lease.owner.id !== owner.id || !isValid(lease)) missing.push(path)
277
+ }
278
+ if (missing.length > 0) return { ok: false, code: 'not-owned', paths: missing }
279
+ const deadline = at + minutes * 60_000
280
+ /** @type {{ path: string, expiresAt: number, holdUntil: number | null }[]} */
281
+ const held = []
282
+ for (const path of targets) {
283
+ const lease = /** @type {Lease} */ (leases.get(path))
284
+ // Only ever later: an earlier deadline never shortens an existing one.
285
+ lease.holdUntil = Math.max(lease.holdUntil ?? 0, deadline)
286
+ held.push({ path, expiresAt: lease.expiresAt, holdUntil: lease.holdUntil })
287
+ }
288
+ return { ok: true, held }
289
+ }
290
+
291
+ /**
292
+ * Read-only projection. Filters stack (AND). Lapsed rows are not reported:
293
+ * they are free regardless of what is still in the table, and `sweep`
294
+ * reclaims the memory. Returned leases are copies.
295
+ * @param {StatusFilter} [filter]
296
+ * @returns {StatusResult}
297
+ */
298
+ function status(filter = {}) {
299
+ /** @type {LeaseView[]} */
300
+ const out = []
301
+ for (const lease of leases.values()) {
302
+ if (!isValid(lease)) continue
303
+ if (filter.scope !== undefined && lease.scope !== filter.scope) continue
304
+ if (filter.session !== undefined && lease.owner.session !== filter.session) continue
305
+ if (filter.path !== undefined && lease.path !== filter.path) continue
306
+ out.push(view(lease))
307
+ }
308
+ out.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
309
+ return { ok: true, leases: out }
310
+ }
311
+
312
+ /**
313
+ * Ask the holder of a path to hand it over. At most one pending request per
314
+ * (requester id, path): a repeat reuses the same `requestId` and the same
315
+ * window instead of opening a second mailbox entry — unless ownership changed
316
+ * hands in between, in which case the stale entry is replaced so the request
317
+ * reaches the actual holder.
318
+ * @param {RequestInput} input
319
+ * @returns {RequestResult}
320
+ */
321
+ function request({ connectionId, from, path }) {
322
+ const at = now()
323
+ const lease = leases.get(path)
324
+ if (!lease || !isValid(lease)) return { ok: false, code: 'free' }
325
+ if (lease.owner.id === from.id) return { ok: false, code: 'self' }
326
+ const key = requestKey(path, from.id)
327
+ const knownId = pendingKeys.get(key)
328
+ const known = knownId !== undefined ? pending.get(knownId) : undefined
329
+ if (known && known.holderId === lease.owner.id) {
330
+ return { ok: true, state: 'pending', requestId: known.requestId, holder: { ...lease.owner }, expiresAt: known.expiresAt }
331
+ }
332
+ if (known) dropRequest(known.requestId)
333
+ const requestId = `req-${++requestSeq}`
334
+ /** @type {PendingRequest} */
335
+ const record = {
336
+ requestId,
337
+ path,
338
+ from: { ...from },
339
+ connectionId,
340
+ holderConnectionId: lease.connectionId,
341
+ holderId: lease.owner.id,
342
+ expiresAt: at + requestWindowMs,
343
+ }
344
+ pending.set(requestId, record)
345
+ pendingKeys.set(key, requestId)
346
+ deliver(lease.connectionId, { event: 'request', requestId, path, from: { ...from }, expiresAt: record.expiresAt })
347
+ return { ok: true, state: 'pending', requestId, holder: { ...lease.owner }, expiresAt: record.expiresAt }
348
+ }
349
+
350
+ /**
351
+ * Answer a pending request as the holder.
352
+ *
353
+ * Precedence, in this order — the order is what makes all four documented
354
+ * outcomes reachable: unknown/elapsed request → `expired`; ownership gone or
355
+ * changed hands → `stale` (no ownership change); reply not from the holding
356
+ * connection/owner → `not-holder` and the request stays pending (a stranger
357
+ * must not be able to consume someone else's request); otherwise `keep`
358
+ * declines, `release` transfers in place.
359
+ * @param {ReplyInput} input
360
+ * @returns {ReplyResult}
361
+ */
362
+ function reply({ connectionId, owner, requestId, decision }) {
363
+ const at = now()
364
+ const record = pending.get(requestId)
365
+ if (!record) return { ok: false, code: 'expired' }
366
+ if (at >= record.expiresAt) {
367
+ dropRequest(requestId)
368
+ settle(record, 'expired')
369
+ return { ok: false, code: 'expired' }
370
+ }
371
+ const lease = leases.get(record.path)
372
+ if (!lease || !isValid(lease)) {
373
+ dropRequest(requestId)
374
+ settle(record, 'expired')
375
+ return { ok: false, code: 'stale' }
376
+ }
377
+ if (lease.owner.id !== record.holderId) {
378
+ dropRequest(requestId)
379
+ settle(record, 'expired')
380
+ return { ok: false, code: 'stale' }
381
+ }
382
+ if (lease.connectionId !== connectionId || lease.owner.id !== owner.id) return { ok: false, code: 'not-holder' }
383
+ dropRequest(requestId)
384
+ if (decision === 'keep') {
385
+ settle(record, 'declined')
386
+ return { ok: true, state: 'declined' }
387
+ }
388
+ lease.owner = { ...record.from }
389
+ lease.connectionId = record.connectionId
390
+ lease.acquiredAt = at
391
+ lease.expiresAt = at + ttlMs
392
+ lease.holdUntil = null
393
+ settle(record, 'transferred')
394
+ return { ok: true, state: 'transferred' }
395
+ }
396
+
397
+ /**
398
+ * Reclaim memory for lapsed leases and elapsed requests. Purely an
399
+ * optimisation for long-lived brokers — validity is decided by the clock, not
400
+ * by this pass — except that an elapsed request is the only moment the
401
+ * requester learns its request expired.
402
+ * @returns {{ droppedLeases: number, droppedRequests: number }}
403
+ */
404
+ function sweep() {
405
+ const at = now()
406
+ let droppedLeases = 0
407
+ let droppedRequests = 0
408
+ for (const [path, lease] of [...leases]) {
409
+ if (at >= validUntil(lease)) {
410
+ leases.delete(path)
411
+ droppedLeases += 1
412
+ }
413
+ }
414
+ for (const [requestId, record] of [...pending]) {
415
+ if (at >= record.expiresAt) {
416
+ dropRequest(requestId)
417
+ settle(record, 'expired')
418
+ droppedRequests += 1
419
+ }
420
+ }
421
+ return { droppedLeases, droppedRequests }
422
+ }
423
+
424
+ return Object.freeze({ connect, disconnect, acquire, release, hold, status, request, reply, sweep })
425
+ }
package/src/lsp/tools.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // The LSP semantic tools: four read-only queries plus `lsp_rename`, the one
2
2
  // rewriting tool (cross-file WorkspaceEdit application through the fs version
3
- // guard). Pure-object definitions with object-rooted schemas; 1-based
4
- // positions in, structured text out; every LSP failure becomes an ordinary
5
- // tool error result, never a broken turn.
3
+ // guard, under ONE batch file lease). Pure-object definitions with
4
+ // object-rooted schemas; 1-based positions in, structured text out; every LSP
5
+ // failure becomes an ordinary tool error result, never a broken turn.
6
6
  import { familyForLanguageId, languageIdForFile } from './registry.js'
7
7
  import { uriToPath } from './uri.js'
8
8
  import { applyTextEdits, detectLineEndings, extractChanges, normalizeLineEndings, restoreLineEndings } from './rename.js'
9
9
  import { unifiedDiff } from '../hashline-edit/diff.js'
10
+ import { createEditorLease } from '../file-lease/singleton.js'
11
+ import { isLeaseConflict } from '../file-lease/client.js'
10
12
 
11
13
  const SEVERITY = { 1: 'error', 2: 'warning', 3: 'information', 4: 'hint' }
12
14
 
@@ -34,9 +36,105 @@ function asLocationArray(result) {
34
36
  .filter(Boolean)
35
37
  }
36
38
 
37
- /** Build the five tool definitions for one calling agent's enable. */
38
- export function createLspTools({ manager, ctx, agent }) {
39
- const editLock = ctx.get?.('weirEditLock')
39
+ /**
40
+ * The sandbox denial marker the host backend raises, by prefix only: every
41
+ * effective mode ends with its own mode name, and a denial never moved bytes.
42
+ */
43
+ const SANDBOX_DENIAL_MARKER = '[sandbox: file access denied under'
44
+
45
+ /** @param {unknown} error @returns {boolean} */
46
+ function isSandboxDenial(error) {
47
+ return String(/** @type {any} */ (error)?.message ?? '').includes(SANDBOX_DENIAL_MARKER)
48
+ }
49
+
50
+ /** @param {unknown} error @returns {string} */
51
+ function messageOf(error) {
52
+ return String(/** @type {any} */ (error)?.message ?? error)
53
+ }
54
+
55
+ /**
56
+ * The three-way truth of a failed rename (design D12): which targets are on
57
+ * disk, which PROVABLY are not, and which cannot be proven either way.
58
+ *
59
+ * Only these three failures prove that no byte of a target landed: a refused
60
+ * claim (nothing ran), the filesystem version guard (the write was rejected
61
+ * before it moved bytes) and a sandbox denial (the backend refused the
62
+ * operation). Anything else — an I/O error, an abort, a backend that threw
63
+ * after a partial write — is UNPROVEN, and claiming it was not written would be
64
+ * a lie the model would act on.
65
+ *
66
+ * @param {unknown} error
67
+ * @returns {boolean}
68
+ */
69
+ function isProvablyUnwritten(error) {
70
+ if (isLeaseConflict(error)) return true
71
+ const value = /** @type {any} */ (error)
72
+ if (String(value?.code ?? '').includes('FS_STALE_VERSION')) return true
73
+ return String(value?.message ?? '').includes('FS_STALE_VERSION') || isSandboxDenial(error)
74
+ }
75
+
76
+ /**
77
+ * The honest failure report: three lists and no rollback claim. The identity of
78
+ * the caller's claim is stated truthfully too — a batch refused before the
79
+ * claim was granted holds nothing, while a batch that failed mid-write keeps its
80
+ * leases until the turn ends (design D2/D10).
81
+ *
82
+ * @param {{ failure: unknown, remaining: any[], written: any[], entered: boolean }} detail
83
+ * @returns {Error}
84
+ */
85
+ function renameFailure({ failure, remaining, written, entered }) {
86
+ const list = (plans) => `[${plans.map((plan) => JSON.stringify(plan.target.displayPath)).join(', ')}]`
87
+ const proven = !entered || isProvablyUnwritten(failure)
88
+ const notWritten = proven ? remaining : []
89
+ const uncertain = proven ? [] : remaining
90
+ const failedPath = entered ? JSON.stringify(remaining[0]?.target.displayPath ?? '') : undefined
91
+ /** @type {Error & { cause?: unknown }} */
92
+ const error = new Error([
93
+ failedPath === undefined
94
+ ? `lsp_rename: the rename was refused before any write: ${messageOf(failure)}`
95
+ : `lsp_rename: write failed for ${failedPath}: ${messageOf(failure)}`,
96
+ `filesAlreadyWritten: ${list(written)};`,
97
+ `filesNotWritten: ${list(notWritten)};`,
98
+ `filesUncertain: ${list(uncertain)};`,
99
+ entered
100
+ ? 'ownership retained (no rollback)'
101
+ : 'no claim was taken for this batch (no rollback)',
102
+ ].join('\n'))
103
+ error.cause = failure
104
+ return error
105
+ }
106
+
107
+ /**
108
+ * The optional `leaseClient` seam on a scope that may not declare it.
109
+ *
110
+ * A cordis context THROWS on reading a property it does not inject
111
+ * ("cannot get property \"leaseClient\" without inject"), so probing it directly
112
+ * makes the seam a trap: `lsp` injects `tools`/`subprocess`/`fs`, never a lease
113
+ * client, and every `lsp_rename` would fail at tool construction. An explicit
114
+ * argument (unit tests, an embedder) always wins; the probe only ever answers
115
+ * `undefined` when the composition does not carry one.
116
+ * @param {any} scope
117
+ * @returns {any}
118
+ */
119
+ function optionalLeaseClient(scope) {
120
+ try {
121
+ return scope?.leaseClient
122
+ } catch {
123
+ return undefined
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Build the five tool definitions for one calling agent's enable.
129
+ *
130
+ * `leaseClient` is the injection seam (explicit argument or a carrying ctx),
131
+ * because this module's caller forwards only `{manager, ctx, agent}`. Nothing
132
+ * else in the row reads it: no service is published or consumed for leases
133
+ * (design D11) — the default is the process-wide client, resolved on the first
134
+ * managed write.
135
+ */
136
+ export function createLspTools({ manager, ctx, agent, leaseClient }) {
137
+ const lease = createEditorLease({ leaseClient: leaseClient ?? optionalLeaseClient(ctx), ctx })
40
138
  // Optional sandbox policy capture (S23, mirrors hashline-edit): the
41
139
  // sandboxed fs backend enforces the session policy only when the caller
42
140
  // passes it per call. Absent service (headless test compositions, other
@@ -179,7 +277,7 @@ export function createLspTools({ manager, ctx, agent }) {
179
277
  },
180
278
  {
181
279
  name: 'lsp_rename',
182
- description: `Rename the symbol at a 1-based position across the workspace: the language server computes every edit location and the resulting WorkspaceEdit is applied to disk through the filesystem version guard. Every target file is preflighted before anything is written (a bad edit range aborts the call with zero writes); then files are written one by one and a mid-write failure (file changed on disk, sandbox denial, I/O) stops immediately, reporting exactly which files were already written and which were not. The original line-ending style (LF/CRLF) of each file is preserved.`,
280
+ description: `Rename the symbol at a 1-based position across the workspace: the language server computes every edit location and the resulting WorkspaceEdit is applied to disk through the filesystem version guard, under one file-lease claim covering every preflighted target. Every target file is preflighted before anything is written (a bad edit range aborts the call with zero writes); then files are written one by one and a mid-write failure (file changed on disk, sandbox denial, I/O) stops immediately, reporting exactly which files were already written, which were not written, and which could not be proven either way. Nothing is rolled back. The original line-ending style (LF/CRLF) of each file is preserved.`,
183
281
  parameters: {
184
282
  type: 'object',
185
283
  properties: {
@@ -254,25 +352,39 @@ export function createLspTools({ manager, ctx, agent }) {
254
352
  return { text: `lsp_rename: no-op — the server's edits leave every file unchanged; nothing was written.` }
255
353
  }
256
354
 
257
- // Phase 2 — write pass: one atomic replaceIfVersion write per file.
258
- // A failure stops the pass immediately and names both file lists.
355
+ // Phase 2 — write pass under ONE batch claim (design D2): the complete
356
+ // preflighted target set is acquired atomically before the first byte, so
357
+ // a conflict anywhere refuses the whole call with nothing written and no
358
+ // implicit takeover. The claim is NOT released here: it lives until the
359
+ // turn ends, like every other managed edit claim (D10), so the session's
360
+ // own later edits of these files cannot conflict with itself.
259
361
  const written = []
260
- if (editLock) {
261
- await editLock.publishBatch(exec, {cwd: resolveCwd, effectivePolicy: policy, args,
262
- plans: writePlans.map(plan => ({filePath: plan.filePath, content: plan.after, version: plan.version}))})
263
- written.push(...writePlans)
264
- } else for (const plan of writePlans) {
265
- try {
266
- await ctx.fs.writeText(plan.target, plan.after, { kind: 'replaceIfVersion', version: plan.version }, exec.signal, policy)
267
- written.push(plan)
268
- } catch (error) {
269
- const notWritten = writePlans.slice(written.length).map((plan) => plan.target.displayPath)
270
- const already = written.map((plan) => plan.target.displayPath)
271
- throw new Error(
272
- `lsp_rename: write failed for ${plan.target.displayPath}: ${error?.message ?? error}\n` +
273
- `Rename partially applied. filesAlreadyWritten: [${already.join(', ')}]; filesNotWritten: [${notWritten.join(', ')}]`,
274
- )
275
- }
362
+ let entered = false
363
+ /** @type {unknown} */
364
+ let failure = null
365
+ try {
366
+ await lease.client().withLeases(
367
+ lease.ownerOf(exec.agent),
368
+ plans.map((plan) => plan.target.displayPath),
369
+ lease.scopeOf(exec.agent, resolveCwd),
370
+ async () => {
371
+ entered = true
372
+ for (const plan of writePlans) {
373
+ await ctx.fs.writeText(plan.target, plan.after, { kind: 'replaceIfVersion', version: plan.version }, exec.signal, policy)
374
+ written.push(plan)
375
+ }
376
+ },
377
+ )
378
+ } catch (error) {
379
+ failure = error
380
+ }
381
+ if (failure !== null) {
382
+ // The failure report is the whole contract here (design D12): which
383
+ // files are provably on disk, which provably are not, which cannot be
384
+ // proven, and never a rollback claim. `written` holds exactly the
385
+ // files that returned from writeText, so the failing target is the
386
+ // head of the remaining slice.
387
+ throw renameFailure({ failure, remaining: writePlans.slice(written.length), written, entered })
276
388
  }
277
389
 
278
390
  const editCount = writePlans.reduce((sum, plan) => sum + plan.edits, 0)
@@ -284,8 +396,6 @@ export function createLspTools({ manager, ctx, agent }) {
284
396
  },
285
397
  },
286
398
  ]
287
- // Only a definition built with the captured service is admitted by Edit Lock.
288
- if (editLock) for (const tool of tools) if (tool.name === 'lsp_rename') editLock.claim?.(tool)
289
399
  return tools
290
400
  }
291
401