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.
|
|
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
|
-
//
|
|
47
|
-
//
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|