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.
@@ -5,10 +5,13 @@ import { openCapabilityStore } from './store/store.js'
5
5
  import { createMcpRegistry } from './mcp-registry.js'
6
6
  import { createMcpGate } from './mcp-gate.js'
7
7
  import { createMcpDrain } from './mcp-drain.js'
8
- import { createMcpMount, mcpGroupOptions, isMcpResidueId, detectMcpNameAmbiguity, MCP_GROUP_PREFIX, resolveEphemeralMcpTree } from './mcp-mount.js'
8
+ import { createMcpMount, mcpGroupOptions, isMcpResidueId, detectMcpNameAmbiguity, MCP_GROUP_PREFIX, resolveEphemeralMcpTree, resolveScopedMcpTree } from './mcp-mount.js'
9
9
  import { registerMcpFacadeBridge } from './mcp-facade-plugin.js'
10
- import { skillSelectionFor } from './skill-selection-plugin.js'
11
- import { createMcpRuntime, createMcpIdentityLock } from './mcp-runtime.js'
10
+ import { mcpMountOwnership } from './mcp-mount-ownership.js'
11
+ import { lifecycleFeedRegistry } from './lifecycle-feed.js'
12
+ import { createMcpRuntime } from './mcp-runtime.js'
13
+ import { createMcpOperationCoordinator } from './mcp-operation-coordinator.js'
14
+ import { createMcpInventoryProjection, partitionMcpInventory } from './mcp-inventory.js'
12
15
  import { materializeMcpClientConfig } from './mcp-credentials.js'
13
16
  import { hasMcpSecrets, redactMcpEntry, fingerprintMcpEntry } from './mcp-config.js'
14
17
  import { createMcpProcessAdapter } from './mcp-process.js'
@@ -17,19 +20,8 @@ import { mcpDiagnostic, sanitizeMcpText } from './mcp-diagnostics.js'
17
20
  const message = error => (error instanceof Error ? error.message : String(error))
18
21
  const now = () => Date.now()
19
22
 
20
- export function partitionMcpRows(rows, mounted, registryServers) {
21
- const managed = []
22
- const unmanaged = []
23
- for (const row of rows) {
24
- if (typeof row?.id === 'string' && isMcpResidueId(row.id)) {
25
- const identity = row.id.slice(MCP_GROUP_PREFIX.length)
26
- const registry = registryServers[identity]
27
- managed.push({ identity, groupId: row.id, label: registry?.label ?? identity, generation: mounted.get(identity)?.generation ?? registry?.generation ?? null, state: mounted.has(identity) ? 'mounted' : 'registered' })
28
- } else if (row?.name === '@deepseek-ai/dsh-mcp-client' && !(typeof row.id === 'string' && row.id.startsWith(MCP_GROUP_PREFIX))) {
29
- unmanaged.push({ serverName: row.config?.serverName ?? row.id ?? 'unknown', state: 'unmanaged', readOnly: true })
30
- }
31
- }
32
- return { managed, unmanaged }
23
+ export function partitionMcpRows(rows, mounted, registryServers, ownership = mcpMountOwnership) {
24
+ return partitionMcpInventory(rows, mounted, registryServers, { isResidue: isMcpResidueId, groupPrefix: MCP_GROUP_PREFIX, ownership })
33
25
  }
34
26
 
35
27
  export function hostRowHoldingReservation(rows, serverName) {
@@ -42,28 +34,36 @@ export function createMcpManager(dependencies = {}) {
42
34
  const profileContext = ctx.get?.('profileContext')
43
35
  const store = dependencies.store ?? openCapabilityStore({ profileContext })
44
36
  const registry = dependencies.registry ?? createMcpRegistry({ store })
37
+ let defaultLifecycleActive = true
45
38
  const lifecycle = dependencies.lifecycle ?? {
46
- snapshotFor: sessionId => skillSelectionFor(ctx)?.lifecycle?.snapshotFor(sessionId) ?? null,
39
+ snapshotFor(sessionId) {
40
+ if (!defaultLifecycleActive) return null
41
+ const owningLifecycle = lifecycleFeedRegistry.lifecycleFor(face)
42
+ return owningLifecycle?.snapshotFor?.(sessionId) ?? null
43
+ },
47
44
  }
48
45
  const runtime = dependencies.runtime ?? createMcpRuntime()
49
- // Every runtime.begin path runs inside this per-identity queue (design D6).
50
- const serialize = createMcpIdentityLock()
51
46
  const drain = dependencies.drain ?? createMcpDrain({ timeoutMs: config.drainTimeoutMs ?? 30_000 })
47
+ const coordinator = createMcpOperationCoordinator({ runtime, drain, transition: transitionRuntime })
48
+ const { serialize, beginOperation, finishOperation, abortOperation } = coordinator
52
49
  const gate = dependencies.gate ?? createMcpGate({ registry, lifecycle, runtime, registrationFor: runtime.registrationFor })
53
50
  const loader = dependencies.loader ?? ctx.get?.('loader')
54
- const defaultListRows = () => [...(loader?.entries?.() ?? [])].map(entry => ({ id: entry?.options?.id ?? entry?.id ?? null, name: entry?.options?.name ?? null, config: entry?.options?.config ?? {} }))
55
- const listEntryIds = dependencies.listEntryIds ?? (() => defaultListRows().map(row => row.id).filter(Boolean))
56
- const listRows = dependencies.listRows ?? defaultListRows
51
+ const ownership = dependencies.ownership ?? mcpMountOwnership
52
+ const defaultHostRows = () => [...(loader?.entries?.() ?? [])].map(entry => ({ id: entry?.options?.id ?? entry?.id ?? null, name: entry?.options?.name ?? null, config: entry?.options?.config ?? {} }))
57
53
  const startErrors = []
58
54
  /** A strict mount of this identity supersedes its earlier start/mount failures. */
59
55
  const clearStartErrors = identity => {
60
56
  for (let index = startErrors.length - 1; index >= 0; index -= 1) if (startErrors[index].identity === identity) startErrors.splice(index, 1)
61
57
  }
62
58
  const onError = (reason, cause) => ctx.logger?.warn?.(sanitizeMcpText(`${reason}${cause ? `: ${message(cause)}` : ''}`))
63
- const defaultFiberOf = async groupId => {
59
+ const owningTree = ctx.fiber?.entry?.parent?.tree ?? null
60
+ const scopedTree = owningTree ? resolveScopedMcpTree(owningTree) : undefined
61
+ const defaultFiberOf = async (groupId, context = {}) => {
62
+ const tree = context.tree ?? mount.treeForGroup?.(groupId)
63
+ const resolve = tree?.resolve ?? (id => loader?.resolve?.(id))
64
64
  for (const candidate of [`${groupId}:${groupId}-client`, `${groupId}-client`]) {
65
65
  let entry = null
66
- try { entry = loader?.resolve?.(candidate) } catch { /* next shape */ }
66
+ try { entry = resolve?.(candidate) } catch { /* next shape */ }
67
67
  if (entry?.fiber) {
68
68
  await entry.fiber.await()
69
69
  return { state: 'running' }
@@ -71,15 +71,23 @@ export function createMcpManager(dependencies = {}) {
71
71
  }
72
72
  return null
73
73
  }
74
- const mount = dependencies.mount ?? createMcpMount({ loader, onError, startupTimeoutMs: config.startupTimeoutMs ?? 15_000, cleanupTimeoutMs: config.cleanupTimeoutMs ?? 15_000 })
74
+ const mount = dependencies.mount ?? createMcpMount({
75
+ loader, onError, ownership,
76
+ startupTimeoutMs: config.startupTimeoutMs ?? 15_000,
77
+ cleanupTimeoutMs: config.cleanupTimeoutMs ?? 15_000,
78
+ ...(scopedTree !== undefined ? { ephemeralTree: scopedTree } : {}),
79
+ })
75
80
  const fiberOf = dependencies.fiberOf ?? defaultFiberOf
81
+ const listRows = dependencies.listRows ?? (() => [...defaultHostRows(), ...(mount.listRows?.() ?? [])])
82
+ const listEntryIds = dependencies.listEntryIds ?? (() => listRows().map(row => row.id).filter(Boolean))
76
83
  const processAdapter = dependencies.processAdapter ?? createMcpProcessAdapter({ subprocess: dependencies.subprocess ?? ctx.get?.('subprocess') })
77
84
  const credentialsFor = () => dependencies.credentials ?? ctx.get?.('credentials')
78
85
  const support = () => {
79
86
  const credentials = credentialsFor()
80
- return { credentials: typeof credentials?.set === 'function' && typeof credentials?.resolve === 'function', ephemeral: resolveEphemeralMcpTree(loader).kind === 'ok' }
87
+ return { credentials: typeof credentials?.set === 'function' && typeof credentials?.resolve === 'function', ephemeral: scopedTree?.kind === 'ok' || resolveEphemeralMcpTree(loader).kind === 'ok' }
81
88
  }
82
89
  const publicNames = new Map() // publicName → { identity, generation }
90
+ const publicOwnerAttempts = new WeakMap() // plain public owner object → internal bridge attempt
83
91
  const bridges = new Map() // groupId → { identity, generation, release }
84
92
  const receipts = { mount: new Map(), test: new Map() }
85
93
  const host = {
@@ -92,9 +100,13 @@ export function createMcpManager(dependencies = {}) {
92
100
  const restrictions = new Map() // agent → Map(publicName → { owner, dispose })
93
101
 
94
102
  const identityOf = owner => typeof owner === 'string' ? owner : owner?.identity
95
- const sameOwner = (owner, identity, generation) => typeof owner === 'string'
96
- ? owner === identity // legacy test facts carried no generation
97
- : owner?.identity === identity && owner?.generation === generation
103
+ const attemptOf = owner => owner && (typeof owner === 'object' || typeof owner === 'function') ? publicOwnerAttempts.get(owner) : null
104
+ const sameOwner = (owner, identity, generation, attempt = null) => {
105
+ const same = typeof owner === 'string'
106
+ ? owner === identity // legacy test facts carried no generation
107
+ : owner?.identity === identity && owner?.generation === generation
108
+ return same && (!attempt || attemptOf(owner) === attempt)
109
+ }
98
110
  const isAgent = agent => (typeof agent === 'object' && agent !== null) || typeof agent === 'function'
99
111
  function rememberAgent(agent) {
100
112
  if (!isAgent(agent)) return null
@@ -125,7 +137,12 @@ export function createMcpManager(dependencies = {}) {
125
137
  const present = publicNames.has(name)
126
138
  const owner = publicNames.get(name)
127
139
  const existing = held?.get(name)
128
- const shouldDeny = present && !gate.enabledFor(agent, identityOf(owner))
140
+ const definition = attemptOf(owner)?.definitions?.get(name)
141
+ let foreignDefinition = false
142
+ if (owningTree && definition && (!existing || existing.owner !== owner) && typeof host.tools?.get === 'function') {
143
+ try { foreignDefinition = host.tools.get(name, agent) !== definition } catch { /* fail closed through the normal gate deny */ }
144
+ }
145
+ const shouldDeny = present && !foreignDefinition && !gate.enabledFor(agent, identityOf(owner))
129
146
  if (shouldDeny && !existing) {
130
147
  const restrict = agent?.ctx?.tools?.restrict
131
148
  if (typeof restrict !== 'function') throw new Error(`cannot restrict MCP tool "${name}" without agent tools`)
@@ -170,10 +187,14 @@ export function createMcpManager(dependencies = {}) {
170
187
  if (rememberAgent(agent)) reconcileAgent(agent)
171
188
  }
172
189
 
173
- function registerPublicName(name, identity, generation, captureRollback) {
190
+ function registerPublicName(name, identity, generation, captureRollback, attempt = null, definition = null) {
174
191
  const hadPrevious = publicNames.has(name)
175
192
  const previous = publicNames.get(name)
176
193
  const attempted = { identity, generation }
194
+ // Preserve the public {identity,generation} shape while binding exact
195
+ // attempt identity for same-generation concurrent/stale cleanup.
196
+ if (attempt) publicOwnerAttempts.set(attempted, attempt)
197
+ if (attempt && definition) attempt.definitions.set(name, definition)
177
198
  const previousDenies = []
178
199
  for (const [agent, held] of restrictions) {
179
200
  const record = held.get(name)
@@ -228,8 +249,8 @@ export function createMcpManager(dependencies = {}) {
228
249
  syncName(name, added)
229
250
  }
230
251
 
231
- function unregisterPublicName(name, identity, generation) {
232
- if (!sameOwner(publicNames.get(name), identity, generation)) return false
252
+ function unregisterPublicName(name, identity, generation, attempt = null) {
253
+ if (!sameOwner(publicNames.get(name), identity, generation, attempt)) return false
233
254
  publicNames.delete(name)
234
255
  try { syncName(name) } catch (cause) { onError(`mcp-schema-cleanup-failed:${name}`, cause) }
235
256
  return true
@@ -251,37 +272,41 @@ export function createMcpManager(dependencies = {}) {
251
272
  if (held.size === 0) restrictions.delete(agent)
252
273
  }
253
274
 
254
- // Agents that predate this manager are authoritative through the Host
255
- // registry. No public names exist yet, so bootstrap only records identity.
256
- if (typeof agentsService?.list === 'function') {
257
- try { liveAgents() } catch (cause) { onError('mcp-agent-bootstrap-failed', cause) }
258
- }
259
-
260
- function releaseBridge(groupId, expected = null) {
275
+ function releaseBridge(groupId, expected) {
261
276
  const bridge = bridges.get(groupId)
262
- if (!bridge) return
263
- if (expected && (bridge.identity !== expected.identity || bridge.generation !== expected.generation)) return
277
+ if (!bridge || !expected || bridge !== expected) return false
264
278
  bridges.delete(groupId)
265
279
  for (const [name, owner] of [...publicNames]) {
266
- if (sameOwner(owner, bridge.identity, bridge.generation)) unregisterPublicName(name, bridge.identity, bridge.generation)
280
+ if (sameOwner(owner, bridge.identity, bridge.generation, bridge)) unregisterPublicName(name, bridge.identity, bridge.generation, bridge)
267
281
  }
268
282
  bridge.release()
283
+ bridge.definitions.clear()
284
+ return true
269
285
  }
270
- function registerBridge(groupId, identity, generation) {
286
+ function registerBridge(groupId, identity, generation, owner = null) {
287
+ if (bridges.has(groupId)) throw Object.assign(new Error(`MCP manager group ${groupId} already has a bridge`), { code: 'duplicate-mcp-facade-owner' })
288
+ const record = { groupId, identity, generation, owner, release: null, definitions: new Map() }
271
289
  const release = registerMcpFacadeBridge(groupId, {
272
290
  host, identity, generation,
273
291
  admit: agent => gate.permit(agent, identity, generation),
274
292
  assertPermit: gate.assertPermit,
275
293
  isEnabledFor: agent => gate.enabledFor(agent, identity),
276
294
  drain,
277
- onRegister: (name, captureRollback) => registerPublicName(name, identity, generation, captureRollback),
278
- onUnregister: name => unregisterPublicName(name, identity, generation),
295
+ onRegister: (name, captureRollback, definition) => registerPublicName(name, identity, generation, captureRollback, record, definition),
296
+ onUnregister: name => unregisterPublicName(name, identity, generation, record),
279
297
  onError,
280
- })
281
- bridges.set(groupId, { identity, generation, release })
282
- }
283
- function discoveryFor(identity, generation) {
284
- const names = [...publicNames.entries()].filter(([, owner]) => owner.identity === identity && owner.generation === generation).map(([name]) => name).sort()
298
+ }, owner ?? record)
299
+ record.release = release
300
+ bridges.set(groupId, record)
301
+ return record
302
+ }
303
+ function discoveryFor(identity, generation, attempt = null) {
304
+ const currentGroup = mount.mounted.get(identity)?.groupId ?? null
305
+ const names = [...publicNames.entries()].filter(([, owner]) => {
306
+ if (owner.identity !== identity || owner.generation !== generation) return false
307
+ if (attempt) return attemptOf(owner) === attempt
308
+ return !attemptOf(owner) || attemptOf(owner).groupId === currentGroup
309
+ }).map(([name]) => name).sort()
285
310
  return { toolCount: names.length, toolNamesPreview: names.slice(0, 20), truncated: names.length > 20 }
286
311
  }
287
312
 
@@ -297,24 +322,6 @@ export function createMcpManager(dependencies = {}) {
297
322
  }
298
323
  return result
299
324
  }
300
- function finishOperation(token, phase, details = {}) {
301
- return transitionRuntime(token.identity, () => runtime.finish(token, phase, details))
302
- }
303
- function beginOperation(identity, phase = 'replacing', details = {}) {
304
- return transitionRuntime(identity, () => {
305
- const token = runtime.begin(identity, { phase, ...details })
306
- drain.closeAll(identity)
307
- return token
308
- })
309
- }
310
- function abortOperation(token) {
311
- const previousPhase = token.previous?.phase
312
- return transitionRuntime(token.identity, () => {
313
- const restored = runtime.restore(token)
314
- if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
315
- return restored
316
- })
317
- }
318
325
  async function waitForDrain(identity) {
319
326
  const result = await drain.drainAll(identity)
320
327
  if (!result.drained) {
@@ -349,8 +356,11 @@ export function createMcpManager(dependencies = {}) {
349
356
  return (await processAdapter.prepare(client)).execution
350
357
  }
351
358
 
352
- async function reconcileOne(identity, entry, suppliedToken = null) {
353
- const token = suppliedToken ?? beginOperation(identity)
359
+ function reconcileOne(identity, entry, suppliedToken = null) {
360
+ if (suppliedToken !== null) return reconcileFenced(identity, entry, suppliedToken)
361
+ return serialize(identity, () => reconcileFenced(identity, entry, beginOperation(identity)))
362
+ }
363
+ async function reconcileFenced(identity, entry, token) {
354
364
  try {
355
365
  const current = await registry.read()
356
366
  const registered = current.kind === 'ok' ? current.servers[identity] : null
@@ -361,15 +371,20 @@ export function createMcpManager(dependencies = {}) {
361
371
  }
362
372
  await waitForDrain(identity)
363
373
  if (mount.mounted.has(identity)) {
364
- const removed = await mount.unmount(identity)
374
+ const previousMount = mount.mounted.get(identity)
375
+ const previousBridge = bridges.get(previousMount.groupId) ?? null
376
+ const afterCleanup = () => releaseBridge(previousMount.groupId, previousBridge)
377
+ const removed = await mount.unmount(identity, {}, { afterCleanup })
365
378
  if (removed.status !== 'cleaned') {
366
379
  finishOperation(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
367
380
  return { status: removed.status, identity, cleanup: removed, saved: true }
368
381
  }
369
- releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
382
+ // Legacy mount doubles do not consume context; exact compare-delete
383
+ // makes this compatibility fallback harmless for the real mount.
384
+ afterCleanup()
370
385
  }
371
386
  const prepared = await prepare(entry)
372
- const groupId = `${MCP_GROUP_PREFIX}${identity}`
387
+ let attempt = null
373
388
  const result = await mount.mount({
374
389
  identity,
375
390
  generation: entry.generation,
@@ -378,9 +393,13 @@ export function createMcpManager(dependencies = {}) {
378
393
  secretBearing: prepared.secretBearing,
379
394
  execution: prepared.execution,
380
395
  fiberOf,
381
- beforeCreate: () => registerBridge(groupId, identity, entry.generation),
382
- afterCleanup: () => releaseBridge(groupId, { identity, generation: entry.generation }),
383
- discovery: () => discoveryFor(identity, entry.generation),
396
+ beforeCreate: (context = {}) => {
397
+ attempt = registerBridge(context.groupId ?? `${MCP_GROUP_PREFIX}${identity}`, identity, entry.generation, context.owner ?? null)
398
+ },
399
+ afterCleanup: (context = {}) => {
400
+ if (attempt) releaseBridge(context.groupId ?? attempt.groupId, attempt)
401
+ },
402
+ discovery: () => discoveryFor(identity, entry.generation, attempt),
384
403
  readback: async () => {
385
404
  const readback = await registry.read()
386
405
  const actual = readback.kind === 'ok' ? readback.servers[identity] : null
@@ -396,7 +415,6 @@ export function createMcpManager(dependencies = {}) {
396
415
  receipts.mount.set(identity, { time: now(), status: 'indeterminate', diagnostic })
397
416
  return { status: 'indeterminate', identity, generation: entry.generation, incarnation: entry.incarnation ?? null, saved: true, error: diagnostic, mount: { state: 'superseded', observedGeneration: observed.generation }, next: { action: 'reread-registry' } }
398
417
  }
399
- drain.reopenAll(identity)
400
418
  clearStartErrors(identity)
401
419
  const receipt = { time: now(), status: 'mounted', generation: entry.generation, discovery: result.discovery }
402
420
  receipts.mount.set(identity, receipt)
@@ -413,16 +431,23 @@ export function createMcpManager(dependencies = {}) {
413
431
  }
414
432
  }
415
433
 
416
- async function removeRuntime(identity, suppliedToken = null) {
417
- const token = suppliedToken ?? beginOperation(identity, 'removing')
434
+ function removeRuntime(identity, suppliedToken = null) {
435
+ if (suppliedToken !== null) return removeRuntimeFenced(identity, suppliedToken)
436
+ return serialize(identity, () => removeRuntimeFenced(identity, beginOperation(identity, 'removing')))
437
+ }
438
+ async function removeRuntimeFenced(identity, token) {
418
439
  try {
419
440
  await waitForDrain(identity)
420
- const cleanup = await mount.unmount(identity)
441
+ const mountedRecord = mount.mounted.get(identity) ?? null
442
+ const groupId = mountedRecord?.groupId ?? null
443
+ const bridge = groupId ? bridges.get(groupId) ?? null : null
444
+ const afterCleanup = () => { if (groupId) releaseBridge(groupId, bridge) }
445
+ const cleanup = await mount.unmount(identity, {}, { afterCleanup })
421
446
  if (cleanup.status !== 'cleaned') {
422
447
  finishOperation(token, cleanup.status)
423
448
  return { status: cleanup.status, identity, cleanup, error: cleanup.diagnostic, next: { action: 'recover-residue' } }
424
449
  }
425
- releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
450
+ afterCleanup()
426
451
  finishOperation(token, 'unknown', { generation: null, incarnation: null })
427
452
  return { status: 'removed', identity, removed: true }
428
453
  } catch (cause) {
@@ -433,6 +458,7 @@ export function createMcpManager(dependencies = {}) {
433
458
  }
434
459
 
435
460
  async function probe(entry, { targetIdentity = entry.identity } = {}) {
461
+ let attempt = null
436
462
  try {
437
463
  const prepared = await prepare(entry)
438
464
  const result = await mount.probe({
@@ -442,9 +468,13 @@ export function createMcpManager(dependencies = {}) {
442
468
  secretBearing: prepared.secretBearing,
443
469
  secrets: prepared.secrets,
444
470
  fiberOf,
445
- beforeCreate: ({ groupId, identity, generation }) => registerBridge(groupId, identity, generation),
446
- afterCleanup: ({ groupId, identity, generation }) => releaseBridge(groupId, { identity, generation }),
447
- discovery: ({ identity, generation }) => discoveryFor(identity, generation),
471
+ beforeCreate: (context = {}) => {
472
+ attempt = registerBridge(context.groupId ?? `${MCP_GROUP_PREFIX}probe`, context.identity ?? 'probe', context.generation ?? 0, context.owner ?? null)
473
+ },
474
+ afterCleanup: (context = {}) => {
475
+ if (attempt) releaseBridge(context.groupId ?? attempt.groupId, attempt)
476
+ },
477
+ discovery: context => discoveryFor(context?.identity ?? attempt?.identity ?? 'probe', context?.generation ?? 0, attempt),
448
478
  })
449
479
  const outcome = { status: result.status, identity: targetIdentity, discovery: result.discovery, cleanup: result.cleanup }
450
480
  // Receipts share one shape with lastMount: the row reads `diagnostic`.
@@ -475,60 +505,100 @@ export function createMcpManager(dependencies = {}) {
475
505
  }
476
506
  let mountedCount = 0
477
507
  for (const entry of Object.values(current.servers)) {
478
- const result = await serialize(entry.identity, () => reconcileOne(entry.identity, entry))
508
+ const result = await reconcileOne(entry.identity, entry)
479
509
  if (result.status === 'mounted') mountedCount += 1
480
510
  else if (result.status !== 'saved-degraded') startErrors.push({ identity: entry.identity, reason: result.status })
481
511
  }
482
512
  return { mounted: mountedCount }
483
513
 
484
514
  }
485
- const healthy = identity => runtime.snapshot(identity).phase === 'mounted' && mount.mounted.has(identity)
515
+ const healthy = (identity, state = runtime.snapshot(identity)) => state?.phase === 'mounted' && mount.mounted.has(identity)
486
516
  && (mount.mounted.get(identity)?.state ?? 'mounted') === 'mounted'
487
517
 
488
- /** Recover one identity inside its queue slot: sweep untracked residue, then strictly remount. */
518
+ async function sweepRecoveryGroups(groupIds) {
519
+ const expected = new Map(groupIds.map(groupId => [groupId, bridges.get(groupId) ?? null]))
520
+ const afterCleanup = ({ groupId }) => {
521
+ const bridge = expected.get(groupId)
522
+ if (bridge) releaseBridge(groupId, bridge)
523
+ }
524
+ if (typeof mount.sweepResidueDetailed === 'function') {
525
+ return mount.sweepResidueDetailed(groupIds, { afterCleanup })
526
+ }
527
+ const swept = await mount.sweepResidue(groupIds)
528
+ for (const groupId of swept) afterCleanup({ groupId })
529
+ return { swept, blocked: groupIds.filter(id => !swept.includes(id)), foreign: [], unresolved: [] }
530
+ }
531
+
532
+ function residueIdentity(groupId) {
533
+ if (typeof mount.identityForGroup === 'function') return mount.identityForGroup(groupId)?.identity ?? null
534
+ return isMcpResidueId(groupId) ? groupId.slice(MCP_GROUP_PREFIX.length) : null
535
+ }
536
+
537
+ function residueGroups(identity) {
538
+ const listed = listEntryIds().filter(isMcpResidueId).filter(groupId => residueIdentity(groupId) === identity)
539
+ for (const [groupId, bridge] of bridges) if (bridge.identity === identity) listed.push(groupId)
540
+ return [...new Set(listed)]
541
+ }
542
+
543
+ /** Recover one identity inside its queue slot using exact actual group IDs. */
489
544
  function recoverOne(identity, assertOwner) {
490
545
  return serialize(identity, async () => {
491
- // Re-read inside the slot: an earlier queued operation may have
492
- // remounted, edited or removed this identity meanwhile.
493
- const current = await registry.read()
546
+ // Ownership can still reject a queued no-op without touching admission.
547
+ // Once recovery is fenced, the fence precedes its identity reread.
494
548
  if (assertOwner) await assertOwner('recover-cleanup')
495
- if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', identity, skipped: current.kind }
496
- const entry = current.kind === 'ok' ? current.servers[identity] ?? null : null
497
- if (entry && healthy(identity)) return { status: 'mounted', identity, generation: entry.generation, alreadyHealthy: true }
498
- const groupId = `${MCP_GROUP_PREFIX}${identity}`
499
- const residuePresent = listEntryIds().includes(groupId)
500
- if (!entry) {
501
- // Orphan residue of an identity that is no longer registered.
502
- const swept = residuePresent ? await mount.sweepResidue([groupId]) : []
503
- const cleaned = !residuePresent || swept.includes(groupId)
504
- if (cleaned) releaseBridge(groupId)
505
- return cleaned
506
- ? { status: 'removed', identity, swept: residuePresent }
507
- : { status: 'cleanup-failed', identity, error: mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' }), next: { action: 'recover-residue' } }
508
- }
509
549
  const token = beginOperation(identity)
510
- // A tracked failed mount record is retried by reconcileOne's unmount; a
511
- // Loader row the mount map no longer tracks is swept first, or the
512
- // same-name replacement could never be created.
513
- if (!mount.mounted.has(identity) && residuePresent) {
514
- const swept = await mount.sweepResidue([groupId])
515
- if (!swept.includes(groupId)) {
516
- finishOperation(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
517
- const diagnostic = mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' })
518
- receipts.mount.set(identity, { time: now(), status: 'cleanup-failed', diagnostic })
519
- return { status: 'cleanup-failed', identity, generation: entry.generation, saved: true, error: diagnostic, next: { action: 'recover-residue' } }
550
+ let terminal = false
551
+ try {
552
+ const current = await registry.read()
553
+ if (current.kind !== 'ok' && current.kind !== 'absent') {
554
+ terminal = true
555
+ abortOperation(token)
556
+ return { status: 'unavailable', identity, skipped: current.kind }
520
557
  }
521
- releaseBridge(groupId)
522
- }
523
- if (assertOwner) {
524
- try { await assertOwner('mount') } catch (cause) {
525
- // Cleanup may already have removed the old mount. Keep admission
526
- // closed without stranding the queue fence in `replacing`.
527
- finishOperation(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
528
- throw cause
558
+ const entry = current.kind === 'ok' ? current.servers[identity] ?? null : null
559
+ if (entry && healthy(identity, token.previous)) {
560
+ terminal = true
561
+ abortOperation(token)
562
+ return { status: 'mounted', identity, generation: entry.generation, alreadyHealthy: true }
563
+ }
564
+ const groups = residueGroups(identity)
565
+ if (!entry) {
566
+ // Only own or synchronously claimed inactive residue is swept. A
567
+ // foreign active group is retained and never reported as removed.
568
+ const detail = groups.length > 0 ? await sweepRecoveryGroups(groups) : { swept: [], blocked: [], foreign: [], unresolved: [] }
569
+ const cleaned = detail.blocked.length === 0 && detail.unresolved.length === 0 && detail.foreign.length === 0
570
+ terminal = true
571
+ finishOperation(token, cleaned ? 'unknown' : 'cleanup-failed', { generation: null, incarnation: null })
572
+ return cleaned
573
+ ? { status: 'removed', identity, swept: groups.length > 0 }
574
+ : { status: 'cleanup-failed', identity, error: mcpDiagnostic(new Error(`residue Loader group(s) for ${identity} could not be safely removed`), { code: 'cleanup-failed', action: 'recover-residue' }), next: { action: 'recover-residue' } }
575
+ }
576
+ // A tracked failed mount is unmounted by reconcileOne using its actual
577
+ // ID. Only untracked groups are residue candidates here; foreign active
578
+ // groups are skipped and do not become this manager's mount evidence.
579
+ if (!mount.mounted.has(identity) && groups.length > 0) {
580
+ const detail = await sweepRecoveryGroups(groups)
581
+ if (detail.blocked.length > 0 || detail.unresolved.length > 0) {
582
+ terminal = true
583
+ finishOperation(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
584
+ const diagnostic = mcpDiagnostic(new Error(`owned residue Loader group(s) for ${identity} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' })
585
+ receipts.mount.set(identity, { time: now(), status: 'cleanup-failed', diagnostic })
586
+ return { status: 'cleanup-failed', identity, generation: entry.generation, saved: true, error: diagnostic, next: { action: 'recover-residue' } }
587
+ }
529
588
  }
589
+ if (assertOwner) {
590
+ try { await assertOwner('mount') } catch (cause) {
591
+ terminal = true
592
+ finishOperation(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
593
+ throw cause
594
+ }
595
+ }
596
+ terminal = true // reconcileFenced owns every transition from here.
597
+ return reconcileOne(identity, entry, token)
598
+ } catch (cause) {
599
+ if (!terminal) abortOperation(token)
600
+ throw cause
530
601
  }
531
- return reconcileOne(identity, entry, token)
532
602
  })
533
603
  }
534
604
 
@@ -544,7 +614,7 @@ export function createMcpManager(dependencies = {}) {
544
614
  const current = await registry.read()
545
615
  if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', skipped: current.kind, results: [] }
546
616
  const servers = current.kind === 'ok' ? current.servers : {}
547
- const orphans = listEntryIds().filter(isMcpResidueId).map(id => id.slice(MCP_GROUP_PREFIX.length)).filter(id => !servers[id])
617
+ const orphans = listEntryIds().filter(isMcpResidueId).map(residueIdentity).filter(id => id && !servers[id])
548
618
  let candidates = [...new Set([...Object.keys(servers).filter(id => !healthy(id)), ...orphans])]
549
619
  if (identity !== null) candidates = candidates.filter(id => id === identity)
550
620
  const results = []
@@ -567,13 +637,18 @@ export function createMcpManager(dependencies = {}) {
567
637
  const holder = hostRowHoldingReservation(listRows(), entry.client?.serverName ?? entry.transport?.serverName ?? entry.identity)
568
638
  if (holder) return { status: 'conflict', reason: `host row "${holder.id ?? entry.identity}" still holds the serverName reservation — disable it in the host configuration; the managed client mounts afterwards. Until then the server stays unmanaged.` }
569
639
  return serialize(entry.identity, async () => {
570
- const current = await registry.read()
571
- const revision = current.kind === 'ok' ? current.revision : 0
572
640
  const token = beginOperation(entry.identity)
573
- const registered = await registry.register(entry, revision)
574
- if (registered.status !== 'committed') { abortOperation(token); return { status: 'registry-failed', result: registered.status } }
575
- const latest = await registry.read()
576
- return reconcileOne(entry.identity, latest.servers[entry.identity], token)
641
+ try {
642
+ const current = await registry.read()
643
+ const revision = current.kind === 'ok' ? current.revision : 0
644
+ const registered = await registry.register(entry, revision)
645
+ if (registered.status !== 'committed') { abortOperation(token); return { status: 'registry-failed', result: registered.status } }
646
+ const latest = await registry.read()
647
+ return reconcileOne(entry.identity, latest.servers[entry.identity], token)
648
+ } catch (cause) {
649
+ abortOperation(token)
650
+ throw cause
651
+ }
577
652
  })
578
653
  }
579
654
 
@@ -596,45 +671,13 @@ export function createMcpManager(dependencies = {}) {
596
671
  ctx.on?.('agent/created', payload => { agentCreated(payload) })
597
672
  ctx.on?.('agent/disposed', payload => { agentDisposed(payload) })
598
673
 
599
- async function list() {
600
- const current = await registry.read()
601
- const rows = listRows()
602
- const partitioned = partitionMcpRows(rows, mount.mounted, current.kind === 'ok' ? current.servers : {})
603
- if (current.kind === 'absent') return { managed: [], unmanaged: partitioned.unmanaged, warnings: [], registryRevision: 0, capabilities: support() }
604
- if (current.kind !== 'ok') return { managed: [], unmanaged: partitioned.unmanaged, unavailable: { status: current.kind }, registryRevision: current.revision ?? 0, capabilities: support() }
605
- const defaults = new Set(current.enabled)
606
- const managed = Object.values(current.servers).map(entry => {
607
- const phase = runtime.snapshot(entry.identity)
608
- const mountedRecord = mount.mounted.get(entry.identity)
609
- const mounted = phase.phase === 'mounted' && phase.generation === entry.generation && mountedRecord?.generation === entry.generation
610
- const holder = hostRowHoldingReservation(rows, entry.client?.serverName ?? entry.identity)
611
- const state = holder ? 'conflict' : mounted ? 'mounted' : phase.phase === 'saved-degraded' ? 'degraded' : ['cleanup-failed', 'cleanup-unknown'].includes(phase.phase) ? phase.phase : 'registered'
612
- const discovery = discoveryFor(entry.identity, entry.generation)
613
- return {
614
- identity: entry.identity,
615
- label: entry.label,
616
- serverName: entry.client?.serverName ?? entry.identity,
617
- transport: entry.transport?.kind ?? entry.client?.transport ?? 'unknown',
618
- incarnation: entry.incarnation ?? null,
619
- generation: entry.generation,
620
- defaultEnabled: defaults.has(entry.identity),
621
- state,
622
- statusSource: mounted ? 'registry-runtime-mount-readback' : holder ? 'serverName-reservation' : 'registry-runtime',
623
- runtimePhase: phase.phase,
624
- discovery: mounted ? discovery : { toolCount: null, toolNamesPreview: [], truncated: false },
625
- lastMount: receipts.mount.get(entry.identity) ?? null,
626
- lastTest: receipts.test.get(entry.identity) ?? null,
627
- startErrors: startErrors.filter(error => error.identity === entry.identity).map(error => ({ reason: error.reason })),
628
- liveReconnect: 'unknown',
629
- processExit: 'unknown',
630
- stderr: 'unknown',
631
- readOnly: false,
632
- config: redactMcpEntry(entry),
633
- fingerprint: fingerprintMcpEntry(entry),
634
- }
635
- })
636
- return { managed, unmanaged: partitioned.unmanaged, warnings: current.warnings, registryRevision: current.revision, capabilities: support() }
637
- }
674
+ const inventory = createMcpInventoryProjection({
675
+ registry, runtime, mounted: mount.mounted, receipts, listRows, startErrors,
676
+ discoveryFor, support, isResidue: isMcpResidueId, groupPrefix: MCP_GROUP_PREFIX, ownership,
677
+ holdsReservation: hostRowHoldingReservation,
678
+ redactEntry: redactMcpEntry, fingerprintEntry: fingerprintMcpEntry,
679
+ })
680
+ const list = () => inventory.list()
638
681
 
639
682
  const gateFace = {
640
683
  admit: (agent, identity, generation) => gate.admit(agent, identity, generation),
@@ -651,7 +694,7 @@ export function createMcpManager(dependencies = {}) {
651
694
  publicNames,
652
695
  mounted: mount.mounted,
653
696
  beginOperation, abortOperation, reconcileOne, removeRuntime, probe,
654
- remove: identity => serialize(identity, () => removeRuntime(identity)),
697
+ remove: identity => removeRuntime(identity),
655
698
  /** Run one same-identity operation after every earlier one settled. */
656
699
  serialize,
657
700
  support,
@@ -663,11 +706,17 @@ export function createMcpManager(dependencies = {}) {
663
706
  mcpGroupOptions,
664
707
  }
665
708
  try { ctx.reflect?.provide?.('weirMcpManager', face) } catch { /* older row */ }
709
+ // Bootstrap only after face publication so revision-local agent ownership
710
+ // can resolve the exact manager rather than observing an empty service.
711
+ if (typeof agentsService?.list === 'function') {
712
+ try { liveAgents() } catch (cause) { onError('mcp-agent-bootstrap-failed', cause) }
713
+ }
666
714
  void Promise.resolve().then(() => face.start()).catch(cause => {
667
715
  startErrors.push({ identity: null, reason: `start-failed:${message(cause)}` })
668
716
  onError('mcp-manager-start-failed', cause)
669
717
  })
670
718
  return () => {
719
+ defaultLifecycleActive = false
671
720
  try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, permit: async () => null, assertPermit: () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
672
721
  // Child-group disposal normally unregisters tools first. If any public
673
722
  // fact remains, unregistration was not confirmed: KEEP its deny instead