weir-harness 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
6
6
  "license": "MIT",
@@ -30,9 +30,10 @@ const message = error => (error instanceof Error ? error.message : String(error)
30
30
  * admit: (agent: unknown) => Promise<boolean>,
31
31
  * isEnabledFor: (agent: unknown) => boolean,
32
32
  * drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void },
33
+ * onRegister?: (name: string, captureRollback: (rollback: () => void) => void) => void, onUnregister?: (name: string) => void,
33
34
  * onError?: (reason: string, cause?: unknown) => void }} config
34
35
  */
35
- export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {}, assertPermit }) {
36
+ export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onUnregister, onError = () => {}, assertPermit }) {
36
37
  const wrapper = createMcpToolWrapper({ identity, generation, admit, drain, assertPermit })
37
38
 
38
39
  const unknown = (service, method) => {
@@ -43,14 +44,44 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
43
44
 
44
45
  const tools = {
45
46
  register(definition) {
46
- // Bind the public name to this configured identity at registration
47
- // time, then wrap the definition with the last-moment admission check.
47
+ // Host registration happens first, then the generation-owned fact and
48
+ // every live agent converge in this same synchronous segment.
48
49
  const wrapped = wrapper.wrap(definition)
49
50
  const registration = /** @type {{ register(d: unknown): unknown }} */ (host.tools).register(wrapped)
50
- // Report the binding back to the manager (creation-time schema hiding
51
- // and the managed listing read this map).
52
- if (typeof wrapped?.name === 'string') onRegister?.(wrapped.name)
53
- return registration
51
+ if (typeof wrapped?.name !== 'string') return registration
52
+ const name = wrapped.name
53
+ const disposeHost = typeof registration === 'function' ? registration : () => {}
54
+ let rollbackRegistration = null
55
+ try {
56
+ onRegister?.(name, rollback => { rollbackRegistration = rollback })
57
+ } catch (cause) {
58
+ // Host-first rollback is fail-closed: owner facts and partial denies
59
+ // stay in place unless Host removal is confirmed.
60
+ try { disposeHost() } catch (rollbackError) {
61
+ onError('facade-host-registration-rollback-failed', rollbackError)
62
+ throw cause
63
+ }
64
+ try {
65
+ if (typeof rollbackRegistration === 'function') rollbackRegistration()
66
+ else onUnregister?.(name) // legacy registration callbacks
67
+ } catch (rollbackError) {
68
+ onError('facade-owner-registration-rollback-failed', rollbackError)
69
+ }
70
+ throw cause
71
+ }
72
+ let hostRemoved = false
73
+ let completed = false
74
+ return () => {
75
+ if (completed) return
76
+ if (!hostRemoved) {
77
+ // Host-first is fail-closed: if removal throws, the manager fact and
78
+ // deny stay in place. A later retry does not double-dispose the Host.
79
+ disposeHost()
80
+ hostRemoved = true
81
+ }
82
+ onUnregister?.(name)
83
+ completed = true
84
+ }
54
85
  },
55
86
  get(...args) { return /** @type {{ get(...a: unknown[]): unknown }} */ (host.tools).get(...args) },
56
87
  list(...args) { return /** @type {{ list(...a: unknown[]): unknown }} */ (host.tools).list(...args) },
@@ -87,13 +87,183 @@ export function createMcpManager(dependencies = {}) {
87
87
  systemPrompt: dependencies.hostSystemPrompt ?? ctx.get?.('systemPrompt'),
88
88
  mcpResources: dependencies.hostMcpResources ?? ctx.get?.('mcpResources'),
89
89
  }
90
+ const agentsService = dependencies.agents ?? ctx.get?.('agents')
91
+ const seenAgents = new Set() // compatibility fallback; actual agent objects, never session ids
92
+ const restrictions = new Map() // agent → Map(publicName → { owner, dispose })
90
93
 
91
- function releaseBridge(groupId) {
94
+ 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
98
+ const isAgent = agent => (typeof agent === 'object' && agent !== null) || typeof agent === 'function'
99
+ function rememberAgent(agent) {
100
+ if (!isAgent(agent)) return null
101
+ seenAgents.add(agent)
102
+ return agent
103
+ }
104
+ function liveAgents() {
105
+ if (typeof agentsService?.list !== 'function') return [...seenAgents]
106
+ const listed = agentsService.list()
107
+ if (!Array.isArray(listed)) throw new TypeError('agents.list() must return an array')
108
+ for (const agent of listed) rememberAgent(agent)
109
+ return listed
110
+ }
111
+
112
+ /**
113
+ * Reconcile only actual differences. Every held entry owns exactly one
114
+ * independent deny disposer; absent public names never reach restrict().
115
+ */
116
+ function reconcileAgent(agent, matches = () => true, added = null) {
117
+ if (!isAgent(agent)) return
118
+ let held = restrictions.get(agent)
119
+ const names = new Set()
120
+ for (const [name, owner] of publicNames) if (matches(name, owner)) names.add(name)
121
+ for (const [name, record] of held ?? []) if (matches(name, record.owner)) names.add(name)
122
+ let failure = null
123
+ for (const name of names) {
124
+ try {
125
+ const present = publicNames.has(name)
126
+ const owner = publicNames.get(name)
127
+ const existing = held?.get(name)
128
+ const shouldDeny = present && !gate.enabledFor(agent, identityOf(owner))
129
+ if (shouldDeny && !existing) {
130
+ const restrict = agent?.ctx?.tools?.restrict
131
+ if (typeof restrict !== 'function') throw new Error(`cannot restrict MCP tool "${name}" without agent tools`)
132
+ const disposer = restrict.call(agent.ctx.tools, { deny: [name] })
133
+ if (!held) {
134
+ held = new Map()
135
+ restrictions.set(agent, held)
136
+ }
137
+ const record = { owner, dispose: typeof disposer === 'function' ? disposer : null }
138
+ held.set(name, record)
139
+ added?.push({ agent, name, record })
140
+ } else if (!shouldDeny && existing) {
141
+ existing.dispose?.()
142
+ held.delete(name)
143
+ } else if (shouldDeny && existing) {
144
+ // A replacement may reuse the public name. Keep the exact deny and
145
+ // update only its ownership metadata; never dispose/recreate it.
146
+ existing.owner = owner
147
+ }
148
+ } catch (cause) {
149
+ failure ??= cause
150
+ }
151
+ }
152
+ if (held?.size === 0) restrictions.delete(agent)
153
+ if (failure) throw failure
154
+ }
155
+
156
+ function reconcileAgents(matches, added = null) {
157
+ let failure = null
158
+ for (const agent of liveAgents()) {
159
+ try { reconcileAgent(agent, matches, added) } catch (cause) { failure ??= cause }
160
+ }
161
+ if (failure) throw failure
162
+ }
163
+ const syncName = (name, added = null) => reconcileAgents(candidate => candidate === name, added)
164
+ const syncIdentity = identity => reconcileAgents((_name, owner) => identityOf(owner) === identity)
165
+
166
+ function syncSession(sessionId) {
167
+ let agent
168
+ if (typeof agentsService?.get === 'function') agent = agentsService.get(sessionId)
169
+ else agent = [...seenAgents].find(candidate => candidate?.session?.id === sessionId)
170
+ if (rememberAgent(agent)) reconcileAgent(agent)
171
+ }
172
+
173
+ function registerPublicName(name, identity, generation, captureRollback) {
174
+ const hadPrevious = publicNames.has(name)
175
+ const previous = publicNames.get(name)
176
+ const attempted = { identity, generation }
177
+ const previousDenies = []
178
+ for (const [agent, held] of restrictions) {
179
+ const record = held.get(name)
180
+ if (record) previousDenies.push({ agent, record, owner: record.owner })
181
+ }
182
+ const added = []
183
+ publicNames.set(name, attempted)
184
+ captureRollback?.(() => {
185
+ // Object identity distinguishes attempts even within one generation.
186
+ // A newer owner must never be overwritten by a stale Host rollback.
187
+ if (publicNames.get(name) !== attempted) return
188
+ let failure = null
189
+ const fail = cause => {
190
+ failure ??= cause
191
+ onError(`mcp-schema-rollback-failed:${name}`, cause)
192
+ }
193
+ if (hadPrevious) publicNames.set(name, previous)
194
+ else publicNames.delete(name)
195
+
196
+ // Recreate denies removed by the failed sync before reusing surviving
197
+ // records, then dispose only denies introduced by this attempt.
198
+ for (const { agent, owner } of previousDenies) {
199
+ if (restrictions.get(agent)?.has(name)) continue
200
+ try {
201
+ const restrict = agent?.ctx?.tools?.restrict
202
+ if (typeof restrict !== 'function') throw new Error(`cannot restrict MCP tool "${name}" without agent tools`)
203
+ const disposer = restrict.call(agent.ctx.tools, { deny: [name] })
204
+ let held = restrictions.get(agent)
205
+ if (!held) {
206
+ held = new Map()
207
+ restrictions.set(agent, held)
208
+ }
209
+ held.set(name, { owner, dispose: typeof disposer === 'function' ? disposer : null })
210
+ } catch (cause) { fail(cause) }
211
+ }
212
+ for (const { agent, record, owner } of previousDenies) {
213
+ if (restrictions.get(agent)?.get(name) === record) record.owner = owner
214
+ }
215
+ for (const { agent, record } of added.reverse()) {
216
+ const held = restrictions.get(agent)
217
+ if (held?.get(name) !== record) continue
218
+ try {
219
+ record.dispose?.()
220
+ if (held.get(name) === record) held.delete(name)
221
+ if (held.size === 0) restrictions.delete(agent)
222
+ } catch (cause) { fail(cause) }
223
+ }
224
+ if (failure) throw failure
225
+ })
226
+ // Until confirmed Host disposal, retain the attempted owner and every
227
+ // partial deny. The facade invokes only this transaction's rollback.
228
+ syncName(name, added)
229
+ }
230
+
231
+ function unregisterPublicName(name, identity, generation) {
232
+ if (!sameOwner(publicNames.get(name), identity, generation)) return false
233
+ publicNames.delete(name)
234
+ try { syncName(name) } catch (cause) { onError(`mcp-schema-cleanup-failed:${name}`, cause) }
235
+ return true
236
+ }
237
+
238
+ function disposeAgentRestrictions(agent, { onlyAbsent = false } = {}) {
239
+ const held = restrictions.get(agent)
240
+ if (!held) return
241
+ for (const [name, record] of held) {
242
+ if (onlyAbsent && publicNames.has(name)) continue
243
+ try {
244
+ record.dispose?.()
245
+ held.delete(name)
246
+ } catch (cause) {
247
+ // Cleanup is fail-closed: an unconfirmed disposer stays owned here.
248
+ onError(`mcp-schema-dispose-failed:${name}`, cause)
249
+ }
250
+ }
251
+ if (held.size === 0) restrictions.delete(agent)
252
+ }
253
+
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) {
92
261
  const bridge = bridges.get(groupId)
93
262
  if (!bridge) return
263
+ if (expected && (bridge.identity !== expected.identity || bridge.generation !== expected.generation)) return
94
264
  bridges.delete(groupId)
95
- for (const [name, owner] of publicNames) {
96
- if (owner.identity === bridge.identity && owner.generation === bridge.generation) publicNames.delete(name)
265
+ for (const [name, owner] of [...publicNames]) {
266
+ if (sameOwner(owner, bridge.identity, bridge.generation)) unregisterPublicName(name, bridge.identity, bridge.generation)
97
267
  }
98
268
  bridge.release()
99
269
  }
@@ -104,7 +274,8 @@ export function createMcpManager(dependencies = {}) {
104
274
  assertPermit: gate.assertPermit,
105
275
  isEnabledFor: agent => gate.enabledFor(agent, identity),
106
276
  drain,
107
- onRegister: name => publicNames.set(name, { identity, generation }),
277
+ onRegister: (name, captureRollback) => registerPublicName(name, identity, generation, captureRollback),
278
+ onUnregister: name => unregisterPublicName(name, identity, generation),
108
279
  onError,
109
280
  })
110
281
  bridges.set(groupId, { identity, generation, release })
@@ -114,16 +285,35 @@ export function createMcpManager(dependencies = {}) {
114
285
  return { toolCount: names.length, toolNamesPreview: names.slice(0, 20), truncated: names.length > 20 }
115
286
  }
116
287
 
288
+ /** Every manager-owned runtime mutation converges visibility after the actual state change. */
289
+ function transitionRuntime(identity, change) {
290
+ const before = runtime.snapshot(identity)
291
+ const result = change()
292
+ const after = runtime.snapshot(identity)
293
+ const changed = before.phase !== after.phase || before.generation !== after.generation
294
+ || (before.incarnation ?? null) !== (after.incarnation ?? null) || before.operationId !== after.operationId
295
+ if (changed) {
296
+ try { syncIdentity(identity) } catch (cause) { onError(`mcp-visibility-sync-failed:${identity}`, cause) }
297
+ }
298
+ return result
299
+ }
300
+ function finishOperation(token, phase, details = {}) {
301
+ return transitionRuntime(token.identity, () => runtime.finish(token, phase, details))
302
+ }
117
303
  function beginOperation(identity, phase = 'replacing', details = {}) {
118
- const token = runtime.begin(identity, { phase, ...details })
119
- drain.closeAll(identity)
120
- return token
304
+ return transitionRuntime(identity, () => {
305
+ const token = runtime.begin(identity, { phase, ...details })
306
+ drain.closeAll(identity)
307
+ return token
308
+ })
121
309
  }
122
310
  function abortOperation(token) {
123
311
  const previousPhase = token.previous?.phase
124
- const restored = runtime.restore(token)
125
- if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
126
- return restored
312
+ return transitionRuntime(token.identity, () => {
313
+ const restored = runtime.restore(token)
314
+ if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
315
+ return restored
316
+ })
127
317
  }
128
318
  async function waitForDrain(identity) {
129
319
  const result = await drain.drainAll(identity)
@@ -173,7 +363,7 @@ export function createMcpManager(dependencies = {}) {
173
363
  if (mount.mounted.has(identity)) {
174
364
  const removed = await mount.unmount(identity)
175
365
  if (removed.status !== 'cleaned') {
176
- runtime.finish(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
366
+ finishOperation(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
177
367
  return { status: removed.status, identity, cleanup: removed, saved: true }
178
368
  }
179
369
  releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
@@ -189,7 +379,7 @@ export function createMcpManager(dependencies = {}) {
189
379
  execution: prepared.execution,
190
380
  fiberOf,
191
381
  beforeCreate: () => registerBridge(groupId, identity, entry.generation),
192
- afterCleanup: () => releaseBridge(groupId),
382
+ afterCleanup: () => releaseBridge(groupId, { identity, generation: entry.generation }),
193
383
  discovery: () => discoveryFor(identity, entry.generation),
194
384
  readback: async () => {
195
385
  const readback = await registry.read()
@@ -201,7 +391,7 @@ export function createMcpManager(dependencies = {}) {
201
391
  if (!observed || observed.generation !== entry.generation) throw new Error('mounted generation readback failed')
202
392
  // Only the fence owner may report mounted and reopen admission: a
203
393
  // superseded token means another operation now owns this identity.
204
- if (!runtime.finish(token, 'mounted', { generation: entry.generation, incarnation: entry.incarnation ?? null })) {
394
+ if (!finishOperation(token, 'mounted', { generation: entry.generation, incarnation: entry.incarnation ?? null })) {
205
395
  const diagnostic = mcpDiagnostic(new Error(`MCP operation on ${identity} was superseded before it could report mounted`), { code: 'operation-superseded', action: 'reread-registry' })
206
396
  receipts.mount.set(identity, { time: now(), status: 'indeterminate', diagnostic })
207
397
  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' } }
@@ -214,7 +404,7 @@ export function createMcpManager(dependencies = {}) {
214
404
  } catch (cause) {
215
405
  const cleanupStatus = ['cleanup-failed', 'cleanup-unknown'].includes(cause?.status) ? cause.status : null
216
406
  const status = cleanupStatus ?? (cause?.code === 'unsupported' ? 'unsupported' : 'saved-degraded')
217
- runtime.finish(token, cleanupStatus ?? 'saved-degraded', { generation: entry.generation, incarnation: entry.incarnation ?? null })
407
+ finishOperation(token, cleanupStatus ?? 'saved-degraded', { generation: entry.generation, incarnation: entry.incarnation ?? null })
218
408
  const diagnostic = mcpDiagnostic(cause, { code: cause?.code, action: cleanupStatus ? 'recover-residue' : 'retry' })
219
409
  receipts.mount.set(identity, { time: now(), status, diagnostic })
220
410
  startErrors.push({ identity, reason: diagnostic.message })
@@ -229,15 +419,15 @@ export function createMcpManager(dependencies = {}) {
229
419
  await waitForDrain(identity)
230
420
  const cleanup = await mount.unmount(identity)
231
421
  if (cleanup.status !== 'cleaned') {
232
- runtime.finish(token, cleanup.status)
422
+ finishOperation(token, cleanup.status)
233
423
  return { status: cleanup.status, identity, cleanup, error: cleanup.diagnostic, next: { action: 'recover-residue' } }
234
424
  }
235
425
  releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
236
- runtime.finish(token, 'unknown', { generation: null, incarnation: null })
426
+ finishOperation(token, 'unknown', { generation: null, incarnation: null })
237
427
  return { status: 'removed', identity, removed: true }
238
428
  } catch (cause) {
239
429
  const status = cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
240
- runtime.finish(token, status)
430
+ finishOperation(token, status)
241
431
  return { status, identity, error: mcpDiagnostic(cause, { code: status, action: 'recover-residue' }), next: { action: 'recover-residue' } }
242
432
  }
243
433
  }
@@ -253,7 +443,7 @@ export function createMcpManager(dependencies = {}) {
253
443
  secrets: prepared.secrets,
254
444
  fiberOf,
255
445
  beforeCreate: ({ groupId, identity, generation }) => registerBridge(groupId, identity, generation),
256
- afterCleanup: ({ groupId }) => releaseBridge(groupId),
446
+ afterCleanup: ({ groupId, identity, generation }) => releaseBridge(groupId, { identity, generation }),
257
447
  discovery: ({ identity, generation }) => discoveryFor(identity, generation),
258
448
  })
259
449
  const outcome = { status: result.status, identity: targetIdentity, discovery: result.discovery, cleanup: result.cleanup }
@@ -323,7 +513,7 @@ export function createMcpManager(dependencies = {}) {
323
513
  if (!mount.mounted.has(identity) && residuePresent) {
324
514
  const swept = await mount.sweepResidue([groupId])
325
515
  if (!swept.includes(groupId)) {
326
- runtime.finish(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
516
+ finishOperation(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
327
517
  const diagnostic = mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' })
328
518
  receipts.mount.set(identity, { time: now(), status: 'cleanup-failed', diagnostic })
329
519
  return { status: 'cleanup-failed', identity, generation: entry.generation, saved: true, error: diagnostic, next: { action: 'recover-residue' } }
@@ -334,7 +524,7 @@ export function createMcpManager(dependencies = {}) {
334
524
  try { await assertOwner('mount') } catch (cause) {
335
525
  // Cleanup may already have removed the old mount. Keep admission
336
526
  // closed without stranding the queue fence in `replacing`.
337
- runtime.finish(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
527
+ finishOperation(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
338
528
  throw cause
339
529
  }
340
530
  }
@@ -388,14 +578,23 @@ export function createMcpManager(dependencies = {}) {
388
578
  }
389
579
 
390
580
  function agentCreated(payload) {
581
+ const agent = rememberAgent(payload?.agent)
582
+ if (!agent) return
583
+ // Creation remains non-throwing for visibility failures; admission is an
584
+ // independent hard gate. Registration itself uses the throwing path so
585
+ // its facade transaction can roll back.
586
+ try { reconcileAgent(agent) } catch (cause) { onError('mcp-schema-restrict-failed', cause) }
587
+ }
588
+ function agentDisposed(payload) {
391
589
  const agent = payload?.agent
392
- if (publicNames.size === 0) return
393
- const deny = [...publicNames.entries()].filter(([, owner]) => !gate.enabledFor(agent, typeof owner === 'string' ? owner : owner.identity)).map(([name]) => name)
394
- if (deny.length > 0) {
395
- try { agent?.ctx?.tools?.restrict?.({ deny }) } catch (cause) { onError('mcp-schema-restrict-failed', cause) }
396
- }
590
+ if (!isAgent(agent)) return
591
+ // Object identity is load-bearing: a delayed disposal from an old
592
+ // incarnation must never clean the replacement agent of the same session.
593
+ seenAgents.delete(agent)
594
+ disposeAgentRestrictions(agent)
397
595
  }
398
596
  ctx.on?.('agent/created', payload => { agentCreated(payload) })
597
+ ctx.on?.('agent/disposed', payload => { agentDisposed(payload) })
399
598
 
400
599
  async function list() {
401
600
  const current = await registry.read()
@@ -448,7 +647,7 @@ export function createMcpManager(dependencies = {}) {
448
647
  const face = {
449
648
  start, startErrors, adopt, gate: gateFace, drain, registry, runtime,
450
649
  recoverResidue,
451
- list,
650
+ list, syncSession,
452
651
  publicNames,
453
652
  mounted: mount.mounted,
454
653
  beginOperation, abortOperation, reconcileOne, removeRuntime, probe,
@@ -470,11 +669,15 @@ export function createMcpManager(dependencies = {}) {
470
669
  })
471
670
  return () => {
472
671
  try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, permit: async () => null, assertPermit: () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
672
+ // Child-group disposal normally unregisters tools first. If any public
673
+ // fact remains, unregistration was not confirmed: KEEP its deny instead
674
+ // of exposing a still-registered tool during manager teardown.
675
+ for (const agent of [...restrictions.keys()]) disposeAgentRestrictions(agent, { onlyAbsent: true })
473
676
  }
474
677
  }
475
678
  }
476
679
 
477
680
  export const name = 'weir-mcp-manager'
478
- export const inject = ['loader', 'tools', 'systemPrompt', 'mcpResources']
681
+ export const inject = ['loader', 'tools', 'systemPrompt', 'mcpResources', 'agents']
479
682
  export const apply = createMcpManager()
480
683
  export const __message = message
@@ -191,6 +191,7 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
191
191
  const options = mcpGroupOptions({ identity, generation, client: strictMcpClientConfig(input.client, { probe }) })
192
192
  const tree = treeFor(secretBearing)
193
193
  let bridgeReady = false
194
+ let cleanup = null
194
195
  let succeeded = false
195
196
  try {
196
197
  if (typeof input.beforeCreate === 'function') await input.beforeCreate({ groupId: options.id, identity, generation, probe, ephemeral: tree.ephemeral === true })
@@ -211,7 +212,7 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
211
212
  succeeded = true
212
213
  return result
213
214
  } catch (cause) {
214
- const cleanup = await disposeGroup(options.id, { tree, identity, generation, secretBearing, confirmClosed: input.confirmClosed })
215
+ cleanup = await disposeGroup(options.id, { tree, identity, generation, secretBearing, confirmClosed: input.confirmClosed })
215
216
  if (!probe && cleanup.status !== 'cleaned') mounted.set(identity, { groupId: options.id, generation, state: cleanup.status })
216
217
  const diagnostic = mcpDiagnostic(cause, { secrets: input.secrets, action: cleanup.status === 'cleaned' ? 'retry' : 'recover-residue' })
217
218
  throw new McpLifecycleError(cleanup.status === 'cleaned' ? diagnostic.code : cleanup.status, cause, {
@@ -221,7 +222,7 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
221
222
  action: cleanup.status === 'cleaned' ? 'retry' : 'recover-residue',
222
223
  })
223
224
  } finally {
224
- if (!succeeded && bridgeReady && typeof input.afterCleanup === 'function') await input.afterCleanup({ groupId: options.id, identity, generation, probe })
225
+ if (!succeeded && bridgeReady && cleanup?.status === 'cleaned' && typeof input.afterCleanup === 'function') await input.afterCleanup({ groupId: options.id, identity, generation, probe })
225
226
  if (succeeded && probe && typeof input.afterProbeReady === 'function') await input.afterProbeReady({ groupId: options.id, identity, generation })
226
227
  }
227
228
  }
@@ -218,6 +218,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
218
218
  mcpIncarnations: resolved.selection.mcpIncarnations,
219
219
  captured: null,
220
220
  })
221
+ ctx.get?.('weirMcpManager')?.syncSession?.(sessionId)
221
222
  }
222
223
  }
223
224
  return resolved.selection.skills
@@ -725,7 +726,14 @@ export function applyEngineHooks({ ctx, lifecycle, notifier, audit = createAudit
725
726
  // manager row has no managed servers to drain and the adapter reports
726
727
  // the empty truth.
727
728
  drain: createSessionDrainAdapter(() => ctx.get?.('weirMcpManager')?.drain),
728
- publishSnapshot: (sessionId, snapshot) => lifecycle?.publish?.(sessionId, snapshot),
729
+ publishSnapshot: (sessionId, snapshot) => {
730
+ lifecycle?.publish?.(sessionId, snapshot)
731
+ // Committed Apply and receipt recovery share this hook. Reconcile the
732
+ // current live agent after lifecycle publication and before the engine's
733
+ // provider invalidation/fence release; thrown failures retain the
734
+ // engine's existing snapshot-publication-degraded semantics.
735
+ ctx.get?.('weirMcpManager')?.syncSession?.(sessionId)
736
+ },
729
737
  onAccepted: (sessionId, delta, info) => notifier.queueSync(sessionId, delta, info),
730
738
  // The Apply-path convergence emission (5.2 parity with agent/created):
731
739
  // an accepted Apply re-emits the preset invalidation event so every