weir-harness 2.0.0 → 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.
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +105 -7
- package/lib/client.mcp-manager.js +563 -0
- package/package.json +1 -1
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- package/src/file-lease/client.js +268 -35
- package/src/file-lease/index.js +4 -0
- package/src/file-lease/plugin.js +239 -15
- package/src/file-lease/tools.js +426 -38
- package/src/file-lease/watch.js +473 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/protocol.js +3 -2
- package/src/lease-broker/server.js +6 -3
- package/src/lease-broker/state.js +68 -11
- package/src/process/index.js +4 -2
- package/src/shared/audit.js +3 -0
- package/src/shared/child-scope.js +2 -0
- package/src/worktree/rules.js +1 -1
package/src/file-lease/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The model-facing tool surface of the file-lease feature (design D4, D5, D13).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Seven pure definitions. They own no state: the arbiter is the only authority
|
|
4
4
|
// (D6/D8), so a tool translates a model intent into one protocol call and
|
|
5
5
|
// translates the verdict back into something a model can act on. Nothing here
|
|
6
6
|
// re-derives ownership, remembers it, or waits for it.
|
|
@@ -18,9 +18,18 @@
|
|
|
18
18
|
// short paths against the session cwd on the way in and render short names
|
|
19
19
|
// relative to that same cwd on the way out. An absolute path never lands in a
|
|
20
20
|
// message body.
|
|
21
|
+
//
|
|
22
|
+
// Normalization is lexical and it happens HERE, for every tool input: `resolve`
|
|
23
|
+
// folds `.`, `..`, repeated and trailing separators and keeps an absolute input
|
|
24
|
+
// absolute, so one file has one spelling for arbitration and for the
|
|
25
|
+
// subscription table. The broker deliberately does not normalize — there string
|
|
26
|
+
// equality IS a path (docs/features/edit-lock-protocol.md) — so a verbatim `..`
|
|
27
|
+
// spelling would be granted a SECOND lease on a file another session holds, and
|
|
28
|
+
// `edit_lock_watch` would miss the held canonical path entirely.
|
|
21
29
|
|
|
22
|
-
import { basename,
|
|
30
|
+
import { basename, relative, resolve } from 'node:path'
|
|
23
31
|
import { isLeaseConflict } from './client.js'
|
|
32
|
+
import { createWatchCoordinator } from './watch.js'
|
|
24
33
|
|
|
25
34
|
/** Retention bound in minutes; the protocol clamps here and so does the tool. */
|
|
26
35
|
export const MAX_HOLD_MINUTES = 60
|
|
@@ -28,17 +37,191 @@ export const MAX_HOLD_MINUTES = 60
|
|
|
28
37
|
/** The negotiation reply tool: registered only while a request is pending. */
|
|
29
38
|
export const REPLY_TOOL = 'edit_lock_reply'
|
|
30
39
|
|
|
40
|
+
/** The release-subscription tool: one path, one in-memory subscription. */
|
|
41
|
+
export const WATCH_TOOL = 'edit_lock_watch'
|
|
42
|
+
|
|
31
43
|
/** Default retention when the model asks for one without a length. */
|
|
32
44
|
export const DEFAULT_HOLD_MINUTES = MAX_HOLD_MINUTES
|
|
33
45
|
|
|
34
46
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
47
|
+
* UI-only presentation data handed from `execute` to `presentationMeta`
|
|
48
|
+
* (openspec edit-lock-tool-cards, design D1). The conversation cards need a
|
|
49
|
+
* few facts the model never reads — the try_steal deadline, the per-row
|
|
50
|
+
* validity of a status answer, the paths of a degraded call — and the hard
|
|
51
|
+
* constraint is that the result object stays field-for-field what it was. So
|
|
52
|
+
* those facts never become result fields: `execute` files them here and
|
|
53
|
+
* `presentationMeta` takes them out again.
|
|
54
|
+
*
|
|
55
|
+
* The handoff is keyed by the result's JSON text, not by object identity: the
|
|
56
|
+
* tool registry snapshots the value `execute` returned (a lossless-JSON copy,
|
|
57
|
+
* then deep-frozen) and hands that COPY to `render` and `presentationMeta`, so
|
|
58
|
+
* an identity-keyed WeakMap never hits. A copy serializes to the same text,
|
|
59
|
+
* so the same key finds the entry. Each entry is taken once (a queue per key
|
|
60
|
+
* keeps two identical concurrent answers apart), the table is bounded, and a
|
|
61
|
+
* result with no entry still gets the subset its own fields can carry.
|
|
62
|
+
* @type {Map<string, Record<string, any>[]>}
|
|
63
|
+
*/
|
|
64
|
+
const presented = new Map()
|
|
65
|
+
|
|
66
|
+
/** Upper bound on filed-but-never-read entries (a result the host dropped). */
|
|
67
|
+
const MAX_PRESENTED = 256
|
|
68
|
+
|
|
69
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
70
|
+
function presentationKey(value) {
|
|
71
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
72
|
+
try {
|
|
73
|
+
return JSON.stringify(value)
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {unknown} value @returns {Record<string, any> | undefined} */
|
|
80
|
+
function takePresented(value) {
|
|
81
|
+
const key = presentationKey(value)
|
|
82
|
+
if (key === undefined) return undefined
|
|
83
|
+
const queue = presented.get(key)
|
|
84
|
+
if (queue === undefined) return undefined
|
|
85
|
+
const fields = queue.shift()
|
|
86
|
+
if (queue.length === 0) presented.delete(key)
|
|
87
|
+
return fields
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Presentation metadata schema version; the client narrows on it. */
|
|
91
|
+
export const LEASE_META_VERSION = 1
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The closed state set per tool (design D1). Anything outside it is never
|
|
95
|
+
* emitted, so the client can treat an unknown state as malformed metadata.
|
|
96
|
+
* @type {Record<string, readonly string[]>}
|
|
97
|
+
*/
|
|
98
|
+
export const LEASE_META_STATES = Object.freeze({
|
|
99
|
+
edit_lock_acquire: Object.freeze(['claimed', 'degraded']),
|
|
100
|
+
edit_lock_release: Object.freeze(['released', 'degraded']),
|
|
101
|
+
edit_lock_hold: Object.freeze(['held', 'degraded']),
|
|
102
|
+
edit_lock_status: Object.freeze(['status', 'degraded']),
|
|
103
|
+
edit_lock_try_steal: Object.freeze(['pending', 'free', 'self', 'degraded']),
|
|
104
|
+
edit_lock_reply: Object.freeze(['transferred', 'declined', 'degraded']),
|
|
105
|
+
edit_lock_watch: Object.freeze(['watching', 'free', 'self', 'released', 'reset', 'unsupported', 'cancelled', 'degraded']),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* File the presentation data for one result and hand the result back
|
|
110
|
+
* untouched: nothing is added to the object the model reads.
|
|
111
|
+
* @template {object} T
|
|
112
|
+
* @param {T} value
|
|
113
|
+
* @param {Record<string, any> | undefined} fields
|
|
114
|
+
* @returns {T}
|
|
115
|
+
*/
|
|
116
|
+
function present(value, fields) {
|
|
117
|
+
const key = presentationKey(value)
|
|
118
|
+
if (fields === undefined || key === undefined) return value
|
|
119
|
+
const queue = presented.get(key)
|
|
120
|
+
if (queue === undefined) presented.set(key, [fields])
|
|
121
|
+
else queue.push(fields)
|
|
122
|
+
// Insertion order is age order: drop the oldest key once over the bound.
|
|
123
|
+
if (presented.size > MAX_PRESENTED) {
|
|
124
|
+
const oldest = presented.keys().next().value
|
|
125
|
+
if (oldest !== undefined) presented.delete(oldest)
|
|
126
|
+
}
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* File a degraded (arbiter-unreachable) answer: the card shows "protection
|
|
132
|
+
* temporarily unavailable", never a refusal, plus what the call was about.
|
|
133
|
+
* @template {{ code: string }} T
|
|
134
|
+
* @param {T} value
|
|
135
|
+
* @param {Record<string, any>} about
|
|
136
|
+
* @returns {T}
|
|
137
|
+
*/
|
|
138
|
+
function presentDegraded(value, about) {
|
|
139
|
+
return present(value, { state: 'degraded', code: value.code, ...about })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {unknown} list @returns {string[]} */
|
|
143
|
+
function strings(list) {
|
|
144
|
+
return Array.isArray(list) ? list.filter((entry) => typeof entry === 'string') : []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {any} holder */
|
|
148
|
+
function holderOf(holder) {
|
|
149
|
+
return holder && typeof holder === 'object' && typeof holder.label === 'string' && typeof holder.session === 'string'
|
|
150
|
+
? { label: holder.label, session: holder.session }
|
|
151
|
+
: undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The presentation fields one result's OWN fields can carry. This is both the
|
|
156
|
+
* base every `execute` extends and the whole answer for a result that has no
|
|
157
|
+
* filed entry.
|
|
158
|
+
* @param {string} tool
|
|
159
|
+
* @param {any} value
|
|
160
|
+
* @returns {Record<string, any> | undefined}
|
|
161
|
+
*/
|
|
162
|
+
function derivedFields(tool, value) {
|
|
163
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
164
|
+
if (value.degraded === true) {
|
|
165
|
+
const code = typeof value.code === 'string' ? value.code : 'internal'
|
|
166
|
+
// A watch without release events or one cancelled underneath it is not an
|
|
167
|
+
// unreachable arbiter, and the card must not read it as one.
|
|
168
|
+
if (tool === WATCH_TOOL && code === 'release-events-unsupported') return { state: 'unsupported', code }
|
|
169
|
+
if (tool === WATCH_TOOL && code === 'watch-cancelled') return { state: 'cancelled', code }
|
|
170
|
+
return { state: 'degraded', code }
|
|
171
|
+
}
|
|
172
|
+
switch (tool) {
|
|
173
|
+
case 'edit_lock_acquire':
|
|
174
|
+
return { state: 'claimed', paths: strings(value.paths) }
|
|
175
|
+
case 'edit_lock_release':
|
|
176
|
+
return { state: 'released', released: strings(value.released), notOwned: strings(value.notOwned) }
|
|
177
|
+
case 'edit_lock_hold':
|
|
178
|
+
return {
|
|
179
|
+
state: 'held',
|
|
180
|
+
held: (Array.isArray(value.held) ? value.held : []).map((/** @type {any} */ row) => ({ path: String(row?.path ?? ''), holdUntil: typeof row?.holdUntil === 'number' ? row.holdUntil : null })),
|
|
181
|
+
}
|
|
182
|
+
case 'edit_lock_status':
|
|
183
|
+
return { state: 'status', mine: strings(value.mine).map((path) => ({ path })), others: [] }
|
|
184
|
+
case 'edit_lock_try_steal': {
|
|
185
|
+
if (!['pending', 'free', 'self'].includes(value.state)) return undefined
|
|
186
|
+
const holder = holderOf(value.holder)
|
|
187
|
+
return { state: value.state, ...(typeof value.request_id === 'string' ? { requestId: value.request_id } : {}), ...(holder ? { holder } : {}) }
|
|
188
|
+
}
|
|
189
|
+
case REPLY_TOOL:
|
|
190
|
+
return {
|
|
191
|
+
state: value.state === 'transferred' ? 'transferred' : 'declined',
|
|
192
|
+
...(typeof value.request_id === 'string' ? { requestId: value.request_id } : {}),
|
|
193
|
+
...(typeof value.decision === 'string' ? { decision: value.decision } : {}),
|
|
194
|
+
}
|
|
195
|
+
case WATCH_TOOL: {
|
|
196
|
+
if (!LEASE_META_STATES[WATCH_TOOL].includes(value.state)) return undefined
|
|
197
|
+
const holder = holderOf(value.holder)
|
|
198
|
+
return { state: value.state, ...(typeof value.file_path === 'string' ? { path: value.file_path } : {}), ...(holder ? { holder } : {}) }
|
|
199
|
+
}
|
|
200
|
+
default:
|
|
201
|
+
return undefined
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The output contract of every edit-lock tool. `render` is byte-identical to
|
|
207
|
+
* the single-field text rendering it replaces (the model reads `message` and
|
|
208
|
+
* nothing else from the text channel); `presentationMeta` is the UI-only
|
|
209
|
+
* channel the conversation cards read from the persisted tool result.
|
|
210
|
+
* Short names only and epoch-ms instants: an absolute path never appears
|
|
211
|
+
* here either.
|
|
212
|
+
* @param {string} tool
|
|
37
213
|
*/
|
|
38
|
-
function
|
|
214
|
+
function leaseOutput(tool) {
|
|
39
215
|
return {
|
|
40
216
|
schema: { type: 'object' },
|
|
41
|
-
render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: result
|
|
217
|
+
render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: result.message }],
|
|
218
|
+
// `null`, never `undefined`, for a value it cannot describe: the registry
|
|
219
|
+
// rejects a non-JSON projection, which would turn the call into an error.
|
|
220
|
+
presentationMeta: (/** @type {any} */ _args, /** @type {any} */ value) => {
|
|
221
|
+
const fields = takePresented(value) ?? derivedFields(tool, value)
|
|
222
|
+
if (fields === undefined || !LEASE_META_STATES[tool]?.includes(fields.state)) return null
|
|
223
|
+
return { editLock: { v: LEASE_META_VERSION, tool, ...fields } }
|
|
224
|
+
},
|
|
42
225
|
}
|
|
43
226
|
}
|
|
44
227
|
|
|
@@ -93,8 +276,29 @@ export function shortName(path, cwd) {
|
|
|
93
276
|
}
|
|
94
277
|
|
|
95
278
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
279
|
+
* One file, one spelling: the lexical normalization every tool input passes
|
|
280
|
+
* through before it can become a claim, a retention, a release, a transfer
|
|
281
|
+
* request or a release subscription.
|
|
282
|
+
*
|
|
283
|
+
* `resolve` is the right instrument and no other is needed: it folds `.`,
|
|
284
|
+
* `..`, repeated and trailing separators, keeps an absolute input absolute
|
|
285
|
+
* (the base is then ignored) and resolves a relative one against the session
|
|
286
|
+
* cwd. It touches no filesystem, so symlinks and case are left alone — the
|
|
287
|
+
* protocol makes that the client's call, and it keys leases by string equality,
|
|
288
|
+
* which is exactly why `/p/src/../src/a.js` and `/p/src/a.js` must never reach
|
|
289
|
+
* it as two paths.
|
|
290
|
+
* @param {string} raw
|
|
291
|
+
* @param {string} cwd
|
|
292
|
+
* @returns {string}
|
|
293
|
+
*/
|
|
294
|
+
function canonicalPath(raw, cwd) {
|
|
295
|
+
return resolve(cwd, raw)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolve the model's path batch to the canonical, de-duplicated list the
|
|
300
|
+
* protocol accepts. Normalization happens before the de-duplication, so two
|
|
301
|
+
* spellings of one file collapse into one entry instead of becoming two claims.
|
|
98
302
|
* @param {unknown} paths
|
|
99
303
|
* @param {string} cwd
|
|
100
304
|
* @returns {string[]}
|
|
@@ -106,13 +310,15 @@ function absoluteBatch(paths, cwd) {
|
|
|
106
310
|
for (const entry of paths) {
|
|
107
311
|
const raw = text(entry)
|
|
108
312
|
if (raw === undefined) throw new Error('every entry of paths must be a non-empty string')
|
|
109
|
-
const absolute =
|
|
313
|
+
const absolute = canonicalPath(raw, cwd)
|
|
110
314
|
if (!out.includes(absolute)) out.push(absolute)
|
|
111
315
|
}
|
|
112
316
|
return out
|
|
113
317
|
}
|
|
114
318
|
|
|
115
319
|
/**
|
|
320
|
+
* The single-path form of {@link absoluteBatch}: normalize before the path can
|
|
321
|
+
* reach the arbiter or the subscription coordinator.
|
|
116
322
|
* @param {unknown} filePath
|
|
117
323
|
* @param {string} cwd
|
|
118
324
|
* @returns {string}
|
|
@@ -120,7 +326,7 @@ function absoluteBatch(paths, cwd) {
|
|
|
120
326
|
function absoluteOne(filePath, cwd) {
|
|
121
327
|
const raw = text(filePath)
|
|
122
328
|
if (raw === undefined) throw new Error('file_path must be a non-empty string')
|
|
123
|
-
return
|
|
329
|
+
return canonicalPath(raw, cwd)
|
|
124
330
|
}
|
|
125
331
|
|
|
126
332
|
/** `2026-05-05T12:00:00.000Z` for a validity instant. @param {number} at epoch ms */
|
|
@@ -177,6 +383,70 @@ export function describeConflicts(error, cwd, at) {
|
|
|
177
383
|
].join('\n')
|
|
178
384
|
}
|
|
179
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The synchronous answer for a subscription that is already over. Every await in
|
|
388
|
+
* the watch tool re-reads this first (design D2): a release, a reset or a
|
|
389
|
+
* transfer that landed while the authoritative read was in flight is the truth,
|
|
390
|
+
* and a late read result or failure may neither overwrite it nor cancel its
|
|
391
|
+
* pending notice.
|
|
392
|
+
* @param {import('./watch.js').WatchEntry} entry
|
|
393
|
+
* @param {string} short
|
|
394
|
+
* @returns {any | undefined}
|
|
395
|
+
*/
|
|
396
|
+
function terminalOutcome(entry, short) {
|
|
397
|
+
if (entry.state === 'hit') {
|
|
398
|
+
entry.settledByTool = true
|
|
399
|
+
return {
|
|
400
|
+
state: 'released',
|
|
401
|
+
file_path: short,
|
|
402
|
+
message: `The release of ${short} happened while this subscription was being established, so nothing is pending. Claim it with edit_lock_acquire if you still need it — a release is an opportunity, not an ownership grant.`,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (entry.state === 'reset') {
|
|
406
|
+
entry.settledByTool = true
|
|
407
|
+
return {
|
|
408
|
+
state: 'reset',
|
|
409
|
+
file_path: short,
|
|
410
|
+
message: `The ownership arbiter connection was reset while this subscription was being established, so nothing is pending and nothing is known about ownership. Re-check with edit_lock_status, and call edit_lock_watch again if ${short} is still held by another session.`,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (entry.state === 'self') {
|
|
414
|
+
entry.settledByTool = true
|
|
415
|
+
return {
|
|
416
|
+
state: 'self',
|
|
417
|
+
file_path: short,
|
|
418
|
+
message: `This session already owns ${short} — ownership arrived while this subscription was being established — so no release subscription was kept and there is nothing to wait for.`,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return undefined
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The degraded answer for a wait that ended without an ownership fact.
|
|
426
|
+
* @param {string} short
|
|
427
|
+
* @returns {any}
|
|
428
|
+
*/
|
|
429
|
+
function cancelledOutcome(short) {
|
|
430
|
+
return {
|
|
431
|
+
degraded: true,
|
|
432
|
+
code: 'watch-cancelled',
|
|
433
|
+
message: `The release subscription for ${short} was cancelled before it could be established (this agent or the feature was shut down), so nothing is pending. Call edit_lock_watch again in a live agent if you still need the path.`,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Whether one entry is still the active wait it was. `watching` may only be
|
|
439
|
+
* reported for the SAME object that is currently registered (design D2): an
|
|
440
|
+
* overlapping call, a transfer or a disposal can end the wait while a read is
|
|
441
|
+
* in flight, and a stale entry must never come back as `watching`.
|
|
442
|
+
* @param {import('./watch.js').WatchCoordinator} watches
|
|
443
|
+
* @param {import('./watch.js').WatchEntry} entry
|
|
444
|
+
* @returns {boolean}
|
|
445
|
+
*/
|
|
446
|
+
function stillWatching(watches, entry) {
|
|
447
|
+
return entry.state === 'watching' && watches.peek(entry.sessionId, entry.path) === entry
|
|
448
|
+
}
|
|
449
|
+
|
|
180
450
|
/**
|
|
181
451
|
* Create the tool definitions.
|
|
182
452
|
*
|
|
@@ -185,10 +455,15 @@ export function describeConflicts(error, cwd, at) {
|
|
|
185
455
|
* @param {(agent: any) => { id: string, session: string, label: string }} options.ownerOf
|
|
186
456
|
* @param {(agent: any, text: string) => any} [options.deliverNotice] model-facing
|
|
187
457
|
* notice channel, used only where a tool result cannot carry the outcome
|
|
188
|
-
* @
|
|
458
|
+
* @param {import('./watch.js').WatchCoordinator} [options.watchCoordinator] the
|
|
459
|
+
* subscription coordinator shared with the plugin's event routing
|
|
460
|
+
* @param {import('./watch.js').WatchMount} [options.mount] the mount reference
|
|
461
|
+
* that owns this call's subscription, when a mount is in play
|
|
462
|
+
* @returns {any[]} seven definitions, `REPLY_TOOL` included (the plugin decides
|
|
189
463
|
* when that one becomes visible)
|
|
190
464
|
*/
|
|
191
|
-
export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} }) {
|
|
465
|
+
export function createLeaseTools({ client, ownerOf, deliverNotice = () => {}, watchCoordinator = createWatchCoordinator(), mount }) {
|
|
466
|
+
const watches = watchCoordinator
|
|
192
467
|
return [
|
|
193
468
|
{
|
|
194
469
|
name: 'edit_lock_acquire',
|
|
@@ -201,7 +476,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
201
476
|
},
|
|
202
477
|
required: ['paths'],
|
|
203
478
|
},
|
|
204
|
-
output:
|
|
479
|
+
output: leaseOutput('edit_lock_acquire'),
|
|
205
480
|
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
206
481
|
const owner = ownerOf(exec?.agent)
|
|
207
482
|
const cwd = requireCwd(exec)
|
|
@@ -210,15 +485,16 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
210
485
|
const reply = await client.acquire(owner, paths, cwd)
|
|
211
486
|
const granted = Array.isArray(reply?.granted) ? reply.granted : []
|
|
212
487
|
const claimed = granted.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd))
|
|
213
|
-
|
|
488
|
+
const result = {
|
|
214
489
|
paths: claimed,
|
|
215
490
|
message: claimed.length > 0
|
|
216
491
|
? `Claimed ${claimed.length} path(s) for this turn: ${claimed.join(', ')}.`
|
|
217
492
|
: 'Nothing was claimed.',
|
|
218
493
|
}
|
|
494
|
+
return present(result, { state: 'claimed', paths: [...claimed] })
|
|
219
495
|
} catch (error) {
|
|
220
496
|
if (isLeaseConflict(error)) throw new Error(describeConflicts(error, cwd, Date.now()))
|
|
221
|
-
if (isTransport(error)) return unavailable(error, 'nothing was claimed')
|
|
497
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'nothing was claimed'), { paths: paths.map((path) => shortName(path, cwd)) })
|
|
222
498
|
throw error
|
|
223
499
|
}
|
|
224
500
|
},
|
|
@@ -235,7 +511,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
235
511
|
},
|
|
236
512
|
required: ['paths'],
|
|
237
513
|
},
|
|
238
|
-
output:
|
|
514
|
+
output: leaseOutput('edit_lock_release'),
|
|
239
515
|
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
240
516
|
const owner = ownerOf(exec?.agent)
|
|
241
517
|
const cwd = requireCwd(exec)
|
|
@@ -244,7 +520,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
244
520
|
try {
|
|
245
521
|
reply = await client.request('release', { owner, paths })
|
|
246
522
|
} catch (error) {
|
|
247
|
-
if (isTransport(error)) return unavailable(error, 'the release was not confirmed')
|
|
523
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'the release was not confirmed'), { paths: paths.map((path) => shortName(path, cwd)) })
|
|
248
524
|
throw error
|
|
249
525
|
}
|
|
250
526
|
const released = (Array.isArray(reply?.released) ? reply.released : []).map((/** @type {any} */ path) => shortName(String(path), cwd))
|
|
@@ -252,13 +528,13 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
252
528
|
const lines = []
|
|
253
529
|
lines.push(released.length > 0 ? `Released ${released.length} path(s): ${released.join(', ')}.` : 'Released nothing.')
|
|
254
530
|
if (notOwned.length > 0) lines.push(`Not held by this session, so there was nothing to release: ${notOwned.join(', ')}.`)
|
|
255
|
-
return { released, notOwned, message: lines.join('\n') }
|
|
531
|
+
return present({ released, notOwned, message: lines.join('\n') }, { state: 'released', released: [...released], notOwned: [...notOwned] })
|
|
256
532
|
},
|
|
257
533
|
},
|
|
258
534
|
|
|
259
535
|
{
|
|
260
536
|
name: 'edit_lock_hold',
|
|
261
|
-
description: `Keep paths this session already owns past the end of this turn, for up to ${MAX_HOLD_MINUTES} minutes from now. Use it when the work continues after this turn; the paths stay yours and other sessions are refused until the deadline. A longer request is clamped, not ignored. When the deadline passes the paths become available and nothing is announced.`,
|
|
537
|
+
description: `Keep paths this session already owns past the end of this turn, for up to ${MAX_HOLD_MINUTES} minutes from now. Use it when the work continues after this turn; the paths stay yours and other sessions are refused until the deadline. A longer request is clamped, not ignored. When the deadline passes the paths become available again, and the expiry is announced only to a session that explicitly subscribed to that path's release with edit_lock_watch: a matching subscriber gets the usual notice that it may claim the path again, while with no subscriber nothing is announced.`,
|
|
262
538
|
parameters: {
|
|
263
539
|
type: 'object',
|
|
264
540
|
properties: {
|
|
@@ -270,7 +546,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
270
546
|
},
|
|
271
547
|
required: ['paths'],
|
|
272
548
|
},
|
|
273
|
-
output:
|
|
549
|
+
output: leaseOutput('edit_lock_hold'),
|
|
274
550
|
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
275
551
|
const owner = ownerOf(exec?.agent)
|
|
276
552
|
const cwd = requireCwd(exec)
|
|
@@ -286,23 +562,26 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
286
562
|
if (code === 'not-owned') {
|
|
287
563
|
throw new Error(`This session does not own ${paths.map((path) => shortName(path, cwd)).join(', ')}. Retention only extends ownership that already exists — claim the paths with edit_lock_acquire first.`)
|
|
288
564
|
}
|
|
289
|
-
if (isTransport(error)) return unavailable(error, 'no retention was recorded')
|
|
565
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'no retention was recorded'), { paths: paths.map((path) => shortName(path, cwd)) })
|
|
290
566
|
throw error
|
|
291
567
|
}
|
|
292
568
|
const held = Array.isArray(reply?.held) ? reply.held : []
|
|
293
569
|
const at = Date.now()
|
|
570
|
+
/** @type {{ path: string, holdUntil: number }[]} */
|
|
571
|
+
const shown = []
|
|
294
572
|
const entries = held.map((/** @type {any} */ row) => {
|
|
295
573
|
const until = typeof row?.holdUntil === 'number' ? row.holdUntil : typeof row?.expiresAt === 'number' ? row.expiresAt : at
|
|
574
|
+
shown.push({ path: shortName(String(row?.path ?? ''), cwd), holdUntil: until })
|
|
296
575
|
return `${shortName(String(row?.path ?? ''), cwd)} until ${instant(until)} (about ${Math.ceil(secondsUntil(until, at) / 60)}m from now)`
|
|
297
576
|
})
|
|
298
|
-
return {
|
|
577
|
+
return present({
|
|
299
578
|
// Short names only: an absolute path never appears in a tool result,
|
|
300
579
|
// not even in a structured field the model also reads.
|
|
301
580
|
held: held.map((/** @type {any} */ row) => ({ path: shortName(String(row?.path ?? ''), cwd), holdUntil: row?.holdUntil ?? null })),
|
|
302
581
|
message: entries.length > 0
|
|
303
|
-
? `Kept ${entries.length} path(s) past this turn: ${entries.join(', ')}. At most ${MAX_HOLD_MINUTES} minutes per request; a deadline that passes
|
|
582
|
+
? `Kept ${entries.length} path(s) past this turn: ${entries.join(', ')}. At most ${MAX_HOLD_MINUTES} minutes per request; a deadline that passes simply makes the paths available, and the expiry is announced only to a session that explicitly subscribed to that path with edit_lock_watch — a matching subscriber gets the usual chance to claim it again, while with no subscriber nothing is announced.`
|
|
304
583
|
: 'Nothing was retained.',
|
|
305
|
-
}
|
|
584
|
+
}, { state: 'held', held: shown })
|
|
306
585
|
},
|
|
307
586
|
},
|
|
308
587
|
|
|
@@ -317,7 +596,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
317
596
|
},
|
|
318
597
|
required: ['file_path'],
|
|
319
598
|
},
|
|
320
|
-
output:
|
|
599
|
+
output: leaseOutput('edit_lock_try_steal'),
|
|
321
600
|
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
322
601
|
const owner = ownerOf(exec?.agent)
|
|
323
602
|
const cwd = requireCwd(exec)
|
|
@@ -328,9 +607,9 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
328
607
|
reply = await client.requestTransfer(path, owner)
|
|
329
608
|
} catch (error) {
|
|
330
609
|
const code = codeOf(error)
|
|
331
|
-
if (code === 'free') return { state: 'free', message: `Nobody holds ${short} right now: edit it directly, or claim it with edit_lock_acquire so another session cannot take it mid-work.` }
|
|
332
|
-
if (code === 'self') return { state: 'self', message: `This session already owns ${short}; no transfer is needed.` }
|
|
333
|
-
if (isTransport(error)) return unavailable(error, 'no transfer request was created')
|
|
610
|
+
if (code === 'free') return present({ state: 'free', message: `Nobody holds ${short} right now: edit it directly, or claim it with edit_lock_acquire so another session cannot take it mid-work.` }, { state: 'free', path: short })
|
|
611
|
+
if (code === 'self') return present({ state: 'self', message: `This session already owns ${short}; no transfer is needed.` }, { state: 'self', path: short })
|
|
612
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'no transfer request was created'), { path: short })
|
|
334
613
|
throw error
|
|
335
614
|
}
|
|
336
615
|
const requestId = text(reply?.requestId) ?? ''
|
|
@@ -338,12 +617,12 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
338
617
|
const label = text(holder.label) ?? 'the holder'
|
|
339
618
|
const session = text(holder.session) ?? 'unknown'
|
|
340
619
|
const expiresAt = typeof reply?.expiresAt === 'number' ? reply.expiresAt : Date.now()
|
|
341
|
-
return {
|
|
620
|
+
return present({
|
|
342
621
|
state: 'pending',
|
|
343
622
|
request_id: requestId,
|
|
344
623
|
holder: { label, session },
|
|
345
624
|
message: `Request ${requestId} for ${short} is pending with "${label}" (session ${session}). Do not modify ${short} until ownership is granted — a pending request is not an approval, and edits keep being refused meanwhile. The holder answers with edit_lock_reply (release hands it over, keep keeps it); an unanswered request expires in about ${secondsUntil(expiresAt, Date.now())}s and ownership stays where it is. A message will tell you how it settled.`,
|
|
346
|
-
}
|
|
625
|
+
}, { state: 'pending', path: short, requestId, holder: { label, session }, expiresAt })
|
|
347
626
|
},
|
|
348
627
|
},
|
|
349
628
|
|
|
@@ -359,7 +638,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
359
638
|
},
|
|
360
639
|
required: ['request_id', 'decision'],
|
|
361
640
|
},
|
|
362
|
-
output:
|
|
641
|
+
output: leaseOutput(REPLY_TOOL),
|
|
363
642
|
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
364
643
|
const owner = ownerOf(exec?.agent)
|
|
365
644
|
const requestId = text(args?.request_id)
|
|
@@ -374,7 +653,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
374
653
|
if (code === 'expired') throw new Error(`Request ${requestId} has expired and ownership is unchanged. The requester can ask again later if it still needs the path.`)
|
|
375
654
|
if (code === 'stale') throw new Error(`Request ${requestId} is stale: ownership changed before the answer arrived, so nothing was transferred.`)
|
|
376
655
|
if (code === 'not-holder') throw new Error(`Request ${requestId} is not addressed to this session; only the current holder can answer it.`)
|
|
377
|
-
if (isTransport(error)) return unavailable(error, 'the answer was not delivered')
|
|
656
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'the answer was not delivered'), { requestId, decision })
|
|
378
657
|
throw error
|
|
379
658
|
}
|
|
380
659
|
const state = text(reply?.state) ?? 'declined'
|
|
@@ -384,14 +663,14 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
384
663
|
// result it may have already reasoned past.
|
|
385
664
|
deliverNotice(exec?.agent, `Ownership handed over: request ${requestId} is settled and this session no longer holds that path. Claim it again with edit_lock_acquire if you still need it.`)
|
|
386
665
|
}
|
|
387
|
-
return {
|
|
666
|
+
return present({
|
|
388
667
|
request_id: requestId,
|
|
389
668
|
decision,
|
|
390
669
|
state,
|
|
391
670
|
message: state === 'transferred'
|
|
392
671
|
? `Request ${requestId}: transferred. The requester owns the path now; this session no longer holds it.`
|
|
393
672
|
: `Request ${requestId}: declined. Ownership is unchanged and the path is still this session's.`,
|
|
394
|
-
}
|
|
673
|
+
}, { state: state === 'transferred' ? 'transferred' : 'declined', requestId, decision })
|
|
395
674
|
},
|
|
396
675
|
},
|
|
397
676
|
|
|
@@ -400,7 +679,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
400
679
|
description:
|
|
401
680
|
'Read-only ownership report: the paths this session holds, and the sessions that currently hold paths in this working directory. Grants nothing and changes nothing. Ownership is arbitrated centrally, so the answer covers other processes too.',
|
|
402
681
|
parameters: { type: 'object', properties: {}, required: [] },
|
|
403
|
-
output:
|
|
682
|
+
output: leaseOutput('edit_lock_status'),
|
|
404
683
|
async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
|
|
405
684
|
const owner = ownerOf(exec?.agent)
|
|
406
685
|
const cwd = requireCwd(exec)
|
|
@@ -408,7 +687,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
408
687
|
try {
|
|
409
688
|
reply = await client.status({ scope: cwd })
|
|
410
689
|
} catch (error) {
|
|
411
|
-
if (isTransport(error)) return unavailable(error, 'no ownership could be read')
|
|
690
|
+
if (isTransport(error)) return presentDegraded(unavailable(error, 'no ownership could be read'), {})
|
|
412
691
|
throw error
|
|
413
692
|
}
|
|
414
693
|
const leases = Array.isArray(reply?.leases) ? reply.leases : []
|
|
@@ -416,12 +695,18 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
416
695
|
const mine = leases.filter((/** @type {any} */ lease) => text(lease?.owner?.id) === owner.id || text(lease?.owner?.session) === owner.session)
|
|
417
696
|
const others = leases.filter((/** @type {any} */ lease) => !mine.includes(lease))
|
|
418
697
|
const lines = []
|
|
698
|
+
/** @type {{ path: string, until: number, retained: boolean }[]} */
|
|
699
|
+
const shownMine = []
|
|
700
|
+
/** @type {{ path: string, label: string, session: string, until: number, lapsed: boolean }[]} */
|
|
701
|
+
const shownOthers = []
|
|
419
702
|
lines.push(mine.length > 0 ? `Held by this session (${mine.length}):` : 'This session holds nothing.')
|
|
420
703
|
for (const lease of mine) {
|
|
421
704
|
const until = retainedUntil(lease)
|
|
705
|
+
const expiresAt = typeof lease?.expiresAt === 'number' ? lease.expiresAt : at
|
|
422
706
|
const validity = until === undefined
|
|
423
|
-
? `valid for about ${secondsUntil(
|
|
707
|
+
? `valid for about ${secondsUntil(expiresAt, at)}s`
|
|
424
708
|
: `retained until ${instant(until)}`
|
|
709
|
+
shownMine.push({ path: shortName(String(lease?.path ?? ''), cwd), until: until ?? expiresAt, retained: until !== undefined })
|
|
425
710
|
lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — ${validity}`)
|
|
426
711
|
}
|
|
427
712
|
if (others.length > 0) {
|
|
@@ -431,10 +716,113 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
|
|
|
431
716
|
const label = text(holder.label) ?? 'another session'
|
|
432
717
|
const session = text(holder.session) ?? 'unknown'
|
|
433
718
|
const target = Math.max(typeof lease?.expiresAt === 'number' ? lease.expiresAt : 0, typeof lease?.holdUntil === 'number' ? lease.holdUntil : 0)
|
|
719
|
+
shownOthers.push({ path: shortName(String(lease?.path ?? ''), cwd), label, session, until: target, lapsed: !(target > at) })
|
|
434
720
|
lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — "${label}" (session ${session}), ${target > at ? `about ${secondsUntil(target, at)}s left` : 'lapsed, claimable'}`)
|
|
435
721
|
}
|
|
436
722
|
}
|
|
437
|
-
return
|
|
723
|
+
return present(
|
|
724
|
+
{ mine: mine.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd)), message: lines.join('\n') },
|
|
725
|
+
{ state: 'status', at, mine: shownMine, others: shownOthers },
|
|
726
|
+
)
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
{
|
|
731
|
+
name: WATCH_TOOL,
|
|
732
|
+
description:
|
|
733
|
+
'Wait for the next release of one contested path without polling it. Use it after an acquisition conflict or a declined/expired transfer request. The answer says whether anything is actually pending: a path that is already free or already yours returns that immediately and keeps no subscription. While it is held by another session, this subscribes once for this session and the next release arrives as a message; a release is only an opportunity to call edit_lock_acquire, never a grant of ownership, and another waiting session may claim it first. Repeating the call is idempotent.',
|
|
734
|
+
parameters: {
|
|
735
|
+
type: 'object',
|
|
736
|
+
properties: {
|
|
737
|
+
file_path: { type: 'string', description: 'The contested file to watch for a release, absolute or relative to the session working directory.' },
|
|
738
|
+
},
|
|
739
|
+
required: ['file_path'],
|
|
740
|
+
},
|
|
741
|
+
output: leaseOutput(WATCH_TOOL),
|
|
742
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
743
|
+
const owner = ownerOf(exec?.agent)
|
|
744
|
+
const cwd = requireCwd(exec)
|
|
745
|
+
const path = absoluteOne(args?.file_path, cwd)
|
|
746
|
+
const short = shortName(path, cwd)
|
|
747
|
+
// Every answer below carries the watched short path for the card, the
|
|
748
|
+
// degraded ones included (their result object has no path field).
|
|
749
|
+
const settle = (/** @type {any} */ value) => present(value, { ...derivedFields(WATCH_TOOL, value), path: short })
|
|
750
|
+
// Registration comes FIRST (D2): a release that lands between the
|
|
751
|
+
// registration and the authoritative read is caught by the event
|
|
752
|
+
// callback, and one that landed before it is caught by the read below.
|
|
753
|
+
// The opposite order would leave a permanent hole for that release.
|
|
754
|
+
const entry = watches.register(owner.session, path, mount, exec?.agent)
|
|
755
|
+
|
|
756
|
+
/** @type {any} */
|
|
757
|
+
let reply
|
|
758
|
+
try {
|
|
759
|
+
// ONE read per entry: an overlapping call for the same session and
|
|
760
|
+
// path joins this flight instead of running its own, so both calls
|
|
761
|
+
// judge the same answer and the same entry object.
|
|
762
|
+
reply = await watches.shareStatus(entry, () => client.status({ path }))
|
|
763
|
+
} catch (error) {
|
|
764
|
+
// A terminal outcome wins over any failure (D2): a release, a reset or
|
|
765
|
+
// a transfer that landed while the read was in flight IS the answer,
|
|
766
|
+
// and its pending notice must survive. A late rejection may only
|
|
767
|
+
// cancel a plain wait.
|
|
768
|
+
const terminal = terminalOutcome(entry, short)
|
|
769
|
+
if (terminal !== undefined) return settle(terminal)
|
|
770
|
+
// No authoritative answer means no subscription: never leave a
|
|
771
|
+
// record that could wait forever for a release nobody described.
|
|
772
|
+
// The removal is conditional on THIS entry still being active, so a
|
|
773
|
+
// late caller can never cancel a newer episode.
|
|
774
|
+
watches.cancel(owner.session, path, entry)
|
|
775
|
+
if (isTransport(error)) return settle(unavailable(error, 'no release subscription was created'))
|
|
776
|
+
throw error
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Same precedence on the success path: the entry may have been consumed
|
|
780
|
+
// or ended while the read was in flight, whatever the read now says.
|
|
781
|
+
const terminal = terminalOutcome(entry, short)
|
|
782
|
+
if (terminal !== undefined) return settle(terminal)
|
|
783
|
+
|
|
784
|
+
const leases = Array.isArray(reply?.leases) ? reply.leases : []
|
|
785
|
+
if (leases.length === 0) {
|
|
786
|
+
watches.cancel(owner.session, path, entry)
|
|
787
|
+
return settle({
|
|
788
|
+
state: 'free',
|
|
789
|
+
file_path: short,
|
|
790
|
+
message: `Nobody holds ${short} right now, so no release subscription was kept. Claim it with edit_lock_acquire (or just edit it) before working on it.`,
|
|
791
|
+
})
|
|
792
|
+
}
|
|
793
|
+
const holder = leases[0]?.owner ?? {}
|
|
794
|
+
const mine = text(holder.id) === owner.id || text(holder.session) === owner.session
|
|
795
|
+
if (mine) {
|
|
796
|
+
// `self` is terminal, not merely cancelled: an overlapping call that
|
|
797
|
+
// shares this entry must read the same verdict instead of reporting a
|
|
798
|
+
// wait that no longer exists.
|
|
799
|
+
watches.cancel(owner.session, path, entry, 'self')
|
|
800
|
+
return settle({
|
|
801
|
+
state: 'self',
|
|
802
|
+
file_path: short,
|
|
803
|
+
message: `This session already owns ${short}, so no release subscription was kept and there is nothing to wait for.`,
|
|
804
|
+
})
|
|
805
|
+
}
|
|
806
|
+
if (typeof client.supportsEvent !== 'function' || client.supportsEvent('released') !== true) {
|
|
807
|
+
watches.cancel(owner.session, path, entry)
|
|
808
|
+
return settle({
|
|
809
|
+
degraded: true,
|
|
810
|
+
code: 'release-events-unsupported',
|
|
811
|
+
message: `The connected arbiter does not announce release events (release-events-unsupported), so no release subscription was created for ${short}. Only this subscription is unavailable: conflicts, managed edits and negotiation behave exactly as before, and this tool can be used again once the arbiter restarts with the capability.`,
|
|
812
|
+
})
|
|
813
|
+
}
|
|
814
|
+
// `watching` is only claimed while THIS entry is still the active one:
|
|
815
|
+
// an overlapping call or a disposal may have ended the wait while the
|
|
816
|
+
// read was in flight, and a stale entry must never read as a live wait.
|
|
817
|
+
if (!stillWatching(watches, entry)) return settle(terminalOutcome(entry, short) ?? cancelledOutcome(short))
|
|
818
|
+
const label = text(holder.label) ?? 'another session'
|
|
819
|
+
const session = text(holder.session) ?? 'unknown'
|
|
820
|
+
return settle({
|
|
821
|
+
state: 'watching',
|
|
822
|
+
file_path: short,
|
|
823
|
+
holder: { label, session },
|
|
824
|
+
message: `Watching ${short} for its next release; "${label}" (session ${session}) holds it now. A release notice will arrive as a message: it means only that the path is free to compete for, so call edit_lock_acquire then. It is not an ownership grant and it reserves nothing — another waiting session may claim the path first, and the ordinary holder conflict applies.`,
|
|
825
|
+
})
|
|
438
826
|
},
|
|
439
827
|
},
|
|
440
828
|
]
|