weir-harness 1.2.3 → 2.1.0

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.
Files changed (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -1,30 +1,14 @@
1
- // Task 8.2 of the session-capability-manager change: per-server runtime
2
- // mounting. Every Weir-managed MCP server gets its own isolated
3
- // cordis:group — the Weir proxy facade plus the stock
4
- // `@deepseek-ai/dsh-mcp-client` (referenced by package-name string, never
5
- // statically imported) — created and destroyed at runtime through the
6
- // host-supported loader API (1.14 PASS-WITH-LIMITS):
7
- // loader.create(options) → await loader.await() → judge readiness by the
8
- // child client's fiber state (NEVER at create() return — the group's
9
- // children are created asynchronously, the 1.14 spike misread exactly this).
10
- // Startup also removes `weir-mcp-*` residue rows that a crashed previous
11
- // run persisted into the profile config (loader.create/remove write through
12
- // to the root tree, CITED dsh-app-boot/lib/index.js:245).
1
+ // Managed MCP Loader lifecycle. Registry configuration is the authority; the
2
+ // stock client is named by string and never statically imported.
3
+ import { randomUUID } from 'node:crypto'
13
4
  import { isSegment } from './store/paths.js'
5
+ import { McpLifecycleError, mcpDiagnostic } from './mcp-diagnostics.js'
14
6
 
15
7
  export const MCP_GROUP_PREFIX = 'weir-mcp-'
16
-
8
+ const DEFAULT_STARTUP_TIMEOUT = 15_000
9
+ const DEFAULT_CLEANUP_TIMEOUT = 15_000
17
10
  const message = error => (error instanceof Error ? error.message : String(error))
18
11
 
19
- /**
20
- * Composition-time name-ambiguity detection (8.2, first implemented here —
21
- * only a suggestion in G3b). Two shapes, both fail closed:
22
- * - one configured name is another's `__` prefix (mcp__alpha__x__echo could
23
- * come from `alpha` or `alpha__x` — collide is EXECUTED in G3b);
24
- * - two servers declare the same public tool name.
25
- * @param {Array<{ identity: string, publicNames?: string[] }>} entries
26
- * @returns {{ kind: 'ok' } | { kind: 'conflict', conflicts: Array<{ kind: string, a: string, b: string, reason: string }> }}
27
- */
28
12
  export function detectMcpNameAmbiguity(entries) {
29
13
  const conflicts = []
30
14
  const identities = entries.map(entry => entry.identity)
@@ -38,22 +22,13 @@ export function detectMcpNameAmbiguity(entries) {
38
22
  const declared = new Map()
39
23
  for (const entry of entries) {
40
24
  for (const name of entry.publicNames ?? []) {
41
- if (declared.has(name)) {
42
- conflicts.push({ kind: 'duplicate-public-name', a: declared.get(name), b: entry.identity, reason: `public tool name "${name}" is declared by both "${declared.get(name)}" and "${entry.identity}"` })
43
- } else {
44
- declared.set(name, entry.identity)
45
- }
25
+ if (declared.has(name)) conflicts.push({ kind: 'duplicate-public-name', a: declared.get(name), b: entry.identity, reason: `public tool name "${name}" is declared by both "${declared.get(name)}" and "${entry.identity}"` })
26
+ else declared.set(name, entry.identity)
46
27
  }
47
28
  }
48
29
  return conflicts.length === 0 ? { kind: 'ok' } : { kind: 'conflict', conflicts }
49
30
  }
50
31
 
51
- /**
52
- * The isolated-group options for one managed server (plain data handed to
53
- * loader.create — the client is named by package string so nothing
54
- * `@deepseek-ai/*` is statically imported).
55
- * @param {{ identity: string, generation: number, client: unknown }} input
56
- */
57
32
  export function mcpGroupOptions({ identity, generation, client }) {
58
33
  if (!isSegment(identity)) throw new TypeError(`invalid MCP server identity: ${JSON.stringify(identity)}`)
59
34
  const group = `${MCP_GROUP_PREFIX}${identity}`
@@ -69,77 +44,242 @@ export function mcpGroupOptions({ identity, generation, client }) {
69
44
  }
70
45
  }
71
46
 
72
- /** Ids of `weir-mcp-*` group entries (crash residue or our own mounts). */
73
47
  export const isMcpResidueId = id => typeof id === 'string' && id.startsWith(MCP_GROUP_PREFIX) && !id.endsWith('-facade') && !id.endsWith('-client')
74
48
 
75
49
  /**
76
- * @param {{ loader: { create(options: unknown): Promise<unknown>, remove(id: string): Promise<unknown>, await(): Promise<unknown>, resolve?(id: string): unknown },
77
- * onError?: (reason: string, cause?: unknown) => void }} options
50
+ * The stock EntryTree methods write through their tree. Its root EntryGroup
51
+ * methods only mutate the runtime store, which is the non-profile-writing seam
52
+ * used for materialized secrets. Any other shape is unsupported, never a
53
+ * fallback to the write-through tree.
78
54
  */
79
- export function createMcpMount({ loader, onError = () => {} }) {
80
- /** @type {Map<string, { groupId: string, generation: number }>} */
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') {
60
+ return { kind: 'unsupported', reason: 'ephemeral-loader-unavailable' }
61
+ }
62
+ return {
63
+ kind: 'ok',
64
+ tree: {
65
+ ephemeral: true,
66
+ create: options => root.create(options),
67
+ remove: id => root.remove(id),
68
+ },
69
+ }
70
+ }
71
+
72
+ export function strictMcpClientConfig(client, { probe = false } = {}) {
73
+ const reconnect = probe
74
+ ? { enabled: false, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 1 }
75
+ : client?.reconnect
76
+ return { ...client, failOnStartupError: true, ...(reconnect ? { reconnect } : {}) }
77
+ }
78
+
79
+ async function bounded(promise, timeoutMs, code, label) {
80
+ let timer
81
+ const timeout = new Promise((_, reject) => {
82
+ timer = setTimeout(() => reject(Object.assign(new Error(`${label} timed out after ${timeoutMs}ms`), { code })), timeoutMs)
83
+ })
84
+ try {
85
+ return await Promise.race([Promise.resolve(promise), timeout])
86
+ } finally {
87
+ clearTimeout(timer)
88
+ }
89
+ }
90
+
91
+ export function createMcpMount({ loader, onError = () => {}, startupTimeoutMs = DEFAULT_STARTUP_TIMEOUT, cleanupTimeoutMs = DEFAULT_CLEANUP_TIMEOUT, ephemeralTree } = {}) {
92
+ /** @type {Map<string, { groupId: string, generation: number, state?: string }>} */
81
93
  const mounted = new Map()
82
94
 
95
+ const treeFor = secretBearing => {
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' })
99
+ if (typeof loader?.create !== 'function' || typeof loader?.remove !== 'function') {
100
+ throw new McpLifecycleError('unsupported', new Error('MCP Loader lifecycle is unavailable'), { status: 'unsupported' })
101
+ }
102
+ // Compatibility for secret-free legacy records only. New secret-bearing
103
+ // paths can never reach this write-through branch.
104
+ return { ephemeral: false, create: options => loader.create(options), remove: id => loader.remove(id) }
105
+ }
106
+
107
+ function entriesFor(groupId) {
108
+ if (typeof loader?.entries === 'function') {
109
+ return [...loader.entries()].filter(entry => entry?.id === groupId || String(entry?.id ?? '').startsWith(`${groupId}:`))
110
+ }
111
+ if (loader?.store && typeof loader.store === 'object') {
112
+ return Object.values(loader.store).filter(entry => entry?.id === groupId || entry?.options?.id === groupId || String(entry?.options?.id ?? '').startsWith(`${groupId}-`))
113
+ }
114
+ return null
115
+ }
116
+
117
+ function forgetGroup(groupId, generation) {
118
+ for (const [identity, entry] of mounted) {
119
+ if (entry.groupId === groupId && (!generation || entry.generation === generation)) mounted.delete(identity)
120
+ }
121
+ }
122
+
83
123
  /**
84
- * Remove `weir-mcp-*` residue groups (crash leftovers that the host
85
- * would mount directly on the next boot). Returns the removed ids.
86
- * @param {string[]} candidateIds - entry ids visible in the root tree
124
+ * Capture and settle group/client/descendant fibers BEFORE removing rows.
125
+ * remove() is not the barrier: its return does not await async plugin
126
+ * disposal. A rejected disposal or an unconfirmed close blocks same-name
127
+ * replacement and leaves enough state for residue recovery.
87
128
  */
88
- async function sweepResidue(candidateIds) {
89
- const removed = []
90
- for (const id of candidateIds.filter(isMcpResidueId)) {
129
+ async function disposeGroup(groupId, context = {}) {
130
+ const tree = context.tree ?? treeFor(Boolean(context.secretBearing))
131
+ const captured = entriesFor(groupId)
132
+ if (captured?.length === 0) {
133
+ forgetGroup(groupId, context.generation)
134
+ return { status: 'cleaned', removed: false, groupId }
135
+ }
136
+ if (captured === null && context.requireCapture === true) {
137
+ return { status: 'cleanup-unknown', removed: false, groupId, diagnostic: mcpDiagnostic(new Error('cannot capture Loader fibers for disposal'), { code: 'cleanup-unknown', action: 'recover-residue' }) }
138
+ }
139
+ const fibers = (captured ?? []).map(entry => entry?.fiber).filter(Boolean)
140
+ const disposals = fibers.map((fiber, index) => {
141
+ try {
142
+ return bounded(fiber.dispose?.(), cleanupTimeoutMs, 'cleanup-timeout', `MCP disposal ${groupId}[${index}]`)
143
+ } catch (cause) {
144
+ return Promise.reject(cause)
145
+ }
146
+ })
147
+ const settled = await Promise.allSettled(disposals)
148
+ const rejected = settled.find(result => result.status === 'rejected')
149
+ if (rejected) {
150
+ return { status: rejected.reason?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed', removed: false, groupId, diagnostic: mcpDiagnostic(rejected.reason, { code: rejected.reason?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed', action: 'recover-residue' }) }
151
+ }
152
+ try {
153
+ await bounded(tree.remove(groupId), cleanupTimeoutMs, 'cleanup-timeout', `MCP Loader remove ${groupId}`)
154
+ } catch (cause) {
155
+ return { status: cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed', removed: false, groupId, diagnostic: mcpDiagnostic(cause, { code: cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed', action: 'recover-residue' }) }
156
+ }
157
+ const remaining = entriesFor(groupId)
158
+ let closeConfirmed = remaining === null ? true : remaining.length === 0
159
+ if (typeof context.confirmClosed === 'function') {
91
160
  try {
92
- await loader.remove(id)
93
- removed.push(id)
161
+ closeConfirmed = closeConfirmed && await bounded(context.confirmClosed({ groupId, entries: captured, fibers }), cleanupTimeoutMs, 'cleanup-timeout', `MCP close confirmation ${groupId}`) === true
94
162
  } catch (cause) {
95
- onError(`residue-sweep-failed:${id}`, cause)
163
+ return { status: 'cleanup-unknown', removed: false, groupId, diagnostic: mcpDiagnostic(cause, { code: 'cleanup-unknown', action: 'recover-residue' }) }
96
164
  }
165
+ } else if (captured !== null) {
166
+ closeConfirmed = closeConfirmed && fibers.every(fiber => fiber.uid === null || fiber.state === 4 || fiber.state === 5)
167
+ }
168
+ if (!closeConfirmed || remaining === null && context.requireCapture === true) {
169
+ return { status: 'cleanup-unknown', removed: false, groupId, diagnostic: mcpDiagnostic(new Error('MCP transport closure or Loader row removal could not be confirmed'), { code: 'cleanup-unknown', action: 'recover-residue' }) }
170
+ }
171
+ forgetGroup(groupId, context.generation)
172
+ return { status: 'cleaned', removed: true, groupId }
173
+ }
174
+
175
+ async function sweepResidue(candidateIds) {
176
+ const removed = []
177
+ for (const id of candidateIds.filter(isMcpResidueId)) {
178
+ const cleanup = await disposeGroup(id, { tree: treeFor(false), identity: id.slice(MCP_GROUP_PREFIX.length) })
179
+ if (cleanup.status === 'cleaned') removed.push(id)
180
+ else onError(`residue-sweep-${cleanup.status}:${id}`, cleanup.diagnostic)
97
181
  }
98
182
  return removed
99
183
  }
100
184
 
101
- /**
102
- * Mount one managed server. Readiness is judged AFTER `loader.await()` by
103
- * the child client's fiber state (1.14 must-fix), never at create() return.
104
- * @param {{ identity: string, generation: number, client: unknown, fiberOf?: (groupId: string) => { state?: string } | null }} input
105
- */
106
- async function mount({ identity, generation, client, fiberOf }) {
107
- const options = mcpGroupOptions({ identity, generation, client })
108
- await loader.create(options)
109
- await loader.await()
110
- const fiber = typeof fiberOf === 'function' ? await fiberOf(options.id) : { state: 'running' }
111
- if (fiber === null) {
112
- const reason = `mcp-mount-error: client entry has no fiber after loader.await()`
113
- onError(reason)
114
- throw new Error(reason)
115
- }
116
- mounted.set(identity, { groupId: options.id, generation })
117
- return { groupId: options.id }
185
+ async function mountOne(input) {
186
+ const { identity, generation, fiberOf, probe = false, secretBearing = false } = input
187
+ const previous = mounted.get(identity)
188
+ if (!probe && (previous?.state === 'cleanup-failed' || previous?.state === 'cleanup-unknown')) {
189
+ 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
+ }
191
+ const options = mcpGroupOptions({ identity, generation, client: strictMcpClientConfig(input.client, { probe }) })
192
+ const tree = treeFor(secretBearing)
193
+ let bridgeReady = false
194
+ let succeeded = false
195
+ try {
196
+ if (typeof input.beforeCreate === 'function') await input.beforeCreate({ groupId: options.id, identity, generation, probe, ephemeral: tree.ephemeral === true })
197
+ bridgeReady = true
198
+ await bounded(tree.create(options), startupTimeoutMs, 'startup-timeout', `MCP Loader create ${identity}`)
199
+ await bounded(loader.await(), startupTimeoutMs, 'startup-timeout', `MCP Loader await ${identity}`)
200
+ const fiber = typeof fiberOf === 'function'
201
+ ? await bounded(fiberOf(options.id), startupTimeoutMs, 'startup-timeout', `MCP client readiness ${identity}`)
202
+ : { state: 'running' }
203
+ if (fiber === null || fiber === undefined) throw new Error('mcp-mount-error: client entry has no fiber after loader.await()')
204
+ 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)})`)
205
+ const clientEntry = loader?.store?.[`${options.id}-client`]
206
+ if (clientEntry?.error) throw clientEntry.error
207
+ if (typeof input.readback === 'function') await input.readback({ groupId: options.id, identity, generation, probe })
208
+ const discovery = typeof input.discovery === 'function' ? await input.discovery({ groupId: options.id, identity, generation, probe }) : { toolCount: null, toolNamesPreview: [], truncated: false }
209
+ const result = { status: 'mounted', groupId: options.id, identity, generation, probe, discovery, execution: input.execution ?? null }
210
+ if (!probe) mounted.set(identity, { groupId: options.id, generation, state: 'mounted' })
211
+ succeeded = true
212
+ return result
213
+ } catch (cause) {
214
+ const cleanup = await disposeGroup(options.id, { tree, identity, generation, secretBearing, confirmClosed: input.confirmClosed })
215
+ if (!probe && cleanup.status !== 'cleaned') mounted.set(identity, { groupId: options.id, generation, state: cleanup.status })
216
+ const diagnostic = mcpDiagnostic(cause, { secrets: input.secrets, action: cleanup.status === 'cleaned' ? 'retry' : 'recover-residue' })
217
+ throw new McpLifecycleError(cleanup.status === 'cleaned' ? diagnostic.code : cleanup.status, cause, {
218
+ status: cleanup.status === 'cleaned' ? (probe ? 'probe-failed' : 'mount-failed') : cleanup.status,
219
+ secrets: input.secrets,
220
+ cleanup,
221
+ action: cleanup.status === 'cleaned' ? 'retry' : 'recover-residue',
222
+ })
223
+ } finally {
224
+ if (!succeeded && bridgeReady && typeof input.afterCleanup === 'function') await input.afterCleanup({ groupId: options.id, identity, generation, probe })
225
+ if (succeeded && probe && typeof input.afterProbeReady === 'function') await input.afterProbeReady({ groupId: options.id, identity, generation })
226
+ }
118
227
  }
119
228
 
120
- /**
121
- * Unmount one server WITHOUT touching the others (1.14 S3 anomaly: a
122
- * removal once made a sibling vanish — after remove() every remaining
123
- * entry is verified and remounted through remount() if missing).
124
- * @param {string} identity
125
- * @param {{ alive?: () => string[], remount?: (identity: string) => Promise<void> }} [guards]
126
- */
127
- async function unmount(identity, guards = {}) {
229
+ async function unmountOne(identity, guards = {}, context = {}) {
128
230
  const entry = mounted.get(identity)
129
- if (!entry) return { removed: false }
130
- await loader.remove(entry.groupId)
131
- mounted.delete(identity)
231
+ if (!entry && context.required === true) return { status: 'cleanup-unknown', removed: false, reason: 'mount-record-missing' }
232
+ const groupId = entry?.groupId ?? `${MCP_GROUP_PREFIX}${identity}`
233
+ const cleanup = await disposeGroup(groupId, { ...context, identity, generation: entry?.generation, tree: context.tree ?? treeFor(Boolean(context.secretBearing)) })
234
+ if (cleanup.status !== 'cleaned') {
235
+ if (entry) entry.state = cleanup.status
236
+ return cleanup
237
+ }
132
238
  if (typeof guards.alive === 'function' && typeof guards.remount === 'function') {
133
239
  for (const [other] of mounted) {
134
- const groupId = `${MCP_GROUP_PREFIX}${other}`
135
- if (!guards.alive().includes(groupId)) {
240
+ const siblingGroup = `${MCP_GROUP_PREFIX}${other}`
241
+ if (!guards.alive().includes(siblingGroup)) {
136
242
  onError(`sibling-vanished-after-unmount:${other}`)
137
243
  await guards.remount(other)
138
244
  }
139
245
  }
140
246
  }
141
- return { removed: true }
247
+ return { status: 'cleaned', removed: Boolean(entry) || cleanup.removed, groupId }
248
+ }
249
+
250
+ async function probe(input) {
251
+ const suffix = randomUUID().replaceAll('-', '').slice(0, 16)
252
+ const identity = input.identity ?? `probe-${suffix}`
253
+ const serverName = input.serverName ?? `probe${suffix}`
254
+ const client = { ...input.client, serverName }
255
+ let ready
256
+ try {
257
+ ready = await mountOne({ ...input, identity, client, generation: input.generation ?? 0, probe: true })
258
+ } catch (cause) {
259
+ if (cause instanceof McpLifecycleError) throw cause
260
+ throw new McpLifecycleError('probe-failed', cause, { status: 'probe-failed', secrets: input.secrets })
261
+ }
262
+ try {
263
+ const cleanup = await disposeGroup(ready.groupId, { tree: treeFor(Boolean(input.secretBearing)), identity, generation: ready.generation, secretBearing: Boolean(input.secretBearing), confirmClosed: input.confirmClosed, requireCapture: true })
264
+ if (cleanup.status !== 'cleaned') return { status: cleanup.status, identity, generation: ready.generation, cleanup, diagnostic: cleanup.diagnostic }
265
+ if (typeof input.afterCleanup === 'function') await input.afterCleanup({ groupId: ready.groupId, identity, generation: ready.generation, probe: true, cleanup })
266
+ return { ...ready, status: 'mounted', cleanup }
267
+ } catch (cause) {
268
+ throw new McpLifecycleError('cleanup-failed', cause, { status: 'cleanup-failed', secrets: input.secrets, action: 'recover-residue' })
269
+ }
270
+ }
271
+
272
+ const queues = new Map()
273
+ function serialize(identity, task) {
274
+ const previous = queues.get(identity) ?? Promise.resolve()
275
+ const run = previous.then(task, task)
276
+ const tail = run.catch(() => {})
277
+ queues.set(identity, tail)
278
+ tail.then(() => { if (queues.get(identity) === tail) queues.delete(identity) })
279
+ return run
142
280
  }
281
+ const mount = input => input?.probe ? mountOne(input) : serialize(input.identity, () => mountOne(input))
282
+ const unmount = (identity, guards = {}, context = {}) => serialize(identity, () => unmountOne(identity, guards, context))
143
283
 
144
- return { mount, unmount, sweepResidue, mounted, message }
284
+ return { mount, unmount, probe, sweepResidue, disposeGroup, mounted, entriesFor, message }
145
285
  }