weir-harness 2.1.1 → 2.1.3

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.
@@ -72,32 +72,6 @@ export function createMcpRuntime() {
72
72
  }
73
73
  }
74
74
 
75
- /**
76
- * Per-identity FIFO for every operation that opens a runtime fence
77
- * (runtime.begin). Same-identity operations run strictly one after another
78
- * (design D6), so a Restart can never begin while an edit-mode Test holds the
79
- * fence and leave the server mounted but refusing calls. A task's failure never
80
- * poisons the queue; different identities never wait on each other. Not
81
- * reentrant: callers already inside serialize() pass their token instead.
82
- */
83
- export function createMcpIdentityLock() {
84
- const tails = new Map()
85
- return function serialize(identity, task) {
86
- const previous = tails.get(identity) ?? Promise.resolve()
87
- const run = previous.then(() => task())
88
- const tail = run.then(() => {}, () => {})
89
- tails.set(identity, tail)
90
- void tail.then(() => { if (tails.get(identity) === tail) tails.delete(identity) })
91
- return run
92
- }
93
- }
94
-
95
- const fallbackLocks = new WeakMap()
96
- /** The manager's own lock, or one stable lock per manager object for fakes/older faces. */
97
- export function mcpIdentityLockFor(manager) {
98
- if (typeof manager?.serialize === 'function') return (identity, task) => manager.serialize(identity, task)
99
- if (!manager || (typeof manager !== 'object' && typeof manager !== 'function')) return createMcpIdentityLock()
100
- let lock = fallbackLocks.get(manager)
101
- if (!lock) { lock = createMcpIdentityLock(); fallbackLocks.set(manager, lock) }
102
- return lock
103
- }
75
+ // Compatibility exports: the implementations live with the manager-owned
76
+ // coordinator so queue/fence/drain rules have one semantic implementation.
77
+ export { createMcpIdentityLock, mcpIdentityLockFor } from './mcp-operation-coordinator.js'
@@ -3,6 +3,8 @@ import { dirname, join, resolve } from 'node:path'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { createSkillIdentity } from './skill-identity.js'
5
5
  import { parseSkillText } from './frontmatter.js'
6
+ import { canonicalPathKey } from './skill-applicability.js'
7
+ import { requireSkillScanPaths } from './skill-scan-path-registry.js'
6
8
 
7
9
  export const SKILL_ROOT_RANKS = Object.freeze({ 'project-dsh': 100, 'project-agents': 200, custom: 300, 'user-dsh': 400, 'user-agents': 500, bundled: 600 })
8
10
 
@@ -19,7 +21,7 @@ export async function findProjectRoot(cwd, fs = nodeFs) {
19
21
  }
20
22
 
21
23
  /** Homes are supplied by the host adapter, not inferred from the process profile. */
22
- export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillDirs = [], bundledSkillDir, weirBuiltinDir, includeDefaultRoots = true }, fs = nodeFs) {
24
+ export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillDirs = [], bundledSkillDir, weirBuiltinDir, includeDefaultRoots = true, skillScanPathRegistry }, fs = nodeFs) {
23
25
  const roots = []
24
26
  const add = (path, source, scope, extra = {}) => roots.push({ path: resolve(path), source, scope, rank: SKILL_ROOT_RANKS[source], ...extra })
25
27
  if (includeDefaultRoots && cwd !== undefined) {
@@ -37,7 +39,21 @@ export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillD
37
39
  }
38
40
  // Host bundles are not automatically Weir-owned or selected.
39
41
  if (bundledSkillDir !== undefined) add(bundledSkillDir, 'bundled', 'custom', { trustedHost: true })
40
- return roots
42
+ // Existing composition/default roots keep their source, scope and rank. User
43
+ // registry roots append in registry order and never replace Creative's roots.
44
+ if (skillScanPathRegistry) {
45
+ for (const path of await requireSkillScanPaths(skillScanPathRegistry)) add(path, 'custom', 'custom')
46
+ }
47
+ // Canonical identity, not lexical spelling, controls enumeration. A root whose
48
+ // canonical key cannot be proven stays independent so discovery's established
49
+ // partial-failure path reports its real unreadable/missing outcome.
50
+ const seen = new Set()
51
+ return roots.filter((root, index) => {
52
+ const key = canonicalPathKey(root.path) ?? `unresolved:${index}`
53
+ if (seen.has(key)) return false
54
+ seen.add(key)
55
+ return true
56
+ })
41
57
  }
42
58
 
43
59
  const rootKey = root => JSON.stringify([resolve(root.path), root.source, root.scope, root.rank])
@@ -0,0 +1,238 @@
1
+ // Profile-scoped, user-managed Skill discovery roots. The capability store owns
2
+ // locking, revisions and crash-safe writes; this module owns strict payload
3
+ // validation and canonical directory semantics. No mutation executes a command,
4
+ // appends to a session, or treats a failed write as a discovery change.
5
+ import { constants } from 'node:fs'
6
+ import { access, realpath, stat } from 'node:fs/promises'
7
+ import { homedir } from 'node:os'
8
+ import * as nodePath from 'node:path'
9
+ import { canonicalPathKey } from './skill-applicability.js'
10
+
11
+ export const SKILL_SCAN_PATH_UNIT = Object.freeze({ kind: 'skill-scan-paths' })
12
+
13
+ const message = error => error instanceof Error ? error.message : String(error)
14
+ const codeOf = error => error?.code
15
+ const missing = error => ['ENOENT', 'ENOTDIR'].includes(codeOf(error))
16
+ const denied = error => ['EACCES', 'EPERM'].includes(codeOf(error))
17
+ const clone = paths => [...paths]
18
+
19
+ class SkillPathFailure extends Error {
20
+ constructor(code, text, result = undefined) {
21
+ super(text)
22
+ this.name = 'SkillPathFailure'
23
+ this.code = code
24
+ this.result = result
25
+ }
26
+ }
27
+
28
+ const validation = (code, text) => ({
29
+ status: 'validation-failed', saved: false, revision: null, paths: null,
30
+ error: { code, message: text },
31
+ })
32
+
33
+ const unavailable = (code, text, revision = null) => ({
34
+ status: 'unavailable', saved: false, revision, paths: null,
35
+ error: { code, message: text },
36
+ })
37
+
38
+ /**
39
+ * Comparison of paths already canonicalized by realpath. Registry payloads never
40
+ * store this folded key: the canonical spelling remains the display/read value.
41
+ */
42
+ function storedPathKey(path, pathApi, platform) {
43
+ let key = pathApi.normalize(path)
44
+ if (platform === 'win32') key = key.replaceAll('\\', '/').toLowerCase()
45
+ return key.replace(/\/+$/, '')
46
+ }
47
+
48
+ /** Strict payload shape: unknown or malformed records are never repaired. */
49
+ function payloadPaths(payload, pathApi, platform) {
50
+ if (payload === undefined) return []
51
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)
52
+ || Object.keys(payload).length !== 1 || !Array.isArray(payload.paths)
53
+ || !payload.paths.every(path => typeof path === 'string' && path.length > 0 && pathApi.isAbsolute(path))) {
54
+ throw new SkillPathFailure('invalid-payload', 'Skill scan path registry payload is invalid')
55
+ }
56
+ const seen = new Set()
57
+ for (const path of payload.paths) {
58
+ const key = storedPathKey(path, pathApi, platform)
59
+ if (seen.has(key)) throw new SkillPathFailure('invalid-payload', 'Skill scan path registry contains duplicate paths')
60
+ seen.add(key)
61
+ }
62
+ return clone(payload.paths)
63
+ }
64
+
65
+ /**
66
+ * @param {{
67
+ * store: ReturnType<typeof import('./store/store.js').openCapabilityStore>,
68
+ * fs?: { realpath(path: string): Promise<string>, stat(path: string): Promise<{ isDirectory(): boolean }>, access(path: string, mode?: number): Promise<void> },
69
+ * home?: string,
70
+ * platform?: string,
71
+ * pathApi?: typeof nodePath,
72
+ * }} options
73
+ */
74
+ export function createSkillScanPathRegistry(options) {
75
+ const store = options.store
76
+ const fs = options.fs ?? { realpath, stat, access }
77
+ const platform = options.platform ?? process.platform
78
+ const pathApi = options.pathApi ?? (platform === 'win32' ? nodePath.win32 : nodePath)
79
+ const home = options.home ?? homedir()
80
+ const unit = SKILL_SCAN_PATH_UNIT
81
+
82
+ const pathKey = path => storedPathKey(path, pathApi, platform)
83
+
84
+ function expanded(input) {
85
+ if (typeof input !== 'string') throw new SkillPathFailure('path-invalid', 'Skill scan path must be a string')
86
+ const trimmed = input.trim()
87
+ if (!trimmed) throw new SkillPathFailure('path-empty', 'Skill scan path cannot be empty')
88
+ let value = trimmed
89
+ if (trimmed === '~') value = home
90
+ else if (trimmed.startsWith('~/') || (platform === 'win32' && trimmed.startsWith('~\\'))) {
91
+ if (typeof home !== 'string' || !pathApi.isAbsolute(home)) throw new SkillPathFailure('home-unavailable', 'Home directory is unavailable')
92
+ value = pathApi.resolve(home, trimmed.slice(2))
93
+ }
94
+ if (!pathApi.isAbsolute(value)) throw new SkillPathFailure('path-not-absolute', 'Skill scan path must be absolute or start with ~')
95
+ return value
96
+ }
97
+
98
+ async function canonicalForAdd(input) {
99
+ const value = expanded(input)
100
+ let canonical
101
+ try { canonical = await fs.realpath(value) }
102
+ catch (error) {
103
+ if (missing(error)) throw new SkillPathFailure('path-missing', `Skill scan path does not exist: ${value}`)
104
+ if (denied(error)) throw new SkillPathFailure('path-unreadable', `Skill scan path is unreadable: ${value}`)
105
+ throw new SkillPathFailure('path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${message(error)}`)
106
+ }
107
+ let info
108
+ try { info = await fs.stat(canonical) }
109
+ catch (error) {
110
+ throw new SkillPathFailure(denied(error) ? 'path-unreadable' : 'path-missing', `Cannot inspect Skill scan path: ${message(error)}`)
111
+ }
112
+ if (!info.isDirectory()) throw new SkillPathFailure('path-not-directory', `Skill scan path is not a directory: ${canonical}`)
113
+ try { await fs.access(canonical, constants.R_OK) }
114
+ catch (error) { throw new SkillPathFailure('path-unreadable', `Skill scan path is not readable: ${message(error)}`) }
115
+ return { path: canonical, key: pathKey(canonical) }
116
+ }
117
+
118
+ async function keyForRemove(input) {
119
+ const value = expanded(input)
120
+ try { return pathKey(await fs.realpath(value)) }
121
+ catch (error) {
122
+ if (!missing(error)) {
123
+ throw new SkillPathFailure(denied(error) ? 'path-unreadable' : 'path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${message(error)}`)
124
+ }
125
+ // Removal must remain possible after a directory disappears. The shared
126
+ // platform key canonicalizes the deepest surviving ancestor without
127
+ // requiring the removed directory itself to exist.
128
+ const key = canonicalPathKey(value, platform)
129
+ if (key === null) throw new SkillPathFailure('path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${value}`)
130
+ return key
131
+ }
132
+ }
133
+
134
+ async function read() {
135
+ let record
136
+ try { record = await store.read(unit) }
137
+ catch (error) { return unavailable('read-failed', `Skill scan path registry is unreadable: ${message(error)}`) }
138
+ if (record.kind === 'absent') return { status: 'ok', revision: 0, paths: [] }
139
+ if (record.kind !== 'ok') return unavailable(record.kind, `Skill scan path registry is ${record.kind}`)
140
+ try {
141
+ return { status: 'ok', revision: record.revision, paths: payloadPaths(record.payload, pathApi, platform) }
142
+ } catch (error) {
143
+ return unavailable(error?.code ?? 'invalid-payload', message(error), record.revision)
144
+ }
145
+ }
146
+
147
+ async function currentAfterConflict(result) {
148
+ const current = await read()
149
+ if (current.status === 'ok') return { revision: current.revision, paths: current.paths, error: null }
150
+ return {
151
+ revision: result.revision ?? current.revision ?? null,
152
+ paths: null,
153
+ error: { code: 'current-state-unavailable', message: `Revision conflict; current registry state is unavailable: ${current.error?.message ?? current.status}` },
154
+ }
155
+ }
156
+
157
+ async function mutate(kind, input, expectedRevision, options = {}) {
158
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
159
+ return validation('revision-invalid', 'expectedRevision must be a non-negative safe integer')
160
+ }
161
+ let candidate
162
+ try { candidate = kind === 'add' ? await canonicalForAdd(input) : { key: await keyForRemove(input) } }
163
+ catch (error) {
164
+ if (error instanceof SkillPathFailure) return validation(error.code, error.message)
165
+ return validation('path-validation-failed', message(error))
166
+ }
167
+
168
+ let result
169
+ try {
170
+ result = await store.commit(unit, expectedRevision, async payload => {
171
+ let paths
172
+ try { paths = payloadPaths(payload, pathApi, platform) }
173
+ catch (error) {
174
+ throw new SkillPathFailure(error?.code ?? 'invalid-payload', message(error), unavailable(error?.code ?? 'invalid-payload', message(error)))
175
+ }
176
+ const index = paths.findIndex(path => pathKey(path) === candidate.key)
177
+ if (kind === 'add' && index >= 0) {
178
+ throw new SkillPathFailure('duplicate', `Skill scan path is already registered: ${paths[index]}`, {
179
+ status: 'duplicate', saved: false, revision: expectedRevision, paths: clone(paths),
180
+ error: { code: 'duplicate', message: `Skill scan path is already registered: ${paths[index]}` },
181
+ })
182
+ }
183
+ if (kind === 'remove' && index < 0) {
184
+ throw new SkillPathFailure('not-found', `Skill scan path is not registered: ${input}`, {
185
+ status: 'not-found', saved: false, revision: expectedRevision, paths: clone(paths),
186
+ error: { code: 'not-found', message: `Skill scan path is not registered: ${input}` },
187
+ })
188
+ }
189
+ // Ownership is rechecked under the unit lock, immediately before the
190
+ // payload is handed to the store's atomic write phase.
191
+ await options.assertOwner?.()
192
+ return { paths: kind === 'add' ? [...paths, candidate.path] : paths.filter((_, i) => i !== index) }
193
+ })
194
+ } catch (error) {
195
+ if (error?.code === 'owner-changed') throw error
196
+ if (error instanceof SkillPathFailure && error.result) return error.result
197
+ return {
198
+ status: 'storage-failed', saved: false, revision: null, paths: null,
199
+ error: { code: 'storage-failed', message: message(error) },
200
+ }
201
+ }
202
+
203
+ if (result.status === 'committed') {
204
+ return {
205
+ status: kind === 'add' ? 'saved' : 'removed', saved: true,
206
+ revision: result.revision, paths: clone(result.payload.paths), error: null,
207
+ }
208
+ }
209
+ if (result.status === 'revision-conflict') {
210
+ const current = await currentAfterConflict(result)
211
+ return {
212
+ status: 'revision-conflict', saved: false, revision: current.revision, paths: current.paths,
213
+ error: current.error ?? { code: 'revision-conflict', message: `Skill scan path registry revision ${expectedRevision} is stale` },
214
+ }
215
+ }
216
+ return {
217
+ status: result.status === 'write-failed' ? 'indeterminate' : result.status,
218
+ saved: false, revision: result.revision ?? null, paths: null,
219
+ error: { code: result.kind ?? result.status, message: result.reason ?? message(result.error ?? `Skill scan path registry write failed: ${result.status}`) },
220
+ }
221
+ }
222
+
223
+ return {
224
+ unit,
225
+ read,
226
+ add: (path, expectedRevision, options) => mutate('add', path, expectedRevision, options),
227
+ remove: (path, expectedRevision, options) => mutate('remove', path, expectedRevision, options),
228
+ }
229
+ }
230
+
231
+ /** Registry read failures must become visible inventory/roots failures. */
232
+ export async function requireSkillScanPaths(registry) {
233
+ const result = await registry.read()
234
+ if (result.status !== 'ok') {
235
+ throw new Error(`Skill scan path registry unavailable: ${result.error?.message ?? result.status}`)
236
+ }
237
+ return result.paths
238
+ }
@@ -7,12 +7,14 @@ import { fileURLToPath } from 'node:url'
7
7
  import { createSkillIdentity, skillIdentityKey } from './skill-identity.js'
8
8
  import { openCapabilityStore } from './store/store.js'
9
9
  import { discoverSkillInventory, resolveSkillRoots } from './skill-inventory.js'
10
+ import { createSkillScanPathRegistry } from './skill-scan-path-registry.js'
10
11
  import { createSkillSelectionProvider } from './skill-selection-provider.js'
11
12
  import { assertBuiltinSkillMigration } from './skill-builtin-migration.js'
12
13
  import { createOfficeAdapter, officeDenials } from './skill-office-adapter.js'
13
14
  import { createPresetInvalidation, emitPresetSelected } from './preset-invalidation.js'
14
15
  import { createLifecycleSnapshots } from './lifecycle-snapshot.js'
15
16
  import { preloadLifecycleSnapshots } from './lifecycle-preload.js'
17
+ import { lifecycleFeedRegistry } from './lifecycle-feed.js'
16
18
  import { classifySelectionFailure } from './selection-status.js'
17
19
  import { resolveInitialSelection, baselineSkillIdentities, bindDefaultSkillNames } from './initial-selection.js'
18
20
  import { partitionUnresolvedSkills } from './skill-applicability.js'
@@ -66,6 +68,9 @@ export function createSkillSelectionPlugin(dependencies = {}) {
66
68
  /** Task 6.5 initialization reports per session (manager surface). */
67
69
  let initialReports = new Map()
68
70
  let lifecycle = null
71
+ const scanPathRegistry = dependencies.skillScanPathRegistry ?? createSkillScanPathRegistry({
72
+ store: openCapabilityStore({ profileContext: ctx.get?.('profileContext') }),
73
+ })
69
74
  try {
70
75
  ctx.skills.registerProvider(control => {
71
76
  const readSelection = dependencies.readSelection ?? (async options => {
@@ -133,6 +138,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
133
138
  customSkillDirs: config.customSkillDirs ?? [],
134
139
  bundledSkillDir: config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR,
135
140
  weirBuiltinDir: config.weirBuiltinDir ?? fileURLToPath(new URL('../../skills/', import.meta.url)),
141
+ skillScanPathRegistry: scanPathRegistry,
136
142
  })
137
143
  const snapshot = await discoverSkillInventory({ roots, machineId, previous })
138
144
  // First-run migration check (D-H 6): a non-equivalent builtin
@@ -322,6 +328,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
322
328
  customSkillDirs: config.customSkillDirs ?? [],
323
329
  bundledSkillDir: config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR,
324
330
  weirBuiltinDir: config.weirBuiltinDir ?? fileURLToPath(new URL('../../skills/', import.meta.url)),
331
+ skillScanPathRegistry: scanPathRegistry,
325
332
  })
326
333
  }
327
334
  // The shared cold-safe audit channel (cordis emit + .weir/audit.jsonl
@@ -646,6 +653,13 @@ export function createSkillSelectionPlugin(dependencies = {}) {
646
653
  }
647
654
  mounted.set(ctx, face)
648
655
  processFace = face
656
+ // Admission ownership is per mounted revision, not the process-local face.
657
+ // Resolve the manager lazily so either row may mount first; the effect owns
658
+ // exactly this token and its idempotent release cannot remove a replacement.
659
+ ctx.effect?.(() => lifecycleFeedRegistry.register({
660
+ lifecycle,
661
+ manager: () => ctx.get?.('weirMcpManager'),
662
+ }), 'weir-capability-lifecycle-feed')
649
663
  // The Host remote can see only this explicitly labeled preset's faces.
650
664
  // Its sessionQuery observation, not any creation-time cwd cache, owns routing.
651
665
  try {
@@ -38,6 +38,7 @@ export function resolveStoreRoot(profileContext) {
38
38
  * | { kind: 'preset', scope: 'global', presetId: string }
39
39
  * | { kind: 'preset', scope: 'workspace', workspaceKey: string, presetId: string }
40
40
  * | { kind: 'mcp-registry' }
41
+ * | { kind: 'skill-scan-paths' }
41
42
  * | { kind: 'distribution', scope: string }} Unit
42
43
  */
43
44
 
@@ -77,6 +78,8 @@ export function unitLayout(unit) {
77
78
  break
78
79
  case 'mcp-registry':
79
80
  return layout(['mcp'], 'registry')
81
+ case 'skill-scan-paths':
82
+ return layout(['skills'], 'scan-paths')
80
83
  case 'distribution':
81
84
  need(u.scope)
82
85
  return layout(['distribution', /** @type {string} */ (u.scope)], 'active')
@@ -1063,6 +1063,29 @@ export function createLeaseClient({
1063
1063
  // Release and replay
1064
1064
  // ---------------------------------------------------------------------
1065
1065
 
1066
+ /**
1067
+ * Release an explicit batch for `owner` (the `edit_lock_release` form).
1068
+ *
1069
+ * The ledger entry goes BEFORE the frame, and it goes even when the frame
1070
+ * never reaches the arbiter: an explicit release is the caller's intent, and
1071
+ * an unconfirmed transport must not resurrect a path the caller already let
1072
+ * go of — a replay on the next connection would re-take it. Only entries this
1073
+ * owner's lineage holds are forgotten; which of the rest the broker actually
1074
+ * released is still its verdict (`released` / `notOwned`).
1075
+ * @param {any} owner
1076
+ * @param {string[]} paths
1077
+ * @returns {Promise<any>}
1078
+ */
1079
+ async function release(owner, paths) {
1080
+ const holder = normalizeOwner(owner)
1081
+ const list = normalizePaths(paths)
1082
+ for (const path of list) {
1083
+ const record = ledger.get(path)
1084
+ if (record !== undefined && record.lineage === holder.id) ledger.delete(path)
1085
+ }
1086
+ return await request('release', { owner: holder, paths: list })
1087
+ }
1088
+
1066
1089
  /**
1067
1090
  * Release everything this session holds EXCEPT retentions (design D10): the
1068
1091
  * `turn/end` rule. A failure is swallowed — turn-boundary release is an
@@ -1142,6 +1165,24 @@ export function createLeaseClient({
1142
1165
  return attempt
1143
1166
  }
1144
1167
 
1168
+ /**
1169
+ * Reconcile a grant whose replay record was superseded while on the wire.
1170
+ * A newer ledger record is newer intent and must not be released by this stale
1171
+ * operation. No record means the path was released; a grant that nevertheless
1172
+ * arrived late needs its own release because the explicit one may have won the
1173
+ * broker race before the grant existed.
1174
+ * @param {any} reply
1175
+ * @param {'granted' | 'held'} field
1176
+ * @param {{ id: string, session: string, label: string }} owner
1177
+ * @param {string} path
1178
+ */
1179
+ async function releaseStaleReplayGrant(reply, field, owner, path) {
1180
+ if (ledger.get(path) !== undefined) return
1181
+ const grants = Array.isArray(reply?.[field]) ? reply[field] : []
1182
+ if (!grants.some((entry) => entry?.path === path)) return
1183
+ await request('release', { owner, paths: [path] })
1184
+ }
1185
+
1145
1186
  /**
1146
1187
  * @returns {Promise<{ replayed: number, dropped: number }>}
1147
1188
  */
@@ -1153,11 +1194,25 @@ export function createLeaseClient({
1153
1194
  if (record.holdUntil === null || record.holdUntil <= at) continue
1154
1195
  const owner = ownerOf(record.lineage, record.session)
1155
1196
  const scope = lastScope
1197
+ let current = record
1156
1198
  try {
1157
1199
  // Re-acquire first: after a daemon restart the table is empty, so a bare
1158
- // `hold` would be `not-owned`. The pair is idempotent by protocol.
1159
- await acquire(owner, [path], requireScope(scope))
1200
+ // `hold` would be `not-owned`. The pair is idempotent by protocol. Unlike
1201
+ // an ordinary acquire, replay must prove its snapshot is still current
1202
+ // before either response writes the path back into the ledger.
1203
+ const leaseScope = requireScope(scope)
1204
+ const acquired = await request('acquire', { owner, scope: leaseScope, paths: [path] })
1205
+ if (ledger.get(path) !== record) {
1206
+ await releaseStaleReplayGrant(acquired, 'granted', owner, path)
1207
+ continue
1208
+ }
1209
+ if (Array.isArray(acquired.granted)) remember(acquired.granted, owner, leaseScope)
1210
+ current = ledger.get(path) ?? record
1160
1211
  const response = await request('hold', { owner, paths: [path], minutes: holdMinutes(record.holdUntil - at) })
1212
+ if (ledger.get(path) !== current) {
1213
+ await releaseStaleReplayGrant(response, 'held', owner, path)
1214
+ continue
1215
+ }
1161
1216
  applyHold(response, owner)
1162
1217
  replayed += 1
1163
1218
  } catch (error) {
@@ -1166,7 +1221,9 @@ export function createLeaseClient({
1166
1221
  // rather than forgetting a retention nobody has seen fail.
1167
1222
  break
1168
1223
  }
1169
- ledger.delete(path)
1224
+ // A late failure is stale for the same reason as a late grant: neither
1225
+ // may delete a newer record installed after this snapshot was taken.
1226
+ if (ledger.get(path) === current) ledger.delete(path)
1170
1227
  dropped += 1
1171
1228
  }
1172
1229
  }
@@ -1290,6 +1347,7 @@ export function createLeaseClient({
1290
1347
  reply,
1291
1348
  releaseSession,
1292
1349
  releaseAll,
1350
+ release,
1293
1351
  replayHolds,
1294
1352
  onEvent,
1295
1353
  onReset,
@@ -0,0 +1,89 @@
1
+ // Transfer Negotiation owns the local reply window, not the broker request.
2
+ //
3
+ // One instance belongs to one plugin mount, matching the mount's conditional
4
+ // tool registrations. Process-wide request dedupe remains in the plugin: a
5
+ // sibling mount that did not deliver a request has no local window to close.
6
+ // The broker remains the only ownership and request-settlement authority.
7
+
8
+ /** @param {unknown} value @returns {string | undefined} */
9
+ function text(value) {
10
+ return typeof value === 'string' && value.length > 0 ? value : undefined
11
+ }
12
+
13
+ /**
14
+ * Whether one structured tool outcome closes its local request.
15
+ *
16
+ * `transferred` and `declined` are broker settlements; `expired` and `stale`
17
+ * say the broker request is already over; `not-holder` closes only this
18
+ * unauthorized agent's local window. A transport result contains no broker
19
+ * verdict, so a valid pending request survives for an idempotent retry.
20
+ * Unknown results and failures retain the old finally-block behaviour and
21
+ * close the local request rather than inventing a new retry contract.
22
+ *
23
+ * @param {any} outcome a reply result or thrown error carrying state/code
24
+ * @returns {boolean}
25
+ */
26
+ function closesLocalRequest(outcome) {
27
+ const code = text(outcome?.code)
28
+ const state = text(outcome?.state)
29
+ if (code === 'transport') return false
30
+ if (state === 'transferred' || state === 'declined') return true
31
+ if (code === 'expired' || code === 'stale' || code === 'not-holder') return true
32
+ return true
33
+ }
34
+
35
+ /**
36
+ * Create the pending-request and reply-window state for one plugin mount.
37
+ *
38
+ * This module never calls the client. In particular, local `not-holder`
39
+ * cleanup cannot consume the broker request, which remains answerable by the
40
+ * real holder.
41
+ */
42
+ export function createTransferNegotiation() {
43
+ /** @type {WeakMap<object, Map<string, { path: string, from: any }>>} */
44
+ const pendingByAgent = new WeakMap()
45
+
46
+ return {
47
+ /**
48
+ * Add or refresh one request in an agent's local reply window.
49
+ * @param {object} agent
50
+ * @param {string} requestId
51
+ * @param {{ path: string, from: any }} request
52
+ */
53
+ openRequest(agent, requestId, request) {
54
+ let pending = pendingByAgent.get(agent)
55
+ if (pending === undefined) {
56
+ pending = new Map()
57
+ pendingByAgent.set(agent, pending)
58
+ }
59
+ pending.set(requestId, request)
60
+ },
61
+
62
+ /**
63
+ * Apply one reply attempt's structured outcome to its own request id.
64
+ *
65
+ * @param {object} agent
66
+ * @param {unknown} requestId
67
+ * @param {any} outcome reply result or thrown error
68
+ * @returns {number | undefined} requests still pending, or undefined when
69
+ * this agent has no local window (the adapter then has nothing to retire)
70
+ */
71
+ finishReply(agent, requestId, outcome) {
72
+ const pending = pendingByAgent.get(agent)
73
+ if (pending === undefined) return undefined
74
+ const id = text(requestId)
75
+ if (closesLocalRequest(outcome) && id !== undefined) pending.delete(id)
76
+ if (pending.size === 0) pendingByAgent.delete(agent)
77
+ return pending.size
78
+ },
79
+
80
+ /**
81
+ * Close every local request for an agent. Turn end, disposal and unmount
82
+ * call this independently of any broker outcome.
83
+ * @param {object} agent
84
+ */
85
+ closeAgent(agent) {
86
+ pendingByAgent.delete(agent)
87
+ },
88
+ }
89
+ }