weir-harness 2.0.0 → 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.
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +105 -7
- package/lib/client.mcp-manager.js +563 -0
- package/package.json +1 -1
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- package/src/file-lease/client.js +268 -35
- package/src/file-lease/index.js +4 -0
- package/src/file-lease/plugin.js +239 -15
- package/src/file-lease/tools.js +426 -38
- package/src/file-lease/watch.js +473 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/protocol.js +3 -2
- package/src/lease-broker/server.js +6 -3
- package/src/lease-broker/state.js +68 -11
- package/src/process/index.js +4 -2
- package/src/shared/audit.js +3 -0
- package/src/shared/child-scope.js +2 -0
- package/src/worktree/rules.js +1 -1
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
// Task 6.1 preload (design D6): at plugin apply, load the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// Task 6.1 preload (design D6): at plugin apply, load the EFFECTIVE selections
|
|
2
|
+
// of KNOWN sessions into the lifecycle memory snapshot, so the agent/created
|
|
3
|
+
// listener's synchronous read hits memory instead of disk. This module is the
|
|
4
|
+
// async boundary — it runs at mount time, NEVER inside the agent/created
|
|
5
|
+
// listener. A slow or failed preload costs one blocking synchronous disk read
|
|
6
|
+
// per agent creation (the listener's miss path), never correctness. Records
|
|
7
|
+
// that do not decode are pre-marked BLOCKED, so the fail-closed state is ready
|
|
8
|
+
// before the first agent is created.
|
|
9
|
+
//
|
|
10
|
+
// "Effective" is the same priority the synchronous read path applies: the
|
|
11
|
+
// accepted selection when one exists, otherwise the durable inherited snapshot
|
|
12
|
+
// (a fork of a fork, or a fork whose source only resolved an initial set). Both
|
|
13
|
+
// are loaded here, because a cold session read (Badge/manager receipt, MCP
|
|
14
|
+
// gate, Skill catalog) must report the inherited set rather than an empty one
|
|
15
|
+
// until the fork's agent is recreated. A corrupt record of EITHER unit blocks
|
|
16
|
+
// the session — never a fall-through to the other one.
|
|
9
17
|
import { readdir } from 'node:fs/promises'
|
|
10
18
|
import { join } from 'node:path'
|
|
11
19
|
import { isSegment } from './store/paths.js'
|
|
@@ -14,10 +22,17 @@ import { openCapabilityStore } from './store/store.js'
|
|
|
14
22
|
/** @param {unknown} error */
|
|
15
23
|
const message = error => (error instanceof Error ? error.message : String(error))
|
|
16
24
|
|
|
25
|
+
/** @param {unknown} payload @returns {number} capturedAt used by the inherited restore */
|
|
26
|
+
const capturedAtOf = payload => {
|
|
27
|
+
const value = /** @type {Record<string, unknown>} */ (payload ?? {}).capturedAt
|
|
28
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
/**
|
|
18
32
|
* @param {{
|
|
19
33
|
* lifecycle: {
|
|
20
|
-
* publish(sessionId: string, next: { revision: number, skills?: unknown[], mcpServers?: string[]
|
|
34
|
+
* publish(sessionId: string, next: { revision: number, skills?: unknown[], mcpServers?: string[],
|
|
35
|
+
* captured?: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }): unknown,
|
|
21
36
|
* markBlocked(sessionId: string, reason: 'corrupt'|'unknown-schema'|'torn'|'unreadable'): unknown,
|
|
22
37
|
* peek(sessionId: string): unknown,
|
|
23
38
|
* },
|
|
@@ -45,20 +60,63 @@ export async function preloadLifecycleSnapshots({ lifecycle, profileContext, pla
|
|
|
45
60
|
if (!isSegment(name)) continue
|
|
46
61
|
// A fresher in-memory publication (e.g. an Apply accepted while this
|
|
47
62
|
// preload was in flight) always wins over the disk record it came from.
|
|
48
|
-
|
|
63
|
+
// Capture the exact observed object so EVERY post-await publication or
|
|
64
|
+
// blocked marker can be gated on the same freshness proof.
|
|
65
|
+
const observed = lifecycle.peek(name) ?? null
|
|
66
|
+
if (observed) continue
|
|
49
67
|
try {
|
|
50
68
|
const record = await active.read({ kind: 'selection', sessionId: name })
|
|
69
|
+
// The first await may have allowed an accepted Apply (or another
|
|
70
|
+
// publisher) to replace the memory snapshot. Never publish/mark stale.
|
|
71
|
+
if (lifecycle.peek(name) !== observed) continue
|
|
51
72
|
if (record.kind === 'ok') {
|
|
52
73
|
const payload = record.payload ?? {}
|
|
53
74
|
lifecycle.publish(name, {
|
|
54
75
|
revision: record.revision,
|
|
55
76
|
skills: Array.isArray(payload.skills) ? payload.skills : [],
|
|
56
77
|
mcpServers: Array.isArray(payload.mcpServers) ? payload.mcpServers : [],
|
|
78
|
+
mcpIncarnations: payload.mcpIncarnations,
|
|
57
79
|
})
|
|
58
80
|
loaded++
|
|
59
|
-
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (record.kind !== 'absent') {
|
|
60
84
|
lifecycle.markBlocked(name, record.kind)
|
|
61
85
|
blocked++
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
// No accepted record: the effective set may be the durable inherited
|
|
89
|
+
// snapshot. Same fail-closed treatment for a record that exists but does
|
|
90
|
+
// not decode — the session must not look uninitialized (and be handed a
|
|
91
|
+
// fresh initial baseline) merely because its inheritance is damaged.
|
|
92
|
+
const inherited = await active.read({ kind: 'inherited', sessionId: name })
|
|
93
|
+
// A second independent read creates another interleaving window: an
|
|
94
|
+
// accepted Apply here MUST outrank the older inherited unit.
|
|
95
|
+
if (lifecycle.peek(name) !== observed) continue
|
|
96
|
+
if (inherited.kind === 'ok') {
|
|
97
|
+
const payload = /** @type {Record<string, unknown>} */ (inherited.payload ?? {})
|
|
98
|
+
const parent = payload.parent && typeof payload.parent === 'object'
|
|
99
|
+
? /** @type {Record<string, unknown>} */ (payload.parent)
|
|
100
|
+
: null
|
|
101
|
+
lifecycle.publish(name, {
|
|
102
|
+
revision: inherited.revision,
|
|
103
|
+
skills: Array.isArray(payload.skills) ? payload.skills : [],
|
|
104
|
+
mcpServers: Array.isArray(payload.mcpServers) ? payload.mcpServers : [],
|
|
105
|
+
mcpIncarnations: payload.mcpIncarnations,
|
|
106
|
+
captured: parent && typeof parent.sessionId === 'string' && parent.sessionId.length > 0
|
|
107
|
+
? {
|
|
108
|
+
parentSessionId: parent.sessionId,
|
|
109
|
+
parentRevision: Number.isSafeInteger(parent.revision) ? /** @type {number} */ (parent.revision) : null,
|
|
110
|
+
capturedAt: capturedAtOf(payload),
|
|
111
|
+
}
|
|
112
|
+
: null,
|
|
113
|
+
})
|
|
114
|
+
loaded++
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (inherited.kind !== 'absent') {
|
|
118
|
+
lifecycle.markBlocked(name, inherited.kind)
|
|
119
|
+
blocked++
|
|
62
120
|
}
|
|
63
121
|
} catch (error) {
|
|
64
122
|
warn(`lifecycle snapshot preload skipped session "${name}": ${message(error)}`)
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
// never yields: no promise chains, no async helpers, only blocking
|
|
10
10
|
// synchronous file I/O where durability is required.
|
|
11
11
|
//
|
|
12
|
-
// 6.1 — in-memory snapshot:
|
|
13
|
-
// plugin apply (lifecycle-preload.js) and after every Apply
|
|
14
|
-
// apply-engine publishSnapshot hook, invoked inside the same
|
|
15
|
-
// segment as the internal snapshot swap). The listener reads the
|
|
16
|
-
// snapshot synchronously (measured ≈0.2 ms in G4b); on a memory miss it
|
|
17
|
-
// falls back to
|
|
18
|
-
//
|
|
12
|
+
// 6.1 — in-memory snapshot: the session's EFFECTIVE capability set is loaded
|
|
13
|
+
// into memory at plugin apply (lifecycle-preload.js) and after every Apply
|
|
14
|
+
// acceptance (the apply-engine publishSnapshot hook, invoked inside the same
|
|
15
|
+
// non-async segment as the internal snapshot swap). The listener reads the
|
|
16
|
+
// memory snapshot synchronously (measured ≈0.2 ms in G4b); on a memory miss it
|
|
17
|
+
// falls back to blocking synchronous reads of the session records — the
|
|
18
|
+
// accepted selection FIRST, then the durable inherited snapshot when no
|
|
19
|
+
// accepted record exists (a fork of a fork, a resumed incarnation), never the
|
|
20
|
+
// host storageDomain (a promise interface, unusable here, CITED).
|
|
19
21
|
//
|
|
20
22
|
// 6.2 — synchronous durable capture: a subagent's inherited snapshot is
|
|
21
23
|
// written inside the listener through the capability store's SYNC commit path
|
|
@@ -39,19 +41,29 @@
|
|
|
39
41
|
// decoded does NOT throw — the session is created/resumed as usual and
|
|
40
42
|
// the memory snapshot becomes BLOCKED (empty Skill view, all managed MCP
|
|
41
43
|
// calls refused, recovery entry visible). Never a full-discovery
|
|
42
|
-
// fallback; the original file is never rewritten.
|
|
44
|
+
// fallback; the original file is never rewritten. An inherited record is
|
|
45
|
+
// consulted ONLY when no accepted record exists, and an existing record
|
|
46
|
+
// that cannot be decoded blocks just as hard — a corrupt higher-priority
|
|
47
|
+
// record is never skipped in favour of a lower-priority one or of the
|
|
48
|
+
// initial baseline.
|
|
43
49
|
// - resume/fork incarnation (depth 0 yet a parentSession header): the named
|
|
44
|
-
// parent's
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
50
|
+
// parent's EFFECTIVE snapshot (accepted, inherited, or the already-resolved
|
|
51
|
+
// initial one) is captured best-effort under the SAME root rule — a failed
|
|
52
|
+
// capture never throws and never rejects the session; the session runs
|
|
53
|
+
// BLOCKED like an unreadable root record, with the manager naming an
|
|
54
|
+
// explicit Apply as the recovery gesture. A parent with NO effective
|
|
55
|
+
// snapshot at all (proven absent on supported storage) is not an empty
|
|
56
|
+
// set: the capture is skipped and the fork resolves the ordinary root
|
|
57
|
+
// initialization policy for itself.
|
|
58
|
+
// - subagent (one-shot / background / supervised member / escalation; a
|
|
59
|
+
// depth-0 fork child is handled by the incarnation rule above): a parent
|
|
60
|
+
// snapshot that cannot be read, or a capture that cannot be written, makes
|
|
61
|
+
// the listener THROW. The host dispatches agent/created serially and a
|
|
62
|
+
// listener failure rejects the creation, so throwing is the INTENTIONAL
|
|
63
|
+
// bail that refuses the subagent while the parent session continues and
|
|
64
|
+
// learns the reason through the delegation result. Never an unrestricted
|
|
65
|
+
// pass, and never the root-initialization fallback (that is a depth-0 fork
|
|
66
|
+
// affordance only).
|
|
55
67
|
import { isSegment } from './store/paths.js'
|
|
56
68
|
import { openCapabilityStore } from './store/store.js'
|
|
57
69
|
import { skillIdentityKey } from './skill-identity.js'
|
|
@@ -60,17 +72,28 @@ import { skillIdentityKey } from './skill-identity.js'
|
|
|
60
72
|
export const INHERITED_ORIGIN = 'inherited'
|
|
61
73
|
|
|
62
74
|
/**
|
|
63
|
-
* @typedef {{ state: 'ready', sessionId: string, revision: number, skills: unknown[], mcpServers: string[],
|
|
75
|
+
* @typedef {{ state: 'ready', sessionId: string, revision: number, skills: unknown[], mcpServers: string[], mcpIncarnations?: Record<string, string|null>,
|
|
64
76
|
* captured: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }} ReadySnapshot
|
|
65
77
|
* @typedef {{ state: 'blocked', sessionId: string, reason: 'corrupt'|'unknown-schema'|'torn'|'unreadable' }} BlockedSnapshot
|
|
66
78
|
* @typedef {ReadySnapshot | BlockedSnapshot} LifecycleSnapshot
|
|
67
|
-
* @typedef {{ revision: number, skills?: unknown[], mcpServers?: string[],
|
|
79
|
+
* @typedef {{ revision: number, skills?: unknown[], mcpServers?: string[], mcpIncarnations?: Record<string, string|null>,
|
|
68
80
|
* captured?: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }} SnapshotInput
|
|
69
81
|
*/
|
|
70
82
|
|
|
71
83
|
/** @param {unknown} error */
|
|
72
84
|
const message = error => (error instanceof Error ? error.message : String(error))
|
|
73
85
|
|
|
86
|
+
export function normalizeMcpIncarnations(value) {
|
|
87
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
88
|
+
return Object.fromEntries(Object.entries(value).filter(([, incarnation]) => incarnation === null || typeof incarnation === 'string'))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mcpIncarnationsFor(servers, mapping) {
|
|
92
|
+
const normalized = normalizeMcpIncarnations(mapping)
|
|
93
|
+
if (!normalized) return undefined
|
|
94
|
+
return Object.fromEntries(servers.filter(identity => Object.hasOwn(normalized, identity)).map(identity => [identity, normalized[identity]]))
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
/**
|
|
75
98
|
* In-memory lifecycle snapshots plus the synchronous read/capture paths.
|
|
76
99
|
* All durable I/O delegates to the capability store's sync path: the same
|
|
@@ -118,6 +141,7 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
118
141
|
revision: next.revision,
|
|
119
142
|
skills: Object.freeze(structuredClone(Array.isArray(next.skills) ? next.skills : [])),
|
|
120
143
|
mcpServers: Object.freeze((Array.isArray(next.mcpServers) ? next.mcpServers : []).slice()),
|
|
144
|
+
...(normalizeMcpIncarnations(next.mcpIncarnations) ? { mcpIncarnations: Object.freeze(normalizeMcpIncarnations(next.mcpIncarnations)) } : {}),
|
|
121
145
|
captured: next.captured ? Object.freeze({ ...next.captured }) : null,
|
|
122
146
|
})
|
|
123
147
|
snapshots.set(sessionId, snapshot)
|
|
@@ -146,11 +170,14 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
146
170
|
|
|
147
171
|
/**
|
|
148
172
|
* THE synchronous read path (6.1): memory hit returns immediately; a
|
|
149
|
-
* miss performs
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
173
|
+
* miss performs blocking synchronous reads of the durable records — the
|
|
174
|
+
* store's sync read, never a promise, never a yield. The effective snapshot
|
|
175
|
+
* is the accepted selection when one exists, otherwise the durable inherited
|
|
176
|
+
* snapshot (a fork whose source only ever resolved an initial set, or a fork
|
|
177
|
+
* of a fork). An absent pair returns null (no effective snapshot; the fork's
|
|
178
|
+
* own root initialization is layered above this mechanism). Any record that
|
|
179
|
+
* exists and does not decode becomes BLOCKED — a corrupt higher-priority
|
|
180
|
+
* record never falls through to a lower-priority one.
|
|
154
181
|
* @param {string} sessionId
|
|
155
182
|
* @returns {LifecycleSnapshot | null}
|
|
156
183
|
*/
|
|
@@ -166,19 +193,62 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
166
193
|
// closed BLOCKED (6.4), never a guessed full authorization.
|
|
167
194
|
return markBlocked(sessionId, 'unreadable')
|
|
168
195
|
}
|
|
169
|
-
// No accepted record yet — a legitimate cold state, not a failure.
|
|
170
|
-
if (record.kind === 'absent') return null
|
|
171
196
|
if (record.kind === 'ok') {
|
|
172
197
|
const payload = /** @type {Record<string, unknown>} */ (record.payload ?? {})
|
|
173
198
|
return publish(sessionId, {
|
|
174
199
|
revision: record.revision,
|
|
175
200
|
skills: Array.isArray(payload.skills) ? payload.skills : [],
|
|
176
201
|
mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers) : [],
|
|
202
|
+
mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
|
|
177
203
|
})
|
|
178
204
|
}
|
|
179
205
|
// corrupt / unknown-schema / torn / unsupported: fail closed BLOCKED
|
|
180
206
|
// (6.4 root rule). The original file is never rewritten from this path.
|
|
181
|
-
return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (record.kind))
|
|
207
|
+
if (record.kind !== 'absent') return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (record.kind))
|
|
208
|
+
// No accepted record — a legitimate cold state. The EFFECTIVE set may still
|
|
209
|
+
// be the durable inherited snapshot this session was created with.
|
|
210
|
+
return snapshotFromInherited(sessionId)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The inherited fallback of {@link snapshotFor}: no accepted record exists,
|
|
215
|
+
* so the effective set is whatever durable inheritance the session was given
|
|
216
|
+
* at creation. The snapshot keeps the inherited revision and restores the
|
|
217
|
+
* parent/captured metadata — it is NEVER reported as an accepted revision
|
|
218
|
+
* (the receipt's CAS token stays accepted-only, 0 when absent). A record
|
|
219
|
+
* that exists but does not decode blocks exactly like a corrupt selection
|
|
220
|
+
* record: no fall-through to the initial baseline.
|
|
221
|
+
* @param {string} sessionId
|
|
222
|
+
* @returns {LifecycleSnapshot | null}
|
|
223
|
+
*/
|
|
224
|
+
function snapshotFromInherited(sessionId) {
|
|
225
|
+
let inherited
|
|
226
|
+
try {
|
|
227
|
+
inherited = store.readSync({ kind: 'inherited', sessionId })
|
|
228
|
+
} catch {
|
|
229
|
+
return markBlocked(sessionId, 'unreadable')
|
|
230
|
+
}
|
|
231
|
+
if (inherited.kind !== 'ok') {
|
|
232
|
+
if (inherited.kind === 'absent') return null
|
|
233
|
+
return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (inherited.kind))
|
|
234
|
+
}
|
|
235
|
+
const payload = /** @type {Record<string, unknown>} */ (inherited.payload ?? {})
|
|
236
|
+
const parent = /** @type {Record<string, unknown> | null} */ (
|
|
237
|
+
payload.parent && typeof payload.parent === 'object' ? payload.parent : null)
|
|
238
|
+
const capturedAt = typeof payload.capturedAt === 'number' && Number.isFinite(payload.capturedAt) ? payload.capturedAt : 0
|
|
239
|
+
return publish(sessionId, {
|
|
240
|
+
revision: inherited.revision,
|
|
241
|
+
skills: Array.isArray(payload.skills) ? payload.skills : [],
|
|
242
|
+
mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers) : [],
|
|
243
|
+
mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
|
|
244
|
+
captured: parent && typeof parent.sessionId === 'string' && parent.sessionId.length > 0
|
|
245
|
+
? {
|
|
246
|
+
parentSessionId: parent.sessionId,
|
|
247
|
+
parentRevision: Number.isSafeInteger(parent.revision) ? /** @type {number} */ (parent.revision) : null,
|
|
248
|
+
capturedAt,
|
|
249
|
+
}
|
|
250
|
+
: null,
|
|
251
|
+
})
|
|
182
252
|
}
|
|
183
253
|
|
|
184
254
|
/**
|
|
@@ -219,13 +289,15 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
219
289
|
}
|
|
220
290
|
|
|
221
291
|
/**
|
|
222
|
-
* Durably capture a
|
|
292
|
+
* Durably capture a session's inherited snapshot (6.2), synchronously.
|
|
223
293
|
* The inherited sets follow creation-time inheritance (6.3): verbatim
|
|
224
294
|
* parent at creation, child-previous ∩ current-parent on an explicit
|
|
225
295
|
* resume/escalation, narrowed by delegation constraints — never widened.
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
296
|
+
* The parent's EFFECTIVE set is its accepted selection when one exists,
|
|
297
|
+
* otherwise its own inherited snapshot (a fork of a fork). A parent with NO
|
|
298
|
+
* effective snapshot at all is not an empty inheritance: the depth-0 fork
|
|
299
|
+
* caller skips this capture entirely (see `agentCreated`) and lets that fork
|
|
300
|
+
* run the ordinary root initialization instead.
|
|
229
301
|
*
|
|
230
302
|
* NOT ATOMIC with the host's session publication (see the module header):
|
|
231
303
|
* a crash between the two durable acts leaves a child session without a
|
|
@@ -270,9 +342,11 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
270
342
|
: null
|
|
271
343
|
let mcpServers = previousMcp == null ? parentMcp.slice() : intersectStrings(previousMcp, parentMcp)
|
|
272
344
|
if (Array.isArray(constraints.allowMcpServers)) mcpServers = intersectStrings(mcpServers, constraints.allowMcpServers)
|
|
345
|
+
const mcpIncarnations = mcpIncarnationsFor(mcpServers, parent?.mcpIncarnations)
|
|
273
346
|
const payload = {
|
|
274
347
|
skills: structuredClone(skills),
|
|
275
348
|
mcpServers,
|
|
349
|
+
...(mcpIncarnations ? { mcpIncarnations } : {}),
|
|
276
350
|
origin: INHERITED_ORIGIN,
|
|
277
351
|
parent: { sessionId: parentSessionId, revision: parent ? parent.revision : null },
|
|
278
352
|
capturedAt,
|
|
@@ -290,6 +364,7 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
290
364
|
revision: result.revision,
|
|
291
365
|
skills: payload.skills,
|
|
292
366
|
mcpServers: payload.mcpServers,
|
|
367
|
+
mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
|
|
293
368
|
captured: { parentSessionId, parentRevision: payload.parent.revision, capturedAt },
|
|
294
369
|
})
|
|
295
370
|
}
|
|
@@ -320,7 +395,7 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
320
395
|
// record becomes BLOCKED inside snapshotFor — this path NEVER throws.
|
|
321
396
|
const own = snapshotFor(sessionId)
|
|
322
397
|
// Resume/fork incarnation (D1): depth zero yet a parentSession
|
|
323
|
-
// header. The read side demands an
|
|
398
|
+
// header. The read side demands an effective snapshot for such a
|
|
324
399
|
// session exactly when it carries NO accepted record of its own, so
|
|
325
400
|
// the capture side aligns with that gate: an incarnation WITH its
|
|
326
401
|
// own accepted record keeps it verbatim (an explicit user Apply is
|
|
@@ -331,12 +406,25 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
331
406
|
// parentSession equal to the session id is no parent (defensive).
|
|
332
407
|
const parentSessionId = session.header?.parentSession
|
|
333
408
|
if (own === null && typeof parentSessionId === 'string' && parentSessionId.length > 0 && parentSessionId !== sessionId) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
409
|
+
// The fork inherits the source's EFFECTIVE set: accepted, else
|
|
410
|
+
// inherited, else the already-resolved initial snapshot. Only a
|
|
411
|
+
// parent snapshot PROVEN absent (null on supported storage) is not a
|
|
412
|
+
// set to copy — an unsupported store proves nothing, so that case
|
|
413
|
+
// keeps the old attempt-and-block shape instead of silently
|
|
414
|
+
// initializing the fork.
|
|
415
|
+
const parent = snapshotFor(parentSessionId)
|
|
416
|
+
const provenAbsent = parent === null && support.supported
|
|
417
|
+
if (!provenAbsent) {
|
|
418
|
+
try {
|
|
419
|
+
captureInherited(sessionId, parentSessionId)
|
|
420
|
+
} catch (captureError) {
|
|
421
|
+
warn(`resume incarnation capture failed for session "${sessionId}": ${message(captureError)}`)
|
|
422
|
+
markBlocked(sessionId, 'unreadable')
|
|
423
|
+
}
|
|
339
424
|
}
|
|
425
|
+
// provenAbsent: no capture and no empty inherited record. The first
|
|
426
|
+
// readSelection then runs the ordinary root initialization for this
|
|
427
|
+
// depth-0 fork and publishes its own revision-0 snapshot.
|
|
340
428
|
}
|
|
341
429
|
return undefined
|
|
342
430
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* admit: (agent: unknown) => Promise<boolean>,
|
|
14
14
|
* drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void } }} config
|
|
15
15
|
*/
|
|
16
|
-
export function createMcpToolWrapper({ identity, generation, admit, drain }) {
|
|
16
|
+
export function createMcpToolWrapper({ identity, generation, admit, drain, assertPermit }) {
|
|
17
17
|
/**
|
|
18
18
|
* Wrap one tool definition with the last-moment check.
|
|
19
19
|
* @param {{ name?: unknown, execute?: unknown, [key: string]: unknown }} definition
|
|
@@ -33,7 +33,7 @@ export function createMcpToolWrapper({ identity, generation, admit, drain }) {
|
|
|
33
33
|
*/
|
|
34
34
|
async execute(args, exec) {
|
|
35
35
|
const admitted = await admit(exec?.agent)
|
|
36
|
-
if (!admitted) {
|
|
36
|
+
if (!admitted || (assertPermit && !assertPermit(admitted))) {
|
|
37
37
|
const error = new Error(`MCP server "${identity}" is not enabled for this agent (admission refused at generation ${generation})`)
|
|
38
38
|
throw Object.assign(error, { code: 'mcp-admission-refused' })
|
|
39
39
|
}
|