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.
- package/cordis.patch.yml +7 -18
- 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-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- 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/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1299 -0
- package/src/file-lease/index.js +34 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/src/worktree/rules.js +1 -1
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
|
@@ -21,6 +21,7 @@ import { openCapabilityStore } from './store/store.js'
|
|
|
21
21
|
import { buildReceiptPayload, buildListPayload, buildConditionsPayload, buildPresetsPayload, buildDefaultGetPayload } from './read-payloads.js'
|
|
22
22
|
import { PRESET_SELECTED_EVENT } from './preset-invalidation.js'
|
|
23
23
|
import { createAudit } from '../shared/audit.js'
|
|
24
|
+
import { mcpDiagnostic, sanitizeMcpPayload } from './mcp-diagnostics.js'
|
|
24
25
|
|
|
25
26
|
/** The host-root service key the typert gateway resolves (S27: ctx.get from the host root). */
|
|
26
27
|
export const CAPABILITY_READ_SERVICE_KEY = 'weirCapabilityRead'
|
|
@@ -71,15 +72,74 @@ export function capabilityReadBridge(presetId) {
|
|
|
71
72
|
return feeds?.size === 1 ? [...feeds][0].faces : null
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The isolated `weirMcpManager` owned by the exact preset revision a live
|
|
77
|
+
* session's Agent retains, or undefined when that cannot be proven. The
|
|
78
|
+
* registry's `serviceFor(agent, name)` reads `agent.ctx`'s retained mount, so
|
|
79
|
+
* an old session keeps resolving its old revision after a config change or a
|
|
80
|
+
* bundle toggle remounted the preset next to it. Everything is read through
|
|
81
|
+
* caller-supplied ctx getters (never a static runtime import).
|
|
82
|
+
* @param {string} sessionId
|
|
83
|
+
* @param {{ agents?: () => any, agentPresets?: () => any }} [runtime]
|
|
84
|
+
*/
|
|
85
|
+
function sessionManager(sessionId, runtime) {
|
|
86
|
+
try {
|
|
87
|
+
const agent = runtime?.agents?.()?.get?.(sessionId)
|
|
88
|
+
// A cold or unknown session has no live Agent and hence no retained mount.
|
|
89
|
+
if (!agent?.ctx || agent.id !== sessionId) return undefined
|
|
90
|
+
const registry = runtime?.agentPresets?.()
|
|
91
|
+
if (typeof registry?.serviceFor !== 'function') return undefined
|
|
92
|
+
return registry.serviceFor(agent, 'weirMcpManager') ?? undefined
|
|
93
|
+
} catch { return undefined }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the feed owning ONE session's read/mutation.
|
|
98
|
+
*
|
|
99
|
+
* A single feed is the fast path (no agent lookup). Several feeds for one
|
|
100
|
+
* preset are legitimate — two retained revisions of the same preset stay
|
|
101
|
+
* mounted while old sessions still use the old one — so the owner is the
|
|
102
|
+
* unique feed whose `faces.mcpManager()` IS the manager the session's own
|
|
103
|
+
* revision provides. Every other outcome fails closed with `owner-ambiguous`:
|
|
104
|
+
* no runtime seam, a cold/unknown agent, a revision without a manager, no
|
|
105
|
+
* matching feed, or several feeds sharing that manager (overlapping
|
|
106
|
+
* generations of one revision). Never newest-wins.
|
|
107
|
+
* @param {string} presetId
|
|
108
|
+
* @param {string} sessionId
|
|
109
|
+
* @param {{ agents?: () => any, agentPresets?: () => any }} [runtime]
|
|
110
|
+
*/
|
|
111
|
+
function sessionFeed(presetId, sessionId, runtime) {
|
|
75
112
|
const feeds = bridgeFeeds.get(presetId)
|
|
76
113
|
if (!feeds?.size) throw new CapabilityReadError('bridge-absent', `capability read: no feed for preset ${presetId}`)
|
|
77
|
-
if (feeds.size
|
|
78
|
-
|
|
114
|
+
if (feeds.size === 1) return [...feeds][0]
|
|
115
|
+
const ambiguous = reason => new CapabilityReadError('owner-ambiguous', `capability read: overlapping feeds for preset ${presetId}: ${reason}`)
|
|
116
|
+
const manager = sessionManager(sessionId, runtime)
|
|
117
|
+
if (manager === undefined) throw ambiguous(`session ${JSON.stringify(sessionId)} has no live agent with a preset-owned MCP manager`)
|
|
118
|
+
const matches = [...feeds].filter(feed => {
|
|
119
|
+
try { return feed.faces?.mcpManager?.() === manager } catch { return false }
|
|
120
|
+
})
|
|
121
|
+
if (matches.length === 0) throw ambiguous(`no feed belongs to the revision of session ${JSON.stringify(sessionId)}`)
|
|
122
|
+
if (matches.length > 1) throw ambiguous(`${matches.length} feeds share the manager of session ${JSON.stringify(sessionId)}`)
|
|
123
|
+
return matches[0]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Exact feed token used by mutation ownership snapshots and their rechecks.
|
|
128
|
+
* @param {string} presetId
|
|
129
|
+
* @param {string} sessionId
|
|
130
|
+
* @param {{ agents?: () => any, agentPresets?: () => any }} [runtime] ctx getters for the host `agents` and `agentPresets` services
|
|
131
|
+
*/
|
|
132
|
+
export function capabilityReadFeed(presetId, sessionId, runtime = undefined) { return sessionFeed(presetId, sessionId, runtime) }
|
|
133
|
+
|
|
134
|
+
/** @param {{ bridge?: Function, agents?: () => any, agentPresets?: () => any }} dependencies */
|
|
135
|
+
function feedResolver(dependencies) {
|
|
136
|
+
if (dependencies.bridge) return dependencies.bridge
|
|
137
|
+
const runtime = { agents: dependencies.agents, agentPresets: dependencies.agentPresets }
|
|
138
|
+
return (presetId, sessionId) => sessionFeed(presetId, sessionId, runtime)
|
|
79
139
|
}
|
|
80
140
|
|
|
81
141
|
/** Extract the current projected owner and its last committed selection generation. */
|
|
82
|
-
function
|
|
142
|
+
export function capabilityOwnership(observation, sessionId) {
|
|
83
143
|
if (!observation || observation.header?.id !== sessionId) {
|
|
84
144
|
throw new CapabilityReadError('unknown-session', `capability read: unknown session ${JSON.stringify(sessionId)}`)
|
|
85
145
|
}
|
|
@@ -95,7 +155,7 @@ function ownership(observation, sessionId) {
|
|
|
95
155
|
|
|
96
156
|
/** Create the host read service. Observations are caller-owned leases, always disposed. */
|
|
97
157
|
export function createCapabilityReadService(dependencies = {}) {
|
|
98
|
-
const bridgeFor = dependencies
|
|
158
|
+
const bridgeFor = feedResolver(dependencies)
|
|
99
159
|
const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
|
|
100
160
|
const ensure = dependencies.ensureRegistered ?? (() => true)
|
|
101
161
|
const onDegraded = dependencies.onDegraded ?? (() => {})
|
|
@@ -105,7 +165,7 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
105
165
|
try {
|
|
106
166
|
lease = await observeSession(sessionId)
|
|
107
167
|
if (!lease || typeof lease[Symbol.dispose] !== 'function') throw new CapabilityReadError('owner-unavailable', 'capability read: session observation is unavailable')
|
|
108
|
-
return { lease, owner:
|
|
168
|
+
return { lease, owner: capabilityOwnership(lease, sessionId) }
|
|
109
169
|
} catch (cause) {
|
|
110
170
|
lease?.[Symbol.dispose]?.()
|
|
111
171
|
if (cause instanceof CapabilityReadError) throw cause
|
|
@@ -117,7 +177,7 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
117
177
|
const first = await observe(sessionId)
|
|
118
178
|
try {
|
|
119
179
|
const { presetId, cwd, selectionSeq } = first.owner
|
|
120
|
-
const feed = bridgeFor(presetId)
|
|
180
|
+
const feed = bridgeFor(presetId, sessionId)
|
|
121
181
|
if (!feed) throw new CapabilityReadError('bridge-absent', `capability read: no feed for preset ${presetId}`)
|
|
122
182
|
const faces = feed.faces ?? feed
|
|
123
183
|
const options = { cwd, scope: { session: { id: sessionId } } }
|
|
@@ -149,7 +209,7 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
149
209
|
}
|
|
150
210
|
const second = await observe(sessionId)
|
|
151
211
|
try {
|
|
152
|
-
if (second.owner.presetId !== presetId || second.owner.cwd !== cwd || second.owner.selectionSeq !== selectionSeq || bridgeFor(presetId) !== feed) {
|
|
212
|
+
if (second.owner.presetId !== presetId || second.owner.cwd !== cwd || second.owner.selectionSeq !== selectionSeq || bridgeFor(presetId, sessionId) !== feed) {
|
|
153
213
|
throw new CapabilityReadError('owner-changed', 'capability read: owner or feed changed during read')
|
|
154
214
|
}
|
|
155
215
|
return payload
|
|
@@ -185,27 +245,143 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
185
245
|
}
|
|
186
246
|
}
|
|
187
247
|
|
|
248
|
+
export const CAPABILITY_MUTATION_SERVICE_KEY = 'weirCapabilityMutation'
|
|
249
|
+
export const CAPABILITY_MUTATION_NAMESPACE = 'weirCapabilityMutations'
|
|
250
|
+
export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch']
|
|
251
|
+
|
|
252
|
+
export function sameMutationOwner(first, second) {
|
|
253
|
+
const profile = value => value ? `${value.home ?? ''}\0${value.name ?? ''}` : ''
|
|
254
|
+
return first.owner.presetId === second.owner.presetId
|
|
255
|
+
&& first.owner.cwd === second.owner.cwd
|
|
256
|
+
&& first.feed === second.feed
|
|
257
|
+
&& profile(first.profile) === profile(second.profile)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Session-preset/profile/feed ownership for mutations; selectionSeq is not compared. */
|
|
261
|
+
export function createCapabilityMutationService(dependencies = {}) {
|
|
262
|
+
const bridgeFor = feedResolver(dependencies)
|
|
263
|
+
const ensure = dependencies.ensureRegistered ?? (() => true)
|
|
264
|
+
const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
|
|
265
|
+
const observe = async sessionId => {
|
|
266
|
+
let lease
|
|
267
|
+
try {
|
|
268
|
+
lease = await observeSession(sessionId)
|
|
269
|
+
if (!lease || typeof lease[Symbol.dispose] !== 'function') throw new CapabilityReadError('owner-unavailable', 'capability mutation: session observation unavailable')
|
|
270
|
+
const owner = capabilityOwnership(lease, sessionId)
|
|
271
|
+
const feed = bridgeFor(owner.presetId, sessionId)
|
|
272
|
+
if (!feed) throw new CapabilityReadError('bridge-absent', `capability mutation: no feed for preset ${owner.presetId}`)
|
|
273
|
+
const faces = feed.faces ?? feed
|
|
274
|
+
return { lease, owner, feed, faces, profile: faces.profileContext?.() }
|
|
275
|
+
} catch (cause) {
|
|
276
|
+
lease?.[Symbol.dispose]?.()
|
|
277
|
+
if (cause instanceof CapabilityReadError) throw cause
|
|
278
|
+
throw new CapabilityReadError('unknown-session', `capability mutation: cannot observe session ${JSON.stringify(sessionId)}`, { cause })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function mutate(sessionId, request, batch = false) {
|
|
282
|
+
if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
|
|
283
|
+
const first = await observe(sessionId)
|
|
284
|
+
try {
|
|
285
|
+
const assertOwner = async () => {
|
|
286
|
+
const current = await observe(sessionId)
|
|
287
|
+
try {
|
|
288
|
+
if (!sameMutationOwner(first, current)) throw new CapabilityReadError('owner-changed', 'capability mutation: session preset/profile/feed owner changed')
|
|
289
|
+
} finally { current.lease[Symbol.dispose]() }
|
|
290
|
+
}
|
|
291
|
+
if (typeof first.faces.mcpOperations !== 'function') throw new CapabilityReadError('bridge-absent', 'capability mutation: owning preset has no MCP operations')
|
|
292
|
+
const operations = first.faces.mcpOperations({ id: sessionId, header: { cwd: first.owner.cwd } }, { assertOwner })
|
|
293
|
+
let result
|
|
294
|
+
try {
|
|
295
|
+
result = batch ? await operations.executeBatch(request) : request?.action === 'preview' ? await operations.preview(request) : await operations.execute(request)
|
|
296
|
+
} catch (cause) {
|
|
297
|
+
result = { status: cause?.code === 'owner-changed' ? 'owner-changed' : 'validation-failed', saved: false, error: mcpDiagnostic(cause, { code: cause?.code ?? 'validation-failed' }) }
|
|
298
|
+
}
|
|
299
|
+
// A response may be stale by the time it is serialized. Never claim a
|
|
300
|
+
// rollback; return the operation's actual saved facts with owner-changed.
|
|
301
|
+
try {
|
|
302
|
+
await assertOwner()
|
|
303
|
+
} catch (cause) {
|
|
304
|
+
if (result.status !== 'owner-changed') {
|
|
305
|
+
return sanitizeMcpPayload({
|
|
306
|
+
...result,
|
|
307
|
+
status: 'owner-changed',
|
|
308
|
+
ownerChanged: true,
|
|
309
|
+
actual: {
|
|
310
|
+
status: result.status,
|
|
311
|
+
saved: result.saved === true,
|
|
312
|
+
registryRevision: result.registryRevision ?? null,
|
|
313
|
+
generation: result.generation ?? null,
|
|
314
|
+
incarnation: result.incarnation ?? null,
|
|
315
|
+
},
|
|
316
|
+
error: mcpDiagnostic(cause, { code: 'owner-changed' }),
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return sanitizeMcpPayload(result)
|
|
321
|
+
} finally {
|
|
322
|
+
first.lease[Symbol.dispose]()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
mutate: (sessionId, request) => mutate(sessionId, request, false),
|
|
327
|
+
batch: (sessionId, request) => mutate(sessionId, request, true),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Every endpoint the gateway resolves through this package's ONE host-face
|
|
333
|
+
* contribution, in contribution order. A live read says nothing about write
|
|
334
|
+
* availability, so liveness is always checked across this whole set.
|
|
335
|
+
*/
|
|
336
|
+
export const CAPABILITY_ENDPOINTS = [
|
|
337
|
+
...CAPABILITY_READ_METHODS.map(method => `${CAPABILITY_READ_NAMESPACE}/${method}`),
|
|
338
|
+
...CAPABILITY_MUTATION_METHODS.map(method => `${CAPABILITY_MUTATION_NAMESPACE}/${method}`),
|
|
339
|
+
]
|
|
340
|
+
|
|
188
341
|
/**
|
|
189
342
|
* The hand-written typert contribution (S27: empty schemas/model, src-json
|
|
190
343
|
* codecs, no zod, no generated artifacts). Parameters and results are plain
|
|
191
344
|
* JSON; the envelope validates the argument name only.
|
|
345
|
+
*
|
|
346
|
+
* ALL five read and two mutation invocations live in ONE contribution, because
|
|
347
|
+
* the registry keys exactly one record per package+face and rejects any second
|
|
348
|
+
* registration while that record is live: two separate `weir-harness` host-face
|
|
349
|
+
* contributions could never overlap generations, and the face the mutation half
|
|
350
|
+
* used to declare (`host-mutations`) is not a face the registry accepts at all —
|
|
351
|
+
* register() threw and apply() swallowed it into a warning, so the write channel
|
|
352
|
+
* was silently dead. One atomic batch also means recovery restores every
|
|
353
|
+
* endpoint or none.
|
|
192
354
|
*/
|
|
193
|
-
export function
|
|
355
|
+
export function capabilityContribution() {
|
|
194
356
|
return {
|
|
195
|
-
// The registry's validatePackage requires both (missing = silently dead registration): it validates the package name and keys the package record by face.
|
|
357
|
+
// The registry's validatePackage requires both (missing = silently dead registration): it validates the package name, rejects any face except host/client, and keys the package record by face.
|
|
196
358
|
package: 'weir-harness',
|
|
197
359
|
face: 'host',
|
|
198
360
|
schemas: [],
|
|
199
361
|
model: { services: [], events: [], objects: [] },
|
|
200
|
-
invocations:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
362
|
+
invocations: [
|
|
363
|
+
...CAPABILITY_READ_METHODS.map(method => ({
|
|
364
|
+
id: `weir-harness.${CAPABILITY_READ_NAMESPACE}.${method}`,
|
|
365
|
+
service: CAPABILITY_READ_SERVICE_KEY,
|
|
366
|
+
namespace: CAPABILITY_READ_NAMESPACE,
|
|
367
|
+
method,
|
|
368
|
+
invocation: { kind: 'direct' },
|
|
369
|
+
parameters: [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } }],
|
|
370
|
+
result: { mode: 'src-json' },
|
|
371
|
+
})),
|
|
372
|
+
...CAPABILITY_MUTATION_METHODS.map(method => ({
|
|
373
|
+
id: `weir-harness.${CAPABILITY_MUTATION_NAMESPACE}.${method}`,
|
|
374
|
+
service: CAPABILITY_MUTATION_SERVICE_KEY,
|
|
375
|
+
namespace: CAPABILITY_MUTATION_NAMESPACE,
|
|
376
|
+
method,
|
|
377
|
+
invocation: { kind: 'direct' },
|
|
378
|
+
parameters: [
|
|
379
|
+
{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } },
|
|
380
|
+
{ name: 'request', wire: 'request', source: 'json', codec: { mode: 'src-json' } },
|
|
381
|
+
],
|
|
382
|
+
result: { mode: 'src-json' },
|
|
383
|
+
})),
|
|
384
|
+
],
|
|
209
385
|
}
|
|
210
386
|
}
|
|
211
387
|
|
|
@@ -222,6 +398,20 @@ export function capabilityReadContribution() {
|
|
|
222
398
|
* gateway dispatch resolves through it as well, so the loader must order this
|
|
223
399
|
* row after the typert provider — including a composition where a user-layer
|
|
224
400
|
* patch injects typert after the bundle rows (S27).
|
|
401
|
+
*
|
|
402
|
+
* Registration lifecycle (S27.5, gateway/definition-unavailable): a fiber
|
|
403
|
+
* reload runs apply() again while the previous incarnation's effects are still
|
|
404
|
+
* unwinding, so BOTH the service provision and the contribution registration can
|
|
405
|
+
* lose that race, and the old incarnation then withdraws what the new one
|
|
406
|
+
* skipped. Because the gateway refuses a withdrawn strict definition before it
|
|
407
|
+
* ever reaches a service method (hasSeen + no live definition), the recovery
|
|
408
|
+
* must live in this plugin's own fiber: it subscribes to `typert.local` changes
|
|
409
|
+
* and re-attempts provision+registration on a coalesced microtask while the
|
|
410
|
+
* endpoints are incomplete. Every one of those facts is per-fiber state: a
|
|
411
|
+
* pending retry, the disposal latch and the warning latch belong to THIS
|
|
412
|
+
* generation, so an old generation can never consume the survivor's retry, and
|
|
413
|
+
* a disposed fiber cannot resurrect the endpoints (Cordis rejects its effect
|
|
414
|
+
* creation with INACTIVE_EFFECT — the final barrier, never a retry loop).
|
|
225
415
|
*/
|
|
226
416
|
export function apply(ctx) {
|
|
227
417
|
const warn = text => ctx.logger?.warn?.(text)
|
|
@@ -236,46 +426,107 @@ export function apply(ctx) {
|
|
|
236
426
|
warn('weir capability remote: typert service is unavailable in this composition; capability reads stay unavailable on the client')
|
|
237
427
|
return
|
|
238
428
|
}
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const endpointLive =
|
|
245
|
-
try { return Boolean(typert.local?.get?.(
|
|
429
|
+
// Per-fiber lifecycle state. NEVER module-global: the previous incarnation's
|
|
430
|
+
// listener and this one can both observe the same withdrawal.
|
|
431
|
+
let disposed = false
|
|
432
|
+
let retryQueued = false
|
|
433
|
+
let registrationWarned = false
|
|
434
|
+
const endpointLive = endpoint => {
|
|
435
|
+
try { return Boolean(typert.local?.get?.(endpoint)) } catch { return false }
|
|
246
436
|
}
|
|
247
|
-
const
|
|
248
|
-
|
|
437
|
+
const endpointsComplete = () => CAPABILITY_ENDPOINTS.every(endpointLive)
|
|
438
|
+
let readService
|
|
439
|
+
let mutationService
|
|
440
|
+
// Provide one service unless another incarnation still holds the host-root
|
|
441
|
+
// key. A host-root provide lives on the ROOT fiber (S27: the gateway resolves
|
|
442
|
+
// ctx.get from the host root), so the first incarnation's receiver survives
|
|
443
|
+
// every reload; replacing it is neither possible nor needed. A lost race is
|
|
444
|
+
// not fatal — the retry below keeps every endpoint self-healing.
|
|
445
|
+
const ensureService = (key, service) => {
|
|
249
446
|
try {
|
|
250
|
-
|
|
447
|
+
const current = typeof ctx.get === 'function' ? ctx.get(key) : undefined
|
|
448
|
+
if (current === service) return true
|
|
449
|
+
if (current !== undefined) return false
|
|
450
|
+
ctx.root.reflect.provide(key, service)
|
|
251
451
|
return true
|
|
452
|
+
} catch { return false }
|
|
453
|
+
}
|
|
454
|
+
const ensureServices = () => {
|
|
455
|
+
const read = ensureService(CAPABILITY_READ_SERVICE_KEY, readService)
|
|
456
|
+
const mutation = ensureService(CAPABILITY_MUTATION_SERVICE_KEY, mutationService)
|
|
457
|
+
return read && mutation
|
|
458
|
+
}
|
|
459
|
+
// Exactly ONE register attempt per call: the registry rejects the whole batch
|
|
460
|
+
// or commits all of it, so looping could only repeat the same rejection.
|
|
461
|
+
const ensureContribution = () => {
|
|
462
|
+
if (endpointsComplete()) return true
|
|
463
|
+
try {
|
|
464
|
+
typert.register(capabilityContribution())
|
|
252
465
|
} catch (cause) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
466
|
+
if (!endpointsComplete()) {
|
|
467
|
+
// A disposed fiber's rejection is terminal (the survivor owns the retry).
|
|
468
|
+
if (cause?.code === 'INACTIVE_EFFECT') {
|
|
469
|
+
disposed = true
|
|
470
|
+
return false
|
|
471
|
+
}
|
|
472
|
+
if (!registrationWarned) {
|
|
473
|
+
registrationWarned = true
|
|
474
|
+
warn(`weir capability remote: registration failed (${cause instanceof Error ? cause.message : String(cause)}); capability endpoints stay unavailable on the client`)
|
|
475
|
+
}
|
|
476
|
+
return false
|
|
477
|
+
}
|
|
258
478
|
}
|
|
479
|
+
return endpointsComplete()
|
|
259
480
|
}
|
|
481
|
+
const ensureRegistered = () => ensureContribution()
|
|
482
|
+
// Coalesce a burst of registry changes into one microtask. Waiting a turn also
|
|
483
|
+
// lets the disposing incarnation finish synchronously releasing its service
|
|
484
|
+
// keys, which a synchronous retry inside the change callback would still see.
|
|
485
|
+
const queueRetry = () => {
|
|
486
|
+
if (disposed || retryQueued) return
|
|
487
|
+
retryQueued = true
|
|
488
|
+
queueMicrotask(() => {
|
|
489
|
+
retryQueued = false
|
|
490
|
+
if (disposed || endpointsComplete()) return
|
|
491
|
+
ensureServices()
|
|
492
|
+
ensureContribution()
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
// Latch disposal as early as the fiber allows, before a queued retry can run
|
|
496
|
+
// against a fiber Cordis has already detached. A fiber that is already gone
|
|
497
|
+
// needs no latch: its next register() is rejected with INACTIVE_EFFECT.
|
|
498
|
+
try {
|
|
499
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => () => { disposed = true })
|
|
500
|
+
} catch { /* detached fiber: the effect rejection is the barrier */ }
|
|
260
501
|
try {
|
|
261
502
|
const audit = createAudit(ctx)
|
|
262
503
|
const onDegraded = facts => {
|
|
263
504
|
warn(`weir capability read degraded: method=${facts.method} session=${facts.sessionId} preset=${facts.presetId} header=${facts.headerPreset} source=${facts.source} warnings=${facts.warnings.join(';')} provider=${facts.providerReason ?? '-'}: ${facts.providerError ?? '-'}`)
|
|
264
505
|
audit({ id: facts.sessionId, header: { cwd: facts.cwd } }, 'capability-read', facts)
|
|
265
506
|
}
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
})
|
|
274
|
-
Object.freeze(
|
|
275
|
-
ctx.root.reflect.provide(CAPABILITY_READ_SERVICE_KEY, service)
|
|
276
|
-
ensureRegistered()
|
|
507
|
+
// Per-session owner resolution reads the host agents registry and the
|
|
508
|
+
// preset registry lazily (both host-root services; no mount-time inject).
|
|
509
|
+
const runtime = { agents: () => ctx.get?.('agents'), agentPresets: () => ctx.get?.('agentPresets') }
|
|
510
|
+
readService = createCapabilityReadService({ ensureRegistered, sessionQuery: () => ctx.get?.('sessionQuery'), onDegraded, ...runtime })
|
|
511
|
+
readService.typertRemote = Object.freeze({ service: readService, serviceKey: CAPABILITY_READ_SERVICE_KEY, namespace: CAPABILITY_READ_NAMESPACE })
|
|
512
|
+
Object.freeze(readService)
|
|
513
|
+
mutationService = createCapabilityMutationService({ ensureRegistered, sessionQuery: () => ctx.get?.('sessionQuery'), ...runtime })
|
|
514
|
+
mutationService.typertRemote = Object.freeze({ service: mutationService, serviceKey: CAPABILITY_MUTATION_SERVICE_KEY, namespace: CAPABILITY_MUTATION_NAMESPACE })
|
|
515
|
+
Object.freeze(mutationService)
|
|
277
516
|
} catch (cause) {
|
|
278
517
|
warn(`weir capability remote: setup failed (${cause instanceof Error ? cause.message : String(cause)}); capability reads stay unavailable on the client`)
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
// Provision BEFORE registering: the gateway resolves the service key of a
|
|
521
|
+
// live definition, so an endpoint must never outlive its receiver.
|
|
522
|
+
ensureServices()
|
|
523
|
+
ensureContribution()
|
|
524
|
+
// Independent per-fiber subscription: hosts without it keep the older
|
|
525
|
+
// self-heal-on-call behaviour (each service call still checks the endpoints).
|
|
526
|
+
try {
|
|
527
|
+
typert.local?.subscribe?.(() => { if (!endpointsComplete()) queueRetry() })
|
|
528
|
+
} catch (cause) {
|
|
529
|
+
warn(`weir capability remote: registration watch failed (${cause instanceof Error ? cause.message : String(cause)}); the capability channel only self-heals on the next call`)
|
|
279
530
|
}
|
|
280
531
|
}
|
|
281
532
|
|
|
@@ -154,7 +154,7 @@ export function resolveSkillNamesForSave(names, candidates, appliedSkills) {
|
|
|
154
154
|
return { identities, missing, ambiguous }
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
export function resolveInitialSelection({ defaultsRecord, builtinIdentities = [], enabledMcpIdentities = [] }) {
|
|
157
|
+
export function resolveInitialSelection({ defaultsRecord, builtinIdentities = [], enabledMcpIdentities = [], mcpIncarnations }) {
|
|
158
158
|
// A cleared marker is an explicit removal (9.4): it behaves as ABSENT —
|
|
159
159
|
// clearing is never an explicit empty set, which stays a savable choice.
|
|
160
160
|
const cleared = defaultsRecord && defaultsRecord.kind === 'ok'
|
|
@@ -169,13 +169,14 @@ export function resolveInitialSelection({ defaultsRecord, builtinIdentities = []
|
|
|
169
169
|
selection: {
|
|
170
170
|
skills: Array.isArray(payload.skills) ? structuredClone(payload.skills) : [],
|
|
171
171
|
mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers).slice() : [],
|
|
172
|
+
...((mcpIncarnations ?? payload.mcpIncarnations) ? { mcpIncarnations: structuredClone(mcpIncarnations ?? payload.mcpIncarnations) } : {}),
|
|
172
173
|
},
|
|
173
174
|
missing: Array.isArray(payload.unresolvedRefs) ? payload.unresolvedRefs.slice() : [],
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
return {
|
|
177
178
|
source: 'builtin-baseline',
|
|
178
|
-
selection: { skills: builtinIdentities.slice(), mcpServers: enabledMcpIdentities.slice() },
|
|
179
|
+
selection: { skills: builtinIdentities.slice(), mcpServers: enabledMcpIdentities.slice(), ...(mcpIncarnations ? { mcpIncarnations: structuredClone(mcpIncarnations) } : {}) },
|
|
179
180
|
missing: [],
|
|
180
181
|
}
|
|
181
182
|
}
|
|
@@ -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)}`)
|