weir-harness 1.1.7 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "1.1.7",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
6
6
  "license": "MIT",
@@ -97,6 +97,73 @@ export function createDomainRegistry(open, resolveRoot = managementRootFor) {
97
97
  })
98
98
  }
99
99
 
100
+ /** @type {WeakMap<object, Map<string, {registry: any, settled: WeakMap<object, any>, recoveryNotices: Map<string, string>, mounts: number}>>} */
101
+ const sharedRegistriesByHost = new WeakMap()
102
+
103
+ /** Process-shared domain registry container (change
104
+ * edit-lock-shared-domain-mounts, design D1): several preset mounts of this
105
+ * bundle coexist for the whole process, and every mount of one management
106
+ * root must resolve the SAME domain — one runtime, one publisher reservation,
107
+ * one lifecycle and one endpoint — instead of the second mount degrading to a
108
+ * same-process remote client of the first.
109
+ *
110
+ * The container is keyed by host (`ctx.root ?? ctx`, the same key expression
111
+ * the write-scope store uses — proven to be shared across preset mounts) plus
112
+ * a caller-supplied configuration discriminator: mounts whose effective
113
+ * domain configuration differs (a fixed root/authority row) NEVER share; they
114
+ * keep today's independent-registry semantics exactly.
115
+ *
116
+ * Reference-counted by mount: each apply takes one count, each disposer
117
+ * releases exactly one (idempotently); a factory that throws enters no count.
118
+ * Only the LAST release closes every domain through the established drain
119
+ * discipline (`registry.all() → domain.close()`), revoking sessions and
120
+ * releasing reservations; the entry is then dropped, so a later generation
121
+ * reopens fresh. The per-entry `settled` store and the per-root
122
+ * automatic-recovery notice queue share the entry's lifetime: they outlive
123
+ * any single mount (a preset switch lets a sibling mount's listeners read
124
+ * the binding the first mount settled, and the mount that hears the first
125
+ * post-recovery turn drains the summary), and they die with the last
126
+ * release so a closed domain is never mistaken for a live binding.
127
+ *
128
+ * @template T
129
+ * @param {object} host
130
+ * @param {string} discriminator
131
+ * @param {() => ReturnType<typeof createDomainRegistry<T>>} makeRegistry
132
+ * @returns {{registry: ReturnType<typeof createDomainRegistry<T>>, settled: WeakMap<object, T>, recoveryNotices: Map<string, string>, release: () => Promise<void>}} */
133
+ export function sharedDomainRegistryFor(host, discriminator, makeRegistry) {
134
+ let entries = sharedRegistriesByHost.get(host)
135
+ if (!entries?.has(discriminator)) {
136
+ // A throwing factory mutates nothing and enters no count.
137
+ const created = makeRegistry()
138
+ if (!entries) {
139
+ entries = new Map()
140
+ sharedRegistriesByHost.set(host, entries)
141
+ }
142
+ entries.set(discriminator, { registry: created, settled: new WeakMap(), recoveryNotices: new Map(), mounts: 0 })
143
+ }
144
+ const entry = /** @type {{registry: any, settled: WeakMap<object, any>, recoveryNotices: Map<string, string>, mounts: number}} */ (entries.get(discriminator))
145
+ entry.mounts += 1
146
+ let released = false
147
+ return Object.freeze({
148
+ registry: entry.registry,
149
+ settled: entry.settled,
150
+ /** One queued automatic-recovery summary per root, shared so the mount
151
+ * that hears the first post-recovery turn drains it (design D3). */
152
+ recoveryNotices: entry.recoveryNotices,
153
+ /** Idempotent: only the first call counts; only the last call closes. */
154
+ async release() {
155
+ if (released) return
156
+ released = true
157
+ entry.mounts -= 1
158
+ if (entry.mounts > 0 || entries.get(discriminator) !== entry) return
159
+ entries.delete(discriminator)
160
+ const results = await Promise.allSettled(entry.registry.all().map(async domain => (await domain).close()))
161
+ const failure = results.find(result => result.status === 'rejected')
162
+ if (failure?.status === 'rejected') throw failure.reason
163
+ },
164
+ })
165
+ }
166
+
100
167
  /** @param {string} path */
101
168
  export function isDirectory(path) {
102
169
  try { return lstatSync(path).isDirectory() } catch { return false }
@@ -26,7 +26,7 @@ import { createStaleSweepScheduler } from './stale-sweep.js'
26
26
  import { createRemoteEditLockDomain, endpointFor, serveEditLockEndpoint } from './remote.js'
27
27
  import { registerBindingRuntime, createBindingAttester } from './binding-attestation.js'
28
28
  import { localDomain, remoteDomain } from './domain.js'
29
- import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor } from './domains.js'
29
+ import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor, sharedDomainRegistryFor } from './domains.js'
30
30
  import { createRecoveryDriver } from './recovery.js'
31
31
  import { createSettlementDriver } from './settle.js'
32
32
  import { editLockLimits } from '../settings/sections.js'
@@ -41,6 +41,29 @@ const inject = ['tools', 'fs', 'agents']
41
41
  /** Tool names whose definitions must be Edit Lock routed when enabled. */
42
42
  export const GUARDED_TOOLS = Object.freeze(['write', 'edit', 'hash_edit', 'lsp_rename', 'str_replace_editor'])
43
43
 
44
+ /** Claimed managed-editor executions, shared by every mount of this bundle in
45
+ * the process (change edit-lock-shared-domain-mounts, design D3): membership
46
+ * is execute-object identity, so cross-host admission is impossible and an
47
+ * execution claimed by a disposed mount is harmless — no live guard reads it.
48
+ * Whichever mount's guard stage hears an execution, the verdict is the same.
49
+ * @type {WeakSet<Function>} */
50
+ const managed = new WeakSet()
51
+
52
+ /** Host-lifetime live weirEditLock services (design D3): every mount registers
53
+ * its service at apply and unregisters at dispose, so the endpoint currency
54
+ * proof can accept any live sibling mount's service identity.
55
+ * @type {WeakMap<object, Set<object>>} */
56
+ const liveServicesByHost = new WeakMap()
57
+ /** @param {object} host @returns {Set<object>} */
58
+ function liveServicesFor(host) {
59
+ let services = liveServicesByHost.get(host)
60
+ if (!services) {
61
+ services = new Set()
62
+ liveServicesByHost.set(host, services)
63
+ }
64
+ return services
65
+ }
66
+
44
67
  /** Row config wins over the settings section; `root` + `authorityDirectory`
45
68
  * pin one fixed domain (development compositions and probes).
46
69
  * @param {unknown} config @param {unknown} [settings] */
@@ -370,6 +393,10 @@ return (ctx, config = {}) => {
370
393
  evidence?.failed(platformFailure)
371
394
  ctx.logger?.warn?.(platformFailure.message)
372
395
  }
396
+ /** The shared-registry handle, assigned inside the mount try so a failed
397
+ * apply can release its count (design D1: a failed mount enters no count).
398
+ * @type {any} */
399
+ let shared = null
373
400
  try {
374
401
  /** @type {any} */
375
402
  let sandboxPolicyRef = null
@@ -377,6 +404,11 @@ return (ctx, config = {}) => {
377
404
  // Capture the original host filesystem once; the manager is its only writer.
378
405
  const fs = ctx.fs
379
406
  const audit = createAudit(ctx)
407
+ // The host-lifetime key shared by the write-scope store, the domain registry
408
+ // container and the live-service set (design D1/D3) — identical to the key
409
+ // expression the settings evidence store uses.
410
+ const host = ctx.root ?? ctx
411
+ const liveServices = liveServicesFor(host)
380
412
  /** Successful undefined means no live runtime; missing service means unknown.
381
413
  * @param {string} sessionId */
382
414
  const servingRuntime = sessionId => {
@@ -396,12 +428,12 @@ return (ctx, config = {}) => {
396
428
  const disposedAgents = new WeakSet()
397
429
  /** One queued automatic-recovery summary notice per management root (design
398
430
  * D3): delivered to the first agent that finishes domain start, then gone.
399
- * @type {Map<string, string>} */
400
- const recoveryNotices = new Map()
401
- // The tool registry may hand back a normalized copy of a definition; the
402
- // claimed execute function is what actually runs, so it is the identity.
403
- /** @type {WeakSet<Function>} */
404
- const managed = new WeakSet()
431
+ * Shared across sibling mounts with the domain registry (change
432
+ * edit-lock-shared-domain-mounts): the domain opens through the first
433
+ * mount's closure, so the queue must live in the shared entry for the
434
+ * mount that hears the first post-recovery turn to drain it. Assigned
435
+ * with `shared` below. @type {Map<string, string>} */
436
+ let recoveryNotices
405
437
  /** Managed write registrations outlive this mount: the tool is installed
406
438
  * into the agent's OWN scope layer, which a preset rebind never disposes.
407
439
  * Their disposers live in a host-lifetime store so any later generation can
@@ -410,7 +442,7 @@ return (ctx, config = {}) => {
410
442
  * tearing the managed write down mid-gap would expose the stock writers
411
443
  * until the next mount's per-agent re-install.
412
444
  * @type {WeakMap<object, import('./write-scopes.js').WriteScopeEntry>} */
413
- const writeScopes = writeScopesFor(ctx.root ?? ctx)
445
+ const writeScopes = writeScopesFor(host)
414
446
  let closed = false
415
447
  /** Remote channel agents on the publisher side → their notice sink.
416
448
  * @type {WeakMap<object, (event: unknown) => void>} */
@@ -419,8 +451,11 @@ return (ctx, config = {}) => {
419
451
  const startFailures = new WeakMap()
420
452
  /** Publisher-classified failures, never inferred from text. @type {WeakMap<object, any>} */
421
453
  const bindingFailures = new WeakMap()
422
- /** Settled domain per bound agent, for synchronous gates. @type {WeakMap<object, any>} */
423
- const settled = new WeakMap()
454
+ /** Settled domain per bound agent, for synchronous gates. Shared across
455
+ * sibling mounts with the domain registry (design D3): whichever mount
456
+ * hears an event reads the same binding. Assigned with `shared` below.
457
+ * @type {WeakMap<object, any>} */
458
+ let settled
424
459
  /** In-flight setup per agent, shared by every entry point (creation,
425
460
  * preset-switch, catch-up, lazy re-bind) so concurrent callers can never
426
461
  * double-register. @type {WeakMap<object, Promise<void>>} */
@@ -524,8 +559,11 @@ return (ctx, config = {}) => {
524
559
  } else if (requireProof) return false
525
560
  // Fresh creation keeps the established missing/throwing-registry
526
561
  // fallback. A reclaim or retry never uses that fallback as proof.
562
+ // Currency proof (design D3): the agent's resolved service must be a
563
+ // LIVE service of this process — after a preset switch that is the
564
+ // sibling mount's service, never this mount's own necessarily.
527
565
  if (typeof presets?.serviceFor === 'function') {
528
- try { return presets.serviceFor(agent, 'weirEditLock') === service }
566
+ try { return liveServices.has(presets.serviceFor(agent, 'weirEditLock')) }
529
567
  catch { return !requireProof }
530
568
  }
531
569
  return true
@@ -541,12 +579,21 @@ return (ctx, config = {}) => {
541
579
  throw error
542
580
  }
543
581
  }
544
- const registry = createDomainRegistry(root => {
582
+ // One domain registry per host + effective domain configuration, shared by
583
+ // every same-process preset mount of this bundle (design D1): a sibling
584
+ // mount never degrades to a same-process remote client, and the LAST
585
+ // disposer closes all domains through the established drain discipline. The
586
+ // first mount's `open` closure serves every sharing mount — mounts are
587
+ // process-persistent, so the capture is safe (design D1 trade-off).
588
+ shared = sharedDomainRegistryFor(host, options.fixed ? `${options.fixed.root}\n${options.fixed.directory}` : '', () => createDomainRegistry(root => {
545
589
  const fixed = options.fixed
546
590
  if (fixed) return openDomain(fixed.root, fixed.directory, root)
547
591
  try { exclude(root) } catch (error) { ctx.logger?.warn?.(`edit lock: could not update .git/info/exclude: ${/** @type {any} */ (error)?.message ?? error}`) }
548
592
  return openDomain(root, join(root, AUTHORITY_DIR), root)
549
- }, resolveRoot)
593
+ }, resolveRoot))
594
+ const registry = shared.registry
595
+ settled = shared.settled
596
+ recoveryNotices = shared.recoveryNotices
550
597
  /** @param {any} agent */
551
598
  const domainOf = agent => registry.forAgent(agent)
552
599
  /** Lazy re-bind (design D2): an own-preset main agent with no binding —
@@ -734,6 +781,9 @@ return (ctx, config = {}) => {
734
781
  },
735
782
  })
736
783
  ctx.reflect.provide('weirEditLock', service)
784
+ // Any live sibling mount's service identity attests endpoint currency
785
+ // (design D3); unregister at dispose.
786
+ liveServices.add(service)
737
787
 
738
788
  const offTools = registerLockTools(ctx, service)
739
789
 
@@ -1394,6 +1444,7 @@ return (ctx, config = {}) => {
1394
1444
  return async () => {
1395
1445
  if (closed) return
1396
1446
  closed = true
1447
+ liveServices.delete(service)
1397
1448
  offDelegateLifecycle()
1398
1449
  evidence?.disposing()
1399
1450
  try {
@@ -1405,10 +1456,9 @@ return (ctx, config = {}) => {
1405
1456
  offView()
1406
1457
  offCommand?.()
1407
1458
  for (const off of offTools) off?.()
1408
- // Retain uncertain evidence until every generation-owned domain is drained.
1409
- const results = await Promise.allSettled(registry.all().map(async domain => (await domain).close()))
1410
- const failure = results.find(result => result.status === 'rejected')
1411
- if (failure?.status === 'rejected') throw failure.reason
1459
+ // Retain uncertain evidence until every generation-owned domain is
1460
+ // drained; only the LAST sharing disposer closes them (design D1).
1461
+ await shared.release()
1412
1462
  evidence?.dispose()
1413
1463
  } catch (error) {
1414
1464
  evidence?.failed(error)
@@ -1417,6 +1467,10 @@ return (ctx, config = {}) => {
1417
1467
  }
1418
1468
  } catch (error) {
1419
1469
  evidence?.failed(error)
1470
+ // A mount that failed mid-apply must not stay counted in the shared
1471
+ // registry (design D1); domains are lazy, so this release closes nothing
1472
+ // unless a sibling mount already opened one — its count keeps it alive.
1473
+ if (shared) void shared.release().catch(() => {})
1420
1474
  throw error
1421
1475
  }
1422
1476
  }
@@ -195,11 +195,25 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
195
195
  }
196
196
  /** Register once before publishing an agent's editing tools. A session the
197
197
  * manager already knows (restart, re-created agent) is never re-armed: it
198
- * starts interrupted and needs trusted resume. @param {object} agent
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
199
201
  * @returns {Promise<'active'|'interrupted'>} */
200
202
  function start(agent) {
201
203
  if (closed) return Promise.reject(new Error('edit lifecycle closed'))
202
- if (entries.has(agent)) return Promise.reject(new Error('agent already registered'))
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
+ }
203
217
  const sessionId = sessionForAgent(agent)
204
218
  if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
205
219
  const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)