weir-harness 1.2.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -1,579 +0,0 @@
1
- import { bindingGuidance, bindingRefusalReason, bindingFailure } from './binding-guidance.js'
2
- import { createEditLockHost } from './host.js'
3
- import { createNegotiation } from './negotiation.js'
4
- import { RECOVERY_LIMITS } from './recovery.js'
5
- import { isMissingTarget, STALE_SWEEP_COOLDOWN_MS } from './stale-sweep.js'
6
-
7
- /** Host-owned lifecycle controller. The authenticated transport must await stop;
8
- * stock GUI cancel acceptance alone does not constitute this acknowledgement.
9
- * Resume and confirm are trusted human ingress only; never expose them as tools.
10
- * @param {Awaited<ReturnType<typeof import('./runtime.js').openEditLockRuntime>>} runtime
11
- * @param {(agent: object) => string | undefined} sessionForAgent
12
- * @param {{deliver?: (agent: object, text: string, wake?: boolean) => void, onPending?: (agent: object, pending: number) => void, negotiationTimeoutMs?: number, onStaleRelease?: (row: {resourceId: string, owner: string, generation: number}, triggerSessionId: string | null) => void, onAdminRecovery?: (info: {revision: number, idempotent: boolean, record: any, trigger: string | null}) => void, onRevocationRepair?: (info: {owner: string, fromEpoch: number | undefined, toEpoch: number, trigger: string}) => void, warn?: (message: string) => void, sweepCooldownMs?: number, sweepNow?: () => number, servingRuntime?: (sessionId: string) => object | undefined}} [options] */
13
- export function createEditLockLifecycle(runtime, sessionForAgent, options = {}) {
14
- /** @typedef {{sessionId:string, state:'starting'|'active'|'recovering'|'stopped'|'resuming', attempt:number, ready:Promise<'active'|'interrupted'>, stop?:Promise<unknown>}} Entry */
15
- /** @type {Map<object, Entry>} */
16
- const entries = new Map()
17
- const host = createEditLockHost(runtime, agent => {
18
- const entry = entries.get(agent)
19
- // Cleanup-only recovery keeps the execution for release/reply; business
20
- // ingress is gated separately below.
21
- return !!entry && (entry.state === 'active' || entry.state === 'recovering') && sessionForAgent(agent) === entry.sessionId
22
- })
23
- /** A stopped session must say so: the generic authentication error made a
24
- * deliberate Stop look like a broken registration. @param {Entry | undefined} entry */
25
- function denyStopped(entry) {
26
- if (entry?.state === 'stopped' || entry?.state === 'resuming') {
27
- throw new Error('Edit Lock: editing in this session was stopped by the user. Edits and new ownership stay denied until the user chooses Continue editing (Edit Lock panel or /edit-lock resume); edit_lock_release and edit_lock_status still work. Do not retry the edit.')
28
- }
29
- if (entry?.state === 'starting') throw new Error('Edit Lock: this session is still registering; retry shortly.')
30
- }
31
- /** Session-owned token at the current epoch for subtractive cleanup only.
32
- * @param {Entry} entry @param {string} resourceId */
33
- function cleanupToken(entry, resourceId) {
34
- const { status, session } = sessionStatus(entry.sessionId)
35
- const lock = status.locks.find(/** @param {any} item */ item => item.resourceId === resourceId)
36
- if (!session || !lock || lock.owner !== entry.sessionId) throw new Error('lock not owned by this session')
37
- return { managerIncarnation: status.managerIncarnation, sessionId: entry.sessionId, executionEpoch: session.executionEpoch, resourceId, generation: lock.generation }
38
- }
39
- /** Business edits and new ownership need an active (not recovering) session.
40
- * @param {any} exec */
41
- function business(exec) {
42
- const entry = entries.get(exec?.agent)
43
- denyStopped(entry)
44
- if (entry?.state === 'recovering') throw new Error('cleanup-only recovery: business edits and new ownership are denied; release locks, answer requests or pause, then wait for a human /edit-lock resume')
45
- }
46
- /** A bare "resource owned" left the model without a next step. Name the owner
47
- * and the two things that can be done; keep the original words so callers and
48
- * logs that match on them still do. Never changes the decision.
49
- * @param {() => Promise<any>} attempt @param {{filePath?: string, cwd?: string}} request */
50
- async function explainOwned(attempt, request) {
51
- try { return await attempt() } catch (error) {
52
- if (!/^resource owned$/.test(String(/** @type {any} */ (error)?.message))) throw error
53
- let owner = 'another session'
54
- try {
55
- const resourceId = runtime.requests.resource(String(request?.filePath), String(request?.cwd))
56
- const lock = runtime.control.status().locks.find(/** @param {any} item */ item => item.resourceId === resourceId)
57
- if (lock) owner = `session ${lock.owner}${lock.status === 'active' ? '' : ` (${lock.status})`}`
58
- } catch { /* the plain explanation still applies */ }
59
- throw new Error(`resource owned: this file is being edited by ${owner}. Ask for it with edit_lock_try_steal, or work on other files; do not retry the same edit.`, { cause: error })
60
- }
61
- }
62
- let closed = false
63
- /** @type {Promise<void> | undefined} */
64
- let shutdown
65
- /** @type {Set<Promise<unknown>>} */
66
- const disposing = new Set()
67
- const negotiation = createNegotiation({
68
- control: runtime.control,
69
- executionFor: agent => host.executionFor(agent),
70
- agentFor: sessionId => [...entries].find(([agent, entry]) => entry.sessionId === sessionId && sessionForAgent(agent) === sessionId)?.[0],
71
- deliver: options.deliver ?? (() => {}),
72
- onPending: options.onPending,
73
- timeoutMs: options.negotiationTimeoutMs,
74
- })
75
- /** Close admission synchronously, then revoke at the manager queue position.
76
- * @param {Entry} entry */
77
- function revoke(entry) {
78
- entry.state = 'stopped'
79
- entry.attempt++
80
- try { entry.stop = Promise.resolve(runtime.control.cancelSession(entry.sessionId)) }
81
- catch (error) { entry.stop = Promise.reject(error) }
82
- entry.stop.catch(() => {})
83
- return entry.stop
84
- }
85
- /** @param {object} agent */
86
- function stop(agent) {
87
- const entry = entries.get(agent)
88
- if (!entry) return Promise.reject(new Error('unknown lifecycle agent'))
89
- if (entry.state === 'stopped' && entry.stop) return entry.stop
90
- return revoke(entry)
91
- }
92
- /** @param {object} agent */
93
- function entryFor(agent) {
94
- const entry = entries.get(agent)
95
- if (!entry || sessionForAgent(agent) !== entry.sessionId) throw new Error('unknown lifecycle agent')
96
- return entry
97
- }
98
- /** @param {string} sessionId */
99
- function sessionStatus(sessionId) {
100
- const status = runtime.control.status()
101
- return { status, session: status.sessions.find(/** @param {any} s */ s => s.sessionId === sessionId) }
102
- }
103
- /** Stop, await durable revocation, then forget this agent object. A new
104
- * agent for the same session starts interrupted. @param {object} agent */
105
- async function forget(agent) {
106
- const entry = entries.get(agent)
107
- if (!entry) return
108
- // Keep the stopped entry until durable revocation succeeds. A failed
109
- // disconnect must remain visible and retryable, never admit a replacement.
110
- const stopping = stop(agent)
111
- disposing.add(stopping)
112
- try {
113
- await stopping
114
- if (entries.get(agent) === entry) entries.delete(agent)
115
- } catch (error) {
116
- if (entry.stop === stopping) entry.stop = undefined
117
- throw error
118
- } finally { disposing.delete(stopping) }
119
- }
120
- /** @param {object} agent */
121
- function statusOf(agent) {
122
- const entry = entryFor(agent)
123
- const { session, status } = sessionStatus(entry.sessionId)
124
- // Retention settlement is read-time as well as timer-driven (design D5), so a
125
- // missed timer can never leave stale ownership behind a status read.
126
- return {
127
- sessionId: entry.sessionId,
128
- state: entry.state,
129
- interrupted: session?.interrupted ?? null,
130
- revoked: session?.revoked ?? null,
131
- executionEpoch: session?.executionEpoch ?? null,
132
- locks: status.locks.filter(/** @param {any} lock */ lock => lock.owner === entry.sessionId),
133
- recovery: runtime.control.recoveryUsage(entry.sessionId),
134
- retention: runtime.control.settlement(entry.sessionId, Date.now()),
135
- }
136
- }
137
- /** Publisher-authoritative sweep coalescing (one lifecycle serves ONE
138
- * authority): a trigger arriving while a sweep runs joins it; a trigger
139
- * inside the cooldown window is answered without scanning. The client-side
140
- * mount scheduler stays the first-line throttle; this guard is what makes
141
- * concurrent local and cross-process triggers converge on one scan.
142
- * @type {Promise<{released: any[], skipped: any[], failed: any[]}> | null} */
143
- let sweepInFlight = null
144
- let sweepCompletedAt = Number.NEGATIVE_INFINITY
145
- const sweepCooldownMs = options.sweepCooldownMs ?? STALE_SWEEP_COOLDOWN_MS
146
- const sweepNow = options.sweepNow ?? (() => Date.now())
147
- const warn = options.warn ?? (() => {})
148
- /** The scan half of sweepStale, always run under the coalescing guard.
149
- * Releases audit through options.onStaleRelease after durable success;
150
- * skips stay quiet (counted only); each failure earns exactly one bounded
151
- * warning. @param {string | null} triggerSessionId */
152
- async function runStaleSweep(triggerSessionId) {
153
- const status = runtime.control.status()
154
- const sessions = new Map(status.sessions.map(/** @param {any} item */ item => [item.sessionId, item]))
155
- /** @type {{resourceId: string, owner: string, generation: number, executionEpoch: number, status: string}[]} */
156
- const observed = []
157
- for (const lock of status.locks) {
158
- const session = sessions.get(lock.owner)
159
- if (!session || !isMissingTarget(lock.resourceId)) continue
160
- observed.push({ resourceId: lock.resourceId, owner: lock.owner, generation: lock.generation, executionEpoch: session.executionEpoch, status: lock.status })
161
- }
162
- if (observed.length === 0) return { released: [], skipped: [], failed: [] }
163
- const result = await runtime.control.releaseStale(observed, isMissingTarget)
164
- for (const row of result.released) {
165
- try { options.onStaleRelease?.(row, triggerSessionId) } catch { /* audit is log-only and never breaks the sweep */ }
166
- }
167
- for (const failure of result.failed ?? []) {
168
- try { warn(`edit lock stale sweep: release failed for ${/** @type {any} */ (failure)?.row?.resourceId}: ${/** @type {any} */ (failure)?.reason}`) } catch { /* logging never breaks the sweep */ }
169
- }
170
- return result
171
- }
172
- /** Holder liveness signal (change edit-lock-binding-transfer, task 1.1): the
173
- * DSH agent `status` vocabulary is exactly 'idle' | 'running' (idle covers the
174
- * idle and maintenance phases). Only 'idle' proves the holder is not mid-turn;
175
- * a missing, non-string or throwing read is undeterminable and never admits.
176
- * @param {object} holder @returns {string | undefined} */
177
- function holderStatus(holder) {
178
- try {
179
- const status = /** @type {any} */ (holder)?.status
180
- return typeof status === 'string' ? status : undefined
181
- } catch { return undefined }
182
- }
183
- /** Read-only startup admission. A revoked session is startable: it binds as
184
- * interrupted and its revocation ends only through the trusted resume path
185
- * (the epoch advance is the repair; binding alone grants no authority).
186
- * @param {object} agent */
187
- function assertStartable(agent) {
188
- if (closed) throw new Error('edit lifecycle closed')
189
- const sessionId = sessionForAgent(agent)
190
- if (!sessionId) throw new Error('unregistered lifecycle agent')
191
- // Health probe: a poisoned manager refuses the retry here, before any
192
- // holder is touched. A revoked session passes — it binds as interrupted
193
- // and only the trusted resume path ends the revocation.
194
- sessionStatus(sessionId)
195
- }
196
- /** Register once before publishing an agent's editing tools. A session the
197
- * manager already knows (restart, re-created agent) is never re-armed: it
198
- * starts interrupted and needs trusted resume. Presenting the SAME agent
199
- * object again adopts the established entry instead of registering twice
200
- * (change edit-lock-shared-domain-mounts, design D2). @param {object} agent
201
- * @returns {Promise<'active'|'interrupted'>} */
202
- function start(agent) {
203
- if (closed) return Promise.reject(new Error('edit lifecycle closed'))
204
- const existing = entries.get(agent)
205
- if (existing) {
206
- // Same-agent binding adoption: a sibling mount's idempotent re-setup
207
- // presents the same agent object and takes the established start result
208
- // verbatim — no openSession, no revocation, no epoch advance, no state
209
- // change. A stopped/revoked entry keeps its state: adoption returns its
210
- // established result, and business ingress stays gated by
211
- // denyStopped/business until a trusted resume. Identity continuity is
212
- // required — a host that reused the agent object for a different
213
- // session keeps the duplicate-registration refusal.
214
- if (sessionForAgent(agent) === existing.sessionId) return existing.ready
215
- return Promise.reject(new Error('agent already registered'))
216
- }
217
- const sessionId = sessionForAgent(agent)
218
- if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
219
- const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
220
- if (prior) {
221
- let serving
222
- try { serving = options.servingRuntime?.(sessionId) } catch { /* unknown: refuse */ }
223
- // A registry-named local holder that is provably not mid-turn is the
224
- // TRANSFERABLE class (serving-idle): passive paths still refuse, but the
225
- // refusal announces the automatic takeover instead of a window hunt.
226
- const attestation = serving === prior[0]
227
- ? (/** @type {any} */ (prior[0]).remote !== true && holderStatus(prior[0]) === 'idle' ? 'serving-idle' : 'serving')
228
- : 'unknown'
229
- const refusal = () => {
230
- const pid = /** @type {any} */ (prior[0]).remote === true ? undefined : process.pid
231
- const reason = bindingRefusalReason(pid, attestation)
232
- const failure = bindingFailure(pid, attestation)
233
- // Remote callers render after their retry budget; local callers have no wire.
234
- return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
235
- { bindingFailure: failure })
236
- }
237
- // Remote entries are channel proxies, NOT runtime identities. Only the
238
- // endpoint's nonce attestation may reclaim them (including same-process).
239
- if (/** @type {any} */ (prior[0]).remote === true || serving !== agent || prior[0] === serving) return Promise.reject(refusal())
240
- // Concurrent claimants re-check after the shared revocation settles;
241
- // only the serving runtime can win, and no file lock is released.
242
- return forget(prior[0]).then(() => start(agent), () => { throw refusal() })
243
- }
244
- /** @type {Entry} */
245
- const entry = {sessionId, state:'starting', attempt:0, ready:Promise.resolve(/** @type {'active'} */ ('active'))}
246
- entries.set(agent, entry)
247
- entry.ready = (async () => {
248
- let known
249
- try { known = sessionStatus(sessionId).session }
250
- catch (error) { await revoke(entry).catch(() => {}); throw error }
251
- if (known) {
252
- await revoke(entry)
253
- return /** @type {'interrupted'} */ ('interrupted')
254
- }
255
- const attempt = entry.attempt
256
- try {
257
- const execution = await runtime.control.openSession(sessionId)
258
- if (closed || entry.state !== 'starting' || entry.attempt !== attempt || sessionForAgent(agent) !== sessionId) {
259
- throw new Error('agent stopped during registration')
260
- }
261
- entry.state = 'active'
262
- host.attach(agent, execution)
263
- return /** @type {'active'} */ ('active')
264
- } catch (error) {
265
- // Failed registration never leaves an armed manager session behind.
266
- await stop(agent)
267
- throw error
268
- }
269
- })()
270
- return entry.ready
271
- }
272
- /** Trusted driving-ingress transfer admission (change
273
- * edit-lock-binding-transfer, design D1/D2): take the binding from a
274
- * same-process, local (non-remote-proxy) holder that is registry-named and
275
- * provably not mid-turn, reusing the exact superseded-reclaim semantics —
276
- * durable revocation and forget, no lock is released, the claimant binds as
277
- * interrupted. Every other shape (remote proxy, holder not registry-named,
278
- * mid-turn, undeterminable liveness, unavailable registry) delegates to the
279
- * ordinary start(), keeping its fail-closed refusal and its classification.
280
- * Passive entry points (creation setup, catch-up, preset switch) never call
281
- * this. @param {object} agent @returns {Promise<'active'|'interrupted'>} */
282
- function transferStart(agent) {
283
- if (closed) return Promise.reject(new Error('edit lifecycle closed'))
284
- if (entries.has(agent)) return Promise.reject(new Error('agent already registered'))
285
- const sessionId = sessionForAgent(agent)
286
- if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
287
- const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
288
- if (!prior) return start(agent)
289
- let serving
290
- try { serving = options.servingRuntime?.(sessionId) } catch { /* unattestable: fall through to the ordinary refusal */ }
291
- if (/** @type {any} */ (prior[0]).remote !== true && serving === prior[0] && holderStatus(prior[0]) === 'idle') {
292
- // Concurrent claimants re-check after the shared revocation settles;
293
- // no file lock is released and the claimant starts interrupted. A failed
294
- // revocation keeps the transferable refusal: the holder entry is retained
295
- // (start()'s reclaim-failure discipline) and the next driving ingress
296
- // re-attempts the transfer.
297
- return forget(prior[0]).then(() => start(agent), () => {
298
- const reason = bindingRefusalReason(process.pid, 'serving-idle')
299
- const failure = bindingFailure(process.pid, 'serving-idle')
300
- throw Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
301
- { bindingFailure: failure })
302
- })
303
- }
304
- return start(agent)
305
- }
306
- return Object.freeze({
307
- assertStartable,
308
- /** Local bindings only; endpoint arbitration reports channel proxies. */
309
- bindings() {
310
- return [...entries].filter(([agent]) => /** @type {any} */ (agent).remote !== true).map(([agent, entry]) => {
311
- let attestation = 'unknown'
312
- try {
313
- if (options.servingRuntime) attestation = options.servingRuntime(entry.sessionId) === agent ? 'serving' : 'stale'
314
- } catch { /* unavailable evidence */ }
315
- return { sessionId: entry.sessionId, present: true, holderPid: process.pid, attestable: attestation !== 'unknown', attestation }
316
- })
317
- },
318
- // Only this narrowed service belongs in the tool context.
319
- service: Object.freeze({
320
- /** @param {any} exec @param {any} request */
321
- async publish(exec, request) { business(exec); return explainOwned(() => host.publish(exec, request), request) },
322
- /** @param {any} exec @param {any} request */
323
- async publishBatch(exec, request) { business(exec); return host.publishBatch(exec, request) },
324
- /** @param {any} exec @param {any} request */
325
- async acquire(exec, request) { business(exec); return explainOwned(() => host.acquire(exec, request), request) },
326
- /** Release stays available while stopped or recovering: it only removes authority.
327
- * @param {any} exec @param {{filePath: string, cwd: string}} request */
328
- async release(exec, request) {
329
- const entry = entries.get(exec?.agent)
330
- if (!entry || sessionForAgent(exec.agent) !== entry.sessionId) throw new Error('agent has no authenticated edit execution')
331
- const { resourceId, observation } = runtime.requests.releaseResource(request.filePath, request.cwd)
332
- await runtime.requests.release(cleanupToken(entry, resourceId), observation)
333
- return { resourceId, released: true }
334
- },
335
- /** Read-only observation; available in every lifecycle state. @param {any} exec */
336
- locks(exec) {
337
- const entry = entries.get(exec?.agent)
338
- if (!entry || sessionForAgent(exec.agent) !== entry.sessionId) throw new Error('agent has no authenticated edit execution')
339
- return runtime.control.status().locks.map(/** @param {any} lock */ lock => ({ ...lock, mine: lock.owner === entry.sessionId }))
340
- },
341
- /** @param {{agent: object}} exec @param {{filePath: string, cwd: string}} request */
342
- async trySteal(exec, request) {
343
- business(exec)
344
- host.executionFor(exec.agent)
345
- return negotiation.request(exec.agent, runtime.requests.resource(request.filePath, request.cwd))
346
- },
347
- /** @param {{agent: object}} exec @param {{requestId: string, decision: 'release'|'keep'}} request */
348
- async reply(exec, request) { denyStopped(entries.get(exec?.agent)); return negotiation.reply(exec.agent, request.requestId, request.decision) },
349
- /** @param {{agent: object}} exec */
350
- pendingRequests(exec) { return negotiation.pending(host.executionFor(exec.agent).sessionId) },
351
- }),
352
- start,
353
- /** Explicit driving-ingress transfer entry; ordinary start() never transfers. */
354
- transferStart,
355
- stop,
356
- /** Normal end of an agent: an agent that is still active (never stopped,
357
- * not recovering) can never edit again, so its active locks are released
358
- * first — otherwise every finished subagent would block its files forever.
359
- * Stopped, abnormal and unresolved state is retained for a human.
360
- * @param {object} agent */
361
- async dispose(agent) {
362
- const entry = entries.get(agent)
363
- if (entry?.state === 'active') {
364
- try {
365
- const tokens = sessionStatus(entry.sessionId).status.locks
366
- .filter(/** @param {any} lock */ lock => lock.owner === entry.sessionId && lock.status === 'active')
367
- .map(/** @param {any} lock */ lock => cleanupToken(entry, lock.resourceId))
368
- if (tokens.length) await runtime.control.releaseMany(tokens)
369
- } catch { /* an unresolved fence or race keeps the locks; revocation still follows */ }
370
- }
371
- return forget(agent)
372
- },
373
- /** Connection loss or crash: stop and forget WITHOUT releasing anything.
374
- * @param {object} agent */
375
- forget,
376
- /** Trusted explicit Continue. Consumes a one-use receipt bound to requestId;
377
- * retained interrupted locks become pending-confirmation, never active.
378
- * @param {object} agent @param {string} requestId */
379
- async resume(agent, requestId) {
380
- if (closed) throw new Error('edit lifecycle closed')
381
- const entry = entryFor(agent)
382
- // A recovering session is first durably revoked; abnormal locks stay abnormal.
383
- if (entry.state === 'recovering') revoke(entry)
384
- if (entry.state !== 'stopped' || !entry.stop) throw new Error('agent is not interrupted')
385
- await entry.stop
386
- if (closed || entry.state !== 'stopped') throw new Error('agent state changed before resume')
387
- entry.state = 'resuming'
388
- const attempt = ++entry.attempt
389
- try {
390
- const { status, session } = sessionStatus(entry.sessionId)
391
- // A revoked session resumes exactly like a stopped one: the
392
- // disposition already released its locks, so resume confirms the empty
393
- // set and the epoch advance past revokedEpoch ends the revocation.
394
- const wasRevoked = session?.revoked === true
395
- const fromEpoch = session?.executionEpoch
396
- if (!session?.interrupted) throw new Error('session is not durably interrupted')
397
- if (!session?.interrupted) throw new Error('session is not durably interrupted')
398
- const execution = {managerIncarnation: status.managerIncarnation, sessionId: entry.sessionId, executionEpoch: session.executionEpoch}
399
- const receipt = await runtime.control.issueExecutionReceipt(execution, requestId)
400
- const resumed = await runtime.control.resume(execution, requestId, receipt)
401
- if (closed || entry.state !== 'resuming' || entry.attempt !== attempt || sessionForAgent(agent) !== entry.sessionId) {
402
- throw new Error('agent stopped during resume')
403
- }
404
- entry.state = 'active'
405
- host.attach(agent, resumed)
406
- if (wasRevoked) {
407
- // Repair of an administrative revocation is audited through the same
408
- // shared channel as the recovery itself — never the session log.
409
- const trigger = requestId.startsWith('auto:user-message:') ? 'auto' : 'manual'
410
- try { options.onRevocationRepair?.({ owner: entry.sessionId, fromEpoch, toEpoch: resumed.executionEpoch, trigger }) } catch { /* audit is log-only */ }
411
- }
412
- return statusOf(agent)
413
- } catch (error) {
414
- // Anything that may have resumed without attaching is revoked again.
415
- if (entry.state === 'resuming' && entry.attempt === attempt) await revoke(entry).catch(() => {})
416
- throw error
417
- }
418
- },
419
- /** Trusted per-file confirmation of a pending-confirmation lock.
420
- * @param {object} agent @param {string} resourceId */
421
- async confirm(agent, resourceId) {
422
- if (closed) throw new Error('edit lifecycle closed')
423
- entryFor(agent)
424
- await runtime.control.confirm(host.executionFor(agent), resourceId)
425
- },
426
- /** Trusted classifier (durable turn/end error): retained locks become
427
- * abnormal and the session enters cleanup-only recovery. No locks → no-op.
428
- * @param {object} agent @param {string} reason @returns {Promise<string[]>} */
429
- async classifyAbnormal(agent, reason) {
430
- const entry = entryFor(agent)
431
- if (entry.state !== 'active') return []
432
- const marked = await runtime.control.markAbnormal(entry.sessionId, reason)
433
- const abnormal = statusOf(agent).locks.some(/** @param {any} lock */ lock => lock.status === 'abnormal')
434
- if (abnormal && entry.state === 'active') entry.state = 'recovering'
435
- return marked
436
- },
437
- /** Explicit bounded retention of every lock the session currently holds. The
438
- * caller resolves the configured caps; ownership is untouched, so this only
439
- * extends it. Never available while stopped or recovering: those locks are
440
- * handled as abnormal locks, and a retention period must not hide them.
441
- * @param {object} agent @param {number} ms
442
- * @param {{singleMaxMs: number, cumulativeMaxMs: number}} caps */
443
- async hold(agent, ms, caps) {
444
- const entry = entryFor(agent)
445
- denyStopped(entry)
446
- if (entry.state === 'recovering') throw new Error('cleanup-only recovery: no retention request is available while abnormal locks are pending')
447
- const held = await runtime.control.hold(entry.sessionId, ms, { ...caps, now: Date.now() })
448
- return { ...held, lockCount: statusOf(agent).locks.length }
449
- },
450
- /** The holder started a new turn: every lock it holds leaves the holding state
451
- * at once (spec: Holding state semantics). Safe while stopped; a stopped
452
- * session has no retention period anyway. @param {object} agent */
453
- async turnStarted(agent) {
454
- const entry = entries.get(agent)
455
- if (!entry) return null
456
- return runtime.control.endHold(entry.sessionId, Date.now())
457
- },
458
- /** Read-time settlement plus its release half (design D5). Releases the
459
- * session's active locks when the period has genuinely run out, so it is
460
- * idempotent and safe from a status read or a timer. A host that is mid-turn is
461
- * never released implicitly: the release is deferred to the turn it ends on,
462
- * because releasing under a running turn would drop the holder's own locks.
463
- * `force` is the fallback disposition after the notices run out, which is a
464
- * deliberate policy decision rather than an expiry.
465
- * @param {object} agent @param {boolean} [force]
466
- * @returns {Promise<{released: string[]}>} */
467
- async settleExpired(agent, force = false, idle = undefined) {
468
- const entry = entries.get(agent)
469
- if (!entry) return { released: [] }
470
- // A channel agent on the publisher side has no status of its own; the host
471
- // that runs the agent says whether it is idle.
472
- const isIdle = idle ?? /** @type {any} */ (agent)?.status === 'idle'
473
- if (!force && !isIdle) return { released: [], deferred: true }
474
- if (!force && !runtime.control.settlement(entry.sessionId, Date.now()).expired) return { released: [] }
475
- // Expiry is re-checked inside the transaction, so a hold extended or ended
476
- // after the check above is never released by this call.
477
- const released = await runtime.control.releaseActive(entry.sessionId, Date.now(), !force)
478
- return { released }
479
- },
480
- /** Retention state of one session, settled. @param {object} agent */
481
- retention(agent) { return runtime.control.settlement(entryFor(agent).sessionId, Date.now()) },
482
- /** @param {object} agent */
483
- recoveryUsage(agent) { return runtime.control.recoveryUsage(entryFor(agent).sessionId) },
484
- /** @param {object} agent @param {{attempts?: number, elapsedMs?: number, pauseMs?: number}} delta */
485
- chargeRecovery(agent, delta) { return runtime.control.chargeRecovery(entryFor(agent).sessionId, delta) },
486
- /** Bounded pause; charged durably up front, never extends or releases.
487
- * @param {object} agent @param {number} minutes */
488
- async pause(agent, minutes) {
489
- const entry = entryFor(agent)
490
- if (entry.state !== 'recovering') throw new Error('pause is only available during cleanup-only recovery')
491
- if (!Number.isFinite(minutes) || minutes <= 0) throw new Error('minutes must be positive')
492
- const ms = Math.round(minutes * 60_000)
493
- if (ms > RECOVERY_LIMITS.singlePauseMs) throw new Error(`a single pause is limited to ${RECOVERY_LIMITS.singlePauseMs / 60_000} minutes`)
494
- const usage = runtime.control.recoveryUsage(entry.sessionId)
495
- if (usage.pauseMs + ms > RECOVERY_LIMITS.cumulativePauseMs) throw new Error(`cumulative pause budget exhausted (${Math.floor((RECOVERY_LIMITS.cumulativePauseMs - usage.pauseMs) / 60_000)} minutes left)`)
496
- await runtime.control.chargeRecovery(entry.sessionId, { pauseMs: ms })
497
- return { pausedMs: ms, cumulativePauseMs: usage.pauseMs + ms }
498
- },
499
- /** Trusted maintenance ingress for the stale-lock sweep (message-triggered;
500
- * local composition and the peer channel both land here). The publisher
501
- * shares the filesystem with the authority, so the scan ALWAYS runs on this
502
- * side — a client process only triggers it. Coalescing is authoritative
503
- * HERE, not only in the client-side mount scheduler: one lifecycle serves
504
- * one authority, so a single in-flight sweep plus a cooldown window makes
505
- * concurrent local and cross-process triggers converge on one scan — a
506
- * trigger arriving while a sweep runs JOINS it (same result, no extra
507
- * audit), and a trigger inside the cooldown window is answered with a
508
- * `coalesced: 'cooldown'` result without scanning. A lock row is a candidate ONLY
509
- * when lstat of its stored canonical resource id fails with ENOENT; the
510
- * manager's releaseStale re-verifies the full observed row (owner,
511
- * generation, owner execution epoch, lock status) and re-probes the target
512
- * at the FIFO execution point, so an owner that resumed, confirmed,
513
- * re-generated or sits under an unresolved fence between scan and
514
- * execution is a skip, never an error. Silent by contract: each durable
515
- * release is reported through options.onStaleRelease (shared audit);
516
- * skips stay quiet (counted only); each FAILURE (persistence, poison,
517
- * unexpected) gets exactly one bounded warning through options.warn.
518
- * Never writes to any conversation.
519
- * @param {string | null} [triggerSessionId] session whose genuine user
520
- * message armed the sweep (audit metadata only)
521
- * @returns {Promise<{released: any[], skipped: any[], failed: any[], coalesced?: string}>} */
522
- sweepStale(triggerSessionId = null) {
523
- if (closed) return Promise.reject(new Error('edit lifecycle closed'))
524
- if (sweepInFlight) return sweepInFlight
525
- if (sweepNow() - sweepCompletedAt < sweepCooldownMs) {
526
- return Promise.resolve({ released: [], skipped: [], failed: [], coalesced: 'cooldown' })
527
- }
528
- const running = runStaleSweep(triggerSessionId)
529
- sweepInFlight = running
530
- // The cooldown window opens at completion (success or failure), matching
531
- // the client-side scheduler's semantics.
532
- const settle = () => { if (sweepInFlight === running) { sweepInFlight = null; sweepCompletedAt = sweepNow() } }
533
- running.then(settle, settle)
534
- return running
535
- },
536
- /** Trusted human unlock by exact generation; never exposed as a tool.
537
- * Session interruption and pending confirmations are untouched.
538
- * @param {string} resourceId @param {number} generation */
539
- unlock(resourceId, generation) {
540
- if (closed) return Promise.reject(new Error('edit lifecycle closed'))
541
- return runtime.control.adminUnlock(resourceId, generation)
542
- },
543
- /** Trusted online administrative recovery (design D4), executed
544
- * publisher-side as one serialized manager transaction — the maintenance
545
- * panel's one-click confirmation reaches here directly (local domain) or
546
- * through the peer channel (`adminRecover` kind). The `trigger` identity
547
- * comes from the CALLER (the channel's bound agent or the local settings
548
- * plane), never from the request payload; it is audit metadata only.
549
- * Never exposed as a tool.
550
- * @param {any} request @param {string | null} [trigger] */
551
- async adminRecoverOnline(request, trigger = null) {
552
- if (closed) throw new Error('edit lifecycle closed')
553
- const result = await runtime.control.adminRecoverOnline(request)
554
- // Shared audit after durable success; it never breaks the recovery.
555
- try { options.onAdminRecovery?.({ ...result, trigger: trigger ?? null }) } catch { /* log-only */ }
556
- return result
557
- },
558
- /** Domain-wide observation for trusted UI/commands; grants nothing. */
559
- locks() { return runtime.control.status().locks },
560
- /** Effective observation for trusted UI/commands; not a write permission. */
561
- status: statusOf,
562
- close() {
563
- if (shutdown) return shutdown
564
- closed = true
565
- negotiation.close()
566
- const stops = [...entries.keys()].map(stop).concat([...disposing])
567
- shutdown = (async () => {
568
- const results = await Promise.allSettled(stops)
569
- await Promise.allSettled([...entries.values()].map(entry => entry.ready))
570
- await host.close()
571
- // host.close drains and closes its runtime. The external owner must
572
- // seal IPC admission before calling this and retain its lease until return.
573
- const errors = results.filter(result => result.status === 'rejected').map(result => result.reason)
574
- if (errors.length) throw new AggregateError(errors, 'edit lifecycle revocation failed')
575
- })()
576
- return shutdown
577
- },
578
- })
579
- }
@@ -1,61 +0,0 @@
1
- // Edit Lock retention policy: the defaults and the overlay resolver the
2
- // lifecycle (edit-lock/index.js) consumes. Moved verbatim out of
3
- // src/settings/sections.js (subsystem-seam-hygiene, D4) — the owner subsystem
4
- // now holds the policy; settings' field-default parity consumes this module
5
- // directly (the temporary sections.js re-export was removed by dead-export-cleanup).
6
-
7
- /** Resolved Edit Lock retention policy. The flat keys are declared in the
8
- * settings sections table; this is the ONE place that turns them into the
9
- * values the lifecycle consumes, so defaults and validation cannot drift
10
- * apart. */
11
- export const EDIT_LOCK_DEFAULTS = Object.freeze({
12
- holdDefaultMinutes: 30,
13
- holdSingleMaxMinutes: 30,
14
- holdCumulativeMaxMinutes: 120,
15
- nudgeAttempts: 2,
16
- nudgeFallback: 'release',
17
- })
18
-
19
- /** Overlay one Edit Lock section onto the defaults. A key the user never set
20
- * falls back to its default; a key that is present but unusable (alone or in
21
- * combination) throws with the flat settings key named, because silently clamping
22
- * a retention cap would change how long other sessions stay blocked from those
23
- * files. The Edit Lock composition refuses retention requests with that error and
24
- * runs every other path (settling, status, release, stop, unlock) on defaults.
25
- * @param {any} section */
26
- export function editLockLimits(section) {
27
- const value = section && typeof section === 'object' ? section : {}
28
- /** @type {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'}} */
29
- const limits = { ...EDIT_LOCK_DEFAULTS }
30
- /** @param {'holdDefaultMinutes'|'holdSingleMaxMinutes'|'holdCumulativeMaxMinutes'} key @param {string} name */
31
- const take = (key, name) => {
32
- const input = value[key]
33
- if (input === undefined) return
34
- if (typeof input !== 'number' || !Number.isFinite(input) || input <= 0) {
35
- throw new Error(`weir-settings: ${name} must be a positive number`)
36
- }
37
- limits[key] = input
38
- }
39
- take('holdDefaultMinutes', 'editLockHoldDefaultMinutes')
40
- take('holdSingleMaxMinutes', 'editLockHoldSingleMaxMinutes')
41
- take('holdCumulativeMaxMinutes', 'editLockHoldCumulativeMaxMinutes')
42
- if (limits.holdDefaultMinutes > limits.holdSingleMaxMinutes) {
43
- throw new Error('weir-settings: editLockHoldDefaultMinutes must not exceed editLockHoldSingleMaxMinutes')
44
- }
45
- if (limits.holdCumulativeMaxMinutes < limits.holdSingleMaxMinutes) {
46
- throw new Error('weir-settings: editLockHoldCumulativeMaxMinutes must be at least editLockHoldSingleMaxMinutes')
47
- }
48
- if (value.nudgeAttempts !== undefined) {
49
- if (!Number.isSafeInteger(value.nudgeAttempts) || value.nudgeAttempts < 0) {
50
- throw new Error('weir-settings: editLockNudgeAttempts must be a non-negative integer')
51
- }
52
- limits.nudgeAttempts = value.nudgeAttempts
53
- }
54
- if (value.nudgeFallback !== undefined) {
55
- if (value.nudgeFallback !== 'release' && value.nudgeFallback !== 'abnormal') {
56
- throw new Error('weir-settings: editLockNudgeFallback must be "release" or "abnormal"')
57
- }
58
- limits.nudgeFallback = value.nudgeFallback
59
- }
60
- return Object.freeze(limits)
61
- }