weir-harness 2.1.1 → 2.1.3

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.
@@ -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
  ]