weir-harness 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +105 -7
- package/lib/client.mcp-manager.js +563 -0
- package/package.json +1 -1
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +40 -9
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +575 -146
- package/src/capabilities/mcp-mount.js +220 -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 +170 -57
- package/src/file-lease/client.js +268 -35
- package/src/file-lease/index.js +4 -0
- package/src/file-lease/plugin.js +239 -15
- package/src/file-lease/tools.js +426 -38
- package/src/file-lease/watch.js +473 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/protocol.js +3 -2
- package/src/lease-broker/server.js +6 -3
- package/src/lease-broker/state.js +68 -11
- package/src/process/index.js +4 -2
- package/src/shared/audit.js +3 -0
- package/src/shared/child-scope.js +2 -0
- package/src/worktree/rules.js +1 -1
package/src/file-lease/plugin.js
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
// to a session that is mid-turn simply lands inside that turn and costs no
|
|
17
17
|
// extra wake. A request for a session that is not here (another process, a
|
|
18
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.
|
|
19
24
|
//
|
|
20
25
|
// The transport is process-wide (D8/D11). Two preset mounts of this bundle
|
|
21
26
|
// share ONE client per socket path, and unmounting must never close it — that
|
|
@@ -28,6 +33,7 @@
|
|
|
28
33
|
import { createLineageResolver } from './lineage.js'
|
|
29
34
|
import { userTextMessage } from '../shared/user-message.js'
|
|
30
35
|
import { createLeaseTools, REPLY_TOOL, shortName } from './tools.js'
|
|
36
|
+
import { createWatchCoordinator } from './watch.js'
|
|
31
37
|
import { AUDIT_TYPES, createAudit } from '../shared/audit.js'
|
|
32
38
|
import { getSharedLeaseClient } from './singleton.js'
|
|
33
39
|
import { buildLeaseView } from './view.js'
|
|
@@ -92,11 +98,87 @@ function noticedFor(agent) {
|
|
|
92
98
|
return noticed
|
|
93
99
|
}
|
|
94
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
|
+
|
|
95
152
|
/** @param {unknown} error @returns {string} */
|
|
96
153
|
function messageOf(error) {
|
|
97
154
|
return String(/** @type {any} */ (error)?.message ?? error)
|
|
98
155
|
}
|
|
99
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
|
+
|
|
100
182
|
/**
|
|
101
183
|
* The session this agent acts for: the durable header id when there is one,
|
|
102
184
|
* else the live session id, else the agent id. Non-empty by construction, and
|
|
@@ -206,6 +288,39 @@ function settledText(state, requestId, short) {
|
|
|
206
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.`
|
|
207
289
|
}
|
|
208
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
|
+
|
|
209
324
|
/**
|
|
210
325
|
* Adapt one degradation record from the lease client to the shared audit
|
|
211
326
|
* channel (design D7, `AUDIT_TYPES.fileLease`).
|
|
@@ -302,10 +417,11 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
302
417
|
/** @type {Set<any>} agents whose reply tool this mount registered */
|
|
303
418
|
const replyAgents = new Set()
|
|
304
419
|
/**
|
|
305
|
-
* `requestId -> the session that asked for it`. A `settled` event
|
|
306
|
-
* session
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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).
|
|
309
425
|
* @type {Map<string, { session: string, path: string }>}
|
|
310
426
|
*/
|
|
311
427
|
const issued = new Map()
|
|
@@ -336,6 +452,28 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
336
452
|
})
|
|
337
453
|
const ownerOf = injectedOwnerOf ?? createOwnerResolver(agents)
|
|
338
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)
|
|
339
477
|
|
|
340
478
|
/**
|
|
341
479
|
* Deliver a notice without letting delivery break the caller: both channels
|
|
@@ -503,6 +641,9 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
503
641
|
const session = sessionIdOf(agent)
|
|
504
642
|
retireReplyTool(agent)
|
|
505
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)
|
|
506
647
|
if (session === undefined) return
|
|
507
648
|
quiet(() => client.releaseAll(session))
|
|
508
649
|
}
|
|
@@ -553,18 +694,71 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
553
694
|
notify(agent, requestText({ requestId, short: shortName(path, cwdOf(agent)), label, fromSession, seconds }), agent?.status === 'idle')
|
|
554
695
|
}
|
|
555
696
|
|
|
556
|
-
/**
|
|
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
|
+
*/
|
|
557
707
|
function onSettled(event) {
|
|
558
708
|
const requestId = text(event?.requestId)
|
|
559
709
|
if (requestId === undefined) return
|
|
710
|
+
const state = text(event?.state) ?? 'expired'
|
|
560
711
|
const entry = issued.get(requestId)
|
|
561
712
|
issued.delete(requestId)
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
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)
|
|
565
726
|
if (!agent) return
|
|
566
|
-
|
|
567
|
-
|
|
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()
|
|
568
762
|
}
|
|
569
763
|
|
|
570
764
|
/** @param {any} event */
|
|
@@ -573,17 +767,18 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
573
767
|
try {
|
|
574
768
|
if (event.event === 'request') await onRequest(event)
|
|
575
769
|
else if (event.event === 'settled') onSettled(event)
|
|
770
|
+
else if (event.event === 'released') onReleased(event)
|
|
576
771
|
} catch {
|
|
577
772
|
// A listener is a consumer; it must never break the transport.
|
|
578
773
|
}
|
|
579
774
|
}
|
|
580
775
|
|
|
581
776
|
/**
|
|
582
|
-
* The tools see the client through this narrow facade for one reason:
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
* facade adds that
|
|
586
|
-
* the arbiter.
|
|
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.
|
|
587
782
|
*/
|
|
588
783
|
const routedClient = Object.freeze({
|
|
589
784
|
acquire: (/** @type {any} */ owner, /** @type {any} */ paths, /** @type {any} */ scope) => client.acquire(owner, paths, scope),
|
|
@@ -591,6 +786,12 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
591
786
|
status: (/** @type {any} */ filter) => client.status(filter),
|
|
592
787
|
request: (/** @type {any} */ op, /** @type {any} */ payload) => client.request(op, payload),
|
|
593
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,
|
|
594
795
|
requestTransfer: async (/** @type {any} */ path, /** @type {any} */ from) => {
|
|
595
796
|
const reply = await client.requestTransfer(path, from)
|
|
596
797
|
const requestId = text(reply?.requestId)
|
|
@@ -603,6 +804,8 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
603
804
|
client: routedClient,
|
|
604
805
|
ownerOf,
|
|
605
806
|
deliverNotice: (agent, text) => notify(agent, text, false),
|
|
807
|
+
watchCoordinator: watches,
|
|
808
|
+
mount: watchMount,
|
|
606
809
|
})
|
|
607
810
|
const replyDefinition = definitions.find((definition) => definition?.name === REPLY_TOOL)
|
|
608
811
|
const staticDefinitions = definitions.filter((definition) => definition?.name !== REPLY_TOOL)
|
|
@@ -656,6 +859,18 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
656
859
|
ctx.logger?.warn?.(`file-lease: negotiation events are unavailable: ${messageOf(error)}`)
|
|
657
860
|
}
|
|
658
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
|
+
|
|
659
874
|
/**
|
|
660
875
|
* The arbiter's health as the read-only surface reports it (D13): the client's
|
|
661
876
|
* own connection state, never a fresh probe, so looking cannot change a
|
|
@@ -758,6 +973,15 @@ export function createFileLeasePlugin(options = {}) {
|
|
|
758
973
|
} catch {
|
|
759
974
|
// Already gone.
|
|
760
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)
|
|
761
985
|
try {
|
|
762
986
|
disposeView()
|
|
763
987
|
} catch {
|