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.
- package/package.json +1 -1
- package/src/capabilities/apply-engine.js +20 -4
- package/src/capabilities/lifecycle-feed.js +47 -0
- package/src/capabilities/mcp-drain.js +6 -0
- package/src/capabilities/mcp-facade-plugin.js +14 -4
- package/src/capabilities/mcp-facade.js +1 -1
- package/src/capabilities/mcp-inventory.js +152 -0
- package/src/capabilities/mcp-manager.js +220 -171
- package/src/capabilities/mcp-mount-ownership.js +114 -0
- package/src/capabilities/mcp-mount.js +334 -82
- package/src/capabilities/mcp-operation-coordinator.js +76 -0
- package/src/capabilities/mcp-runtime.js +3 -29
- package/src/capabilities/skill-selection-plugin.js +8 -0
- package/src/file-lease/client.js +61 -3
- package/src/file-lease/negotiation.js +89 -0
- package/src/file-lease/plugin.js +25 -23
- package/src/file-lease/tools.js +71 -121
- package/src/file-lease/watch.js +225 -36
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import { isSegment } from './store/paths.js'
|
|
5
5
|
import { McpLifecycleError, mcpDiagnostic } from './mcp-diagnostics.js'
|
|
6
|
+
import {
|
|
7
|
+
MCP_GROUP_PREFIX,
|
|
8
|
+
createMcpMountOwnership,
|
|
9
|
+
isMcpResidueId,
|
|
10
|
+
isSuffixedMcpGroupId,
|
|
11
|
+
resolveMcpGroupIdentity,
|
|
12
|
+
} from './mcp-mount-ownership.js'
|
|
13
|
+
|
|
14
|
+
export { MCP_GROUP_PREFIX, isMcpResidueId } from './mcp-mount-ownership.js'
|
|
6
15
|
|
|
7
|
-
export const MCP_GROUP_PREFIX = 'weir-mcp-'
|
|
8
16
|
const DEFAULT_STARTUP_TIMEOUT = 15_000
|
|
9
17
|
const DEFAULT_CLEANUP_TIMEOUT = 15_000
|
|
10
18
|
const message = error => (error instanceof Error ? error.message : String(error))
|
|
@@ -29,9 +37,10 @@ export function detectMcpNameAmbiguity(entries) {
|
|
|
29
37
|
return conflicts.length === 0 ? { kind: 'ok' } : { kind: 'conflict', conflicts }
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
export function mcpGroupOptions({ identity, generation, client }) {
|
|
40
|
+
export function mcpGroupOptions({ identity, generation, client, groupId = null }) {
|
|
33
41
|
if (!isSegment(identity)) throw new TypeError(`invalid MCP server identity: ${JSON.stringify(identity)}`)
|
|
34
|
-
const group = `${MCP_GROUP_PREFIX}${identity}`
|
|
42
|
+
const group = groupId ?? `${MCP_GROUP_PREFIX}${identity}`
|
|
43
|
+
if (!isMcpResidueId(group)) throw new TypeError(`invalid actual MCP groupId: ${JSON.stringify(group)}`)
|
|
35
44
|
return {
|
|
36
45
|
id: group,
|
|
37
46
|
name: 'cordis:group',
|
|
@@ -44,31 +53,44 @@ export function mcpGroupOptions({ identity, generation, client }) {
|
|
|
44
53
|
}
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
* used for materialized secrets. Any other shape is unsupported, never a
|
|
53
|
-
* fallback to the write-through tree.
|
|
54
|
-
*/
|
|
55
|
-
export function resolveEphemeralMcpTree(loader) {
|
|
56
|
-
const root = loader?.root
|
|
57
|
-
const tree = root?.tree
|
|
58
|
-
if (tree?.root !== root || typeof root?.create !== 'function' || typeof root?.remove !== 'function'
|
|
59
|
-
|| typeof tree?.await !== 'function' || typeof tree?.resolve !== 'function') {
|
|
56
|
+
function nonWritingTree(tree, { scoped = false } = {}) {
|
|
57
|
+
const root = tree?.root
|
|
58
|
+
if (tree?.root?.tree !== tree || typeof root?.create !== 'function' || typeof root?.remove !== 'function'
|
|
59
|
+
|| typeof tree?.await !== 'function' || typeof tree?.resolve !== 'function'
|
|
60
|
+
|| typeof tree?.entries !== 'function' || !tree?.store || typeof tree.store !== 'object') {
|
|
60
61
|
return { kind: 'unsupported', reason: 'ephemeral-loader-unavailable' }
|
|
61
62
|
}
|
|
62
63
|
return {
|
|
63
64
|
kind: 'ok',
|
|
64
65
|
tree: {
|
|
65
66
|
ephemeral: true,
|
|
67
|
+
scoped,
|
|
68
|
+
source: tree,
|
|
69
|
+
store: tree.store,
|
|
66
70
|
create: options => root.create(options),
|
|
67
71
|
remove: id => root.remove(id),
|
|
72
|
+
entries: () => [...tree.entries()],
|
|
73
|
+
resolve: id => tree.resolve(id),
|
|
74
|
+
await: () => tree.await(),
|
|
68
75
|
},
|
|
69
76
|
}
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
/** The exact retained-revision seam proven by the real scoped-tree canary. */
|
|
80
|
+
export function resolveScopedMcpTree(tree) {
|
|
81
|
+
return nonWritingTree(tree, { scoped: true })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The stock EntryTree methods write through their tree. Its root EntryGroup
|
|
86
|
+
* methods only mutate the runtime store, which is the non-profile-writing seam
|
|
87
|
+
* used for materialized secrets. Any other shape is unsupported, never a
|
|
88
|
+
* fallback to the write-through tree.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveEphemeralMcpTree(loader) {
|
|
91
|
+
return nonWritingTree(loader?.root?.tree)
|
|
92
|
+
}
|
|
93
|
+
|
|
72
94
|
export function strictMcpClientConfig(client, { probe = false } = {}) {
|
|
73
95
|
const reconnect = probe
|
|
74
96
|
? { enabled: false, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 1 }
|
|
@@ -88,38 +110,134 @@ async function bounded(promise, timeoutMs, code, label) {
|
|
|
88
110
|
}
|
|
89
111
|
}
|
|
90
112
|
|
|
91
|
-
export function createMcpMount({
|
|
113
|
+
export function createMcpMount({
|
|
114
|
+
loader,
|
|
115
|
+
onError = () => {},
|
|
116
|
+
startupTimeoutMs = DEFAULT_STARTUP_TIMEOUT,
|
|
117
|
+
cleanupTimeoutMs = DEFAULT_CLEANUP_TIMEOUT,
|
|
118
|
+
ephemeralTree,
|
|
119
|
+
ownership = createMcpMountOwnership(),
|
|
120
|
+
} = {}) {
|
|
92
121
|
/** @type {Map<string, { groupId: string, generation: number, state?: string }>} */
|
|
93
122
|
const mounted = new Map()
|
|
123
|
+
const holder = {}
|
|
124
|
+
const scopedTrees = new Set()
|
|
94
125
|
|
|
95
|
-
const
|
|
96
|
-
const resolved = ephemeralTree === undefined ? resolveEphemeralMcpTree(loader) : ephemeralTree
|
|
97
|
-
if (resolved?.kind === 'ok' && resolved.tree) return resolved.tree
|
|
98
|
-
if (secretBearing) throw new McpLifecycleError('unsupported', new Error('secret-bearing MCP mount requires a proven non-profile-writing Loader root group'), { status: 'unsupported', action: 'use-secret-free-config' })
|
|
126
|
+
const legacyTree = () => {
|
|
99
127
|
if (typeof loader?.create !== 'function' || typeof loader?.remove !== 'function') {
|
|
100
128
|
throw new McpLifecycleError('unsupported', new Error('MCP Loader lifecycle is unavailable'), { status: 'unsupported' })
|
|
101
129
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
130
|
+
return {
|
|
131
|
+
ephemeral: false,
|
|
132
|
+
scoped: false,
|
|
133
|
+
source: loader,
|
|
134
|
+
store: loader.store,
|
|
135
|
+
create: options => loader.create(options),
|
|
136
|
+
remove: id => loader.remove(id),
|
|
137
|
+
entries: typeof loader.entries === 'function' ? () => [...loader.entries()] : null,
|
|
138
|
+
resolve: typeof loader.resolve === 'function' ? id => loader.resolve(id) : null,
|
|
139
|
+
await: typeof loader.await === 'function' ? () => loader.await() : async () => {},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const hostTree = () => {
|
|
144
|
+
const resolved = resolveEphemeralMcpTree(loader)
|
|
145
|
+
return resolved.kind === 'ok' ? resolved.tree : legacyTree()
|
|
105
146
|
}
|
|
106
147
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
148
|
+
const treeFor = secretBearing => {
|
|
149
|
+
// An explicitly retained/revision-scoped tree is mandatory. If its proven
|
|
150
|
+
// non-writing shape is unavailable, fail closed rather than silently
|
|
151
|
+
// reintroducing a shared-root same-identity collision.
|
|
152
|
+
if (ephemeralTree !== undefined && ephemeralTree?.kind !== 'ok') {
|
|
153
|
+
throw new McpLifecycleError('unsupported', new Error('revision-scoped MCP mount requires its proven non-profile-writing PresetTree'), { status: 'unsupported', action: 'recover-residue' })
|
|
110
154
|
}
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
const resolved = ephemeralTree === undefined ? resolveEphemeralMcpTree(loader) : ephemeralTree
|
|
156
|
+
if (resolved?.kind === 'ok' && resolved.tree) {
|
|
157
|
+
if (resolved.tree.scoped) scopedTrees.add(resolved.tree)
|
|
158
|
+
return resolved.tree
|
|
159
|
+
}
|
|
160
|
+
if (secretBearing) throw new McpLifecycleError('unsupported', new Error('secret-bearing MCP mount requires a proven non-profile-writing Loader root group'), { status: 'unsupported', action: 'use-secret-free-config' })
|
|
161
|
+
// Compatibility for secret-free legacy records only.
|
|
162
|
+
return legacyTree()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function entriesWithTree(tree, groupId) {
|
|
166
|
+
const localId = entry => entry?.options?.id ?? entry?.id
|
|
167
|
+
if (typeof tree?.entries === 'function') {
|
|
168
|
+
return [...tree.entries()].filter(entry => {
|
|
169
|
+
const id = localId(entry)
|
|
170
|
+
return id === groupId || String(id ?? '').startsWith(`${groupId}:`)
|
|
171
|
+
|| id === `${groupId}-facade` || id === `${groupId}-client`
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
if (tree?.store && typeof tree.store === 'object') {
|
|
175
|
+
return Object.values(tree.store).filter(entry => {
|
|
176
|
+
const id = localId(entry)
|
|
177
|
+
return id === groupId || id === `${groupId}-facade` || id === `${groupId}-client`
|
|
178
|
+
})
|
|
113
179
|
}
|
|
114
180
|
return null
|
|
115
181
|
}
|
|
116
182
|
|
|
183
|
+
function treeForGroup(groupId) {
|
|
184
|
+
return ownership.forGroup(groupId)?.tree ?? null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function residueTree(groupId) {
|
|
188
|
+
const owned = treeForGroup(groupId)
|
|
189
|
+
if (owned) return owned
|
|
190
|
+
if (!loader) return treeFor(false)
|
|
191
|
+
const host = hostTree()
|
|
192
|
+
const hostEntries = entriesWithTree(host, groupId)
|
|
193
|
+
if (hostEntries?.some(entry => (entry?.options?.id ?? entry?.id) === groupId)) return host
|
|
194
|
+
return treeFor(false)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function entriesFor(groupId, tree = null) {
|
|
198
|
+
return entriesWithTree(tree ?? residueTree(groupId), groupId)
|
|
199
|
+
}
|
|
200
|
+
|
|
117
201
|
function forgetGroup(groupId, generation) {
|
|
118
202
|
for (const [identity, entry] of mounted) {
|
|
119
203
|
if (entry.groupId === groupId && (!generation || entry.generation === generation)) mounted.delete(identity)
|
|
120
204
|
}
|
|
121
205
|
}
|
|
122
206
|
|
|
207
|
+
function track(record, promise) {
|
|
208
|
+
const pending = Promise.resolve(promise)
|
|
209
|
+
if (!record) return pending
|
|
210
|
+
record.pending.add(pending)
|
|
211
|
+
pending.then(() => record.pending.delete(pending), () => record.pending.delete(pending))
|
|
212
|
+
return pending
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function failedCleanup(groupId, status, cause, record = null) {
|
|
216
|
+
if (record) ownership.transition(record, status)
|
|
217
|
+
return {
|
|
218
|
+
status, removed: false, groupId,
|
|
219
|
+
diagnostic: mcpDiagnostic(cause, { code: status, action: 'recover-residue' }),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function finishCleaned(groupId, context, record, removed) {
|
|
224
|
+
forgetGroup(groupId, context.generation)
|
|
225
|
+
try {
|
|
226
|
+
if (typeof context.afterCleanup === 'function') {
|
|
227
|
+
await context.afterCleanup({
|
|
228
|
+
groupId, identity: context.identity, generation: context.generation,
|
|
229
|
+
probe: context.probe === true, owner: record?.owner,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
} catch (cause) {
|
|
233
|
+
return failedCleanup(groupId, 'cleanup-failed', cause, record)
|
|
234
|
+
}
|
|
235
|
+
if (record && !ownership.release(record, { provenAbsent: true })) {
|
|
236
|
+
return failedCleanup(groupId, 'cleanup-unknown', new Error('MCP mount ownership still has unresolved create/disposal work'), record)
|
|
237
|
+
}
|
|
238
|
+
return { status: 'cleaned', removed, groupId }
|
|
239
|
+
}
|
|
240
|
+
|
|
123
241
|
/**
|
|
124
242
|
* Capture and settle group/client/descendant fibers BEFORE removing rows.
|
|
125
243
|
* remove() is not the barrier: its return does not await async plugin
|
|
@@ -127,19 +245,34 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
|
|
|
127
245
|
* replacement and leaves enough state for residue recovery.
|
|
128
246
|
*/
|
|
129
247
|
async function disposeGroup(groupId, context = {}) {
|
|
130
|
-
const
|
|
131
|
-
|
|
248
|
+
const record = ownership.forGroup(groupId)
|
|
249
|
+
if (record && record.holder !== holder) {
|
|
250
|
+
return failedCleanup(groupId, 'cleanup-unknown', new Error('cannot dispose a foreign active MCP mount owner'))
|
|
251
|
+
}
|
|
252
|
+
const tree = context.tree ?? record?.tree ?? residueTree(groupId)
|
|
253
|
+
if (record?.pending.size) {
|
|
254
|
+
try {
|
|
255
|
+
await bounded(Promise.allSettled([...record.pending]), cleanupTimeoutMs, 'cleanup-timeout', `MCP pending create/disposal ${groupId}`)
|
|
256
|
+
} catch (cause) {
|
|
257
|
+
return failedCleanup(groupId, 'cleanup-unknown', cause, record)
|
|
258
|
+
}
|
|
259
|
+
if (record.pending.size > 0) return failedCleanup(groupId, 'cleanup-unknown', new Error('MCP create/disposal has not settled'), record)
|
|
260
|
+
}
|
|
261
|
+
const captured = entriesWithTree(tree, groupId)
|
|
132
262
|
if (captured?.length === 0) {
|
|
133
|
-
|
|
134
|
-
|
|
263
|
+
if (context.requireCapture === true && context.createTimedOut === true && record?.pending.size) {
|
|
264
|
+
return failedCleanup(groupId, 'cleanup-unknown', new Error('MCP create settlement is not proven'), record)
|
|
265
|
+
}
|
|
266
|
+
return finishCleaned(groupId, context, record, false)
|
|
135
267
|
}
|
|
136
|
-
if (captured === null && context.requireCapture === true) {
|
|
137
|
-
return
|
|
268
|
+
if (captured === null && (context.requireCapture === true || context.createTimedOut === true)) {
|
|
269
|
+
return failedCleanup(groupId, 'cleanup-unknown', new Error('cannot capture Loader fibers for disposal'), record)
|
|
138
270
|
}
|
|
139
271
|
const fibers = (captured ?? []).map(entry => entry?.fiber).filter(Boolean)
|
|
140
272
|
const disposals = fibers.map((fiber, index) => {
|
|
141
273
|
try {
|
|
142
|
-
|
|
274
|
+
const disposal = track(record, fiber.dispose?.())
|
|
275
|
+
return bounded(disposal, cleanupTimeoutMs, 'cleanup-timeout', `MCP disposal ${groupId}[${index}]`)
|
|
143
276
|
} catch (cause) {
|
|
144
277
|
return Promise.reject(cause)
|
|
145
278
|
}
|
|
@@ -147,105 +280,208 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
|
|
|
147
280
|
const settled = await Promise.allSettled(disposals)
|
|
148
281
|
const rejected = settled.find(result => result.status === 'rejected')
|
|
149
282
|
if (rejected) {
|
|
150
|
-
|
|
283
|
+
const status = rejected.reason?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
|
|
284
|
+
return failedCleanup(groupId, status, rejected.reason, record)
|
|
151
285
|
}
|
|
152
286
|
try {
|
|
153
|
-
|
|
287
|
+
const removing = track(record, tree.remove(groupId))
|
|
288
|
+
await bounded(removing, cleanupTimeoutMs, 'cleanup-timeout', `MCP Loader remove ${groupId}`)
|
|
154
289
|
} catch (cause) {
|
|
155
|
-
|
|
290
|
+
const status = cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
|
|
291
|
+
return failedCleanup(groupId, status, cause, record)
|
|
156
292
|
}
|
|
157
|
-
const remaining =
|
|
293
|
+
const remaining = entriesWithTree(tree, groupId)
|
|
158
294
|
let closeConfirmed = remaining === null ? true : remaining.length === 0
|
|
159
295
|
if (typeof context.confirmClosed === 'function') {
|
|
160
296
|
try {
|
|
161
297
|
closeConfirmed = closeConfirmed && await bounded(context.confirmClosed({ groupId, entries: captured, fibers }), cleanupTimeoutMs, 'cleanup-timeout', `MCP close confirmation ${groupId}`) === true
|
|
162
298
|
} catch (cause) {
|
|
163
|
-
return
|
|
299
|
+
return failedCleanup(groupId, 'cleanup-unknown', cause, record)
|
|
164
300
|
}
|
|
165
301
|
} else if (captured !== null) {
|
|
166
302
|
closeConfirmed = closeConfirmed && fibers.every(fiber => fiber.uid === null || fiber.state === 4 || fiber.state === 5)
|
|
167
303
|
}
|
|
168
304
|
if (!closeConfirmed || remaining === null && context.requireCapture === true) {
|
|
169
|
-
return
|
|
305
|
+
return failedCleanup(groupId, 'cleanup-unknown', new Error('MCP transport closure or Loader row removal could not be confirmed'), record)
|
|
170
306
|
}
|
|
171
|
-
|
|
172
|
-
return { status: 'cleaned', removed: true, groupId }
|
|
307
|
+
return finishCleaned(groupId, context, record, true)
|
|
173
308
|
}
|
|
174
309
|
|
|
175
|
-
|
|
176
|
-
const
|
|
310
|
+
function identityForGroup(groupId) {
|
|
311
|
+
const entries = entriesFor(groupId)
|
|
312
|
+
const exact = entries?.find(entry => (entry?.options?.id ?? entry?.id) === groupId)
|
|
313
|
+
// The manager's own plugin ID is residue-shaped but is not a Loader group.
|
|
314
|
+
// Never claim/dispose a standalone row merely because its ID has the prefix.
|
|
315
|
+
if (exact && exact.options?.group !== true) return null
|
|
316
|
+
const row = exact ?? { id: groupId }
|
|
317
|
+
return resolveMcpGroupIdentity({ groupId, mounted, ownership, row })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function sweepResidueDetailed(candidateIds, context = {}) {
|
|
321
|
+
const swept = []
|
|
322
|
+
const blocked = []
|
|
323
|
+
const foreign = []
|
|
324
|
+
const unresolved = []
|
|
177
325
|
for (const id of candidateIds.filter(isMcpResidueId)) {
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
326
|
+
let record = ownership.forGroup(id)
|
|
327
|
+
if (record && record.holder !== holder) {
|
|
328
|
+
foreign.push(id)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
const resolved = identityForGroup(id)
|
|
332
|
+
if (!resolved) {
|
|
333
|
+
unresolved.push(id)
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const tree = record?.tree ?? residueTree(id)
|
|
337
|
+
if (!record) {
|
|
338
|
+
const captured = entriesWithTree(tree, id)
|
|
339
|
+
const activeUnknown = captured?.some(entry => {
|
|
340
|
+
const entryId = entry?.options?.id ?? entry?.id
|
|
341
|
+
if (entryId === id) return false // the container may be active while every child is disabled residue
|
|
342
|
+
const fiber = entry?.fiber
|
|
343
|
+
return fiber && fiber.uid !== null && fiber.state !== 4 && fiber.state !== 5
|
|
344
|
+
})
|
|
345
|
+
// A facade/config row without a module-realm owner may belong to another
|
|
346
|
+
// loaded bundle instance. Active cross-module ownership is unprovable:
|
|
347
|
+
// retain it instead of sweeping a foreign live client.
|
|
348
|
+
if (activeUnknown) {
|
|
349
|
+
foreign.push(id)
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
record = ownership.claimResidue({ identity: resolved.identity, groupId: id, holder, tree })
|
|
353
|
+
}
|
|
354
|
+
if (!record) {
|
|
355
|
+
foreign.push(id)
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
const cleanup = await disposeGroup(id, {
|
|
359
|
+
tree, identity: resolved.identity, secretBearing: false,
|
|
360
|
+
afterCleanup: context.afterCleanup,
|
|
361
|
+
requireCapture: isSuffixedMcpGroupId(id) || tree.scoped === true,
|
|
362
|
+
})
|
|
363
|
+
if (cleanup.status === 'cleaned') swept.push(id)
|
|
364
|
+
else {
|
|
365
|
+
blocked.push(id)
|
|
366
|
+
onError(`residue-sweep-${cleanup.status}:${id}`, cleanup.diagnostic)
|
|
367
|
+
}
|
|
181
368
|
}
|
|
182
|
-
return
|
|
369
|
+
return { swept, blocked, foreign, unresolved }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function sweepResidue(candidateIds, context = {}) {
|
|
373
|
+
return (await sweepResidueDetailed(candidateIds, context)).swept
|
|
183
374
|
}
|
|
184
375
|
|
|
185
376
|
async function mountOne(input) {
|
|
186
377
|
const { identity, generation, fiberOf, probe = false, secretBearing = false } = input
|
|
378
|
+
if (!isSegment(identity)) throw new TypeError(`invalid MCP server identity: ${JSON.stringify(identity)}`)
|
|
187
379
|
const previous = mounted.get(identity)
|
|
188
380
|
if (!probe && (previous?.state === 'cleanup-failed' || previous?.state === 'cleanup-unknown')) {
|
|
189
381
|
throw new McpLifecycleError(previous.state, new Error(`same-name MCP replacement is blocked while ${identity} is ${previous.state}`), { status: previous.state, action: 'recover-residue' })
|
|
190
382
|
}
|
|
191
|
-
const options = mcpGroupOptions({ identity, generation, client: strictMcpClientConfig(input.client, { probe }) })
|
|
192
383
|
const tree = treeFor(secretBearing)
|
|
384
|
+
let record = null
|
|
385
|
+
let options = null
|
|
193
386
|
let bridgeReady = false
|
|
387
|
+
let createStarted = false
|
|
194
388
|
let cleanup = null
|
|
195
389
|
let succeeded = false
|
|
196
390
|
try {
|
|
197
|
-
|
|
391
|
+
record = ownership.claim(identity, {
|
|
392
|
+
holder, tree,
|
|
393
|
+
available: groupId => {
|
|
394
|
+
const existing = entriesWithTree(tree, groupId)
|
|
395
|
+
if (existing?.length) return false
|
|
396
|
+
if (tree.scoped && loader) {
|
|
397
|
+
const hostEntries = entriesWithTree(hostTree(), groupId)
|
|
398
|
+
if (hostEntries?.length) return false
|
|
399
|
+
}
|
|
400
|
+
return true
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
options = mcpGroupOptions({ identity, generation, groupId: record.groupId, client: strictMcpClientConfig(input.client, { probe }) })
|
|
404
|
+
const attempt = () => ({ groupId: options.id, identity, generation, probe, ephemeral: tree.ephemeral === true, owner: record.owner })
|
|
405
|
+
if (typeof input.beforeCreate === 'function') await input.beforeCreate(attempt())
|
|
198
406
|
bridgeReady = true
|
|
199
|
-
|
|
200
|
-
|
|
407
|
+
// A foreign module realm may have claimed the group while an async
|
|
408
|
+
// beforeCreate callback yielded. Never dispose that row as our residue.
|
|
409
|
+
const collision = entriesWithTree(tree, options.id)
|
|
410
|
+
if (collision?.length) throw Object.assign(new Error(`MCP Loader group ${options.id} was claimed by another owner`), { code: 'duplicate-mcp-group-owner' })
|
|
411
|
+
createStarted = true
|
|
412
|
+
// The claim above occupies the actual ID synchronously before this call;
|
|
413
|
+
// its original promise remains tracked even if the bounded wait times out.
|
|
414
|
+
const creating = track(record, tree.create(options))
|
|
415
|
+
await bounded(creating, startupTimeoutMs, 'startup-timeout', `MCP Loader create ${identity}`)
|
|
416
|
+
await bounded(tree.await(), startupTimeoutMs, 'startup-timeout', `MCP Loader await ${identity}`)
|
|
201
417
|
const fiber = typeof fiberOf === 'function'
|
|
202
|
-
? await bounded(fiberOf(options.id), startupTimeoutMs, 'startup-timeout', `MCP client readiness ${identity}`)
|
|
418
|
+
? await bounded(fiberOf(options.id, { tree, owner: record.owner }), startupTimeoutMs, 'startup-timeout', `MCP client readiness ${identity}`)
|
|
203
419
|
: { state: 'running' }
|
|
204
420
|
if (fiber === null || fiber === undefined) throw new Error('mcp-mount-error: client entry has no fiber after loader.await()')
|
|
205
421
|
if (fiber.state !== undefined && !['running', 'active', 2].includes(fiber.state)) throw new Error(`mcp-mount-error: client fiber is not ready (state ${String(fiber.state)})`)
|
|
206
|
-
const clientEntry =
|
|
422
|
+
const clientEntry = tree.store?.[`${options.id}-client`]
|
|
207
423
|
if (clientEntry?.error) throw clientEntry.error
|
|
208
|
-
if (typeof input.readback === 'function') await input.readback(
|
|
209
|
-
const discovery = typeof input.discovery === 'function' ? await input.discovery(
|
|
424
|
+
if (typeof input.readback === 'function') await input.readback(attempt())
|
|
425
|
+
const discovery = typeof input.discovery === 'function' ? await input.discovery(attempt()) : { toolCount: null, toolNamesPreview: [], truncated: false }
|
|
210
426
|
const result = { status: 'mounted', groupId: options.id, identity, generation, probe, discovery, execution: input.execution ?? null }
|
|
211
427
|
if (!probe) mounted.set(identity, { groupId: options.id, generation, state: 'mounted' })
|
|
428
|
+
ownership.transition(record, 'mounted')
|
|
212
429
|
succeeded = true
|
|
213
430
|
return result
|
|
214
431
|
} catch (cause) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
432
|
+
if (!createStarted && record) {
|
|
433
|
+
cleanup = await finishCleaned(options.id, {
|
|
434
|
+
identity, generation, probe,
|
|
435
|
+
afterCleanup: bridgeReady ? input.afterCleanup : null,
|
|
436
|
+
}, record, false)
|
|
437
|
+
} else if (options) {
|
|
438
|
+
cleanup = await disposeGroup(options.id, {
|
|
439
|
+
tree, identity, generation, probe, secretBearing,
|
|
440
|
+
confirmClosed: input.confirmClosed,
|
|
441
|
+
createTimedOut: cause?.code === 'startup-timeout' && createStarted,
|
|
442
|
+
requireCapture: cause?.code === 'startup-timeout' && createStarted,
|
|
443
|
+
afterCleanup: bridgeReady ? input.afterCleanup : null,
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
if (!probe && cleanup && cleanup.status !== 'cleaned') mounted.set(identity, { groupId: options.id, generation, state: cleanup.status })
|
|
447
|
+
const diagnostic = mcpDiagnostic(cause, { secrets: input.secrets, action: cleanup?.status === 'cleaned' ? 'retry' : 'recover-residue' })
|
|
448
|
+
throw new McpLifecycleError(cleanup?.status === 'cleaned' ? diagnostic.code : cleanup?.status ?? 'mount-failed', cause, {
|
|
449
|
+
status: cleanup?.status === 'cleaned' ? (probe ? 'probe-failed' : 'mount-failed') : cleanup?.status ?? 'mount-failed',
|
|
220
450
|
secrets: input.secrets,
|
|
221
451
|
cleanup,
|
|
222
|
-
action: cleanup
|
|
452
|
+
action: cleanup?.status === 'cleaned' ? 'retry' : 'recover-residue',
|
|
223
453
|
})
|
|
224
454
|
} finally {
|
|
225
|
-
if (
|
|
226
|
-
if (succeeded && probe && typeof input.afterProbeReady === 'function') await input.afterProbeReady({ groupId: options.id, identity, generation })
|
|
455
|
+
if (succeeded && probe && typeof input.afterProbeReady === 'function') await input.afterProbeReady({ groupId: options.id, identity, generation, owner: record.owner })
|
|
227
456
|
}
|
|
228
457
|
}
|
|
229
458
|
|
|
230
459
|
async function unmountOne(identity, guards = {}, context = {}) {
|
|
231
460
|
const entry = mounted.get(identity)
|
|
232
|
-
if (!entry
|
|
233
|
-
|
|
234
|
-
|
|
461
|
+
if (!entry) {
|
|
462
|
+
if (context.required === true) return { status: 'cleanup-unknown', removed: false, reason: 'mount-record-missing' }
|
|
463
|
+
// Ordinary cleanup is not residue recovery: without our exact mount record
|
|
464
|
+
// there is no authority to infer and delete a possibly reused legacy ID.
|
|
465
|
+
return { status: 'cleaned', removed: false, groupId: null }
|
|
466
|
+
}
|
|
467
|
+
const groupId = entry.groupId
|
|
468
|
+
const cleanup = await disposeGroup(groupId, {
|
|
469
|
+
...context, identity, generation: entry.generation,
|
|
470
|
+
tree: context.tree ?? treeForGroup(groupId) ?? treeFor(Boolean(context.secretBearing)),
|
|
471
|
+
})
|
|
235
472
|
if (cleanup.status !== 'cleaned') {
|
|
236
|
-
|
|
473
|
+
entry.state = cleanup.status
|
|
237
474
|
return cleanup
|
|
238
475
|
}
|
|
239
476
|
if (typeof guards.alive === 'function' && typeof guards.remount === 'function') {
|
|
240
|
-
for (const [other] of mounted) {
|
|
241
|
-
|
|
242
|
-
if (!guards.alive().includes(siblingGroup)) {
|
|
477
|
+
for (const [other, sibling] of mounted) {
|
|
478
|
+
if (!guards.alive().includes(sibling.groupId)) {
|
|
243
479
|
onError(`sibling-vanished-after-unmount:${other}`)
|
|
244
480
|
await guards.remount(other)
|
|
245
481
|
}
|
|
246
482
|
}
|
|
247
483
|
}
|
|
248
|
-
return { status: 'cleaned', removed:
|
|
484
|
+
return { status: 'cleaned', removed: true, groupId }
|
|
249
485
|
}
|
|
250
486
|
|
|
251
487
|
async function probe(input) {
|
|
@@ -260,14 +496,27 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
|
|
|
260
496
|
if (cause instanceof McpLifecycleError) throw cause
|
|
261
497
|
throw new McpLifecycleError('probe-failed', cause, { status: 'probe-failed', secrets: input.secrets })
|
|
262
498
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
499
|
+
const record = ownership.forGroup(ready.groupId)
|
|
500
|
+
const cleanup = await disposeGroup(ready.groupId, {
|
|
501
|
+
tree: record?.tree ?? treeFor(Boolean(input.secretBearing)), identity, generation: ready.generation,
|
|
502
|
+
probe: true, secretBearing: Boolean(input.secretBearing), confirmClosed: input.confirmClosed,
|
|
503
|
+
requireCapture: true, afterCleanup: input.afterCleanup,
|
|
504
|
+
})
|
|
505
|
+
if (cleanup.status !== 'cleaned') return { status: cleanup.status, identity, generation: ready.generation, cleanup, diagnostic: cleanup.diagnostic }
|
|
506
|
+
return { ...ready, status: 'mounted', cleanup }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function listRows() {
|
|
510
|
+
const rows = []
|
|
511
|
+
for (const tree of scopedTrees) {
|
|
512
|
+
for (const entry of tree.entries()) {
|
|
513
|
+
const options = entry?.options ?? {}
|
|
514
|
+
const id = options.id ?? entry?.id
|
|
515
|
+
if (!isMcpResidueId(id) || options.group !== true) continue
|
|
516
|
+
rows.push({ id, name: options.name ?? null, config: options.config ?? {} })
|
|
517
|
+
}
|
|
270
518
|
}
|
|
519
|
+
return rows
|
|
271
520
|
}
|
|
272
521
|
|
|
273
522
|
const queues = new Map()
|
|
@@ -282,5 +531,8 @@ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs =
|
|
|
282
531
|
const mount = input => input?.probe ? mountOne(input) : serialize(input.identity, () => mountOne(input))
|
|
283
532
|
const unmount = (identity, guards = {}, context = {}) => serialize(identity, () => unmountOne(identity, guards, context))
|
|
284
533
|
|
|
285
|
-
return {
|
|
534
|
+
return {
|
|
535
|
+
mount, unmount, probe, sweepResidue, sweepResidueDetailed, disposeGroup,
|
|
536
|
+
mounted, entriesFor, identityForGroup, listRows, treeForGroup, ownership, message,
|
|
537
|
+
}
|
|
286
538
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// One manager-owned Operation Coordination module. The per-identity FIFO,
|
|
2
|
+
// runtime fence/token and admission-drain transitions are inseparable here;
|
|
3
|
+
// mount/probe/registry/CAS work remains in their existing modules.
|
|
4
|
+
// The independent createMcpMount Disposal Barrier deliberately stays inner.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-identity FIFO for operations that open a runtime fence. A rejected task
|
|
8
|
+
* does not poison its identity, and identities have independent tails. The
|
|
9
|
+
* queue is not reentrant: a caller already in a slot passes its fence token to
|
|
10
|
+
* the inner manager operation instead of serializing the same identity again.
|
|
11
|
+
*/
|
|
12
|
+
export function createMcpIdentityLock() {
|
|
13
|
+
const tails = new Map()
|
|
14
|
+
return function serialize(identity, task) {
|
|
15
|
+
const previous = tails.get(identity) ?? Promise.resolve()
|
|
16
|
+
const run = previous.then(() => task())
|
|
17
|
+
const tail = run.then(() => {}, () => {})
|
|
18
|
+
tails.set(identity, tail)
|
|
19
|
+
void tail.then(() => { if (tails.get(identity) === tail) tails.delete(identity) })
|
|
20
|
+
return run
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const fallbackLocks = new WeakMap()
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the manager's own queue. The WeakMap fallback only supports legacy
|
|
27
|
+
* test/older faces without serialize; a real manager always exposes its one
|
|
28
|
+
* coordinator queue so this adapter cannot silently fork production ordering.
|
|
29
|
+
*/
|
|
30
|
+
export function mcpIdentityLockFor(manager) {
|
|
31
|
+
if (typeof manager?.serialize === 'function') return (identity, task) => manager.serialize(identity, task)
|
|
32
|
+
if (!manager || (typeof manager !== 'object' && typeof manager !== 'function')) return createMcpIdentityLock()
|
|
33
|
+
let lock = fallbackLocks.get(manager)
|
|
34
|
+
if (!lock) { lock = createMcpIdentityLock(); fallbackLocks.set(manager, lock) }
|
|
35
|
+
return lock
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{
|
|
40
|
+
* runtime: ReturnType<typeof import('./mcp-runtime.js').createMcpRuntime>,
|
|
41
|
+
* drain: { closeAll: (identity: string) => void, reopenAll: (identity: string) => void },
|
|
42
|
+
* transition?: <T>(identity: string, change: () => T) => T,
|
|
43
|
+
* }} options
|
|
44
|
+
*/
|
|
45
|
+
export function createMcpOperationCoordinator({ runtime, drain, transition = (_identity, change) => change() }) {
|
|
46
|
+
const serialize = createMcpIdentityLock()
|
|
47
|
+
|
|
48
|
+
function beginOperation(identity, phase = 'replacing', details = {}) {
|
|
49
|
+
return transition(identity, () => {
|
|
50
|
+
// Both transitions are synchronous and precede every async operation
|
|
51
|
+
// step. No registry, credential or probe await can precede this fence.
|
|
52
|
+
const token = runtime.begin(identity, { phase, ...details })
|
|
53
|
+
drain.closeAll(identity)
|
|
54
|
+
return token
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function finishOperation(token, phase, details = {}) {
|
|
59
|
+
const finished = transition(token.identity, () => runtime.finish(token, phase, details))
|
|
60
|
+
// A stale finish cannot report mounted through runtime.finish and must not
|
|
61
|
+
// reopen admission for whichever operation now owns the identity.
|
|
62
|
+
if (finished && phase === 'mounted') drain.reopenAll(token.identity)
|
|
63
|
+
return finished
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function abortOperation(token) {
|
|
67
|
+
const previousPhase = token.previous?.phase
|
|
68
|
+
return transition(token.identity, () => {
|
|
69
|
+
const restored = runtime.restore(token)
|
|
70
|
+
if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
|
|
71
|
+
return restored
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { serialize, beginOperation, finishOperation, abortOperation }
|
|
76
|
+
}
|