weir-harness 2.1.1 → 2.1.2
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/package.json +1 -1
- package/src/capabilities/apply-engine.js +20 -4
- package/src/capabilities/lifecycle-feed.js +47 -0
- package/src/capabilities/mcp-drain.js +6 -0
- package/src/capabilities/mcp-facade-plugin.js +14 -4
- package/src/capabilities/mcp-facade.js +1 -1
- package/src/capabilities/mcp-inventory.js +152 -0
- package/src/capabilities/mcp-manager.js +220 -171
- package/src/capabilities/mcp-mount-ownership.js +114 -0
- package/src/capabilities/mcp-mount.js +334 -82
- package/src/capabilities/mcp-operation-coordinator.js +76 -0
- package/src/capabilities/mcp-runtime.js +3 -29
- package/src/capabilities/skill-selection-plugin.js +8 -0
- package/src/file-lease/client.js +61 -3
- package/src/file-lease/negotiation.js +89 -0
- package/src/file-lease/plugin.js +25 -23
- package/src/file-lease/tools.js +71 -121
- package/src/file-lease/watch.js +225 -36
|
@@ -72,32 +72,6 @@ export function createMcpRuntime() {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* (design D6), so a Restart can never begin while an edit-mode Test holds the
|
|
79
|
-
* fence and leave the server mounted but refusing calls. A task's failure never
|
|
80
|
-
* poisons the queue; different identities never wait on each other. Not
|
|
81
|
-
* reentrant: callers already inside serialize() pass their token instead.
|
|
82
|
-
*/
|
|
83
|
-
export function createMcpIdentityLock() {
|
|
84
|
-
const tails = new Map()
|
|
85
|
-
return function serialize(identity, task) {
|
|
86
|
-
const previous = tails.get(identity) ?? Promise.resolve()
|
|
87
|
-
const run = previous.then(() => task())
|
|
88
|
-
const tail = run.then(() => {}, () => {})
|
|
89
|
-
tails.set(identity, tail)
|
|
90
|
-
void tail.then(() => { if (tails.get(identity) === tail) tails.delete(identity) })
|
|
91
|
-
return run
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const fallbackLocks = new WeakMap()
|
|
96
|
-
/** The manager's own lock, or one stable lock per manager object for fakes/older faces. */
|
|
97
|
-
export function mcpIdentityLockFor(manager) {
|
|
98
|
-
if (typeof manager?.serialize === 'function') return (identity, task) => manager.serialize(identity, task)
|
|
99
|
-
if (!manager || (typeof manager !== 'object' && typeof manager !== 'function')) return createMcpIdentityLock()
|
|
100
|
-
let lock = fallbackLocks.get(manager)
|
|
101
|
-
if (!lock) { lock = createMcpIdentityLock(); fallbackLocks.set(manager, lock) }
|
|
102
|
-
return lock
|
|
103
|
-
}
|
|
75
|
+
// Compatibility exports: the implementations live with the manager-owned
|
|
76
|
+
// coordinator so queue/fence/drain rules have one semantic implementation.
|
|
77
|
+
export { createMcpIdentityLock, mcpIdentityLockFor } from './mcp-operation-coordinator.js'
|
|
@@ -13,6 +13,7 @@ import { createOfficeAdapter, officeDenials } from './skill-office-adapter.js'
|
|
|
13
13
|
import { createPresetInvalidation, emitPresetSelected } from './preset-invalidation.js'
|
|
14
14
|
import { createLifecycleSnapshots } from './lifecycle-snapshot.js'
|
|
15
15
|
import { preloadLifecycleSnapshots } from './lifecycle-preload.js'
|
|
16
|
+
import { lifecycleFeedRegistry } from './lifecycle-feed.js'
|
|
16
17
|
import { classifySelectionFailure } from './selection-status.js'
|
|
17
18
|
import { resolveInitialSelection, baselineSkillIdentities, bindDefaultSkillNames } from './initial-selection.js'
|
|
18
19
|
import { partitionUnresolvedSkills } from './skill-applicability.js'
|
|
@@ -646,6 +647,13 @@ export function createSkillSelectionPlugin(dependencies = {}) {
|
|
|
646
647
|
}
|
|
647
648
|
mounted.set(ctx, face)
|
|
648
649
|
processFace = face
|
|
650
|
+
// Admission ownership is per mounted revision, not the process-local face.
|
|
651
|
+
// Resolve the manager lazily so either row may mount first; the effect owns
|
|
652
|
+
// exactly this token and its idempotent release cannot remove a replacement.
|
|
653
|
+
ctx.effect?.(() => lifecycleFeedRegistry.register({
|
|
654
|
+
lifecycle,
|
|
655
|
+
manager: () => ctx.get?.('weirMcpManager'),
|
|
656
|
+
}), 'weir-capability-lifecycle-feed')
|
|
649
657
|
// The Host remote can see only this explicitly labeled preset's faces.
|
|
650
658
|
// Its sessionQuery observation, not any creation-time cwd cache, owns routing.
|
|
651
659
|
try {
|
package/src/file-lease/client.js
CHANGED
|
@@ -1063,6 +1063,29 @@ export function createLeaseClient({
|
|
|
1063
1063
|
// Release and replay
|
|
1064
1064
|
// ---------------------------------------------------------------------
|
|
1065
1065
|
|
|
1066
|
+
/**
|
|
1067
|
+
* Release an explicit batch for `owner` (the `edit_lock_release` form).
|
|
1068
|
+
*
|
|
1069
|
+
* The ledger entry goes BEFORE the frame, and it goes even when the frame
|
|
1070
|
+
* never reaches the arbiter: an explicit release is the caller's intent, and
|
|
1071
|
+
* an unconfirmed transport must not resurrect a path the caller already let
|
|
1072
|
+
* go of — a replay on the next connection would re-take it. Only entries this
|
|
1073
|
+
* owner's lineage holds are forgotten; which of the rest the broker actually
|
|
1074
|
+
* released is still its verdict (`released` / `notOwned`).
|
|
1075
|
+
* @param {any} owner
|
|
1076
|
+
* @param {string[]} paths
|
|
1077
|
+
* @returns {Promise<any>}
|
|
1078
|
+
*/
|
|
1079
|
+
async function release(owner, paths) {
|
|
1080
|
+
const holder = normalizeOwner(owner)
|
|
1081
|
+
const list = normalizePaths(paths)
|
|
1082
|
+
for (const path of list) {
|
|
1083
|
+
const record = ledger.get(path)
|
|
1084
|
+
if (record !== undefined && record.lineage === holder.id) ledger.delete(path)
|
|
1085
|
+
}
|
|
1086
|
+
return await request('release', { owner: holder, paths: list })
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1066
1089
|
/**
|
|
1067
1090
|
* Release everything this session holds EXCEPT retentions (design D10): the
|
|
1068
1091
|
* `turn/end` rule. A failure is swallowed — turn-boundary release is an
|
|
@@ -1142,6 +1165,24 @@ export function createLeaseClient({
|
|
|
1142
1165
|
return attempt
|
|
1143
1166
|
}
|
|
1144
1167
|
|
|
1168
|
+
/**
|
|
1169
|
+
* Reconcile a grant whose replay record was superseded while on the wire.
|
|
1170
|
+
* A newer ledger record is newer intent and must not be released by this stale
|
|
1171
|
+
* operation. No record means the path was released; a grant that nevertheless
|
|
1172
|
+
* arrived late needs its own release because the explicit one may have won the
|
|
1173
|
+
* broker race before the grant existed.
|
|
1174
|
+
* @param {any} reply
|
|
1175
|
+
* @param {'granted' | 'held'} field
|
|
1176
|
+
* @param {{ id: string, session: string, label: string }} owner
|
|
1177
|
+
* @param {string} path
|
|
1178
|
+
*/
|
|
1179
|
+
async function releaseStaleReplayGrant(reply, field, owner, path) {
|
|
1180
|
+
if (ledger.get(path) !== undefined) return
|
|
1181
|
+
const grants = Array.isArray(reply?.[field]) ? reply[field] : []
|
|
1182
|
+
if (!grants.some((entry) => entry?.path === path)) return
|
|
1183
|
+
await request('release', { owner, paths: [path] })
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1145
1186
|
/**
|
|
1146
1187
|
* @returns {Promise<{ replayed: number, dropped: number }>}
|
|
1147
1188
|
*/
|
|
@@ -1153,11 +1194,25 @@ export function createLeaseClient({
|
|
|
1153
1194
|
if (record.holdUntil === null || record.holdUntil <= at) continue
|
|
1154
1195
|
const owner = ownerOf(record.lineage, record.session)
|
|
1155
1196
|
const scope = lastScope
|
|
1197
|
+
let current = record
|
|
1156
1198
|
try {
|
|
1157
1199
|
// Re-acquire first: after a daemon restart the table is empty, so a bare
|
|
1158
|
-
// `hold` would be `not-owned`. The pair is idempotent by protocol.
|
|
1159
|
-
|
|
1200
|
+
// `hold` would be `not-owned`. The pair is idempotent by protocol. Unlike
|
|
1201
|
+
// an ordinary acquire, replay must prove its snapshot is still current
|
|
1202
|
+
// before either response writes the path back into the ledger.
|
|
1203
|
+
const leaseScope = requireScope(scope)
|
|
1204
|
+
const acquired = await request('acquire', { owner, scope: leaseScope, paths: [path] })
|
|
1205
|
+
if (ledger.get(path) !== record) {
|
|
1206
|
+
await releaseStaleReplayGrant(acquired, 'granted', owner, path)
|
|
1207
|
+
continue
|
|
1208
|
+
}
|
|
1209
|
+
if (Array.isArray(acquired.granted)) remember(acquired.granted, owner, leaseScope)
|
|
1210
|
+
current = ledger.get(path) ?? record
|
|
1160
1211
|
const response = await request('hold', { owner, paths: [path], minutes: holdMinutes(record.holdUntil - at) })
|
|
1212
|
+
if (ledger.get(path) !== current) {
|
|
1213
|
+
await releaseStaleReplayGrant(response, 'held', owner, path)
|
|
1214
|
+
continue
|
|
1215
|
+
}
|
|
1161
1216
|
applyHold(response, owner)
|
|
1162
1217
|
replayed += 1
|
|
1163
1218
|
} catch (error) {
|
|
@@ -1166,7 +1221,9 @@ export function createLeaseClient({
|
|
|
1166
1221
|
// rather than forgetting a retention nobody has seen fail.
|
|
1167
1222
|
break
|
|
1168
1223
|
}
|
|
1169
|
-
|
|
1224
|
+
// A late failure is stale for the same reason as a late grant: neither
|
|
1225
|
+
// may delete a newer record installed after this snapshot was taken.
|
|
1226
|
+
if (ledger.get(path) === current) ledger.delete(path)
|
|
1170
1227
|
dropped += 1
|
|
1171
1228
|
}
|
|
1172
1229
|
}
|
|
@@ -1290,6 +1347,7 @@ export function createLeaseClient({
|
|
|
1290
1347
|
reply,
|
|
1291
1348
|
releaseSession,
|
|
1292
1349
|
releaseAll,
|
|
1350
|
+
release,
|
|
1293
1351
|
replayHolds,
|
|
1294
1352
|
onEvent,
|
|
1295
1353
|
onReset,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Transfer Negotiation owns the local reply window, not the broker request.
|
|
2
|
+
//
|
|
3
|
+
// One instance belongs to one plugin mount, matching the mount's conditional
|
|
4
|
+
// tool registrations. Process-wide request dedupe remains in the plugin: a
|
|
5
|
+
// sibling mount that did not deliver a request has no local window to close.
|
|
6
|
+
// The broker remains the only ownership and request-settlement authority.
|
|
7
|
+
|
|
8
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
9
|
+
function text(value) {
|
|
10
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether one structured tool outcome closes its local request.
|
|
15
|
+
*
|
|
16
|
+
* `transferred` and `declined` are broker settlements; `expired` and `stale`
|
|
17
|
+
* say the broker request is already over; `not-holder` closes only this
|
|
18
|
+
* unauthorized agent's local window. A transport result contains no broker
|
|
19
|
+
* verdict, so a valid pending request survives for an idempotent retry.
|
|
20
|
+
* Unknown results and failures retain the old finally-block behaviour and
|
|
21
|
+
* close the local request rather than inventing a new retry contract.
|
|
22
|
+
*
|
|
23
|
+
* @param {any} outcome a reply result or thrown error carrying state/code
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function closesLocalRequest(outcome) {
|
|
27
|
+
const code = text(outcome?.code)
|
|
28
|
+
const state = text(outcome?.state)
|
|
29
|
+
if (code === 'transport') return false
|
|
30
|
+
if (state === 'transferred' || state === 'declined') return true
|
|
31
|
+
if (code === 'expired' || code === 'stale' || code === 'not-holder') return true
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create the pending-request and reply-window state for one plugin mount.
|
|
37
|
+
*
|
|
38
|
+
* This module never calls the client. In particular, local `not-holder`
|
|
39
|
+
* cleanup cannot consume the broker request, which remains answerable by the
|
|
40
|
+
* real holder.
|
|
41
|
+
*/
|
|
42
|
+
export function createTransferNegotiation() {
|
|
43
|
+
/** @type {WeakMap<object, Map<string, { path: string, from: any }>>} */
|
|
44
|
+
const pendingByAgent = new WeakMap()
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
/**
|
|
48
|
+
* Add or refresh one request in an agent's local reply window.
|
|
49
|
+
* @param {object} agent
|
|
50
|
+
* @param {string} requestId
|
|
51
|
+
* @param {{ path: string, from: any }} request
|
|
52
|
+
*/
|
|
53
|
+
openRequest(agent, requestId, request) {
|
|
54
|
+
let pending = pendingByAgent.get(agent)
|
|
55
|
+
if (pending === undefined) {
|
|
56
|
+
pending = new Map()
|
|
57
|
+
pendingByAgent.set(agent, pending)
|
|
58
|
+
}
|
|
59
|
+
pending.set(requestId, request)
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply one reply attempt's structured outcome to its own request id.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} agent
|
|
66
|
+
* @param {unknown} requestId
|
|
67
|
+
* @param {any} outcome reply result or thrown error
|
|
68
|
+
* @returns {number | undefined} requests still pending, or undefined when
|
|
69
|
+
* this agent has no local window (the adapter then has nothing to retire)
|
|
70
|
+
*/
|
|
71
|
+
finishReply(agent, requestId, outcome) {
|
|
72
|
+
const pending = pendingByAgent.get(agent)
|
|
73
|
+
if (pending === undefined) return undefined
|
|
74
|
+
const id = text(requestId)
|
|
75
|
+
if (closesLocalRequest(outcome) && id !== undefined) pending.delete(id)
|
|
76
|
+
if (pending.size === 0) pendingByAgent.delete(agent)
|
|
77
|
+
return pending.size
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Close every local request for an agent. Turn end, disposal and unmount
|
|
82
|
+
* call this independently of any broker outcome.
|
|
83
|
+
* @param {object} agent
|
|
84
|
+
*/
|
|
85
|
+
closeAgent(agent) {
|
|
86
|
+
pendingByAgent.delete(agent)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/file-lease/plugin.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { createLineageResolver } from './lineage.js'
|
|
34
34
|
import { userTextMessage } from '../shared/user-message.js'
|
|
35
35
|
import { createLeaseTools, REPLY_TOOL, shortName } from './tools.js'
|
|
36
|
+
import { createTransferNegotiation } from './negotiation.js'
|
|
36
37
|
import { createWatchCoordinator } from './watch.js'
|
|
37
38
|
import { AUDIT_TYPES, createAudit } from '../shared/audit.js'
|
|
38
39
|
import { getSharedLeaseClient } from './singleton.js'
|
|
@@ -410,8 +411,10 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
410
411
|
if (config?.enabled === false) return undefined
|
|
411
412
|
/** @type {(() => void)[]} */
|
|
412
413
|
const disposers = []
|
|
413
|
-
|
|
414
|
-
|
|
414
|
+
// Pending requests and their outcome/lifetime rules belong to THIS mount,
|
|
415
|
+
// just like its conditional tool registrations (D11). Process-wide delivery
|
|
416
|
+
// dedupe stays in noticedByAgent; the broker remains the request authority.
|
|
417
|
+
const negotiation = createTransferNegotiation()
|
|
415
418
|
/** @type {WeakMap<object, { dispose: () => void }>} */
|
|
416
419
|
const replyTools = new WeakMap()
|
|
417
420
|
/** @type {Set<any>} agents whose reply tool this mount registered */
|
|
@@ -520,15 +523,6 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
520
523
|
}
|
|
521
524
|
}
|
|
522
525
|
|
|
523
|
-
/** @param {any} agent @returns {Map<string, { path: string, from: any }>} */
|
|
524
|
-
function pendingOf(agent) {
|
|
525
|
-
let pending = pendingByAgent.get(agent)
|
|
526
|
-
if (pending === undefined) {
|
|
527
|
-
pending = new Map()
|
|
528
|
-
pendingByAgent.set(agent, pending)
|
|
529
|
-
}
|
|
530
|
-
return pending
|
|
531
|
-
}
|
|
532
526
|
|
|
533
527
|
/**
|
|
534
528
|
* Register the reply tool on THIS AGENT's scope (not the mount's): the tool
|
|
@@ -569,25 +563,32 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
569
563
|
return {
|
|
570
564
|
...definition,
|
|
571
565
|
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
566
|
+
/** @type {any} */
|
|
567
|
+
let outcome
|
|
572
568
|
try {
|
|
573
|
-
|
|
569
|
+
const result = await definition.execute(args, exec)
|
|
570
|
+
outcome = result
|
|
571
|
+
return result
|
|
572
|
+
} catch (error) {
|
|
573
|
+
// Domain errors retain their structured code through tools.js; no
|
|
574
|
+
// message parsing belongs in this registration adapter.
|
|
575
|
+
outcome = error
|
|
576
|
+
throw error
|
|
574
577
|
} finally {
|
|
575
|
-
|
|
578
|
+
finishReply(agent, args?.request_id, outcome)
|
|
576
579
|
}
|
|
577
580
|
},
|
|
578
581
|
}
|
|
579
582
|
}
|
|
580
583
|
|
|
581
584
|
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
585
|
+
* Reconcile one reply attempt with its own local request. The negotiation
|
|
586
|
+
* instance decides whether the window survives; the adapter only retires
|
|
587
|
+
* the registration when no request remains.
|
|
588
|
+
* @param {any} agent @param {unknown} requestId @param {any} outcome
|
|
584
589
|
*/
|
|
585
|
-
function
|
|
586
|
-
|
|
587
|
-
if (pending === undefined) return
|
|
588
|
-
const id = text(requestId)
|
|
589
|
-
if (id !== undefined) pending.delete(id)
|
|
590
|
-
if (pending.size === 0) retireReplyTool(agent)
|
|
590
|
+
function finishReply(agent, requestId, outcome) {
|
|
591
|
+
if (negotiation.finishReply(agent, requestId, outcome) === 0) retireReplyTool(agent)
|
|
591
592
|
}
|
|
592
593
|
|
|
593
594
|
/**
|
|
@@ -600,7 +601,7 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
600
601
|
const registered = replyTools.get(agent)
|
|
601
602
|
replyTools.delete(agent)
|
|
602
603
|
replyAgents.delete(agent)
|
|
603
|
-
|
|
604
|
+
negotiation.closeAgent(agent)
|
|
604
605
|
noticedByAgent.delete(agent)
|
|
605
606
|
if (registered === undefined) return
|
|
606
607
|
try {
|
|
@@ -684,7 +685,7 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
684
685
|
const noticed = noticedFor(agent)
|
|
685
686
|
if (noticed.has(requestId)) return
|
|
686
687
|
noticed.add(requestId)
|
|
687
|
-
|
|
688
|
+
negotiation.openRequest(agent, requestId, { path, from: event?.from })
|
|
688
689
|
registerReplyTool(agent)
|
|
689
690
|
const from = event?.from ?? {}
|
|
690
691
|
const label = text(from.label) ?? 'another session'
|
|
@@ -782,6 +783,7 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
782
783
|
*/
|
|
783
784
|
const routedClient = Object.freeze({
|
|
784
785
|
acquire: (/** @type {any} */ owner, /** @type {any} */ paths, /** @type {any} */ scope) => client.acquire(owner, paths, scope),
|
|
786
|
+
release: (/** @type {any} */ owner, /** @type {any} */ paths) => client.release(owner, paths),
|
|
785
787
|
hold: (/** @type {any} */ paths, /** @type {any} */ minutes, /** @type {any} */ owner) => client.hold(paths, minutes, owner),
|
|
786
788
|
status: (/** @type {any} */ filter) => client.status(filter),
|
|
787
789
|
request: (/** @type {any} */ op, /** @type {any} */ payload) => client.request(op, payload),
|
package/src/file-lease/tools.js
CHANGED
|
@@ -383,44 +383,6 @@ export function describeConflicts(error, cwd, at) {
|
|
|
383
383
|
].join('\n')
|
|
384
384
|
}
|
|
385
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
386
|
/**
|
|
425
387
|
* The degraded answer for a wait that ended without an ownership fact.
|
|
426
388
|
* @param {string} short
|
|
@@ -435,16 +397,67 @@ function cancelledOutcome(short) {
|
|
|
435
397
|
}
|
|
436
398
|
|
|
437
399
|
/**
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
* @param {import('./watch.js').
|
|
444
|
-
* @
|
|
400
|
+
* Translate the coordinator's structured establishment verdict into the exact
|
|
401
|
+
* model-facing result and metadata inputs this tool has always produced. All
|
|
402
|
+
* episode state, identity, cancellation and precedence decisions stay in the
|
|
403
|
+
* coordinator; this function only renders them.
|
|
404
|
+
*
|
|
405
|
+
* @param {import('./watch.js').WatchOutcome} outcome
|
|
406
|
+
* @param {string} short
|
|
407
|
+
* @returns {any}
|
|
445
408
|
*/
|
|
446
|
-
function
|
|
447
|
-
|
|
409
|
+
function watchOutcome(outcome, short) {
|
|
410
|
+
switch (outcome.state) {
|
|
411
|
+
case 'released':
|
|
412
|
+
return {
|
|
413
|
+
state: 'released',
|
|
414
|
+
file_path: short,
|
|
415
|
+
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.`,
|
|
416
|
+
}
|
|
417
|
+
case 'reset':
|
|
418
|
+
return {
|
|
419
|
+
state: 'reset',
|
|
420
|
+
file_path: short,
|
|
421
|
+
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.`,
|
|
422
|
+
}
|
|
423
|
+
case 'self':
|
|
424
|
+
return {
|
|
425
|
+
state: 'self',
|
|
426
|
+
file_path: short,
|
|
427
|
+
message: outcome.raced === true
|
|
428
|
+
? `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.`
|
|
429
|
+
: `This session already owns ${short}, so no release subscription was kept and there is nothing to wait for.`,
|
|
430
|
+
}
|
|
431
|
+
case 'cancelled':
|
|
432
|
+
return cancelledOutcome(short)
|
|
433
|
+
case 'free':
|
|
434
|
+
return {
|
|
435
|
+
state: 'free',
|
|
436
|
+
file_path: short,
|
|
437
|
+
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.`,
|
|
438
|
+
}
|
|
439
|
+
case 'unsupported':
|
|
440
|
+
return {
|
|
441
|
+
degraded: true,
|
|
442
|
+
code: 'release-events-unsupported',
|
|
443
|
+
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.`,
|
|
444
|
+
}
|
|
445
|
+
case 'transport':
|
|
446
|
+
return unavailable(outcome.error, 'no release subscription was created')
|
|
447
|
+
case 'watching': {
|
|
448
|
+
const holder = outcome.holder ?? {}
|
|
449
|
+
const label = text(holder.label) ?? 'another session'
|
|
450
|
+
const session = text(holder.session) ?? 'unknown'
|
|
451
|
+
return {
|
|
452
|
+
state: 'watching',
|
|
453
|
+
file_path: short,
|
|
454
|
+
holder: { label, session },
|
|
455
|
+
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.`,
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
default:
|
|
459
|
+
throw new TypeError(`Unknown watch outcome: ${String(outcome?.state)}`)
|
|
460
|
+
}
|
|
448
461
|
}
|
|
449
462
|
|
|
450
463
|
/**
|
|
@@ -518,7 +531,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {}, wa
|
|
|
518
531
|
const paths = absoluteBatch(args?.paths, cwd)
|
|
519
532
|
let reply
|
|
520
533
|
try {
|
|
521
|
-
reply = await client.
|
|
534
|
+
reply = await client.release(owner, paths)
|
|
522
535
|
} catch (error) {
|
|
523
536
|
if (isTransport(error)) return presentDegraded(unavailable(error, 'the release was not confirmed'), { paths: paths.map((path) => shortName(path, cwd)) })
|
|
524
537
|
throw error
|
|
@@ -650,9 +663,9 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {}, wa
|
|
|
650
663
|
reply = await client.reply({ requestId, owner, decision })
|
|
651
664
|
} catch (error) {
|
|
652
665
|
const code = codeOf(error)
|
|
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.`)
|
|
654
|
-
if (code === 'stale') throw new Error(`Request ${requestId} is stale: ownership changed before the answer arrived, so nothing was transferred.`)
|
|
655
|
-
if (code === 'not-holder') throw new Error(`Request ${requestId} is not addressed to this session; only the current holder can answer it.`)
|
|
666
|
+
if (code === 'expired') throw Object.assign(new Error(`Request ${requestId} has expired and ownership is unchanged. The requester can ask again later if it still needs the path.`), { code })
|
|
667
|
+
if (code === 'stale') throw Object.assign(new Error(`Request ${requestId} is stale: ownership changed before the answer arrived, so nothing was transferred.`), { code })
|
|
668
|
+
if (code === 'not-holder') throw Object.assign(new Error(`Request ${requestId} is not addressed to this session; only the current holder can answer it.`), { code })
|
|
656
669
|
if (isTransport(error)) return presentDegraded(unavailable(error, 'the answer was not delivered'), { requestId, decision })
|
|
657
670
|
throw error
|
|
658
671
|
}
|
|
@@ -752,77 +765,14 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {}, wa
|
|
|
752
765
|
// callback, and one that landed before it is caught by the read below.
|
|
753
766
|
// The opposite order would leave a permanent hole for that release.
|
|
754
767
|
const entry = watches.register(owner.session, path, mount, exec?.agent)
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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.`,
|
|
768
|
+
// The coordinator owns the complete episode: one shared status attempt,
|
|
769
|
+
// terminal precedence, identity-safe cancellation and every verdict.
|
|
770
|
+
// This adapter only renders the structured outcome it returns.
|
|
771
|
+
const outcome = await watches.status(entry, () => client.status({ path }), {
|
|
772
|
+
owner,
|
|
773
|
+
supportsRelease: () => typeof client.supportsEvent === 'function' && client.supportsEvent('released') === true,
|
|
825
774
|
})
|
|
775
|
+
return settle(watchOutcome(outcome, short))
|
|
826
776
|
},
|
|
827
777
|
},
|
|
828
778
|
]
|