weir-harness 1.2.0 → 1.2.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/cordis.patch.yml +0 -2
- package/lib/client.blackboard-model.js +172 -56
- package/lib/client.capability-manager.js +0 -1
- package/lib/client.capability-model.js +378 -31
- package/lib/client.chain-model.js +80 -0
- package/lib/client.disabled-categories-editor.js +22 -0
- package/lib/client.edit-lock-maintenance.js +239 -246
- package/lib/client.edit-lock-panel.js +110 -0
- package/lib/client.hash-edit-model.js +48 -0
- package/lib/client.js +267 -1549
- package/lib/client.lsp-model.js +92 -0
- package/lib/client.lsp-panel.js +11 -16
- package/lib/client.lsp-toggle.js +18 -0
- package/lib/client.notify-permissions.js +112 -20
- package/lib/client.notify-web.js +14 -11
- package/lib/client.robash-model.js +26 -0
- package/lib/client.settings-page.js +287 -17
- package/lib/client.worktree-model.js +225 -0
- package/package.json +5 -2
- package/src/blackboard/durable.js +12 -0
- package/src/blackboard/index.js +2 -2
- package/src/blackboard/kernel.js +23 -6
- package/src/blackboard/notify.js +10 -3
- package/src/blackboard/remote.js +31 -13
- package/src/blackboard/storage.js +13 -5
- package/src/capabilities/apply-engine.js +189 -101
- package/src/capabilities/defaults-transaction.js +136 -1
- package/src/capabilities/lifecycle-snapshot.js +83 -204
- package/src/capabilities/mcp-admission.js +1 -23
- package/src/capabilities/mcp-drain.js +30 -19
- package/src/capabilities/mcp-facade.js +23 -3
- package/src/capabilities/preset-library.js +247 -0
- package/src/capabilities/preset-package.js +212 -1
- package/src/capabilities/same-name-policy.js +3 -41
- package/src/capabilities/selection-notify.js +324 -21
- package/src/capabilities/skill-office-adapter.js +7 -1
- package/src/capabilities/skill-selection-plugin.js +315 -729
- package/src/capabilities/store/checked-contract.d.ts +5 -0
- package/src/capabilities/store/fs-adapter.js +52 -0
- package/src/capabilities/store/lock.js +86 -0
- package/src/capabilities/store/paths.js +5 -0
- package/src/capabilities/store/store.js +105 -5
- package/src/creative-guide/guide.js +1 -1
- package/src/delegate/child-composition.js +258 -0
- package/src/delegate/group-coordinator.js +25 -27
- package/src/delegate/index.js +21 -2
- package/src/delegate/rehydrate.js +24 -4
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/spawn-adapter.js +214 -100
- package/src/delegate/status-tool.js +9 -6
- package/src/delegate/supervision-mount.js +35 -6
- package/src/delegate/tool.js +29 -4
- package/src/{shared → delegate}/whitelist-defaults.js +2 -2
- package/src/edit-lock/admin-ledger.js +8 -11
- package/src/edit-lock/admin-recovery.js +2 -1
- package/src/edit-lock/admission.js +12 -2
- package/src/edit-lock/binding-guidance.js +28 -10
- package/src/edit-lock/binding-machine.js +312 -0
- package/src/edit-lock/derived-facts.js +67 -0
- package/src/edit-lock/index.js +44 -244
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +8 -5
- package/src/edit-lock/limits.js +61 -0
- package/src/edit-lock/maintenance.js +5 -2
- package/src/edit-lock/manager.js +6 -2
- package/src/edit-lock/operation-history.js +1 -1
- package/src/edit-lock/peer-client.js +9 -2
- package/src/edit-lock/peer-transport.js +7 -1
- package/src/edit-lock/remote.js +11 -4
- package/src/edit-lock/state.js +2 -4
- package/src/edit-lock/view.js +10 -11
- package/src/lsp/admin.js +2 -24
- package/src/lsp/index.js +1 -1
- package/src/lsp/manager.js +1 -1
- package/src/notify/permissions-admin.js +1 -1
- package/src/{lsp/child-process.js → process/index.js} +23 -6
- package/src/settings/env-admin.js +1 -1
- package/src/settings/index.js +1 -1
- package/src/settings/sections.js +0 -54
- package/src/shared/audit.js +12 -0
- package/src/shared/child-scope.js +3 -1
- package/src/shared/http-json.js +28 -0
- package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
- package/src/worktree/errors.js +3 -0
- package/src/worktree/index.js +1 -2
- package/src/worktree/lane-core.js +641 -0
- package/src/worktree/lane-views.js +147 -0
- package/src/worktree/lane-workflows.js +853 -0
- package/src/worktree/lanes.js +24 -1513
- package/src/worktree/runner.js +5 -4
- package/src/capabilities/content-pin.js +0 -88
- package/src/capabilities/content-refresh.js +0 -331
- package/src/capabilities/distribution-manifest.js +0 -78
- package/src/capabilities/isolated-exec.js +0 -106
- package/src/capabilities/publisher.js +0 -129
- package/src/capabilities/skill-admission.js +0 -181
- package/src/capabilities/update-lifecycle.js +0 -106
- package/src/edit-lock/remote-service.js +0 -28
- package/src/worktree/contract-guidance.test.js +0 -100
|
@@ -18,13 +18,14 @@
|
|
|
18
18
|
// the host storageDomain — a promise interface, unusable here, CITED).
|
|
19
19
|
//
|
|
20
20
|
// 6.2 — synchronous durable capture: a subagent's inherited snapshot is
|
|
21
|
-
// written inside the listener
|
|
22
|
-
// group-2 unit layout
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// blocking the event loop on
|
|
21
|
+
// written inside the listener through the capability store's SYNC commit path
|
|
22
|
+
// (store/store.js commitSync — the group-2 unit layout, record schema, fsync
|
|
23
|
+
// order and the nominal-lease sync lock of store/lock.js, all shared with the
|
|
24
|
+
// async path; capabilities-deepening D4 retired this module's private
|
|
25
|
+
// reimplementation of exactly those conventions). A lock conflict fails the
|
|
26
|
+
// capture — the synchronous listener cannot run the async reclamation
|
|
27
|
+
// protocol, so contention fails closed instead of blocking the event loop on
|
|
28
|
+
// backoff sleeps.
|
|
28
29
|
//
|
|
29
30
|
// The capture is NOT ATOMIC with the host's session publication: the host
|
|
30
31
|
// writing the child session header and this plugin writing the snapshot are
|
|
@@ -51,20 +52,12 @@
|
|
|
51
52
|
// throwing is the INTENTIONAL bail that refuses the subagent while the
|
|
52
53
|
// parent session continues and learns the reason through the delegation
|
|
53
54
|
// result. Never an unrestricted pass.
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import { randomBytes } from 'node:crypto'
|
|
57
|
-
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
58
|
-
import { isSegment, resolveStoreRoot, unitLayout } from './store/paths.js'
|
|
59
|
-
import { decodeRecord, encodeRecord } from './store/record.js'
|
|
60
|
-
import { LOCK_SCHEMA_VERSION, parseOwnerDoc } from './store/lock.js'
|
|
55
|
+
import { isSegment } from './store/paths.js'
|
|
56
|
+
import { openCapabilityStore } from './store/store.js'
|
|
61
57
|
import { skillIdentityKey } from './skill-identity.js'
|
|
62
|
-
import { SUPPORTED_PLATFORMS } from './store/store.js'
|
|
63
58
|
|
|
64
59
|
/** origin marker of a captured inherited snapshot payload. */
|
|
65
60
|
export const INHERITED_ORIGIN = 'inherited'
|
|
66
|
-
/** Nominal lease of the synchronously acquired capture lock (released immediately). */
|
|
67
|
-
export const SYNC_LOCK_LEASE_MS = 10_000
|
|
68
61
|
|
|
69
62
|
/**
|
|
70
63
|
* @typedef {{ state: 'ready', sessionId: string, revision: number, skills: unknown[], mcpServers: string[],
|
|
@@ -75,39 +68,15 @@ export const SYNC_LOCK_LEASE_MS = 10_000
|
|
|
75
68
|
* captured?: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }} SnapshotInput
|
|
76
69
|
*/
|
|
77
70
|
|
|
78
|
-
/** @param {unknown} error @param {...string} codes */
|
|
79
|
-
const hasCode = (error, ...codes) => codes.includes(/** @type {{ code?: string }} */ (error)?.code ?? '')
|
|
80
|
-
|
|
81
71
|
/** @param {unknown} error */
|
|
82
72
|
const message = error => (error instanceof Error ? error.message : String(error))
|
|
83
73
|
|
|
84
|
-
/** fsync one file (mirrors the group-2 fs-adapter fsyncFile step). @param {string} path */
|
|
85
|
-
function fsyncPath(path) {
|
|
86
|
-
const fd = openSync(path, 'r+')
|
|
87
|
-
try {
|
|
88
|
-
fsyncSync(fd)
|
|
89
|
-
} finally {
|
|
90
|
-
closeSync(fd)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** fsync one directory entry change (mirrors the fs-adapter fsyncDir step). @param {string} dir */
|
|
95
|
-
function fsyncDirectory(dir) {
|
|
96
|
-
// Directory fsync is POSIX-only (EPERM on win32 — same gate as fs-adapter.js).
|
|
97
|
-
if (process.platform !== 'darwin' && process.platform !== 'linux') return
|
|
98
|
-
const fd = openSync(dir, 'r')
|
|
99
|
-
try {
|
|
100
|
-
fsyncSync(fd)
|
|
101
|
-
} finally {
|
|
102
|
-
closeSync(fd)
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
74
|
/**
|
|
107
75
|
* In-memory lifecycle snapshots plus the synchronous read/capture paths.
|
|
108
|
-
*
|
|
109
|
-
* (or an explicit test root), the same
|
|
110
|
-
* and record schema — never a parallel
|
|
76
|
+
* All durable I/O delegates to the capability store's sync path: the same
|
|
77
|
+
* root resolution (from `profileContext` or an explicit test root), the same
|
|
78
|
+
* platform gate, the same unit layout and record schema — never a parallel
|
|
79
|
+
* layout.
|
|
111
80
|
* @param {{
|
|
112
81
|
* profileContext?: unknown,
|
|
113
82
|
* root?: string,
|
|
@@ -119,25 +88,16 @@ function fsyncDirectory(dir) {
|
|
|
119
88
|
export function createLifecycleSnapshots(options = {}) {
|
|
120
89
|
const warn = options.warn ?? (() => {})
|
|
121
90
|
const now = options.now ?? Date.now
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
91
|
+
const store = openCapabilityStore({
|
|
92
|
+
root: options.root,
|
|
93
|
+
platform: options.platform,
|
|
94
|
+
profileContext: options.profileContext,
|
|
95
|
+
now,
|
|
96
|
+
})
|
|
97
|
+
const support = store.support
|
|
128
98
|
/** @type {Map<string, LifecycleSnapshot>} */
|
|
129
99
|
const snapshots = new Map()
|
|
130
100
|
|
|
131
|
-
/**
|
|
132
|
-
* @param {'selection'|'inherited'} kind @param {string} sessionId
|
|
133
|
-
* @returns {{ dir: string, record: string, name: string }}
|
|
134
|
-
*/
|
|
135
|
-
function locate(kind, sessionId) {
|
|
136
|
-
const layout = unitLayout(/** @type {import('./store/paths.js').Unit} */ ({ kind, sessionId }))
|
|
137
|
-
const dir = join(/** @type {{ root: string }} */ (support).root, ...layout.segments)
|
|
138
|
-
return { dir, record: join(dir, `${layout.name}.json`), name: layout.name }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
101
|
/**
|
|
142
102
|
* Publish an accepted selection into the memory snapshot. Called at
|
|
143
103
|
* plugin apply (preload), after every Apply acceptance (the engine's
|
|
@@ -187,9 +147,10 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
187
147
|
/**
|
|
188
148
|
* THE synchronous read path (6.1): memory hit returns immediately; a
|
|
189
149
|
* miss performs ONE blocking synchronous read of the durable record —
|
|
190
|
-
* never a promise, never a yield. An absent record
|
|
191
|
-
* accepted selection; initialization priority for
|
|
192
|
-
* sessions is task 6.5). An undecodable record becomes
|
|
150
|
+
* the store's sync read, never a promise, never a yield. An absent record
|
|
151
|
+
* returns null (no accepted selection; initialization priority for
|
|
152
|
+
* brand-new root sessions is task 6.5). An undecodable record becomes
|
|
153
|
+
* BLOCKED.
|
|
193
154
|
* @param {string} sessionId
|
|
194
155
|
* @returns {LifecycleSnapshot | null}
|
|
195
156
|
*/
|
|
@@ -197,82 +158,27 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
197
158
|
const memory = snapshots.get(sessionId)
|
|
198
159
|
if (memory) return memory
|
|
199
160
|
if (!support.supported) return null
|
|
200
|
-
|
|
201
|
-
let text
|
|
161
|
+
let record
|
|
202
162
|
try {
|
|
203
|
-
|
|
204
|
-
} catch
|
|
205
|
-
// No accepted record yet — a legitimate cold state, not a failure.
|
|
206
|
-
if (hasCode(error, 'ENOENT')) return null
|
|
163
|
+
record = store.readSync({ kind: 'selection', sessionId })
|
|
164
|
+
} catch {
|
|
207
165
|
// EACCES and friends: the record exists but cannot be read — fail
|
|
208
166
|
// closed BLOCKED (6.4), never a guessed full authorization.
|
|
209
167
|
return markBlocked(sessionId, 'unreadable')
|
|
210
168
|
}
|
|
211
|
-
|
|
212
|
-
if (
|
|
213
|
-
|
|
169
|
+
// No accepted record yet — a legitimate cold state, not a failure.
|
|
170
|
+
if (record.kind === 'absent') return null
|
|
171
|
+
if (record.kind === 'ok') {
|
|
172
|
+
const payload = /** @type {Record<string, unknown>} */ (record.payload ?? {})
|
|
214
173
|
return publish(sessionId, {
|
|
215
|
-
revision:
|
|
174
|
+
revision: record.revision,
|
|
216
175
|
skills: Array.isArray(payload.skills) ? payload.skills : [],
|
|
217
176
|
mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers) : [],
|
|
218
177
|
})
|
|
219
178
|
}
|
|
220
|
-
// corrupt / unknown-schema / torn: fail closed BLOCKED
|
|
221
|
-
// The original file is never rewritten from this path.
|
|
222
|
-
return markBlocked(sessionId,
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Synchronous acquisition of `<unit>.lock` through the group-2 lock
|
|
227
|
-
* convention: private candidate + fsync + link publication (never
|
|
228
|
-
* wx-then-write, which strands 0-byte locks on crash), OwnerDoc schema,
|
|
229
|
-
* remove-only-if-ours release. A conflict fails the capture immediately:
|
|
230
|
-
* the listener cannot run the async reclamation protocol without
|
|
231
|
-
* yielding, so contention fails closed.
|
|
232
|
-
* @param {string} dir @param {string} name @returns {string} owner token
|
|
233
|
-
*/
|
|
234
|
-
function acquireLockSync(dir, name) {
|
|
235
|
-
const token = randomBytes(12).toString('hex')
|
|
236
|
-
const lockFile = join(dir, `${name}.lock`)
|
|
237
|
-
const candidate = `${lockFile}.${token}.cand`
|
|
238
|
-
const at = now()
|
|
239
|
-
const doc = {
|
|
240
|
-
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
241
|
-
ownerToken: token,
|
|
242
|
-
pid: process.pid,
|
|
243
|
-
host: hostname(),
|
|
244
|
-
startIdentity: { osStart: null, bootNonce: 'sync-capture' },
|
|
245
|
-
acquiredAt: at,
|
|
246
|
-
leaseUntil: at + SYNC_LOCK_LEASE_MS,
|
|
247
|
-
}
|
|
248
|
-
try {
|
|
249
|
-
writeFileSync(candidate, JSON.stringify(doc), { mode: 0o600 })
|
|
250
|
-
fsyncPath(candidate)
|
|
251
|
-
linkSync(candidate, lockFile)
|
|
252
|
-
} catch (error) {
|
|
253
|
-
if (hasCode(error, 'EEXIST')) throw new Error(`inherited snapshot capture lost the lock race for unit "${name}"`, { cause: error })
|
|
254
|
-
if (hasCode(error, 'ENOENT')) throw new Error(`inherited snapshot capture lock candidate vanished for unit "${name}"`, { cause: error })
|
|
255
|
-
throw error
|
|
256
|
-
} finally {
|
|
257
|
-
try {
|
|
258
|
-
unlinkSync(candidate)
|
|
259
|
-
} catch (error) {
|
|
260
|
-
if (!hasCode(error, 'ENOENT')) warn(`inherited snapshot capture candidate cleanup failed: ${message(error)}`)
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
return token
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Remove `<unit>.lock` only while it still names our token (mirrors releaseOwned). @param {string} dir @param {string} name @param {string} token */
|
|
267
|
-
function releaseLockSync(dir, name, token) {
|
|
268
|
-
const lockFile = join(dir, `${name}.lock`)
|
|
269
|
-
try {
|
|
270
|
-
const doc = parseOwnerDoc(readFileSync(lockFile, 'utf8'))
|
|
271
|
-
if (doc.kind === 'ok' && doc.value.ownerToken !== token) return
|
|
272
|
-
unlinkSync(lockFile)
|
|
273
|
-
} catch (error) {
|
|
274
|
-
if (!hasCode(error, 'ENOENT')) warn(`inherited snapshot capture lock release failed: ${message(error)}`)
|
|
275
|
-
}
|
|
179
|
+
// corrupt / unknown-schema / torn / unsupported: fail closed BLOCKED
|
|
180
|
+
// (6.4 root rule). The original file is never rewritten from this path.
|
|
181
|
+
return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (record.kind))
|
|
276
182
|
}
|
|
277
183
|
|
|
278
184
|
/**
|
|
@@ -306,7 +212,7 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
306
212
|
})
|
|
307
213
|
}
|
|
308
214
|
|
|
309
|
-
/** String-set counterpart for managed MCP identities. @param {unknown} values @param {unknown} allowed @returns {string[]} */
|
|
215
|
+
/** String-set counterpart for managed MCP identities. @param {unknown[]} values @param {unknown[]} allowed @returns {string[]} */
|
|
310
216
|
function intersectStrings(values, allowed) {
|
|
311
217
|
const keep = new Set(Array.isArray(allowed) ? allowed : [])
|
|
312
218
|
return (Array.isArray(values) ? values : []).filter(value => typeof value === 'string' && keep.has(value))
|
|
@@ -339,80 +245,53 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
339
245
|
if (!support.supported) {
|
|
340
246
|
throw new Error(`inherited snapshot capture unsupported for subagent "${childSessionId}": ${support.reason}`)
|
|
341
247
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
: null
|
|
374
|
-
let mcpServers = previousMcp == null ? parentMcp.slice() : intersectStrings(previousMcp, parentMcp)
|
|
375
|
-
if (Array.isArray(constraints.allowMcpServers)) mcpServers = intersectStrings(mcpServers, constraints.allowMcpServers)
|
|
376
|
-
const payload = {
|
|
377
|
-
skills: structuredClone(skills),
|
|
378
|
-
mcpServers,
|
|
379
|
-
origin: INHERITED_ORIGIN,
|
|
380
|
-
parent: { sessionId: parentSessionId, revision: parent ? parent.revision : null },
|
|
381
|
-
capturedAt,
|
|
382
|
-
}
|
|
383
|
-
// Atomic commit through the group-2 record schema: temp + fsync +
|
|
384
|
-
// rename + directory fsync. A rename failure is INDETERMINATE — the
|
|
385
|
-
// record may or may not have landed — so the capture fails and the
|
|
386
|
-
// subagent creation is refused; a later explicit resume recaptures.
|
|
387
|
-
const next = encodeRecord(revision, payload, [])
|
|
388
|
-
const temp = join(dir, `${name}.${token}.tmp`)
|
|
389
|
-
try {
|
|
390
|
-
writeFileSync(temp, JSON.stringify(next), { mode: 0o600 })
|
|
391
|
-
fsyncPath(temp)
|
|
392
|
-
} catch (error) {
|
|
393
|
-
try {
|
|
394
|
-
unlinkSync(temp)
|
|
395
|
-
} catch { /* best effort */ }
|
|
396
|
-
throw new Error(`inherited snapshot capture write failed for subagent "${childSessionId}": ${message(error)}`, { cause: error })
|
|
397
|
-
}
|
|
398
|
-
try {
|
|
399
|
-
renameSync(temp, record)
|
|
400
|
-
fsyncDirectory(dir)
|
|
401
|
-
} catch (error) {
|
|
402
|
-
try {
|
|
403
|
-
unlinkSync(temp)
|
|
404
|
-
} catch { /* best effort */ }
|
|
405
|
-
throw new Error(`inherited snapshot capture commit failed for subagent "${childSessionId}": ${message(error)}`, { cause: error })
|
|
406
|
-
}
|
|
407
|
-
return publish(childSessionId, {
|
|
408
|
-
revision,
|
|
409
|
-
skills: payload.skills,
|
|
410
|
-
mcpServers: payload.mcpServers,
|
|
411
|
-
captured: { parentSessionId, parentRevision: payload.parent.revision, capturedAt },
|
|
412
|
-
})
|
|
413
|
-
} finally {
|
|
414
|
-
releaseLockSync(dir, name, token)
|
|
248
|
+
// Same-unit CAS discipline, synchronously through the store's sync commit
|
|
249
|
+
// path: the record decodes under the store's sync lock and the next
|
|
250
|
+
// revision commits only over the exact expected one. A record that cannot
|
|
251
|
+
// be decoded fails closed — it is never overwritten from this path.
|
|
252
|
+
const unit = { kind: 'inherited', sessionId: childSessionId }
|
|
253
|
+
const current = store.readSync(unit)
|
|
254
|
+
if (current.kind !== 'absent' && current.kind !== 'ok') {
|
|
255
|
+
throw new Error(`existing inherited snapshot of "${childSessionId}" is ${current.kind}; refusing to overwrite it`)
|
|
256
|
+
}
|
|
257
|
+
const capturedAt = now()
|
|
258
|
+
// 6.3: an existing record means an explicit resume/escalation — the
|
|
259
|
+
// child's previous snapshot intersected with the CURRENT parent set
|
|
260
|
+
// (removed capabilities never return, added ones never arrive). A fresh
|
|
261
|
+
// capture starts from the parent verbatim. Constraints narrow further.
|
|
262
|
+
const previousSkills = current.kind === 'ok'
|
|
263
|
+
? (Array.isArray(/** @type {Record<string, unknown>} */ (current.payload)?.skills) ? /** @type {unknown[]} */ (/** @type {Record<string, unknown>} */ (current.payload).skills) : [])
|
|
264
|
+
: null
|
|
265
|
+
const parentSkills = parent ? parent.skills : []
|
|
266
|
+
const skills = deriveInheritedSkills(parentSkills, previousSkills, constraints)
|
|
267
|
+
const parentMcp = parent ? parent.mcpServers : []
|
|
268
|
+
const previousMcp = current.kind === 'ok' && Array.isArray(/** @type {Record<string, unknown>} */ (current.payload)?.mcpServers)
|
|
269
|
+
? /** @type {string[]} */ (/** @type {Record<string, unknown>} */ (current.payload).mcpServers)
|
|
270
|
+
: null
|
|
271
|
+
let mcpServers = previousMcp == null ? parentMcp.slice() : intersectStrings(previousMcp, parentMcp)
|
|
272
|
+
if (Array.isArray(constraints.allowMcpServers)) mcpServers = intersectStrings(mcpServers, constraints.allowMcpServers)
|
|
273
|
+
const payload = {
|
|
274
|
+
skills: structuredClone(skills),
|
|
275
|
+
mcpServers,
|
|
276
|
+
origin: INHERITED_ORIGIN,
|
|
277
|
+
parent: { sessionId: parentSessionId, revision: parent ? parent.revision : null },
|
|
278
|
+
capturedAt,
|
|
415
279
|
}
|
|
280
|
+
// The store's sync commit performs the temp+fsync+rename+directory-fsync
|
|
281
|
+
// sequence under the sync lock. A non-committed outcome (lock contention,
|
|
282
|
+
// a CAS race, a failed write) fails the capture and refuses the subagent
|
|
283
|
+
// creation; a later explicit resume recaptures.
|
|
284
|
+
const result = store.commitSync(unit, current.kind === 'ok' ? current.revision : 0, () => payload)
|
|
285
|
+
if (result.status !== 'committed') {
|
|
286
|
+
const reason = /** @type {{ reason?: unknown }} */ (result).reason ?? result.status
|
|
287
|
+
throw new Error(`inherited snapshot capture failed for subagent "${childSessionId}": ${reason}`)
|
|
288
|
+
}
|
|
289
|
+
return publish(childSessionId, {
|
|
290
|
+
revision: result.revision,
|
|
291
|
+
skills: payload.skills,
|
|
292
|
+
mcpServers: payload.mcpServers,
|
|
293
|
+
captured: { parentSessionId, parentRevision: payload.parent.revision, capturedAt },
|
|
294
|
+
})
|
|
416
295
|
}
|
|
417
296
|
|
|
418
297
|
return {
|
|
@@ -52,27 +52,5 @@ export function createMcpToolWrapper({ identity, generation, admit, drain }) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
* Wrap a resource provider: the three resource operations are gated by
|
|
57
|
-
* the TARGET SERVER at dispatch time (8.4, shared resource tool schemas
|
|
58
|
-
* stay visible; only execution is gated).
|
|
59
|
-
* @param {{ list?: unknown, listTemplates?: unknown, read?: unknown }} provider
|
|
60
|
-
*/
|
|
61
|
-
function wrapResourceProvider(provider) {
|
|
62
|
-
const gate = fn => async (args, exec) => {
|
|
63
|
-
const admitted = await admit(exec?.agent)
|
|
64
|
-
if (!admitted) {
|
|
65
|
-
const error = new Error(`MCP server "${identity}" resources are not enabled for this agent`)
|
|
66
|
-
throw Object.assign(error, { code: 'mcp-admission-refused' })
|
|
67
|
-
}
|
|
68
|
-
return fn(args, exec)
|
|
69
|
-
}
|
|
70
|
-
const wrapped = { ...provider }
|
|
71
|
-
for (const key of ['list', 'listTemplates', 'read']) {
|
|
72
|
-
if (typeof provider?.[key] === 'function') wrapped[key] = gate(provider[key])
|
|
73
|
-
}
|
|
74
|
-
return wrapped
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return { wrap, wrapResourceProvider }
|
|
55
|
+
return { wrap }
|
|
78
56
|
}
|
|
@@ -81,28 +81,39 @@ export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* The apply-engine drain seam (
|
|
85
|
-
* engine
|
|
86
|
-
*
|
|
87
|
-
* in session A never closes the gate of session B (8.5 A/B
|
|
88
|
-
*
|
|
89
|
-
*
|
|
84
|
+
* The apply-engine drain seam (capabilities-deepening D3) over the manager's
|
|
85
|
+
* (agent, server)-keyed drain. The engine shares ONE instance across all
|
|
86
|
+
* sessions and passes the session id into begin(removed, sessionId) — so a
|
|
87
|
+
* removal in session A never closes the gate of session B (8.5 A/B
|
|
88
|
+
* isolation), and the returned handle's abort() reopens exactly the gates
|
|
89
|
+
* begin() closed (a failed Apply never permanently gates that session).
|
|
90
|
+
*
|
|
91
|
+
* The drain resolves LAZILY per begin: the manager row may mount after this
|
|
92
|
+
* plugin, and a composition without it has no managed servers to drain —
|
|
93
|
+
* the handle then reports the empty truth (settled, zero in flight) and
|
|
94
|
+
* abort() is a no-op. In-flight registration stays where it always was:
|
|
95
|
+
* mcp-admission.js's enter/exit at handoff, never this seam.
|
|
96
|
+
* @param {() => ReturnType<typeof createMcpDrain> | null | undefined} resolveDrain
|
|
90
97
|
*/
|
|
91
|
-
export function
|
|
98
|
+
export function createSessionDrainAdapter(resolveDrain) {
|
|
92
99
|
return {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Close the removed servers' gates for this session and report the live
|
|
102
|
+
* counts; abort() reopens exactly these (session, server) gates.
|
|
103
|
+
* @param {string[]} removed @param {string} sessionId
|
|
104
|
+
*/
|
|
105
|
+
begin(removed, sessionId) {
|
|
106
|
+
const drain = resolveDrain()
|
|
107
|
+
if (!drain) {
|
|
108
|
+
return {
|
|
109
|
+
snapshot: () => removed.map(server => ({ server, state: 'settled', inFlight: 0 })),
|
|
110
|
+
abort: () => {},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const server of removed) drain.close(sessionId, server)
|
|
103
114
|
return {
|
|
104
|
-
snapshot: () => removed.map(server => drain.snapshot(
|
|
105
|
-
abort: () =>
|
|
115
|
+
snapshot: () => removed.map(server => drain.snapshot(sessionId, server)),
|
|
116
|
+
abort: () => { for (const server of removed) drain.reopen(sessionId, server) },
|
|
106
117
|
}
|
|
107
118
|
},
|
|
108
119
|
}
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// PER AGENT at every prompt assembly — an agent without the server gets an
|
|
15
15
|
// empty string (renderPrompt drops zero-length sections, CITED).
|
|
16
16
|
// - mcpResources.register(provider): resource operations are gated by the
|
|
17
|
-
// target server at dispatch time (8.4)
|
|
17
|
+
// target server at dispatch time (8.4) AND fenced by the Apply drain at
|
|
18
|
+
// handoff (8.6) when the manager wires one in.
|
|
18
19
|
//
|
|
19
20
|
// Fail closed: a host method the facade does not know (the host added a new
|
|
20
21
|
// call to one of these services) makes THIS server fail visibly instead of
|
|
@@ -28,6 +29,7 @@ const message = error => (error instanceof Error ? error.message : String(error)
|
|
|
28
29
|
* identity: string, generation: number,
|
|
29
30
|
* admit: (agent: unknown) => Promise<boolean>,
|
|
30
31
|
* isEnabledFor: (agent: unknown) => boolean,
|
|
32
|
+
* drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void },
|
|
31
33
|
* onError?: (reason: string, cause?: unknown) => void }} config
|
|
32
34
|
*/
|
|
33
35
|
export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {} }) {
|
|
@@ -79,7 +81,10 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
|
|
|
79
81
|
* preserved verbatim and the provider's request path is gated by THIS
|
|
80
82
|
* facade's admission verdict (8.4①: resource operations are gated at
|
|
81
83
|
* dispatch by the target server; shared resource tool schemas stay
|
|
82
|
-
* visible, only execution is gated).
|
|
84
|
+
* visible, only execution is gated). With a drain attached the request
|
|
85
|
+
* also enters the (agent, server) in-flight fence at handoff (8.6), so a
|
|
86
|
+
* removal's commit window rejects late requests instead of letting the
|
|
87
|
+
* old provider serve them.
|
|
83
88
|
*/
|
|
84
89
|
register(server, provider) {
|
|
85
90
|
const request = provider?.request
|
|
@@ -90,7 +95,22 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
|
|
|
90
95
|
const error = new Error(`MCP server "${identity}" resources are not enabled for this agent`)
|
|
91
96
|
throw Object.assign(error, { code: 'mcp-admission-refused' })
|
|
92
97
|
}
|
|
93
|
-
|
|
98
|
+
// The same drain fence the tool wrapper enters at handoff (8.6):
|
|
99
|
+
// between drain.begin and the post-commit snapshot publication the
|
|
100
|
+
// admit verdict above still reads the PRE-Apply snapshot, so a
|
|
101
|
+
// removed server's resource request must be rejected HERE or the
|
|
102
|
+
// old provider keeps serving it through the commit window.
|
|
103
|
+
const label = typeof args?.method === 'string' ? args.method : 'resource'
|
|
104
|
+
const token = drain ? drain.enter(exec?.agent?.id ?? exec?.agent?.session?.id, identity, label) : null
|
|
105
|
+
if (drain && token === null) {
|
|
106
|
+
const error = new Error(`MCP server "${identity}" is draining for this agent after an accepted removal (8.6)`)
|
|
107
|
+
throw Object.assign(error, { code: 'mcp-gate-closed' })
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return await request(args, exec)
|
|
111
|
+
} finally {
|
|
112
|
+
if (drain) drain.exit(exec?.agent?.id ?? exec?.agent?.session?.id, identity, token)
|
|
113
|
+
}
|
|
94
114
|
} }
|
|
95
115
|
: provider
|
|
96
116
|
return /** @type {{ register(s: unknown, p: unknown): unknown }} */ (host.mcpResources).register(server, gated)
|