weir-harness 1.1.6 → 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/lib/client.blackboard-model.js +35 -2
- package/lib/client.blackboard-panel.js +61 -9
- package/lib/client.js +254 -223
- package/lib/client.settings-page.js +132 -46
- package/package.json +1 -1
- package/src/blackboard/durable.js +13 -0
- package/src/blackboard/index.js +45 -1
- package/src/blackboard/remote.js +69 -16
- package/src/blackboard/storage.js +56 -0
- package/src/edit-lock/binding-guidance.js +15 -4
- package/src/edit-lock/domain.js +12 -0
- package/src/edit-lock/domains.js +67 -0
- package/src/edit-lock/index.js +209 -45
- package/src/edit-lock/lifecycle.js +70 -4
- package/src/edit-lock/remote.js +23 -4
- package/src/settings/sections.js +25 -16
package/src/edit-lock/index.js
CHANGED
|
@@ -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] */
|
|
@@ -258,7 +281,7 @@ function registerLockTools(ctx, service) {
|
|
|
258
281
|
output: text('message'),
|
|
259
282
|
async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
|
|
260
283
|
const unavailable = service.startupFailure(exec.agent)
|
|
261
|
-
if (unavailable) return { ...unavailable, message: `Edit Lock unavailable: ${unavailable.reason}. ${unavailable.bindingFailure
|
|
284
|
+
if (unavailable) return { ...unavailable, message: `Edit Lock unavailable: ${unavailable.reason}. ${statusRetryHint(unavailable.bindingFailure)}` }
|
|
262
285
|
const locks = await service.locks(exec)
|
|
263
286
|
const lines = locks.map((/** @type {any} */ lock) => `- ${lock.resourceId} owner=${lock.mine ? 'this session' : lock.owner} status=${lock.status} generation=${lock.generation}${lock.reason ? ` reason=${lock.reason}` : ''}`)
|
|
264
287
|
// The reservation is part of what the holder needs to know: without it the
|
|
@@ -310,6 +333,41 @@ function describe(status) {
|
|
|
310
333
|
return lines.join('\n')
|
|
311
334
|
}
|
|
312
335
|
|
|
336
|
+
/** In-session binding-failure notice copy (change edit-lock-binding-transfer,
|
|
337
|
+
* design D4): the transferable class (same-process idle holder) is a calm
|
|
338
|
+
* self-heal announcement rather than an editing-disabled alarm and never
|
|
339
|
+
* carries the raw holder classification or the reservation warning; a mid-turn
|
|
340
|
+
* holder names the real state and the retry moment. Template layer stays
|
|
341
|
+
* English (AGENTS §3.7). @param {string} reason @param {any} failure */
|
|
342
|
+
function bindingFailureNotice(reason, failure) {
|
|
343
|
+
if (failure?.code === 'binding-duplicate' && failure.holderKind === 'transferable') {
|
|
344
|
+
return "Edit Lock: this session's editing authority is held by an inactive attachment of this same session; it transfers automatically when you send a message."
|
|
345
|
+
}
|
|
346
|
+
if (failure?.code === 'binding-duplicate' && failure.holderKind === 'serving') {
|
|
347
|
+
return "Edit Lock: this session is being edited in another attachment right now; managed file editing stays unavailable until that turn finishes. Do not retry the failed edit; use /edit-lock retry (or the panel's Retry connection) once it finishes — retry does not Continue editing."
|
|
348
|
+
}
|
|
349
|
+
return `Edit Lock could not start this session's edit domain (${reason}); managed file editing is temporarily unavailable. ${failure?.code === 'binding-duplicate' ? 'Do not retry the failed edit: the binding is retried automatically when the user next sends a message, after which editing works normally. Never remove a live publisher reservation.' : 'Inspect Edit Lock maintenance before continuing.'}`
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Read-only binding-unavailable guidance tail for the tool/service surfaces
|
|
353
|
+
* (domainFor and edit_lock_status), split by the recorded holder class.
|
|
354
|
+
* @param {string} failure @param {any} bindingFailure */
|
|
355
|
+
function bindingUnavailableMessage(failure, bindingFailure) {
|
|
356
|
+
const kind = bindingFailure?.holderKind
|
|
357
|
+
if (kind === 'transferable') return `Edit Lock binding is unavailable (${failure}). The holder is an inactive attachment of this same session: the binding transfers automatically when the user next sends a message, and /edit-lock retry also takes it over; neither resumes editing by itself. Do not retry the failed edit.`
|
|
358
|
+
if (kind === 'serving') return `Edit Lock binding is unavailable (${failure}). This session is being edited in another attachment right now; retry the connection once that turn finishes. Do not retry the failed edit.`
|
|
359
|
+
return `Edit Lock binding is unavailable (${failure}). Do not retry the failed edit: the binding is retried automatically when the user next sends a message, after which editing works normally. Never remove a live publisher reservation.`
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** edit_lock_status read-only guidance tail, split by the recorded holder
|
|
363
|
+
* class (change edit-lock-binding-transfer). @param {any} bindingFailure */
|
|
364
|
+
function statusRetryHint(bindingFailure) {
|
|
365
|
+
if (bindingFailure?.retryable !== true) return 'Inspect Edit Lock maintenance before continuing.'
|
|
366
|
+
if (bindingFailure.holderKind === 'transferable') return 'The holder is an inactive attachment of this same session: editing attaches automatically when you next send a message, and /edit-lock retry also takes it over; neither resumes editing by itself.'
|
|
367
|
+
if (bindingFailure.holderKind === 'serving') return 'This session is being edited in another attachment right now; /edit-lock retry retries the connection once that turn finishes — it does not resume editing by itself.'
|
|
368
|
+
return 'Do not retry failed edits: the binding is retried automatically when the user next sends a message, and /edit-lock retry also retries the connection; neither resumes editing by itself.'
|
|
369
|
+
}
|
|
370
|
+
|
|
313
371
|
/** Composition dependencies, not persisted settings. Defaults are the host paths.
|
|
314
372
|
* @param {{ platform?: string, resolveRoot?: typeof managementRootFor, endpoint?: typeof endpointFor, exclude?: typeof excludeFromGit }} [dependencies] */
|
|
315
373
|
export function createEditLockPlugin({ platform = process.platform, resolveRoot = managementRootFor, endpoint = endpointFor, exclude = excludeFromGit } = {}) {
|
|
@@ -335,6 +393,10 @@ return (ctx, config = {}) => {
|
|
|
335
393
|
evidence?.failed(platformFailure)
|
|
336
394
|
ctx.logger?.warn?.(platformFailure.message)
|
|
337
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
|
|
338
400
|
try {
|
|
339
401
|
/** @type {any} */
|
|
340
402
|
let sandboxPolicyRef = null
|
|
@@ -342,6 +404,11 @@ return (ctx, config = {}) => {
|
|
|
342
404
|
// Capture the original host filesystem once; the manager is its only writer.
|
|
343
405
|
const fs = ctx.fs
|
|
344
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)
|
|
345
412
|
/** Successful undefined means no live runtime; missing service means unknown.
|
|
346
413
|
* @param {string} sessionId */
|
|
347
414
|
const servingRuntime = sessionId => {
|
|
@@ -361,12 +428,12 @@ return (ctx, config = {}) => {
|
|
|
361
428
|
const disposedAgents = new WeakSet()
|
|
362
429
|
/** One queued automatic-recovery summary notice per management root (design
|
|
363
430
|
* D3): delivered to the first agent that finishes domain start, then gone.
|
|
364
|
-
*
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
370
437
|
/** Managed write registrations outlive this mount: the tool is installed
|
|
371
438
|
* into the agent's OWN scope layer, which a preset rebind never disposes.
|
|
372
439
|
* Their disposers live in a host-lifetime store so any later generation can
|
|
@@ -375,7 +442,7 @@ return (ctx, config = {}) => {
|
|
|
375
442
|
* tearing the managed write down mid-gap would expose the stock writers
|
|
376
443
|
* until the next mount's per-agent re-install.
|
|
377
444
|
* @type {WeakMap<object, import('./write-scopes.js').WriteScopeEntry>} */
|
|
378
|
-
const writeScopes = writeScopesFor(
|
|
445
|
+
const writeScopes = writeScopesFor(host)
|
|
379
446
|
let closed = false
|
|
380
447
|
/** Remote channel agents on the publisher side → their notice sink.
|
|
381
448
|
* @type {WeakMap<object, (event: unknown) => void>} */
|
|
@@ -384,8 +451,11 @@ return (ctx, config = {}) => {
|
|
|
384
451
|
const startFailures = new WeakMap()
|
|
385
452
|
/** Publisher-classified failures, never inferred from text. @type {WeakMap<object, any>} */
|
|
386
453
|
const bindingFailures = new WeakMap()
|
|
387
|
-
/** Settled domain per bound agent, for synchronous gates.
|
|
388
|
-
|
|
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
|
|
389
459
|
/** In-flight setup per agent, shared by every entry point (creation,
|
|
390
460
|
* preset-switch, catch-up, lazy re-bind) so concurrent callers can never
|
|
391
461
|
* double-register. @type {WeakMap<object, Promise<void>>} */
|
|
@@ -474,16 +544,26 @@ return (ctx, config = {}) => {
|
|
|
474
544
|
})
|
|
475
545
|
let server
|
|
476
546
|
try { server = await serveEditLockEndpoint(lifecycle, endpoint(directory), sinks, attestBinding,
|
|
477
|
-
(agent, requireProof) => {
|
|
547
|
+
(agent, requireProof, transfer = false) => {
|
|
478
548
|
if (closed || disposedAgents.has(agent)) return false
|
|
479
549
|
const agents = ctx.get?.('agents')
|
|
480
550
|
if (typeof agents?.get === 'function') {
|
|
481
|
-
if (
|
|
551
|
+
if (transfer === true) {
|
|
552
|
+
// A transfer claimant is NEVER the registry's serving agent —
|
|
553
|
+
// that mismatch is the defect being repaired (change
|
|
554
|
+
// edit-lock-binding-transfer). Its liveness proof is listing
|
|
555
|
+
// among the live root agents; holder-side admission is the
|
|
556
|
+
// lifecycle's transferStart.
|
|
557
|
+
if (typeof agents.roots !== 'function' || !agents.roots().includes(agent)) return false
|
|
558
|
+
} else if (agents.get(agent.id) !== agent) return false
|
|
482
559
|
} else if (requireProof) return false
|
|
483
560
|
// Fresh creation keeps the established missing/throwing-registry
|
|
484
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.
|
|
485
565
|
if (typeof presets?.serviceFor === 'function') {
|
|
486
|
-
try { return presets.serviceFor(agent, 'weirEditLock')
|
|
566
|
+
try { return liveServices.has(presets.serviceFor(agent, 'weirEditLock')) }
|
|
487
567
|
catch { return !requireProof }
|
|
488
568
|
}
|
|
489
569
|
return true
|
|
@@ -499,12 +579,21 @@ return (ctx, config = {}) => {
|
|
|
499
579
|
throw error
|
|
500
580
|
}
|
|
501
581
|
}
|
|
502
|
-
|
|
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 => {
|
|
503
589
|
const fixed = options.fixed
|
|
504
590
|
if (fixed) return openDomain(fixed.root, fixed.directory, root)
|
|
505
591
|
try { exclude(root) } catch (error) { ctx.logger?.warn?.(`edit lock: could not update .git/info/exclude: ${/** @type {any} */ (error)?.message ?? error}`) }
|
|
506
592
|
return openDomain(root, join(root, AUTHORITY_DIR), root)
|
|
507
|
-
}, resolveRoot)
|
|
593
|
+
}, resolveRoot))
|
|
594
|
+
const registry = shared.registry
|
|
595
|
+
settled = shared.settled
|
|
596
|
+
recoveryNotices = shared.recoveryNotices
|
|
508
597
|
/** @param {any} agent */
|
|
509
598
|
const domainOf = agent => registry.forAgent(agent)
|
|
510
599
|
/** Lazy re-bind (design D2): an own-preset main agent with no binding —
|
|
@@ -520,7 +609,7 @@ return (ctx, config = {}) => {
|
|
|
520
609
|
await setupInFlight.get(agent)
|
|
521
610
|
if (settled.has(agent)) return settled.get(agent)
|
|
522
611
|
const failure = startFailures.get(agent)
|
|
523
|
-
if (failure && bindingFailures.has(agent)) throw new Error(
|
|
612
|
+
if (failure && bindingFailures.has(agent)) throw new Error(bindingUnavailableMessage(failure, bindingFailures.get(agent)))
|
|
524
613
|
if (failure) throw new Error(`this session has no Edit Lock domain: setup failed under the current preset generation (${failure}); the automatic re-bind was already attempted. Recovery: restart DeepSeek Harness or start a new conversation.`)
|
|
525
614
|
// No fence match (foreign preset) or unmount: keep the stock refusal.
|
|
526
615
|
return registry.forAgent(agent)
|
|
@@ -623,7 +712,17 @@ return (ctx, config = {}) => {
|
|
|
623
712
|
if (platformFailure) return { state: 'unavailable', reason: platformFailure.message, bindingFailure: null }
|
|
624
713
|
if (!agent || settled.has(agent) || !startFailures.has(agent)) return null
|
|
625
714
|
let eligible = false
|
|
626
|
-
try {
|
|
715
|
+
try {
|
|
716
|
+
const serving = servingRuntime(agent.id)
|
|
717
|
+
eligible = !closed && !disposedAgents.has(agent) && ownAgent(agent)
|
|
718
|
+
&& (serving === agent
|
|
719
|
+
// The registry names ANOTHER live local agent — the dual-agent defect
|
|
720
|
+
// this change heals (edit-lock-binding-transfer). The recorded
|
|
721
|
+
// duplicate-binding failure is still THIS agent's honest panel state:
|
|
722
|
+
// show it, with its Retry action, instead of hiding behind the ghost's
|
|
723
|
+
// healthy binding. An empty registry stays hidden (unattestable).
|
|
724
|
+
|| (serving !== undefined && bindingFailures.get(agent)?.code === 'binding-duplicate'))
|
|
725
|
+
} catch { /* no trusted live identity */ }
|
|
627
726
|
if (!eligible) return null
|
|
628
727
|
const failure = bindingFailures.get(agent)
|
|
629
728
|
return { state: 'unavailable', reason: startFailures.get(agent),
|
|
@@ -682,6 +781,9 @@ return (ctx, config = {}) => {
|
|
|
682
781
|
},
|
|
683
782
|
})
|
|
684
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)
|
|
685
787
|
|
|
686
788
|
const offTools = registerLockTools(ctx, service)
|
|
687
789
|
|
|
@@ -700,6 +802,15 @@ return (ctx, config = {}) => {
|
|
|
700
802
|
const agent = exec?.agent
|
|
701
803
|
if (!closed && agent && !registry.rootOf(agent) && !startFailures.has(agent) && (agent?.session?.header?.delegationDepth ?? 0) === 0 && ownAgent(agent)) {
|
|
702
804
|
await setupAgent(agent)
|
|
805
|
+
// The lazy re-bind is a driving ingress (design D1, change
|
|
806
|
+
// edit-lock-binding-transfer): when the re-setup just recorded a
|
|
807
|
+
// duplicate-binding failure, ONE transfer-capable retry runs here — an
|
|
808
|
+
// idle same-process holder hands the binding over mid-turn. The recorded
|
|
809
|
+
// failure then suppresses further attempts for the rest of this mount
|
|
810
|
+
// generation, so arbitrary write calls never loop retries.
|
|
811
|
+
if (bindingFailures.get(agent)?.code === 'binding-duplicate') {
|
|
812
|
+
try { await retryBinding(agent, 'write-guard') } catch { /* audited inside retryBinding; the denial below names the recorded cause */ }
|
|
813
|
+
}
|
|
703
814
|
if (admitted()) return next()
|
|
704
815
|
}
|
|
705
816
|
const failure = /** @type {any} */ (agent) ? startFailures.get(agent) : undefined
|
|
@@ -728,8 +839,12 @@ return (ctx, config = {}) => {
|
|
|
728
839
|
* re-bind, so the paths can never diverge. Root selection and successful
|
|
729
840
|
* startup are separate: explicit binding retry reuses the existing scope
|
|
730
841
|
* and root, while ordinary entry points never repeat a failed startup.
|
|
731
|
-
* @param {any} agent
|
|
732
|
-
|
|
842
|
+
* @param {any} agent @param {boolean} [retry] @param {boolean} [transfer]
|
|
843
|
+
* transfer (change edit-lock-binding-transfer) marks the driving-ingress
|
|
844
|
+
* transfer admission: the domain's transferStart re-adjudicates holder
|
|
845
|
+
* idleness at execution, and the post-bind registry-identity check is
|
|
846
|
+
* skipped because the registry legitimately still names the idle holder. */
|
|
847
|
+
async function setupAgentOnce(agent, retry = false, transfer = false) {
|
|
733
848
|
if (closed || settled.has(agent)) return
|
|
734
849
|
// No writer scope, root resolution, directory, reservation or session
|
|
735
850
|
// authority may be installed when the mount preflight failed.
|
|
@@ -764,9 +879,10 @@ return (ctx, config = {}) => {
|
|
|
764
879
|
}
|
|
765
880
|
try {
|
|
766
881
|
const domain = await domainOf(agent)
|
|
767
|
-
if (
|
|
882
|
+
if (transfer) await domain.transferStart(agent)
|
|
883
|
+
else if (retry) await domain.retryStart(agent)
|
|
768
884
|
else await domain.start(agent)
|
|
769
|
-
if (closed || disposedAgents.has(agent) || (retry && (!ownAgent(agent) || servingRuntime(agent.id) !== agent))) {
|
|
885
|
+
if (closed || disposedAgents.has(agent) || (retry && (!ownAgent(agent) || (!transfer && servingRuntime(agent.id) !== agent)))) {
|
|
770
886
|
await domain.forget(agent)
|
|
771
887
|
throw new Error('Edit Lock mount changed during binding')
|
|
772
888
|
}
|
|
@@ -790,7 +906,7 @@ return (ctx, config = {}) => {
|
|
|
790
906
|
if (failure?.code === 'binding-duplicate') bindingFailures.set(agent, failure)
|
|
791
907
|
else bindingFailures.delete(agent)
|
|
792
908
|
try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
|
|
793
|
-
try { deliverLocal(agent,
|
|
909
|
+
try { deliverLocal(agent, bindingFailureNotice(reason, failure)) } catch { /* the panel record remains */ }
|
|
794
910
|
ctx.logger?.warn?.(`edit lock registration failed for ${sessionOf(agent)}: ${reason}`)
|
|
795
911
|
}
|
|
796
912
|
}
|
|
@@ -806,20 +922,64 @@ return (ctx, config = {}) => {
|
|
|
806
922
|
setupInFlight.set(agent, task)
|
|
807
923
|
return task
|
|
808
924
|
}
|
|
809
|
-
/** Trusted
|
|
810
|
-
*
|
|
811
|
-
|
|
812
|
-
|
|
925
|
+
/** Trusted driving ingresses only: the explicit /edit-lock retry (and panel
|
|
926
|
+
* action), the message-armed pre-step retry, and the write-guard lazy
|
|
927
|
+
* re-bind. Same Agent/root/mount; no writer reinstallation. Every attempt
|
|
928
|
+
* past the eligibility gate is audited — a silently swallowed refusal is a
|
|
929
|
+
* defect (change edit-lock-binding-transfer, design D3/D5).
|
|
930
|
+
* @param {any} agent @param {'command' | 'message' | 'write-guard'} [via] */
|
|
931
|
+
async function retryBinding(agent, via = 'command') {
|
|
932
|
+
if (closed || disposedAgents.has(agent) || !ownAgent(agent)) throw new Error('Edit Lock retry requires the current live Agent of this mount')
|
|
813
933
|
if (!registry.rootOf(agent) || bindingFailures.get(agent)?.code !== 'binding-duplicate') throw new Error('Edit Lock startup failure is not eligible for binding retry')
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
934
|
+
const cause = bindingFailures.get(agent)
|
|
935
|
+
const sessionId = sessionOf(agent) ?? null
|
|
936
|
+
const root = registry.rootOf(agent) ?? undefined
|
|
937
|
+
/** @param {string} result @param {Record<string, unknown>} [extra] */
|
|
938
|
+
const record = (result, extra = {}) => audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
|
|
939
|
+
kind: 'binding-transfer', sessionId, code: cause?.code ?? 'binding-duplicate', result, ...extra,
|
|
940
|
+
}, { root })
|
|
941
|
+
// Attestation trichotomy (design D5): the registry naming THIS claimant
|
|
942
|
+
// keeps the existing superseded rebind; naming ANOTHER live local agent
|
|
943
|
+
// routes to the transfer admission (holder idleness is re-adjudicated at
|
|
944
|
+
// execution, publisher-side); an unavailable or empty registry stays
|
|
945
|
+
// fail-closed — the holder cannot be attested.
|
|
946
|
+
let serving
|
|
947
|
+
try { serving = servingRuntime(agent.id) } catch { serving = undefined }
|
|
948
|
+
if (serving === undefined) {
|
|
949
|
+
const error = new Error('Edit Lock binding retry refused: the agent registry is unavailable or names no live agent for this session, so the holder cannot be attested. The recorded cause stays; a later retry remains possible.')
|
|
950
|
+
record('refused', { reason: 'registry-unattestable', detail: error.message })
|
|
951
|
+
throw error
|
|
952
|
+
}
|
|
953
|
+
const transfer = serving !== agent
|
|
954
|
+
try {
|
|
955
|
+
let task = setupInFlight.get(agent)
|
|
956
|
+
if (!task) {
|
|
957
|
+
const inner = setupAgentOnce(agent, true, transfer)
|
|
958
|
+
task = inner.finally(() => { if (setupInFlight.get(agent) === task) setupInFlight.delete(agent) })
|
|
959
|
+
setupInFlight.set(agent, task)
|
|
960
|
+
}
|
|
961
|
+
await task
|
|
962
|
+
} catch (error) {
|
|
963
|
+
record('refused', { reason: /** @type {any} */ (error)?.bindingFailure?.holderKind ?? 'retry-refused', detail: String(/** @type {any} */ (error)?.message ?? error) })
|
|
964
|
+
throw error
|
|
819
965
|
}
|
|
820
|
-
await task
|
|
821
966
|
const failure = startFailures.get(agent)
|
|
822
|
-
if (failure || !settled.has(agent))
|
|
967
|
+
if (failure || !settled.has(agent)) {
|
|
968
|
+
// setupAgentOnce records instead of rethrowing: the refusal's holder
|
|
969
|
+
// class comes from the freshly re-recorded structured failure.
|
|
970
|
+
const error = new Error(failure ?? 'Edit Lock binding remains unavailable')
|
|
971
|
+
record('refused', { reason: bindingFailures.get(agent)?.holderKind ?? 'binding-unavailable', detail: error.message })
|
|
972
|
+
throw error
|
|
973
|
+
}
|
|
974
|
+
if (transfer) {
|
|
975
|
+
record('transferred', { holder: cause?.holderKind ?? null })
|
|
976
|
+
} else if (via === 'message') {
|
|
977
|
+
// The message-armed superseded success keeps its established audit shape
|
|
978
|
+
// (pinned by the editlock-binding-auto-retry integration scenario).
|
|
979
|
+
audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
|
|
980
|
+
kind: 'binding-auto-retry', sessionId, code: cause?.code ?? 'binding-duplicate', result: 'recovered',
|
|
981
|
+
}, { root })
|
|
982
|
+
}
|
|
823
983
|
return 'Edit Lock connection recovered. Editing remains interrupted; use Continue editing when ready. No failed edit was replayed.'
|
|
824
984
|
}
|
|
825
985
|
ctx.on('agent/created', (/** @type {any} */ { agent }) => setupAgent(agent))
|
|
@@ -953,17 +1113,17 @@ return (ctx, config = {}) => {
|
|
|
953
1113
|
lastTurn.set(agent, turn)
|
|
954
1114
|
if (pendingBindingRetry.get(agent) === true) {
|
|
955
1115
|
pendingBindingRetry.delete(agent)
|
|
956
|
-
const cause = bindingFailures.get(agent)
|
|
957
1116
|
try {
|
|
958
|
-
await retryBinding(agent)
|
|
1117
|
+
await retryBinding(agent, 'message')
|
|
959
1118
|
// The genuine message that armed this retry is also this turn's
|
|
960
1119
|
// Continue receipt: re-arm what setupAgentOnce(retry) cleared, then
|
|
961
|
-
// fall through to the ordinary auto-resume consumption below.
|
|
1120
|
+
// fall through to the ordinary auto-resume consumption below. A
|
|
1121
|
+
// transfer success (registry named an idle same-process holder)
|
|
1122
|
+
// lands here identically: bound interrupted, then resumed in-turn.
|
|
962
1123
|
pendingAutoResume.set(agent, true)
|
|
963
|
-
audit(
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
}, { root: registry.rootOf(agent) ?? undefined })
|
|
1124
|
+
// The outcome audit (transferred / refused) was written inside
|
|
1125
|
+
// retryBinding — success AND refusal alike; the catch below is not
|
|
1126
|
+
// a silent swallow any more (design D3).
|
|
967
1127
|
} catch { /* a safe refusal keeps the recorded cause; the turn proceeds with edits denied */ }
|
|
968
1128
|
}
|
|
969
1129
|
const domain = settled.get(agent)
|
|
@@ -1284,6 +1444,7 @@ return (ctx, config = {}) => {
|
|
|
1284
1444
|
return async () => {
|
|
1285
1445
|
if (closed) return
|
|
1286
1446
|
closed = true
|
|
1447
|
+
liveServices.delete(service)
|
|
1287
1448
|
offDelegateLifecycle()
|
|
1288
1449
|
evidence?.disposing()
|
|
1289
1450
|
try {
|
|
@@ -1295,10 +1456,9 @@ return (ctx, config = {}) => {
|
|
|
1295
1456
|
offView()
|
|
1296
1457
|
offCommand?.()
|
|
1297
1458
|
for (const off of offTools) off?.()
|
|
1298
|
-
// Retain uncertain evidence until every generation-owned domain is
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
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()
|
|
1302
1462
|
evidence?.dispose()
|
|
1303
1463
|
} catch (error) {
|
|
1304
1464
|
evidence?.failed(error)
|
|
@@ -1307,6 +1467,10 @@ return (ctx, config = {}) => {
|
|
|
1307
1467
|
}
|
|
1308
1468
|
} catch (error) {
|
|
1309
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(() => {})
|
|
1310
1474
|
throw error
|
|
1311
1475
|
}
|
|
1312
1476
|
}
|
|
@@ -169,6 +169,17 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
169
169
|
}
|
|
170
170
|
return result
|
|
171
171
|
}
|
|
172
|
+
/** Holder liveness signal (change edit-lock-binding-transfer, task 1.1): the
|
|
173
|
+
* DSH agent `status` vocabulary is exactly 'idle' | 'running' (idle covers the
|
|
174
|
+
* idle and maintenance phases). Only 'idle' proves the holder is not mid-turn;
|
|
175
|
+
* a missing, non-string or throwing read is undeterminable and never admits.
|
|
176
|
+
* @param {object} holder @returns {string | undefined} */
|
|
177
|
+
function holderStatus(holder) {
|
|
178
|
+
try {
|
|
179
|
+
const status = /** @type {any} */ (holder)?.status
|
|
180
|
+
return typeof status === 'string' ? status : undefined
|
|
181
|
+
} catch { return undefined }
|
|
182
|
+
}
|
|
172
183
|
/** Read-only startup admission. A revoked session is startable: it binds as
|
|
173
184
|
* interrupted and its revocation ends only through the trusted resume path
|
|
174
185
|
* (the epoch advance is the repair; binding alone grants no authority).
|
|
@@ -184,22 +195,42 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
184
195
|
}
|
|
185
196
|
/** Register once before publishing an agent's editing tools. A session the
|
|
186
197
|
* manager already knows (restart, re-created agent) is never re-armed: it
|
|
187
|
-
* starts interrupted and needs trusted resume.
|
|
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
|
|
188
201
|
* @returns {Promise<'active'|'interrupted'>} */
|
|
189
202
|
function start(agent) {
|
|
190
203
|
if (closed) return Promise.reject(new Error('edit lifecycle closed'))
|
|
191
|
-
|
|
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
|
+
}
|
|
192
217
|
const sessionId = sessionForAgent(agent)
|
|
193
218
|
if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
|
|
194
219
|
const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
|
|
195
220
|
if (prior) {
|
|
196
221
|
let serving
|
|
197
222
|
try { serving = options.servingRuntime?.(sessionId) } catch { /* unknown: refuse */ }
|
|
223
|
+
// A registry-named local holder that is provably not mid-turn is the
|
|
224
|
+
// TRANSFERABLE class (serving-idle): passive paths still refuse, but the
|
|
225
|
+
// refusal announces the automatic takeover instead of a window hunt.
|
|
226
|
+
const attestation = serving === prior[0]
|
|
227
|
+
? (/** @type {any} */ (prior[0]).remote !== true && holderStatus(prior[0]) === 'idle' ? 'serving-idle' : 'serving')
|
|
228
|
+
: 'unknown'
|
|
198
229
|
const refusal = () => {
|
|
199
|
-
const reason = bindingRefusalReason(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid,
|
|
230
|
+
const reason = bindingRefusalReason(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid, attestation)
|
|
200
231
|
// Remote callers render after their retry budget; local callers have no wire.
|
|
201
232
|
return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, reason)}`),
|
|
202
|
-
{ bindingFailure: bindingFailure(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid,
|
|
233
|
+
{ bindingFailure: bindingFailure(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid, attestation) })
|
|
203
234
|
}
|
|
204
235
|
// Remote entries are channel proxies, NOT runtime identities. Only the
|
|
205
236
|
// endpoint's nonce attestation may reclaim them (including same-process).
|
|
@@ -236,6 +267,39 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
236
267
|
})()
|
|
237
268
|
return entry.ready
|
|
238
269
|
}
|
|
270
|
+
/** Trusted driving-ingress transfer admission (change
|
|
271
|
+
* edit-lock-binding-transfer, design D1/D2): take the binding from a
|
|
272
|
+
* same-process, local (non-remote-proxy) holder that is registry-named and
|
|
273
|
+
* provably not mid-turn, reusing the exact superseded-reclaim semantics —
|
|
274
|
+
* durable revocation and forget, no lock is released, the claimant binds as
|
|
275
|
+
* interrupted. Every other shape (remote proxy, holder not registry-named,
|
|
276
|
+
* mid-turn, undeterminable liveness, unavailable registry) delegates to the
|
|
277
|
+
* ordinary start(), keeping its fail-closed refusal and its classification.
|
|
278
|
+
* Passive entry points (creation setup, catch-up, preset switch) never call
|
|
279
|
+
* this. @param {object} agent @returns {Promise<'active'|'interrupted'>} */
|
|
280
|
+
function transferStart(agent) {
|
|
281
|
+
if (closed) return Promise.reject(new Error('edit lifecycle closed'))
|
|
282
|
+
if (entries.has(agent)) return Promise.reject(new Error('agent already registered'))
|
|
283
|
+
const sessionId = sessionForAgent(agent)
|
|
284
|
+
if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
|
|
285
|
+
const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
|
|
286
|
+
if (!prior) return start(agent)
|
|
287
|
+
let serving
|
|
288
|
+
try { serving = options.servingRuntime?.(sessionId) } catch { /* unattestable: fall through to the ordinary refusal */ }
|
|
289
|
+
if (/** @type {any} */ (prior[0]).remote !== true && serving === prior[0] && holderStatus(prior[0]) === 'idle') {
|
|
290
|
+
// Concurrent claimants re-check after the shared revocation settles;
|
|
291
|
+
// no file lock is released and the claimant starts interrupted. A failed
|
|
292
|
+
// revocation keeps the transferable refusal: the holder entry is retained
|
|
293
|
+
// (start()'s reclaim-failure discipline) and the next driving ingress
|
|
294
|
+
// re-attempts the transfer.
|
|
295
|
+
return forget(prior[0]).then(() => start(agent), () => {
|
|
296
|
+
const reason = bindingRefusalReason(process.pid, 'serving-idle')
|
|
297
|
+
throw Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, reason)}`),
|
|
298
|
+
{ bindingFailure: bindingFailure(process.pid, 'serving-idle') })
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
return start(agent)
|
|
302
|
+
}
|
|
239
303
|
return Object.freeze({
|
|
240
304
|
assertStartable,
|
|
241
305
|
/** Local bindings only; endpoint arbitration reports channel proxies. */
|
|
@@ -283,6 +347,8 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
283
347
|
pendingRequests(exec) { return negotiation.pending(host.executionFor(exec.agent).sessionId) },
|
|
284
348
|
}),
|
|
285
349
|
start,
|
|
350
|
+
/** Explicit driving-ingress transfer entry; ordinary start() never transfers. */
|
|
351
|
+
transferStart,
|
|
286
352
|
stop,
|
|
287
353
|
/** Normal end of an agent: an agent that is still active (never stopped,
|
|
288
354
|
* not recovering) can never edit again, so its active locks are released
|
package/src/edit-lock/remote.js
CHANGED
|
@@ -172,6 +172,22 @@ export async function serveEditLockEndpoint(lifecycle, endpoint, sinks, attest,
|
|
|
172
172
|
const agent = Object.freeze({ id: sessionId, remote: true })
|
|
173
173
|
sinks.set(agent, event => transport.notify(event))
|
|
174
174
|
inner = createEditLockPeer(lifecycle, agent)
|
|
175
|
+
// A retry open is a trusted driving ingress (change
|
|
176
|
+
// edit-lock-binding-transfer): when the ordinary start is refused by
|
|
177
|
+
// a duplicate LOCAL holder, the transfer admission re-adjudicates the
|
|
178
|
+
// holder's idleness publisher-side. start() runs first and refuses
|
|
179
|
+
// without side effects, so a non-transferable refusal keeps its
|
|
180
|
+
// original classification and is evaluated exactly once per caller.
|
|
181
|
+
if (message.request.retry === true && typeof lifecycle.transferStart === 'function') {
|
|
182
|
+
try {
|
|
183
|
+
const state = await lifecycle.start(agent)
|
|
184
|
+
return { peer: inner, state }
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (/** @type {any} */ (error)?.bindingFailure?.code !== 'binding-duplicate') throw error
|
|
187
|
+
const state = await lifecycle.transferStart(agent)
|
|
188
|
+
return { peer: inner, state }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
175
191
|
const state = await lifecycle.start(agent)
|
|
176
192
|
return { peer: inner, state }
|
|
177
193
|
}, { nonce: typeof message.request.nonce === 'string' && message.request.nonce.length <= 256 ? message.request.nonce : undefined,
|
|
@@ -199,14 +215,17 @@ export async function serveEditLockEndpoint(lifecycle, endpoint, sinks, attest,
|
|
|
199
215
|
})
|
|
200
216
|
return Object.freeze({
|
|
201
217
|
bindings: arbitration.bindings,
|
|
202
|
-
/** @param {any} agent @param {boolean} [retry]
|
|
203
|
-
|
|
218
|
+
/** @param {any} agent @param {boolean} [retry] @param {boolean} [transfer]
|
|
219
|
+
* transfer: the claimant arrives through a driving ingress whose registry
|
|
220
|
+
* identity is the defect being repaired — the lifecycle transfer admission
|
|
221
|
+
* decides, and the currency proof relaxes accordingly (isCurrentLocal). */
|
|
222
|
+
startLocal(agent, retry = false, transfer = false) {
|
|
204
223
|
return arbitration.openLocal(agent.id, async reclaimed => {
|
|
205
224
|
const current = () => {
|
|
206
|
-
try { return !closed && (isCurrentLocal ? isCurrentLocal(agent, reclaimed || retry) : !(reclaimed || retry)) } catch { return false }
|
|
225
|
+
try { return !closed && (isCurrentLocal ? isCurrentLocal(agent, reclaimed || retry, transfer) : !(reclaimed || retry)) } catch { return false }
|
|
207
226
|
}
|
|
208
227
|
if (!current()) throw new Error('local edit binding claimant is no longer current')
|
|
209
|
-
const state = await lifecycle.start(agent)
|
|
228
|
+
const state = transfer && typeof lifecycle.transferStart === 'function' ? await lifecycle.transferStart(agent) : await lifecycle.start(agent)
|
|
210
229
|
if (!current()) {
|
|
211
230
|
// A stopped entry still has subtractive cleanup authority. Forget it
|
|
212
231
|
// only after durable revocation; never dispose (which releases locks).
|