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
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
// The preset entry point of the file-lease feature (design D4, D5, D10, D11).
|
|
2
|
+
//
|
|
3
|
+
// This module owns exactly three things the arbiter cannot know:
|
|
4
|
+
//
|
|
5
|
+
// 1. The tool surface: five ownership tools that always exist, plus the
|
|
6
|
+
// negotiation reply tool that exists only while a request is pending. A
|
|
7
|
+
// tool nobody can answer is noise; a tool that vanishes mid-turn makes a
|
|
8
|
+
// late answer report "unknown tool", so the reply tool is retired when its
|
|
9
|
+
// pending set empties and no later than the turn boundary.
|
|
10
|
+
// 2. The turn lifecycle (D10): `turn/end` releases what the turn acquired
|
|
11
|
+
// without releasing retention, `agent/disposed` releases everything,
|
|
12
|
+
// retention included. Both are best-effort — a missed release is covered by
|
|
13
|
+
// the lease validity, which is why neither path may ever throw.
|
|
14
|
+
// 3. Wake delivery (D4): a transfer request addressed to a session that is
|
|
15
|
+
// idle in this process opens ONE turn so it can answer; a request addressed
|
|
16
|
+
// to a session that is mid-turn simply lands inside that turn and costs no
|
|
17
|
+
// extra wake. A request for a session that is not here (another process, a
|
|
18
|
+
// disposed agent) is silently dropped: the arbiter will expire it.
|
|
19
|
+
// 4. Release subscriptions (D2, D4, D5): ONE in-memory coordinator per lease
|
|
20
|
+
// client, shared by every mount of this bundle. A broadcast `released` fact
|
|
21
|
+
// is consumed once and delivered as a timer-deferred full UserMessage; a
|
|
22
|
+
// connection reset is a terminal state that tells each subscriber to
|
|
23
|
+
// re-check, never that the path is free.
|
|
24
|
+
//
|
|
25
|
+
// The transport is process-wide (D8/D11). Two preset mounts of this bundle
|
|
26
|
+
// share ONE client per socket path, and unmounting must never close it — that
|
|
27
|
+
// would rip the connection out from under the sibling mount. The default
|
|
28
|
+
// client is therefore a module-level lazy singleton, and the injected `client`
|
|
29
|
+
// option is the seam tests (and any future embedder) use instead.
|
|
30
|
+
//
|
|
31
|
+
// Nothing here imports `@deepseek-ai/*`: services arrive through `ctx`.
|
|
32
|
+
|
|
33
|
+
import { createLineageResolver } from './lineage.js'
|
|
34
|
+
import { userTextMessage } from '../shared/user-message.js'
|
|
35
|
+
import { createLeaseTools, REPLY_TOOL, shortName } from './tools.js'
|
|
36
|
+
import { createWatchCoordinator } from './watch.js'
|
|
37
|
+
import { AUDIT_TYPES, createAudit } from '../shared/audit.js'
|
|
38
|
+
import { getSharedLeaseClient } from './singleton.js'
|
|
39
|
+
import { buildLeaseView } from './view.js'
|
|
40
|
+
|
|
41
|
+
/** Plugin name (composition identity only; this row publishes no service). */
|
|
42
|
+
const PLUGIN_NAME = 'weir-file-lease'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one read-only HTTP route of the feature (design D13). The path keeps the
|
|
46
|
+
* pre-rebuild name on purpose: the client chunk that reads it, the feature doc
|
|
47
|
+
* and the spec all name `/api/weir-edit-lock/view`, and a rename would buy
|
|
48
|
+
* nothing a reader could see.
|
|
49
|
+
*/
|
|
50
|
+
const VIEW_PATH = '/api/weir-edit-lock/view'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The host's exact-duplicate-route refusal, and nothing else.
|
|
54
|
+
*
|
|
55
|
+
* Several preset mounts of this bundle live in ONE process (D11), while `fetch`
|
|
56
|
+
* routes are process-wide: the first mount owns the route and the host refuses
|
|
57
|
+
* a later mount's identical registration. Only that exact message is tolerated;
|
|
58
|
+
* every other registration error is a real fault and must surface.
|
|
59
|
+
* @param {unknown} error
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function duplicateRouteRegistration(error) {
|
|
63
|
+
return messageOf(error) === `connection: exact Fetch route ${JSON.stringify(VIEW_PATH)} is already registered`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Producer tag every injected message carries (AGENTS §3.4). */
|
|
67
|
+
const SOURCE_KIND = 'weir-file-lease'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Request ids this process has already handed to a holder agent.
|
|
71
|
+
*
|
|
72
|
+
* Two preset mounts share ONE client (D11), so both hear the same `request`
|
|
73
|
+
* event; without this a holder would be woken twice for one request, which
|
|
74
|
+
* D4/spec explicitly forbid ("no second wake is spent for the same request").
|
|
75
|
+
* The arbiter allocates a monotonic request id per request and never reuses
|
|
76
|
+
* one, so a value in this set means exactly "this request was delivered".
|
|
77
|
+
* Cleared when the reply window closes (answer, turn end, disposal).
|
|
78
|
+
* @type {WeakMap<object, Set<string>>}
|
|
79
|
+
*/
|
|
80
|
+
const noticedByAgent = new WeakMap()
|
|
81
|
+
|
|
82
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
83
|
+
function text(value) {
|
|
84
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The delivery memo for one holder agent.
|
|
89
|
+
* @param {object} agent
|
|
90
|
+
* @returns {Set<string>}
|
|
91
|
+
*/
|
|
92
|
+
function noticedFor(agent) {
|
|
93
|
+
let noticed = noticedByAgent.get(agent)
|
|
94
|
+
if (noticed === undefined) {
|
|
95
|
+
noticed = new Set()
|
|
96
|
+
noticedByAgent.set(agent, noticed)
|
|
97
|
+
}
|
|
98
|
+
return noticed
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The settled outcomes this process has already delivered, per requesting agent.
|
|
103
|
+
*
|
|
104
|
+
* One `settled` event is one process-wide fact, but every mount hears it and the
|
|
105
|
+
* requester must be told exactly once (D4). `issued` cannot dedupe it: a turn
|
|
106
|
+
* boundary clears that map while the outcome is allowed to arrive afterwards —
|
|
107
|
+
* which is exactly when this memo is the only thing that keeps a second mount
|
|
108
|
+
* from delivering the same outcome a second time. It is keyed by the live agent
|
|
109
|
+
* because that is this process's handle on the requesting session, and it is
|
|
110
|
+
* bounded because the arbiter settles a request exactly once: forgetting an old
|
|
111
|
+
* id can only lose a repeat that never comes.
|
|
112
|
+
* @type {WeakMap<object, Map<string, number>>}
|
|
113
|
+
*/
|
|
114
|
+
const settledNoticesByAgent = new WeakMap()
|
|
115
|
+
|
|
116
|
+
/** How many delivered outcomes one agent's memo remembers (bounded, FIFO). */
|
|
117
|
+
const SETTLED_NOTICE_MEMO_LIMIT = 64
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The broker incarnation every memo entry belongs to.
|
|
121
|
+
*
|
|
122
|
+
* Request ids come from the daemon's own counter, which starts over whenever a
|
|
123
|
+
* new daemon takes the socket, so a live session can be handed an id this
|
|
124
|
+
* process already delivered an outcome for. A transport reset is the boundary
|
|
125
|
+
* between two incarnations: nothing recorded before it says anything about an id
|
|
126
|
+
* issued after it.
|
|
127
|
+
*/
|
|
128
|
+
let settledEpoch = 0
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Claim the one model-facing delivery of a settled outcome (D4).
|
|
132
|
+
* @param {object} agent @param {string} requestId
|
|
133
|
+
* @returns {boolean} whether this caller is the one that may deliver it
|
|
134
|
+
*/
|
|
135
|
+
function claimSettledNotice(agent, requestId) {
|
|
136
|
+
let notices = settledNoticesByAgent.get(agent)
|
|
137
|
+
if (notices === undefined) {
|
|
138
|
+
notices = new Map()
|
|
139
|
+
settledNoticesByAgent.set(agent, notices)
|
|
140
|
+
}
|
|
141
|
+
if (notices.get(requestId) === settledEpoch) return false
|
|
142
|
+
notices.set(requestId, settledEpoch)
|
|
143
|
+
if (notices.size > SETTLED_NOTICE_MEMO_LIMIT) {
|
|
144
|
+
// Oldest first: the ids evicted here belong to outcomes the arbiter already
|
|
145
|
+
// announced once and will never announce again.
|
|
146
|
+
const [oldest] = notices.keys()
|
|
147
|
+
if (oldest !== undefined) notices.delete(oldest)
|
|
148
|
+
}
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @param {unknown} error @returns {string} */
|
|
153
|
+
function messageOf(error) {
|
|
154
|
+
return String(/** @type {any} */ (error)?.message ?? error)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* ONE release-subscription coordinator per lease client (design D2/D4).
|
|
159
|
+
*
|
|
160
|
+
* The client is process-wide, so its view of who waits for which path must be
|
|
161
|
+
* too: keying the coordinator by the client makes two preset mounts share one
|
|
162
|
+
* entry, one status flight and one notice, and lets either mount deliver a
|
|
163
|
+
* pending notice after the other one is gone. The map is weak because the
|
|
164
|
+
* coordinator — like the client — lives exactly as long as something holds it.
|
|
165
|
+
* @type {WeakMap<object, import('./watch.js').WatchCoordinator>}
|
|
166
|
+
*/
|
|
167
|
+
const watchCoordinators = new WeakMap()
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {object} client
|
|
171
|
+
* @returns {import('./watch.js').WatchCoordinator}
|
|
172
|
+
*/
|
|
173
|
+
function coordinatorFor(client) {
|
|
174
|
+
let coordinator = watchCoordinators.get(client)
|
|
175
|
+
if (coordinator === undefined) {
|
|
176
|
+
coordinator = createWatchCoordinator()
|
|
177
|
+
watchCoordinators.set(client, coordinator)
|
|
178
|
+
}
|
|
179
|
+
return coordinator
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The session this agent acts for: the durable header id when there is one,
|
|
184
|
+
* else the live session id, else the agent id. Non-empty by construction, and
|
|
185
|
+
* `undefined` only for an agent object with no identity at all — a caller that
|
|
186
|
+
* cannot name a session cannot release one either.
|
|
187
|
+
* @param {any} agent
|
|
188
|
+
* @returns {string | undefined}
|
|
189
|
+
*/
|
|
190
|
+
function sessionIdOf(agent) {
|
|
191
|
+
return text(agent?.session?.header?.id) ?? text(agent?.session?.id) ?? text(agent?.id)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {any} agent @returns {string | undefined} */
|
|
195
|
+
function cwdOf(agent) {
|
|
196
|
+
return text(agent?.session?.header?.cwd)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Resolve `ownerOf(agent)` from the live lineage rule (D9): the lineage id plus
|
|
201
|
+
* the session and label the arbiter only ever formats.
|
|
202
|
+
* @param {() => any} agents lazy accessor for the `agents` service
|
|
203
|
+
* @returns {(agent: any) => { id: string, session: string, label: string }}
|
|
204
|
+
*/
|
|
205
|
+
function createOwnerResolver(agents) {
|
|
206
|
+
const resolver = createLineageResolver({
|
|
207
|
+
// The ancestor walk reads one header per hop; a missing registry (a cold or
|
|
208
|
+
// partially mounted host) degrades to "this session is its own lineage".
|
|
209
|
+
sessions: {
|
|
210
|
+
get: (/** @type {string} */ id) => {
|
|
211
|
+
try {
|
|
212
|
+
return agents()?.get?.(id)
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
return (agent) => {
|
|
220
|
+
const lineage = resolver.lineageOf(agent)
|
|
221
|
+
return {
|
|
222
|
+
id: lineage,
|
|
223
|
+
session: sessionIdOf(agent) ?? lineage,
|
|
224
|
+
label: resolver.labelOf(agent),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Deliver one model-facing notice (AGENTS §3.4): `followup` when the wake flag
|
|
231
|
+
* is set and the agent is idle — that single call is what opens the turn the
|
|
232
|
+
* holder needs in order to answer — and `steer` otherwise, which lands inside a
|
|
233
|
+
* running turn instead of spending an extra one. Both channels failing is
|
|
234
|
+
* silence: the negotiation simply expires and audit belongs to the client (D7).
|
|
235
|
+
*
|
|
236
|
+
* @param {any} agent
|
|
237
|
+
* @param {string} text
|
|
238
|
+
* @param {boolean} [wake]
|
|
239
|
+
* @returns {Promise<boolean>} whether a channel accepted the message
|
|
240
|
+
*/
|
|
241
|
+
async function defaultDeliver(agent, text, wake = false) {
|
|
242
|
+
const message = userTextMessage(text, SOURCE_KIND)
|
|
243
|
+
// `followup` only when asked to wake an idle agent: a followup to a running
|
|
244
|
+
// agent would queue a second turn, which is exactly the cost D4 forbids.
|
|
245
|
+
const channels = wake && agent?.status === 'idle' ? ['followup', 'steer'] : ['steer', 'followup']
|
|
246
|
+
for (const channel of channels) {
|
|
247
|
+
try {
|
|
248
|
+
const send = agent?.[channel]
|
|
249
|
+
if (typeof send !== 'function') continue
|
|
250
|
+
await send.call(agent, message)
|
|
251
|
+
return true
|
|
252
|
+
} catch {
|
|
253
|
+
// Fall through to the other channel; a total failure stays silent.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The request notice addressed to the holder. Model-facing only: it names the
|
|
261
|
+
* requester, names the tool and the request id, and states that inaction keeps
|
|
262
|
+
* ownership. It must never read like something for a human to act on.
|
|
263
|
+
* @param {{ requestId: string, short: string, label: string, fromSession: string, seconds: number }} detail
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
function requestText({ requestId, short, label, fromSession, seconds }) {
|
|
267
|
+
return [
|
|
268
|
+
`Session "${label}" (session ${fromSession}) is asking to take over ${short}.`,
|
|
269
|
+
`Answer with edit_lock_reply: request_id "${requestId}", decision "release" to hand it over, or "keep" to hold on to it.`,
|
|
270
|
+
`Nothing moves until you answer — if you do not answer, ownership stays yours and the request expires by itself (about ${seconds}s left).`,
|
|
271
|
+
].join(' ')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The result notice addressed to the requester once its request settled.
|
|
276
|
+
* @param {string} state `transferred` | `declined` | `expired`
|
|
277
|
+
* @param {string} requestId
|
|
278
|
+
* @param {string} short
|
|
279
|
+
* @returns {string}
|
|
280
|
+
*/
|
|
281
|
+
function settledText(state, requestId, short) {
|
|
282
|
+
if (state === 'transferred') {
|
|
283
|
+
return `Request ${requestId}: ownership of ${short} was transferred to this session. It is yours now — edit it, and claim it again only if it lapses.`
|
|
284
|
+
}
|
|
285
|
+
if (state === 'declined') {
|
|
286
|
+
return `Request ${requestId}: the holder kept ${short}. Ownership is unchanged, and it becomes available when its validity ends — do not retry the same edit blindly.`
|
|
287
|
+
}
|
|
288
|
+
return `Request ${requestId}: no answer arrived for ${short}, so the request expired and ownership is unchanged. It becomes available when its validity ends; ask again later if you still need it.`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The release notice addressed to a subscriber (D4).
|
|
293
|
+
*
|
|
294
|
+
* It says what happened, what to do next, and — explicitly — what it does NOT
|
|
295
|
+
* mean: the arbiter selected no winner and the path may already be gone again.
|
|
296
|
+
* The path is the model's short name; an absolute path never lands in a message.
|
|
297
|
+
* @param {string} short
|
|
298
|
+
* @returns {string}
|
|
299
|
+
*/
|
|
300
|
+
function releasedNoticeText(short) {
|
|
301
|
+
return [
|
|
302
|
+
`${short} is no longer held by another session.`,
|
|
303
|
+
'This is only an opportunity to call edit_lock_acquire for it — not a grant of ownership, and not a reservation: another session waiting for the same release may claim it first, and the ordinary holder conflict still applies.',
|
|
304
|
+
'If your claim is refused, negotiate with edit_lock_try_steal or call edit_lock_watch again for the next release.',
|
|
305
|
+
].join(' ')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The notice for a subscription a connection reset ended (D5).
|
|
310
|
+
*
|
|
311
|
+
* A reset says nothing about ownership, so this must not read like a release:
|
|
312
|
+
* the subscriber is told to re-check and to subscribe again if it is still
|
|
313
|
+
* refused, and nothing else.
|
|
314
|
+
* @param {string} short
|
|
315
|
+
* @returns {string}
|
|
316
|
+
*/
|
|
317
|
+
function resetNoticeText(short) {
|
|
318
|
+
return [
|
|
319
|
+
`The ownership arbiter connection was reset, so the release subscription for ${short} ended without learning anything about ownership.`,
|
|
320
|
+
'Nothing was released and nothing is free by this notice. Re-check with edit_lock_status, and call edit_lock_watch again if the path is still held by another session.',
|
|
321
|
+
].join(' ')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Adapt one degradation record from the lease client to the shared audit
|
|
326
|
+
* channel (design D7, `AUDIT_TYPES.fileLease`).
|
|
327
|
+
*
|
|
328
|
+
* The client calls its sink synchronously at the moment a managed write
|
|
329
|
+
* degrades, and rate limits it itself (one record per process window), so this
|
|
330
|
+
* adapter must never throttle again. `detail.session` is the owning session id;
|
|
331
|
+
* the shared channel takes the LIVE session object so the JSONL mirror lands in
|
|
332
|
+
* that session's workspace. A session this process can no longer resolve still
|
|
333
|
+
* emits the in-process event with `session: null` — audit is log-only and the
|
|
334
|
+
* degraded write proceeds either way.
|
|
335
|
+
*
|
|
336
|
+
* The client's detail is flat (`{code, session, lineage, scope, paths, at}`);
|
|
337
|
+
* the record names the owner under `owner` so a reader never depends on the
|
|
338
|
+
* client's internal field names. `paths` is a COUNT — no path content.
|
|
339
|
+
*
|
|
340
|
+
* @param {(session: any, type: string, data?: unknown) => void} audit the shared audit emitter
|
|
341
|
+
* @param {() => any} agents lazy `agents` service accessor
|
|
342
|
+
* @param {string} kind the degradation kind (`'degraded'`)
|
|
343
|
+
* @param {Record<string, any>} [detail] the client's degradation detail
|
|
344
|
+
*/
|
|
345
|
+
function auditLeaseDegradation(audit, agents, kind, detail) {
|
|
346
|
+
try {
|
|
347
|
+
const sessionId = text(detail?.session)
|
|
348
|
+
/** @type {any} */
|
|
349
|
+
let session = null
|
|
350
|
+
if (sessionId !== undefined) {
|
|
351
|
+
try {
|
|
352
|
+
session = agents()?.get?.(sessionId)?.session ?? null
|
|
353
|
+
} catch {
|
|
354
|
+
session = null
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
audit(session, AUDIT_TYPES.fileLease, {
|
|
358
|
+
kind,
|
|
359
|
+
...detail,
|
|
360
|
+
owner: { session: sessionId ?? null, lineage: text(detail?.lineage) ?? null },
|
|
361
|
+
})
|
|
362
|
+
} catch {
|
|
363
|
+
// Audit is log-only: a broken sink must never break a managed write.
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Create one plugin instance. Every collaborator is injectable so a unit test
|
|
369
|
+
* can drive the whole surface with a fake client and a fake ctx: the default
|
|
370
|
+
* client is the process-wide singleton and the default cortex is a live cordis
|
|
371
|
+
* context, neither of which a unit test should touch.
|
|
372
|
+
*
|
|
373
|
+
* @param {object} [options]
|
|
374
|
+
* @param {any} [options.client] lease client (default: the process-wide singleton)
|
|
375
|
+
* @param {(overrides?: any) => any} [options.sharedClient] shared-client factory
|
|
376
|
+
* (default: the process-wide singleton; injectable so a unit test can observe
|
|
377
|
+
* the degradation-audit wiring without a socket)
|
|
378
|
+
* @param {(agent: any) => { id: string, session: string, label: string }} [options.ownerOf] owner identity (default: the lineage rule)
|
|
379
|
+
* @param {(agent: any, text: string, wake?: boolean) => any} [options.deliver] model-facing message channel
|
|
380
|
+
* @param {() => number} [options.now] injected clock (epoch ms)
|
|
381
|
+
* @returns {{ name: string, inject: string[], apply: (ctx: any, config?: { enabled?: boolean }) => (() => void) | undefined }}
|
|
382
|
+
*/
|
|
383
|
+
export function createFileLeasePlugin(options = {}) {
|
|
384
|
+
const injectedClient = options?.client ?? null
|
|
385
|
+
const sharedClient = typeof options?.sharedClient === 'function' ? options.sharedClient : getSharedLeaseClient
|
|
386
|
+
const injectedOwnerOf = options?.ownerOf ?? null
|
|
387
|
+
const injectedDeliver = options?.deliver ?? null
|
|
388
|
+
const now = typeof options?.now === 'function' ? options.now : Date.now
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Mount this row.
|
|
392
|
+
*
|
|
393
|
+
* `config.enabled === false` is a COMPOSITION-LAYER SEAM, not a user setting
|
|
394
|
+
* and not a feature flag: it exists so a test composition (or an embedder) can
|
|
395
|
+
* mount this row switched off for one boot without deleting it from the patch.
|
|
396
|
+
* The production presets never write that key — the feature is always on there
|
|
397
|
+
* — so no settings schema, no command and no documentation promises it. With
|
|
398
|
+
* the seam off the mount does nothing at all: no tool is registered and no
|
|
399
|
+
* event is subscribed, which is exactly what "this row is not here" means.
|
|
400
|
+
* Every other value (including omitted) is enabled.
|
|
401
|
+
*
|
|
402
|
+
* @param {any} ctx the row's cordis context
|
|
403
|
+
* @param {{ enabled?: boolean }} [config] composition config (`enabled` seam only)
|
|
404
|
+
* @returns {(() => void) | undefined} the unmount disposer
|
|
405
|
+
*/
|
|
406
|
+
function apply(ctx, config) {
|
|
407
|
+
// The seam is checked before anything touches the ctx: a disabled mount must
|
|
408
|
+
// leave no tool, no listener and no subscription behind (design D11 mount
|
|
409
|
+
// lifetime), so there is nothing to return a disposer for.
|
|
410
|
+
if (config?.enabled === false) return undefined
|
|
411
|
+
/** @type {(() => void)[]} */
|
|
412
|
+
const disposers = []
|
|
413
|
+
/** @type {WeakMap<object, Map<string, { path: string, from: any }>>} */
|
|
414
|
+
const pendingByAgent = new WeakMap()
|
|
415
|
+
/** @type {WeakMap<object, { dispose: () => void }>} */
|
|
416
|
+
const replyTools = new WeakMap()
|
|
417
|
+
/** @type {Set<any>} agents whose reply tool this mount registered */
|
|
418
|
+
const replyAgents = new Set()
|
|
419
|
+
/**
|
|
420
|
+
* `requestId -> the session that asked for it`. A `settled` event names the
|
|
421
|
+
* requester's session, but a broker older than that field pushes the outcome
|
|
422
|
+
* only to the requesting connection (which every session of this process
|
|
423
|
+
* shares), so this map remains the fallback route. It is routing state,
|
|
424
|
+
* never authority: the arbiter decides (D6).
|
|
425
|
+
* @type {Map<string, { session: string, path: string }>}
|
|
426
|
+
*/
|
|
427
|
+
const issued = new Map()
|
|
428
|
+
let disposed = false
|
|
429
|
+
/** @type {() => void} this mount's read-only view route disposer (D13) */
|
|
430
|
+
let disposeView = () => {}
|
|
431
|
+
|
|
432
|
+
/** @returns {any} the `agents` service, or undefined when it is not mounted */
|
|
433
|
+
function agents() {
|
|
434
|
+
try {
|
|
435
|
+
return ctx.get?.('agents')
|
|
436
|
+
} catch {
|
|
437
|
+
return undefined
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// The process-wide client (D11): shared with every other mount of this row
|
|
442
|
+
// and never closed by an unmount. An injected client wins and creates nothing.
|
|
443
|
+
//
|
|
444
|
+
// D7 wiring: the client's own degradation sink is already rate limited per
|
|
445
|
+
// process, so the adapter below only maps it onto the shared audit channel
|
|
446
|
+
// (never a second throttle). Only the SHARED client gets it — an injected
|
|
447
|
+
// client belongs to its embedder and keeps whatever sink it was built with.
|
|
448
|
+
const weirAudit = createAudit(ctx)
|
|
449
|
+
const client = injectedClient ?? sharedClient({
|
|
450
|
+
audit: (/** @type {string} */ kind, /** @type {Record<string, any>} */ detail) =>
|
|
451
|
+
auditLeaseDegradation(weirAudit, agents, kind, detail),
|
|
452
|
+
})
|
|
453
|
+
const ownerOf = injectedOwnerOf ?? createOwnerResolver(agents)
|
|
454
|
+
const deliver = injectedDeliver ?? defaultDeliver
|
|
455
|
+
/**
|
|
456
|
+
* This client's release subscriptions (D2/D4). ONE coordinator per client,
|
|
457
|
+
* not per mount: two mounts that subscribe the same session and path share
|
|
458
|
+
* one entry, one status flight and one notice, and one mount's teardown
|
|
459
|
+
* cannot cancel a wait another mount still owns.
|
|
460
|
+
*/
|
|
461
|
+
const watches = coordinatorFor(client)
|
|
462
|
+
/**
|
|
463
|
+
* This mount's reference on the shared coordinator plus the notice channel it
|
|
464
|
+
* speaks through. The coordinator maintains `disposed`; `notify` only renders
|
|
465
|
+
* the outcome it has already re-checked (a release fact or a reset).
|
|
466
|
+
* @type {import('./watch.js').WatchMount}
|
|
467
|
+
*/
|
|
468
|
+
const watchMount = {
|
|
469
|
+
disposed: false,
|
|
470
|
+
agentFor: (/** @type {string} */ session) => agentFor(session),
|
|
471
|
+
notify: (/** @type {import('./watch.js').WatchEntry} */ entry, /** @type {any} */ agent) => {
|
|
472
|
+
const short = shortName(entry.path, cwdOf(agent))
|
|
473
|
+
notify(agent, entry.state === 'reset' ? resetNoticeText(short) : releasedNoticeText(short), agent?.status === 'idle')
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
watches.join(watchMount)
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Deliver a notice without letting delivery break the caller: both channels
|
|
480
|
+
* failing is silence, by contract.
|
|
481
|
+
* @param {any} agent @param {string} text @param {boolean} [wake]
|
|
482
|
+
*/
|
|
483
|
+
function notify(agent, text, wake = false) {
|
|
484
|
+
if (disposed) return
|
|
485
|
+
try {
|
|
486
|
+
const outcome = deliver(agent, text, wake)
|
|
487
|
+
const promise = /** @type {any} */ (outcome)
|
|
488
|
+
if (promise !== null && typeof promise === 'object' && typeof promise.then === 'function') {
|
|
489
|
+
promise.then(undefined, () => {})
|
|
490
|
+
}
|
|
491
|
+
} catch {
|
|
492
|
+
// Silent: the negotiation expires on its own.
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Run a best-effort release. Never throws and never keeps state to retry:
|
|
498
|
+
* the lease validity is the guarantee, this is the optimisation (D10).
|
|
499
|
+
* @param {() => any} run
|
|
500
|
+
*/
|
|
501
|
+
function quiet(run) {
|
|
502
|
+
try {
|
|
503
|
+
const outcome = run()
|
|
504
|
+
const promise = /** @type {any} */ (outcome)
|
|
505
|
+
if (promise !== null && typeof promise === 'object' && typeof promise.then === 'function') {
|
|
506
|
+
promise.then(undefined, () => {})
|
|
507
|
+
}
|
|
508
|
+
} catch {
|
|
509
|
+
// Same rule as notify().
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** @param {string | undefined} session @returns {any} */
|
|
514
|
+
function agentFor(session) {
|
|
515
|
+
if (session === undefined) return undefined
|
|
516
|
+
try {
|
|
517
|
+
return agents()?.get?.(session)
|
|
518
|
+
} catch {
|
|
519
|
+
return undefined
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** @param {any} agent @returns {Map<string, { path: string, from: any }>} */
|
|
524
|
+
function pendingOf(agent) {
|
|
525
|
+
let pending = pendingByAgent.get(agent)
|
|
526
|
+
if (pending === undefined) {
|
|
527
|
+
pending = new Map()
|
|
528
|
+
pendingByAgent.set(agent, pending)
|
|
529
|
+
}
|
|
530
|
+
return pending
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Register the reply tool on THIS AGENT's scope (not the mount's): the tool
|
|
535
|
+
* answers a request addressed to one holder, and a tool visible to every
|
|
536
|
+
* agent in the preset would offer an answer nobody can give.
|
|
537
|
+
*
|
|
538
|
+
* Idempotent by construction — a second mount on the same agent scope must
|
|
539
|
+
* not throw, and a registration of the same name that this mount does not
|
|
540
|
+
* own is left alone.
|
|
541
|
+
* @param {any} agent
|
|
542
|
+
*/
|
|
543
|
+
function registerReplyTool(agent) {
|
|
544
|
+
if (disposed || replyTools.has(agent)) return
|
|
545
|
+
const scope = agent?.ctx
|
|
546
|
+
if (!scope?.tools?.register) return
|
|
547
|
+
try {
|
|
548
|
+
if (typeof scope.tools.get === 'function' && scope.tools.get(REPLY_TOOL) !== undefined) return
|
|
549
|
+
} catch {
|
|
550
|
+
// A registry probe must never break delivery.
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
const dispose = scope.tools.register(replyToolFor(agent))
|
|
554
|
+
replyTools.set(agent, { dispose: typeof dispose === 'function' ? dispose : () => {} })
|
|
555
|
+
replyAgents.add(agent)
|
|
556
|
+
} catch (error) {
|
|
557
|
+
ctx.logger?.warn?.(`file-lease: the reply tool could not be registered: ${messageOf(error)}`)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The reply definition, wrapped so this mount learns that a pending request
|
|
563
|
+
* was answered (the broker reports it to the requester, not to the holder).
|
|
564
|
+
* @param {any} agent
|
|
565
|
+
* @returns {any}
|
|
566
|
+
*/
|
|
567
|
+
function replyToolFor(agent) {
|
|
568
|
+
const definition = /** @type {any} */ (replyDefinition)
|
|
569
|
+
return {
|
|
570
|
+
...definition,
|
|
571
|
+
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
572
|
+
try {
|
|
573
|
+
return await definition.execute(args, exec)
|
|
574
|
+
} finally {
|
|
575
|
+
answered(agent, args?.request_id)
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* One pending request is gone; the reply tool follows when none is left.
|
|
583
|
+
* @param {any} agent @param {unknown} requestId
|
|
584
|
+
*/
|
|
585
|
+
function answered(agent, requestId) {
|
|
586
|
+
const pending = pendingByAgent.get(agent)
|
|
587
|
+
if (pending === undefined) return
|
|
588
|
+
const id = text(requestId)
|
|
589
|
+
if (id !== undefined) pending.delete(id)
|
|
590
|
+
if (pending.size === 0) retireReplyTool(agent)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The reply window for this agent is over: the tool goes, and the delivery
|
|
595
|
+
* memo goes with it so a later request is never mistaken for a repeat.
|
|
596
|
+
* @param {any} agent
|
|
597
|
+
*/
|
|
598
|
+
function retireReplyTool(agent) {
|
|
599
|
+
if (agent === null || typeof agent !== 'object') return
|
|
600
|
+
const registered = replyTools.get(agent)
|
|
601
|
+
replyTools.delete(agent)
|
|
602
|
+
replyAgents.delete(agent)
|
|
603
|
+
pendingByAgent.delete(agent)
|
|
604
|
+
noticedByAgent.delete(agent)
|
|
605
|
+
if (registered === undefined) return
|
|
606
|
+
try {
|
|
607
|
+
registered.dispose()
|
|
608
|
+
} catch {
|
|
609
|
+
// Already unregistered by the scope's own teardown.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** @param {string | undefined} session */
|
|
614
|
+
function forgetIssued(session) {
|
|
615
|
+
if (session === undefined) return
|
|
616
|
+
for (const [requestId, entry] of [...issued]) if (entry.session === session) issued.delete(requestId)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The turn boundary (D10). ANY reason releases — completed, aborted by the
|
|
621
|
+
* user, or an error: the turn is over, so the session is no longer editing,
|
|
622
|
+
* and the next message must be able to edit immediately. Retention is what
|
|
623
|
+
* survives a boundary, which `releaseSession` already encodes.
|
|
624
|
+
* @param {any} agent
|
|
625
|
+
*/
|
|
626
|
+
function releaseOnTurnEnd(agent) {
|
|
627
|
+
const session = sessionIdOf(agent)
|
|
628
|
+
// The reply tool exists for one turn's pending answers; the boundary ends
|
|
629
|
+
// that window whether or not the holder replied.
|
|
630
|
+
retireReplyTool(agent)
|
|
631
|
+
forgetIssued(session)
|
|
632
|
+
if (session === undefined) return
|
|
633
|
+
quiet(() => client.releaseSession(session))
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* A disposed agent never comes back, so its retention ends with it (D10).
|
|
638
|
+
* @param {any} agent
|
|
639
|
+
*/
|
|
640
|
+
function releaseOnDisposed(agent) {
|
|
641
|
+
const session = sessionIdOf(agent)
|
|
642
|
+
retireReplyTool(agent)
|
|
643
|
+
forgetIssued(session)
|
|
644
|
+
// A disposed agent never comes back: its subscriptions end with it — the
|
|
645
|
+
// active waits AND the consumed notices still waiting for their timer.
|
|
646
|
+
if (session !== undefined) watches.cancelSession(session)
|
|
647
|
+
if (session === undefined) return
|
|
648
|
+
quiet(() => client.releaseAll(session))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The holder a `request` event is addressed to. The wire event names the
|
|
653
|
+
* requester (`from`) but not the holder — it is pushed to the holder's
|
|
654
|
+
* connection, which this process shares across every session — so the
|
|
655
|
+
* holder is whoever the arbiter reports holding that path (D8). A future
|
|
656
|
+
* transport that names the holder is honoured first.
|
|
657
|
+
* @param {any} event @param {string} path
|
|
658
|
+
* @returns {Promise<string | undefined>}
|
|
659
|
+
*/
|
|
660
|
+
async function holderSession(event, path) {
|
|
661
|
+
const named = text(event?.session) ?? text(event?.holder?.session) ?? text(event?.to?.session)
|
|
662
|
+
if (named !== undefined) return named
|
|
663
|
+
try {
|
|
664
|
+
const reply = await client.status({ path })
|
|
665
|
+
const leases = Array.isArray(reply?.leases) ? reply.leases : []
|
|
666
|
+
return text(leases[0]?.owner?.session)
|
|
667
|
+
} catch {
|
|
668
|
+
return undefined
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** @param {any} event */
|
|
673
|
+
async function onRequest(event) {
|
|
674
|
+
const requestId = text(event?.requestId)
|
|
675
|
+
const path = text(event?.path)
|
|
676
|
+
if (requestId === undefined || path === undefined) return
|
|
677
|
+
const session = await holderSession(event, path)
|
|
678
|
+
if (session === undefined) return
|
|
679
|
+
const agent = agentFor(session)
|
|
680
|
+
// Cross-process or already gone: drop silently. The arbiter expires the
|
|
681
|
+
// request and the requester is told; nothing here is asked to fix it.
|
|
682
|
+
if (!agent) return
|
|
683
|
+
// One delivery per request per process, however many mounts are alive.
|
|
684
|
+
const noticed = noticedFor(agent)
|
|
685
|
+
if (noticed.has(requestId)) return
|
|
686
|
+
noticed.add(requestId)
|
|
687
|
+
pendingOf(agent).set(requestId, { path, from: event?.from })
|
|
688
|
+
registerReplyTool(agent)
|
|
689
|
+
const from = event?.from ?? {}
|
|
690
|
+
const label = text(from.label) ?? 'another session'
|
|
691
|
+
const fromSession = text(from.session) ?? 'unknown'
|
|
692
|
+
const expiresAt = typeof event?.expiresAt === 'number' ? event.expiresAt : now()
|
|
693
|
+
const seconds = Math.max(0, Math.ceil((expiresAt - now()) / 1000))
|
|
694
|
+
notify(agent, requestText({ requestId, short: shortName(path, cwdOf(agent)), label, fromSession, seconds }), agent?.status === 'idle')
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* One negotiation outcome from the broker (D4).
|
|
699
|
+
*
|
|
700
|
+
* The event names the requester (`session`) and the path it asked about, and
|
|
701
|
+
* that pair is the routing address: a request outlives the turn that made it,
|
|
702
|
+
* while this mount's `issued` memo does not — a turn boundary clears it — so
|
|
703
|
+
* `issued` only routes an older broker's event, which names no session. An
|
|
704
|
+
* event that names neither is another process's outcome and is ignored here.
|
|
705
|
+
* @param {any} event
|
|
706
|
+
*/
|
|
707
|
+
function onSettled(event) {
|
|
708
|
+
const requestId = text(event?.requestId)
|
|
709
|
+
if (requestId === undefined) return
|
|
710
|
+
const state = text(event?.state) ?? 'expired'
|
|
711
|
+
const entry = issued.get(requestId)
|
|
712
|
+
issued.delete(requestId)
|
|
713
|
+
// The transfer made this session an owner by another route, so a later
|
|
714
|
+
// release notice for the same path would wake it for nothing: the wait is
|
|
715
|
+
// over because the goal was reached, not because a release happened. The
|
|
716
|
+
// wait ends BEFORE the outcome is routed, so the cancellation can never be
|
|
717
|
+
// lost to a cleared route.
|
|
718
|
+
const session = text(event?.session) ?? entry?.session
|
|
719
|
+
const path = text(event?.path) ?? entry?.path
|
|
720
|
+
if (state === 'transferred' && session !== undefined && path !== undefined) {
|
|
721
|
+
watches.settleSelf(session, path)
|
|
722
|
+
}
|
|
723
|
+
// No session and no route: the request was issued in another process.
|
|
724
|
+
if (session === undefined || path === undefined) return
|
|
725
|
+
const agent = agentFor(session)
|
|
726
|
+
if (!agent) return
|
|
727
|
+
// ONE delivery per request in this process, however many mounts hear this
|
|
728
|
+
// event — including the mount whose `issued` entry routed it here.
|
|
729
|
+
if (!claimSettledNotice(agent, requestId)) return
|
|
730
|
+
notify(agent, settledText(state, requestId, shortName(path, cwdOf(agent))), agent?.status === 'idle')
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* One release fact from the broker (D1/D2).
|
|
735
|
+
*
|
|
736
|
+
* Consumption happens inside the coordinator, before anything is scheduled:
|
|
737
|
+
* the entry leaves the active map in `hit`, so a duplicate or repeated
|
|
738
|
+
* observation of the same release finds nothing to consume and delivers
|
|
739
|
+
* nothing. The fact carries no session, so every subscription of this client
|
|
740
|
+
* for that path is consumed — the arbiter never chooses a winner.
|
|
741
|
+
* @param {any} event
|
|
742
|
+
*/
|
|
743
|
+
function onReleased(event) {
|
|
744
|
+
const path = text(event?.path)
|
|
745
|
+
if (path !== undefined) watches.consumePath(path)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The connection that could have delivered a release is gone (D5).
|
|
750
|
+
*
|
|
751
|
+
* Every active subscription ends in `reset` — a terminal state that claims
|
|
752
|
+
* nothing about ownership — and the coordinator's deferred notice tells each
|
|
753
|
+
* live subscriber to re-check and re-subscribe, never that the path became
|
|
754
|
+
* free.
|
|
755
|
+
*/
|
|
756
|
+
function onConnectionReset() {
|
|
757
|
+
// A reset also ends the broker incarnation that allocated the request ids
|
|
758
|
+
// already answered in this process: the next one counts from its own zero.
|
|
759
|
+
settledEpoch += 1
|
|
760
|
+
if (disposed) return
|
|
761
|
+
watches.resetAll()
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** @param {any} event */
|
|
765
|
+
async function onClientEvent(event) {
|
|
766
|
+
if (disposed || event === null || typeof event !== 'object') return
|
|
767
|
+
try {
|
|
768
|
+
if (event.event === 'request') await onRequest(event)
|
|
769
|
+
else if (event.event === 'settled') onSettled(event)
|
|
770
|
+
else if (event.event === 'released') onReleased(event)
|
|
771
|
+
} catch {
|
|
772
|
+
// A listener is a consumer; it must never break the transport.
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* The tools see the client through this narrow facade for one reason: the
|
|
778
|
+
* requester of a transfer has to be remembered from the moment the request
|
|
779
|
+
* is made, because a broker older than `settled.session` pushes the outcome
|
|
780
|
+
* without naming it. The facade adds that routing memo and nothing else —
|
|
781
|
+
* every call still ends in the arbiter.
|
|
782
|
+
*/
|
|
783
|
+
const routedClient = Object.freeze({
|
|
784
|
+
acquire: (/** @type {any} */ owner, /** @type {any} */ paths, /** @type {any} */ scope) => client.acquire(owner, paths, scope),
|
|
785
|
+
hold: (/** @type {any} */ paths, /** @type {any} */ minutes, /** @type {any} */ owner) => client.hold(paths, minutes, owner),
|
|
786
|
+
status: (/** @type {any} */ filter) => client.status(filter),
|
|
787
|
+
request: (/** @type {any} */ op, /** @type {any} */ payload) => client.request(op, payload),
|
|
788
|
+
reply: (/** @type {any} */ input) => client.reply(input),
|
|
789
|
+
// Capability is a property of the CURRENT handshake. A client facade
|
|
790
|
+
// without the query (an embedder, an older injection) advertises nothing,
|
|
791
|
+
// so the watch tool fails explicitly instead of waiting for an event that
|
|
792
|
+
// cannot arrive.
|
|
793
|
+
supportsEvent: (/** @type {string} */ name) =>
|
|
794
|
+
typeof client.supportsEvent === 'function' ? client.supportsEvent(name) === true : false,
|
|
795
|
+
requestTransfer: async (/** @type {any} */ path, /** @type {any} */ from) => {
|
|
796
|
+
const reply = await client.requestTransfer(path, from)
|
|
797
|
+
const requestId = text(reply?.requestId)
|
|
798
|
+
if (requestId !== undefined) issued.set(requestId, { session: text(from?.session) ?? '', path })
|
|
799
|
+
return reply
|
|
800
|
+
},
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
const definitions = createLeaseTools({
|
|
804
|
+
client: routedClient,
|
|
805
|
+
ownerOf,
|
|
806
|
+
deliverNotice: (agent, text) => notify(agent, text, false),
|
|
807
|
+
watchCoordinator: watches,
|
|
808
|
+
mount: watchMount,
|
|
809
|
+
})
|
|
810
|
+
const replyDefinition = definitions.find((definition) => definition?.name === REPLY_TOOL)
|
|
811
|
+
const staticDefinitions = definitions.filter((definition) => definition?.name !== REPLY_TOOL)
|
|
812
|
+
|
|
813
|
+
for (const definition of staticDefinitions) {
|
|
814
|
+
// A second mount on the same scope must not throw: the tool is already
|
|
815
|
+
// there, provided by the sibling mount (D11 allows exactly that shape).
|
|
816
|
+
try {
|
|
817
|
+
if (typeof ctx.tools?.get === 'function' && ctx.tools.get(definition.name) !== undefined) continue
|
|
818
|
+
} catch {
|
|
819
|
+
// A registry probe must never abort the mount.
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
const dispose = ctx.tools?.register?.(definition)
|
|
823
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
824
|
+
} catch (error) {
|
|
825
|
+
ctx.logger?.warn?.(`file-lease: tool ${String(definition?.name)} was not registered: ${messageOf(error)}`)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// The turn boundary. `turn/end` is the host-level event this surface is
|
|
830
|
+
// defined against; this runtime publishes the same boundary as the durable
|
|
831
|
+
// `turn/end` session event, so both feed the ONE handler (release is
|
|
832
|
+
// idempotent per protocol, so a host that emits both is harmless).
|
|
833
|
+
const offTurnEnd = ctx.on?.('turn/end', (/** @type {any} */ payload) => {
|
|
834
|
+
releaseOnTurnEnd(payload?.agent)
|
|
835
|
+
})
|
|
836
|
+
if (typeof offTurnEnd === 'function') disposers.push(offTurnEnd)
|
|
837
|
+
|
|
838
|
+
const offSession = ctx.on?.('session/event', (/** @type {any} */ session, /** @type {any} */ event) => {
|
|
839
|
+
if (event?.type !== 'turn/end') return
|
|
840
|
+
const agent = agentFor(text(session?.id))
|
|
841
|
+
if (agent === undefined) return
|
|
842
|
+
releaseOnTurnEnd(agent)
|
|
843
|
+
})
|
|
844
|
+
if (typeof offSession === 'function') disposers.push(offSession)
|
|
845
|
+
|
|
846
|
+
const offDisposed = ctx.on?.('agent/disposed', (/** @type {any} */ payload) => {
|
|
847
|
+
releaseOnDisposed(payload?.agent)
|
|
848
|
+
})
|
|
849
|
+
if (typeof offDisposed === 'function') disposers.push(offDisposed)
|
|
850
|
+
|
|
851
|
+
/** @type {() => void} */
|
|
852
|
+
let offEvents = () => {}
|
|
853
|
+
try {
|
|
854
|
+
const off = client.onEvent((/** @type {any} */ event) => {
|
|
855
|
+
void onClientEvent(event)
|
|
856
|
+
})
|
|
857
|
+
if (typeof off === 'function') offEvents = off
|
|
858
|
+
} catch (error) {
|
|
859
|
+
ctx.logger?.warn?.(`file-lease: negotiation events are unavailable: ${messageOf(error)}`)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// A vanished broker cannot announce anything, so the reset is a transport
|
|
863
|
+
// lifecycle signal rather than a wire event (D5). A client without it simply
|
|
864
|
+
// never reports resets — the other paths still work.
|
|
865
|
+
/** @type {() => void} */
|
|
866
|
+
let offReset = () => {}
|
|
867
|
+
try {
|
|
868
|
+
const off = typeof client.onReset === 'function' ? client.onReset(() => onConnectionReset()) : undefined
|
|
869
|
+
if (typeof off === 'function') offReset = off
|
|
870
|
+
} catch (error) {
|
|
871
|
+
ctx.logger?.warn?.(`file-lease: transport resets are unavailable: ${messageOf(error)}`)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* The arbiter's health as the read-only surface reports it (D13): the client's
|
|
876
|
+
* own connection state, never a fresh probe, so looking cannot change a
|
|
877
|
+
* verdict. A client that cannot answer is `unavailable` — an unknown
|
|
878
|
+
* connection must never be presented as healthy.
|
|
879
|
+
* @returns {'ok' | 'degraded' | 'unavailable'}
|
|
880
|
+
*/
|
|
881
|
+
function arbiterState() {
|
|
882
|
+
try {
|
|
883
|
+
const state = typeof client.connectionState === 'function' ? client.connectionState() : 'unavailable'
|
|
884
|
+
return state === 'ok' || state === 'degraded' || state === 'unavailable' ? state : 'unavailable'
|
|
885
|
+
} catch {
|
|
886
|
+
return 'unavailable'
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ---- The read-only ownership view (design D13) --------------------------
|
|
891
|
+
//
|
|
892
|
+
// The ONLY user-facing ownership read in the rebuild, and it reads. Nothing
|
|
893
|
+
// in this handler acquires, releases, retains, negotiates or records
|
|
894
|
+
// anything: no lease changes, no conversation entry, no audit row. Every
|
|
895
|
+
// ownership state resolves by itself (turn end, expiry, negotiation; D10),
|
|
896
|
+
// so a surface that could act would reintroduce exactly the "some state
|
|
897
|
+
// needs a human" the redesign deletes.
|
|
898
|
+
//
|
|
899
|
+
// `connection` is a host-plane service, so the preset realm cannot hide it.
|
|
900
|
+
// Its fetch routes are process-wide (D11), which is why a second mount's
|
|
901
|
+
// identical registration is tolerated below and nothing else is.
|
|
902
|
+
ctx.inject?.(['connection'], (/** @type {any} */ scope) => {
|
|
903
|
+
const fetchRegistry = scope?.connection?.fetch
|
|
904
|
+
if (!fetchRegistry?.register) return
|
|
905
|
+
const reply = (/** @type {any} */ payload, /** @type {number} */ status = 200) =>
|
|
906
|
+
new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
|
|
907
|
+
/** @type {(() => void) | undefined} */
|
|
908
|
+
let dispose
|
|
909
|
+
try {
|
|
910
|
+
dispose = fetchRegistry.register({
|
|
911
|
+
path: VIEW_PATH,
|
|
912
|
+
methods: ['POST'],
|
|
913
|
+
requestBody: 'buffered',
|
|
914
|
+
fetch: async (/** @type {any} */ request) => {
|
|
915
|
+
let body
|
|
916
|
+
try {
|
|
917
|
+
body = await request.json()
|
|
918
|
+
} catch {
|
|
919
|
+
body = null
|
|
920
|
+
}
|
|
921
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
922
|
+
if (sessionId === '') {
|
|
923
|
+
return reply({ ok: false, error: { code: 'weir-edit-lock/invalid', message: 'body needs { sessionId }' } }, 400)
|
|
924
|
+
}
|
|
925
|
+
const agent = agentFor(sessionId)
|
|
926
|
+
const cwd = cwdOf(agent)
|
|
927
|
+
// A session with no live agent is its own lineage (D9), and its
|
|
928
|
+
// leases died with the process that held them (D10): the honest
|
|
929
|
+
// answer is "nothing held", never an error to act on.
|
|
930
|
+
const mineLineage = agent === undefined ? sessionId : ownerOf(agent).id
|
|
931
|
+
/** @type {any[]} */
|
|
932
|
+
let leases = []
|
|
933
|
+
try {
|
|
934
|
+
const snapshot = await client.status(cwd === undefined ? {} : { scope: cwd })
|
|
935
|
+
leases = Array.isArray(snapshot?.leases) ? snapshot.leases : []
|
|
936
|
+
} catch {
|
|
937
|
+
// An unreadable arbiter IS the degraded state (D7): answer with the
|
|
938
|
+
// same envelope so the panel reports it, and never claim "nothing
|
|
939
|
+
// held" on the strength of a failed read.
|
|
940
|
+
leases = []
|
|
941
|
+
}
|
|
942
|
+
return reply({ ok: true, value: buildLeaseView({ leases, mineLineage, cwd, arbiter: arbiterState(), now: now() }) })
|
|
943
|
+
},
|
|
944
|
+
})
|
|
945
|
+
} catch (error) {
|
|
946
|
+
// Duplicate-route tolerance (D11): the first mount in this process owns
|
|
947
|
+
// this process-wide route, and the host refuses a later mount's
|
|
948
|
+
// identical registration. Skipping ONLY that exact refusal keeps this
|
|
949
|
+
// mount's inject callback intact instead of aborting it mid-mount, and
|
|
950
|
+
// correctness never depends on mount order because the client and the
|
|
951
|
+
// owner rule are process-wide too.
|
|
952
|
+
if (!duplicateRouteRegistration(error)) throw error
|
|
953
|
+
return
|
|
954
|
+
}
|
|
955
|
+
disposeView = () => {
|
|
956
|
+
try {
|
|
957
|
+
dispose?.()
|
|
958
|
+
} catch {
|
|
959
|
+
// Already released by the injection scope's own teardown.
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return disposeView
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
// Unmount: drop this mount's tools, its listeners, its event subscription and
|
|
966
|
+
// its view route. The client and the daemon keep running (D11) — a sibling
|
|
967
|
+
// mount, or the next mount of this row, owns the connection.
|
|
968
|
+
return () => {
|
|
969
|
+
if (disposed) return
|
|
970
|
+
disposed = true
|
|
971
|
+
try {
|
|
972
|
+
offEvents()
|
|
973
|
+
} catch {
|
|
974
|
+
// Already gone.
|
|
975
|
+
}
|
|
976
|
+
try {
|
|
977
|
+
offReset()
|
|
978
|
+
} catch {
|
|
979
|
+
// Already gone.
|
|
980
|
+
}
|
|
981
|
+
// This mount's references leave the shared coordinator. A wait another
|
|
982
|
+
// mount still owns survives, and a pending notice transfers to a surviving
|
|
983
|
+
// reference; the last mount's teardown clears everything that is left.
|
|
984
|
+
watches.leave(watchMount)
|
|
985
|
+
try {
|
|
986
|
+
disposeView()
|
|
987
|
+
} catch {
|
|
988
|
+
// Already released by the injection scope's own teardown.
|
|
989
|
+
}
|
|
990
|
+
disposeView = () => {}
|
|
991
|
+
for (const agent of [...replyAgents]) retireReplyTool(agent)
|
|
992
|
+
replyAgents.clear()
|
|
993
|
+
issued.clear()
|
|
994
|
+
for (const dispose of disposers.splice(0)) {
|
|
995
|
+
try {
|
|
996
|
+
dispose()
|
|
997
|
+
} catch {
|
|
998
|
+
// Already disposed by the scope's own teardown.
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return { name: PLUGIN_NAME, inject: ['tools', 'agents'], apply }
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* The default instance: same export shape as every other row in this bundle, so
|
|
1009
|
+
* `cordis.patch.yml` can mount the module directly.
|
|
1010
|
+
*/
|
|
1011
|
+
const { name, inject, apply } = createFileLeasePlugin()
|
|
1012
|
+
|
|
1013
|
+
export { name, inject, apply }
|