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.
- package/lib/client.capability-manager.js +467 -2
- package/lib/client.capability-model.js +77 -0
- package/lib/client.js +25 -4
- package/package.json +1 -1
- package/src/capabilities/apply-engine.js +20 -4
- package/src/capabilities/capability-remote.js +97 -10
- 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-inventory.js +18 -2
- package/src/capabilities/skill-scan-path-registry.js +238 -0
- package/src/capabilities/skill-selection-plugin.js +14 -0
- package/src/capabilities/store/paths.js +3 -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
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
|
]
|