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.
- package/cordis.patch.yml +7 -18
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1066 -0
- package/src/file-lease/index.js +30 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/tools.js +137 -27
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +6 -1
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- 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
|
|
4
|
-
// positions in, structured text out; every LSP
|
|
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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
258
|
-
//
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|