weir-harness 1.2.3 → 2.1.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 (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -0,0 +1,482 @@
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
+ // unless a session explicitly subscribed on its own side.
25
+ // - Removal has exactly one outlet (`removeLease`): the row is deleted first and
26
+ // then the fact is announced as `released` to every connected client. Explicit
27
+ // release, holder disconnect, and expiry observed by
28
+ // acquire/status/request/reply/hold/sweep all pass through it, so a lapsed row
29
+ // can never be silently overwritten by a new grant. An atomic transfer never
30
+ // comes through it because the path never becomes unowned.
31
+ // - `release` judges identity only (a lapsed row still belongs to its owner, so
32
+ // releasing it is `released`, keeping the call idempotent), while `hold`
33
+ // additionally requires the row to still be valid — you cannot extend
34
+ // ownership that already lapsed, you must acquire again first.
35
+
36
+ /**
37
+ * @typedef {{ id: string, session: string, label: string }} Owner
38
+ * @typedef {{ path: string, connectionId: string, owner: Owner, scope: string, acquiredAt: number, expiresAt: number, holdUntil: number | null }} Lease
39
+ * @typedef {{ path: string, holder: Owner, expiresAt: number, holdUntil: number | null }} Conflict
40
+ * @typedef {{ path: string, owner: Owner, scope: string, acquiredAt: number, expiresAt: number, holdUntil: number | null }} LeaseView
41
+ * @typedef {{ requestId: string, path: string, from: Owner, connectionId: string, holderConnectionId: string, holderId: string, expiresAt: number }} PendingRequest
42
+ * @typedef {{ event: 'request', requestId: string, path: string, from: Owner, expiresAt: number }} RequestEvent
43
+ * @typedef {{ event: 'settled', requestId: string, path: string, session: string, state: 'transferred' | 'declined' | 'expired' }} SettledEvent
44
+ * `session` is the requester's own session (copied from the request's `from`),
45
+ * carried so a client can route the outcome to that session's agent without
46
+ * depending on a turn-scoped table of request ids.
47
+ * @typedef {{ event: 'released', path: string, reason: 'released' | 'disconnected' | 'expired' }} ReleasedEvent
48
+ * @typedef {{ connectionId: string, owner: Owner, scope: string, paths: string[], ttlMs?: number }} AcquireInput
49
+ * @typedef {{ connectionId: string, owner: Owner, paths: string[] }} ReleaseInput
50
+ * @typedef {{ connectionId: string, owner: Owner, paths: string[], minutes: number }} HoldInput
51
+ * @typedef {{ connectionId: string, from: Owner, path: string }} RequestInput
52
+ * @typedef {{ connectionId: string, owner: Owner, requestId: string, decision: 'release' | 'keep' }} ReplyInput
53
+ * @typedef {{ scope?: string, session?: string, path?: string }} StatusFilter
54
+ * @typedef {{ ok: true, granted: LeaseView[] } | { ok: false, code: 'held', conflicts: Conflict[] }} AcquireResult
55
+ * @typedef {{ ok: true, released: string[], notOwned: string[] }} ReleaseResult
56
+ * @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
57
+ * @typedef {{ ok: true, leases: LeaseView[] }} StatusResult
58
+ * @typedef {{ ok: true, state: 'pending', requestId: string, holder: Owner, expiresAt: number } | { ok: false, code: 'free' | 'self' }} RequestResult
59
+ * @typedef {{ ok: true, state: 'transferred' | 'declined' } | { ok: false, code: 'expired' | 'stale' | 'not-holder' }} ReplyResult
60
+ */
61
+
62
+ /**
63
+ * @param {Lease} lease
64
+ * @returns {LeaseView}
65
+ */
66
+ function view(lease) {
67
+ return {
68
+ path: lease.path,
69
+ owner: { ...lease.owner },
70
+ scope: lease.scope,
71
+ acquiredAt: lease.acquiredAt,
72
+ expiresAt: lease.expiresAt,
73
+ holdUntil: lease.holdUntil,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Create one arbitration kernel.
79
+ *
80
+ * @param {object} [options]
81
+ * @param {() => number} [options.now] injected clock (epoch ms)
82
+ * @param {number} [options.ttlMs] default lease time-to-live
83
+ * @param {number} [options.requestWindowMs] bounded negotiation window
84
+ * @param {number} [options.holdMaxMinutes] retention bound in minutes
85
+ * @param {(connectionId: string, event: RequestEvent | SettledEvent | ReleasedEvent) => void} [options.onEvent] event sink; a missing connection or a throwing sink never affects arbitration
86
+ */
87
+ export function createBroker({
88
+ now = Date.now,
89
+ ttlMs = 120_000,
90
+ requestWindowMs = 120_000,
91
+ holdMaxMinutes = 60,
92
+ onEvent = () => {},
93
+ } = {}) {
94
+ /** @type {Map<string, Lease>} */
95
+ const leases = new Map()
96
+ /** @type {Map<string, PendingRequest>} */
97
+ const pending = new Map()
98
+ /** @type {Map<string, string>} requester+path → requestId, at most one per pair */
99
+ const pendingKeys = new Map()
100
+ /** @type {Set<string>} */
101
+ const connections = new Set()
102
+ let requestSeq = 0
103
+
104
+ /** @param {Lease} lease */
105
+ function validUntil(lease) {
106
+ return Math.max(lease.expiresAt, lease.holdUntil ?? 0)
107
+ }
108
+
109
+ /** @param {Lease} lease */
110
+ function isValid(lease) {
111
+ return now() < validUntil(lease)
112
+ }
113
+
114
+ /** @param {string} path @param {string} fromId */
115
+ function requestKey(path, fromId) {
116
+ return `${fromId}\u0000${path}`
117
+ }
118
+
119
+ /** Drop a mailbox entry and its (requester, path) index slot. @param {string} requestId */
120
+ function dropRequest(requestId) {
121
+ const record = pending.get(requestId)
122
+ if (!record) return
123
+ pending.delete(requestId)
124
+ const key = requestKey(record.path, record.from.id)
125
+ if (pendingKeys.get(key) === requestId) pendingKeys.delete(key)
126
+ }
127
+
128
+ /**
129
+ * Deliver one event. Delivery is best effort by contract: an unknown
130
+ * connection is a silent drop and a throwing sink cannot break arbitration.
131
+ * @param {string} connectionId
132
+ * @param {RequestEvent | SettledEvent | ReleasedEvent} event
133
+ */
134
+ function deliver(connectionId, event) {
135
+ if (!connections.has(connectionId)) return
136
+ try {
137
+ onEvent(connectionId, event)
138
+ } catch {
139
+ // The sink is a transport; it must never be able to corrupt the table.
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The ONE removal outlet for a lease row.
145
+ *
146
+ * The row is deleted before anything is announced, so a later observation of
147
+ * the same lapsed record finds nothing to announce (no duplicate), and every
148
+ * receiver sees the path as unowned at the moment it hears about it. Delivery
149
+ * is a best-effort fact to every currently connected client: a missing or
150
+ * disconnected recipient is skipped, one throwing sink cannot affect the
151
+ * others, and the verdict that caused the removal is already decided.
152
+ *
153
+ * @param {string} path
154
+ * @param {'released' | 'disconnected' | 'expired'} reason
155
+ * @returns {boolean} whether a row was removed
156
+ */
157
+ function removeLease(path, reason) {
158
+ if (!leases.delete(path)) return false
159
+ for (const connectionId of [...connections]) {
160
+ deliver(connectionId, { event: 'released', path, reason })
161
+ }
162
+ return true
163
+ }
164
+
165
+ /**
166
+ * Announce the end of a request to the requester.
167
+ * @param {PendingRequest} record
168
+ * @param {'transferred' | 'declined' | 'expired'} state
169
+ */
170
+ function settle(record, state) {
171
+ deliver(record.connectionId, {
172
+ event: 'settled',
173
+ requestId: record.requestId,
174
+ path: record.path,
175
+ session: record.from.session,
176
+ state,
177
+ })
178
+ }
179
+
180
+ /** @param {string[]} paths */
181
+ function normalize(paths) {
182
+ return [...new Set(paths)].sort()
183
+ }
184
+
185
+ /** Register a connection. @param {string} connectionId */
186
+ function connect(connectionId) {
187
+ connections.add(connectionId)
188
+ }
189
+
190
+ /**
191
+ * Forget a connection: every lease it holds is released immediately (no
192
+ * expiry wait), and every mailbox entry it takes part in is dropped. A
193
+ * request whose holder went away settles as `expired` for a requester that is
194
+ * still connected; a request owned by the departing connection simply dies
195
+ * with it.
196
+ * @param {string} connectionId
197
+ */
198
+ function disconnect(connectionId) {
199
+ connections.delete(connectionId)
200
+ for (const [path, lease] of [...leases]) {
201
+ if (lease.connectionId === connectionId) removeLease(path, 'disconnected')
202
+ }
203
+ for (const [requestId, record] of [...pending]) {
204
+ if (record.connectionId === connectionId) {
205
+ dropRequest(requestId)
206
+ continue
207
+ }
208
+ if (record.holderConnectionId === connectionId) {
209
+ dropRequest(requestId)
210
+ settle(record, 'expired')
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Acquire a whole path set atomically. Paths are evaluated in sorted order so
217
+ * two overlapping requests cannot deadlock and the reply is deterministic.
218
+ * One live foreign holder refuses the entire batch — nothing is granted.
219
+ * @param {AcquireInput} input
220
+ * @returns {AcquireResult}
221
+ */
222
+ function acquire({ connectionId, owner, scope, paths, ttlMs: requestTtlMs }) {
223
+ const at = now()
224
+ const ttl = requestTtlMs !== undefined ? requestTtlMs : ttlMs
225
+ /** @type {Conflict[]} */
226
+ const conflicts = []
227
+ /** @type {string[]} */
228
+ const fresh = []
229
+ /** @type {Lease[]} */
230
+ const adopt = []
231
+ for (const path of normalize(paths)) {
232
+ const lease = leases.get(path)
233
+ if (!lease || !isValid(lease)) {
234
+ // A lapsed row nobody observed yet is a free path whose record is still
235
+ // in the table: reclaim it through the ONE removal outlet so the release
236
+ // is announced instead of being silently overwritten by the grant below.
237
+ if (lease !== undefined) removeLease(path, 'expired')
238
+ fresh.push(path)
239
+ continue
240
+ }
241
+ if (lease.owner.id === owner.id && lease.connectionId === connectionId) {
242
+ adopt.push(lease)
243
+ continue
244
+ }
245
+ conflicts.push({ path, holder: { ...lease.owner }, expiresAt: lease.expiresAt, holdUntil: lease.holdUntil })
246
+ }
247
+ if (conflicts.length > 0) return { ok: false, code: 'held', conflicts }
248
+ /** @type {LeaseView[]} */
249
+ const granted = []
250
+ for (const lease of adopt) {
251
+ // Same owner, same connection: adopt. `acquiredAt` and `holdUntil` are
252
+ // preserved (only the sliding validity moves); session/label/scope are
253
+ // refreshed because this is the latest touch of that lineage.
254
+ lease.owner = { ...owner }
255
+ lease.scope = scope
256
+ lease.expiresAt = at + ttl
257
+ granted.push(view(lease))
258
+ }
259
+ for (const path of fresh) {
260
+ /** @type {Lease} */
261
+ const lease = {
262
+ path,
263
+ connectionId,
264
+ owner: { ...owner },
265
+ scope,
266
+ acquiredAt: at,
267
+ expiresAt: at + ttl,
268
+ holdUntil: null,
269
+ }
270
+ leases.set(path, lease)
271
+ granted.push(view(lease))
272
+ }
273
+ granted.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
274
+ return { ok: true, granted }
275
+ }
276
+
277
+ /**
278
+ * Release whatever this owner still holds in the set. `notOwned` is NOT an
279
+ * error — after a broker restart every path is simply free, and the client's
280
+ * release list is its own business. Repeating a release is therefore a no-op
281
+ * that reports `notOwned`.
282
+ * @param {ReleaseInput} input
283
+ * @returns {ReleaseResult}
284
+ */
285
+ function release({ connectionId, owner, paths }) {
286
+ /** @type {string[]} */
287
+ const released = []
288
+ /** @type {string[]} */
289
+ const notOwned = []
290
+ for (const path of normalize(paths)) {
291
+ const lease = leases.get(path)
292
+ if (lease && lease.connectionId === connectionId && lease.owner.id === owner.id) {
293
+ // Even a lapsed row belongs to its owner until removed, so the explicit
294
+ // release is what takes it out of the table and what gets announced.
295
+ removeLease(path, 'released')
296
+ released.push(path)
297
+ } else {
298
+ notOwned.push(path)
299
+ }
300
+ }
301
+ return { ok: true, released, notOwned }
302
+ }
303
+
304
+ /**
305
+ * Extend validity to a wall-clock deadline. All-or-nothing: one path this
306
+ * owner does not (still) hold refuses the whole batch. The bound is a product
307
+ * constant, never a budget: reaching it has no consequence other than the
308
+ * refusal itself.
309
+ * @param {HoldInput} input
310
+ * @returns {HoldResult}
311
+ */
312
+ function hold({ connectionId, owner, paths, minutes }) {
313
+ if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return { ok: false, code: 'bad-request' }
314
+ if (minutes > holdMaxMinutes) return { ok: false, code: 'limit', maxMinutes: holdMaxMinutes }
315
+ const at = now()
316
+ const targets = normalize(paths)
317
+ /** @type {string[]} */
318
+ const missing = []
319
+ for (const path of targets) {
320
+ const lease = leases.get(path)
321
+ if (lease !== undefined && !isValid(lease)) removeLease(path, 'expired')
322
+ if (!lease || lease.connectionId !== connectionId || lease.owner.id !== owner.id || !isValid(lease)) missing.push(path)
323
+ }
324
+ if (missing.length > 0) return { ok: false, code: 'not-owned', paths: missing }
325
+ const deadline = at + minutes * 60_000
326
+ /** @type {{ path: string, expiresAt: number, holdUntil: number | null }[]} */
327
+ const held = []
328
+ for (const path of targets) {
329
+ const lease = /** @type {Lease} */ (leases.get(path))
330
+ // Only ever later: an earlier deadline never shortens an existing one.
331
+ lease.holdUntil = Math.max(lease.holdUntil ?? 0, deadline)
332
+ held.push({ path, expiresAt: lease.expiresAt, holdUntil: lease.holdUntil })
333
+ }
334
+ return { ok: true, held }
335
+ }
336
+
337
+ /**
338
+ * Read-only projection. Filters stack (AND). Lapsed rows are not reported:
339
+ * they are free regardless of what is still in the table, and `sweep`
340
+ * reclaims the memory. Returned leases are copies.
341
+ * @param {StatusFilter} [filter]
342
+ * @returns {StatusResult}
343
+ */
344
+ function status(filter = {}) {
345
+ /** @type {LeaseView[]} */
346
+ const out = []
347
+ /** @type {string[]} */
348
+ const lapsed = []
349
+ for (const lease of leases.values()) {
350
+ if (!isValid(lease)) {
351
+ // Read-only means "takes no valid lease": a record the clock already
352
+ // made free is reclaimed and announced here, exactly like a sweep.
353
+ lapsed.push(lease.path)
354
+ continue
355
+ }
356
+ if (filter.scope !== undefined && lease.scope !== filter.scope) continue
357
+ if (filter.session !== undefined && lease.owner.session !== filter.session) continue
358
+ if (filter.path !== undefined && lease.path !== filter.path) continue
359
+ out.push(view(lease))
360
+ }
361
+ for (const path of lapsed) removeLease(path, 'expired')
362
+ out.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
363
+ return { ok: true, leases: out }
364
+ }
365
+
366
+ /**
367
+ * Ask the holder of a path to hand it over. At most one pending request per
368
+ * (requester id, path): a repeat reuses the same `requestId` and the same
369
+ * window instead of opening a second mailbox entry — unless ownership changed
370
+ * hands in between, in which case the stale entry is replaced so the request
371
+ * reaches the actual holder.
372
+ * @param {RequestInput} input
373
+ * @returns {RequestResult}
374
+ */
375
+ function request({ connectionId, from, path }) {
376
+ const at = now()
377
+ const lease = leases.get(path)
378
+ if (!lease || !isValid(lease)) {
379
+ if (lease !== undefined) removeLease(path, 'expired')
380
+ return { ok: false, code: 'free' }
381
+ }
382
+ if (lease.owner.id === from.id) return { ok: false, code: 'self' }
383
+ const key = requestKey(path, from.id)
384
+ const knownId = pendingKeys.get(key)
385
+ const known = knownId !== undefined ? pending.get(knownId) : undefined
386
+ if (known && known.holderId === lease.owner.id) {
387
+ return { ok: true, state: 'pending', requestId: known.requestId, holder: { ...lease.owner }, expiresAt: known.expiresAt }
388
+ }
389
+ if (known) dropRequest(known.requestId)
390
+ const requestId = `req-${++requestSeq}`
391
+ /** @type {PendingRequest} */
392
+ const record = {
393
+ requestId,
394
+ path,
395
+ from: { ...from },
396
+ connectionId,
397
+ holderConnectionId: lease.connectionId,
398
+ holderId: lease.owner.id,
399
+ expiresAt: at + requestWindowMs,
400
+ }
401
+ pending.set(requestId, record)
402
+ pendingKeys.set(key, requestId)
403
+ deliver(lease.connectionId, { event: 'request', requestId, path, from: { ...from }, expiresAt: record.expiresAt })
404
+ return { ok: true, state: 'pending', requestId, holder: { ...lease.owner }, expiresAt: record.expiresAt }
405
+ }
406
+
407
+ /**
408
+ * Answer a pending request as the holder.
409
+ *
410
+ * Precedence, in this order — the order is what makes all four documented
411
+ * outcomes reachable: unknown/elapsed request → `expired`; ownership gone or
412
+ * changed hands → `stale` (no ownership change); reply not from the holding
413
+ * connection/owner → `not-holder` and the request stays pending (a stranger
414
+ * must not be able to consume someone else's request); otherwise `keep`
415
+ * declines, `release` transfers in place.
416
+ * @param {ReplyInput} input
417
+ * @returns {ReplyResult}
418
+ */
419
+ function reply({ connectionId, owner, requestId, decision }) {
420
+ const at = now()
421
+ const record = pending.get(requestId)
422
+ if (!record) return { ok: false, code: 'expired' }
423
+ if (at >= record.expiresAt) {
424
+ dropRequest(requestId)
425
+ settle(record, 'expired')
426
+ return { ok: false, code: 'expired' }
427
+ }
428
+ const lease = leases.get(record.path)
429
+ if (!lease || !isValid(lease)) {
430
+ if (lease !== undefined) removeLease(record.path, 'expired')
431
+ dropRequest(requestId)
432
+ settle(record, 'expired')
433
+ return { ok: false, code: 'stale' }
434
+ }
435
+ if (lease.owner.id !== record.holderId) {
436
+ dropRequest(requestId)
437
+ settle(record, 'expired')
438
+ return { ok: false, code: 'stale' }
439
+ }
440
+ if (lease.connectionId !== connectionId || lease.owner.id !== owner.id) return { ok: false, code: 'not-holder' }
441
+ dropRequest(requestId)
442
+ if (decision === 'keep') {
443
+ settle(record, 'declined')
444
+ return { ok: true, state: 'declined' }
445
+ }
446
+ lease.owner = { ...record.from }
447
+ lease.connectionId = record.connectionId
448
+ lease.acquiredAt = at
449
+ lease.expiresAt = at + ttlMs
450
+ lease.holdUntil = null
451
+ settle(record, 'transferred')
452
+ return { ok: true, state: 'transferred' }
453
+ }
454
+
455
+ /**
456
+ * Reclaim memory for lapsed leases and elapsed requests. Purely an
457
+ * optimisation for long-lived brokers — validity is decided by the clock, not
458
+ * by this pass — except that an elapsed request is the only moment the
459
+ * requester learns its request expired.
460
+ * @returns {{ droppedLeases: number, droppedRequests: number }}
461
+ */
462
+ function sweep() {
463
+ const at = now()
464
+ let droppedLeases = 0
465
+ let droppedRequests = 0
466
+ for (const [path, lease] of [...leases]) {
467
+ if (at >= validUntil(lease)) {
468
+ if (removeLease(path, 'expired')) droppedLeases += 1
469
+ }
470
+ }
471
+ for (const [requestId, record] of [...pending]) {
472
+ if (at >= record.expiresAt) {
473
+ dropRequest(requestId)
474
+ settle(record, 'expired')
475
+ droppedRequests += 1
476
+ }
477
+ }
478
+ return { droppedLeases, droppedRequests }
479
+ }
480
+
481
+ return Object.freeze({ connect, disconnect, acquire, release, hold, status, request, reply, sweep })
482
+ }
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