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.
@@ -72,32 +72,6 @@ export function createMcpRuntime() {
72
72
  }
73
73
  }
74
74
 
75
- /**
76
- * Per-identity FIFO for every operation that opens a runtime fence
77
- * (runtime.begin). Same-identity operations run strictly one after another
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 {
@@ -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
- await acquire(owner, [path], requireScope(scope))
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
- ledger.delete(path)
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
+ }
@@ -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
- /** @type {WeakMap<object, Map<string, { path: string, from: any }>>} */
414
- const pendingByAgent = new WeakMap()
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
- return await definition.execute(args, exec)
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
- answered(agent, args?.request_id)
578
+ finishReply(agent, args?.request_id, outcome)
576
579
  }
577
580
  },
578
581
  }
579
582
  }
580
583
 
581
584
  /**
582
- * One pending request is gone; the reply tool follows when none is left.
583
- * @param {any} agent @param {unknown} requestId
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 answered(agent, requestId) {
586
- const pending = pendingByAgent.get(agent)
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
- pendingByAgent.delete(agent)
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
- pendingOf(agent).set(requestId, { path, from: event?.from })
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),
@@ -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
- * 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}
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 stillWatching(watches, entry) {
447
- return entry.state === 'watching' && watches.peek(entry.sessionId, entry.path) === entry
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.request('release', { owner, paths })
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
- /** @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.`,
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
  ]