weir-harness 1.2.0 → 1.2.2
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 +0 -2
- package/lib/client.blackboard-model.js +172 -56
- package/lib/client.capability-manager.js +0 -1
- package/lib/client.capability-model.js +378 -31
- package/lib/client.chain-model.js +80 -0
- package/lib/client.disabled-categories-editor.js +22 -0
- package/lib/client.edit-lock-maintenance.js +239 -246
- package/lib/client.edit-lock-panel.js +110 -0
- package/lib/client.hash-edit-model.js +48 -0
- package/lib/client.js +267 -1549
- package/lib/client.lsp-model.js +92 -0
- package/lib/client.lsp-panel.js +11 -16
- package/lib/client.lsp-toggle.js +18 -0
- package/lib/client.notify-permissions.js +112 -20
- package/lib/client.notify-web.js +14 -11
- package/lib/client.robash-model.js +26 -0
- package/lib/client.settings-page.js +287 -17
- package/lib/client.worktree-model.js +225 -0
- package/package.json +5 -2
- package/src/blackboard/durable.js +12 -0
- package/src/blackboard/index.js +2 -2
- package/src/blackboard/kernel.js +23 -6
- package/src/blackboard/notify.js +10 -3
- package/src/blackboard/remote.js +31 -13
- package/src/blackboard/storage.js +13 -5
- package/src/capabilities/apply-engine.js +189 -101
- package/src/capabilities/defaults-transaction.js +136 -1
- package/src/capabilities/lifecycle-snapshot.js +83 -204
- package/src/capabilities/mcp-admission.js +1 -23
- package/src/capabilities/mcp-drain.js +30 -19
- package/src/capabilities/mcp-facade.js +23 -3
- package/src/capabilities/preset-library.js +247 -0
- package/src/capabilities/preset-package.js +212 -1
- package/src/capabilities/same-name-policy.js +3 -41
- package/src/capabilities/selection-notify.js +324 -21
- package/src/capabilities/skill-office-adapter.js +7 -1
- package/src/capabilities/skill-selection-plugin.js +315 -729
- package/src/capabilities/store/checked-contract.d.ts +5 -0
- package/src/capabilities/store/fs-adapter.js +52 -0
- package/src/capabilities/store/lock.js +86 -0
- package/src/capabilities/store/paths.js +5 -0
- package/src/capabilities/store/store.js +105 -5
- package/src/creative-guide/guide.js +1 -1
- package/src/delegate/child-composition.js +258 -0
- package/src/delegate/group-coordinator.js +25 -27
- package/src/delegate/index.js +21 -2
- package/src/delegate/rehydrate.js +24 -4
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/spawn-adapter.js +214 -100
- package/src/delegate/status-tool.js +9 -6
- package/src/delegate/supervision-mount.js +35 -6
- package/src/delegate/tool.js +29 -4
- package/src/{shared → delegate}/whitelist-defaults.js +2 -2
- package/src/edit-lock/admin-ledger.js +8 -11
- package/src/edit-lock/admin-recovery.js +2 -1
- package/src/edit-lock/admission.js +12 -2
- package/src/edit-lock/binding-guidance.js +28 -10
- package/src/edit-lock/binding-machine.js +312 -0
- package/src/edit-lock/derived-facts.js +67 -0
- package/src/edit-lock/index.js +44 -244
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +8 -5
- package/src/edit-lock/limits.js +61 -0
- package/src/edit-lock/maintenance.js +5 -2
- package/src/edit-lock/manager.js +6 -2
- package/src/edit-lock/operation-history.js +1 -1
- package/src/edit-lock/peer-client.js +9 -2
- package/src/edit-lock/peer-transport.js +7 -1
- package/src/edit-lock/remote.js +11 -4
- package/src/edit-lock/state.js +2 -4
- package/src/edit-lock/view.js +10 -11
- package/src/lsp/admin.js +2 -24
- package/src/lsp/index.js +1 -1
- package/src/lsp/manager.js +1 -1
- package/src/notify/permissions-admin.js +1 -1
- package/src/{lsp/child-process.js → process/index.js} +23 -6
- package/src/settings/env-admin.js +1 -1
- package/src/settings/index.js +1 -1
- package/src/settings/sections.js +0 -54
- package/src/shared/audit.js +12 -0
- package/src/shared/child-scope.js +3 -1
- package/src/shared/http-json.js +28 -0
- package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
- package/src/worktree/errors.js +3 -0
- package/src/worktree/index.js +1 -2
- package/src/worktree/lane-core.js +641 -0
- package/src/worktree/lane-views.js +147 -0
- package/src/worktree/lane-workflows.js +853 -0
- package/src/worktree/lanes.js +24 -1513
- package/src/worktree/runner.js +5 -4
- package/src/capabilities/content-pin.js +0 -88
- package/src/capabilities/content-refresh.js +0 -331
- package/src/capabilities/distribution-manifest.js +0 -78
- package/src/capabilities/isolated-exec.js +0 -106
- package/src/capabilities/publisher.js +0 -129
- package/src/capabilities/skill-admission.js +0 -181
- package/src/capabilities/update-lifecycle.js +0 -106
- package/src/edit-lock/remote-service.js +0 -28
- package/src/worktree/contract-guidance.test.js +0 -100
package/src/edit-lock/index.js
CHANGED
|
@@ -17,7 +17,8 @@ import { openReservedEditLockRuntime } from './reserved-runtime.js'
|
|
|
17
17
|
import { reservationPathFor } from './reservation.js'
|
|
18
18
|
import { createEditLockLifecycle } from './lifecycle.js'
|
|
19
19
|
import { installEditLockWriteScope } from './tool-scope.js'
|
|
20
|
-
import {
|
|
20
|
+
import { writeScopesFor } from './write-scopes.js'
|
|
21
|
+
import { createBindingMachine } from './binding-machine.js'
|
|
21
22
|
import { createResourceIdentity } from './resource-identity.js'
|
|
22
23
|
import { userTextMessage } from '../shared/user-message.js'
|
|
23
24
|
import { isGenuineUserMessage } from '../shared/runtime-messages.js'
|
|
@@ -29,7 +30,7 @@ import { localDomain, remoteDomain } from './domain.js'
|
|
|
29
30
|
import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor, sharedDomainRegistryFor } from './domains.js'
|
|
30
31
|
import { createRecoveryDriver } from './recovery.js'
|
|
31
32
|
import { createSettlementDriver } from './settle.js'
|
|
32
|
-
import { editLockLimits } from '
|
|
33
|
+
import { editLockLimits } from './limits.js'
|
|
33
34
|
import { buildColdView, buildView, unavailableView } from './view.js'
|
|
34
35
|
import { parseSnapshot } from './snapshot.js'
|
|
35
36
|
import { readSnapshotBytes, errorMessage } from './read-authority.js'
|
|
@@ -333,21 +334,9 @@ function describe(status) {
|
|
|
333
334
|
return lines.join('\n')
|
|
334
335
|
}
|
|
335
336
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
}
|
|
337
|
+
// The binding-failure notice copy moved with the binding state machine to
|
|
338
|
+
// binding-machine.js (change edit-lock-deepening, design D3) — it renders only
|
|
339
|
+
// inside setupAgentOnce's failure record.
|
|
351
340
|
|
|
352
341
|
/** Read-only binding-unavailable guidance tail for the tool/service surfaces
|
|
353
342
|
* (domainFor and edit_lock_status), split by the recorded holder class.
|
|
@@ -423,9 +412,10 @@ return (ctx, config = {}) => {
|
|
|
423
412
|
warn: message => ctx.logger?.warn?.(message),
|
|
424
413
|
setTimer: fn => (safeSetTimer(ctx) ?? globalThis.setTimeout)(fn, 0),
|
|
425
414
|
})
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
415
|
+
// The binding state machine's stores (disposedAgents, startFailures,
|
|
416
|
+
// bindingFailures, setupInFlight, boundAgents, dispositions,
|
|
417
|
+
// agentDispositions) live in binding-machine.js and arrive destructured
|
|
418
|
+
// below (change edit-lock-deepening, design D3).
|
|
429
419
|
/** One queued automatic-recovery summary notice per management root (design
|
|
430
420
|
* D3): delivered to the first agent that finishes domain start, then gone.
|
|
431
421
|
* Shared across sibling mounts with the domain registry (change
|
|
@@ -447,19 +437,11 @@ return (ctx, config = {}) => {
|
|
|
447
437
|
/** Remote channel agents on the publisher side → their notice sink.
|
|
448
438
|
* @type {WeakMap<object, (event: unknown) => void>} */
|
|
449
439
|
const sinks = new WeakMap()
|
|
450
|
-
/** Why registration failed for an agent, for the panel. @type {WeakMap<object, string>} */
|
|
451
|
-
const startFailures = new WeakMap()
|
|
452
|
-
/** Publisher-classified failures, never inferred from text. @type {WeakMap<object, any>} */
|
|
453
|
-
const bindingFailures = new WeakMap()
|
|
454
440
|
/** Settled domain per bound agent, for synchronous gates. Shared across
|
|
455
441
|
* sibling mounts with the domain registry (design D3): whichever mount
|
|
456
442
|
* hears an event reads the same binding. Assigned with `shared` below.
|
|
457
443
|
* @type {WeakMap<object, any>} */
|
|
458
444
|
let settled
|
|
459
|
-
/** In-flight setup per agent, shared by every entry point (creation,
|
|
460
|
-
* preset-switch, catch-up, lazy re-bind) so concurrent callers can never
|
|
461
|
-
* double-register. @type {WeakMap<object, Promise<void>>} */
|
|
462
|
-
const setupInFlight = new WeakMap()
|
|
463
445
|
/** `wake` is used only for an ownership request addressed to an idle holder:
|
|
464
446
|
* inject alone would sit unread until the holder's next user message, which is
|
|
465
447
|
* how a request expired unanswered. Interrupted holders are never woken.
|
|
@@ -682,6 +664,31 @@ return (ctx, config = {}) => {
|
|
|
682
664
|
* lost timer (suspend, sleep) only delays the release, never the decision.
|
|
683
665
|
* @type {WeakMap<object, {timer?: any}>} */
|
|
684
666
|
const expiry = new WeakMap()
|
|
667
|
+
/** A genuine user message asked for the stopped session's automatic resume.
|
|
668
|
+
* Boolean, consumed once by the next turn's first pre-step (message-driven
|
|
669
|
+
* auto-resume D1/D6); WeakMap keys die with their agent. @type {WeakMap<object, boolean>} */
|
|
670
|
+
const pendingAutoResume = new WeakMap()
|
|
671
|
+
const delegateResumes = new Map()
|
|
672
|
+
const turnClassifications = new WeakMap()
|
|
673
|
+
// The per-agent binding state machine (change edit-lock-deepening, design
|
|
674
|
+
// D3): setup/retry/dispose/startup-diagnostic plus the binding stores,
|
|
675
|
+
// shared by every entry point below. The remaining wiring reads the same
|
|
676
|
+
// container objects through these destructured aliases.
|
|
677
|
+
const {
|
|
678
|
+
disposedAgents, startFailures, bindingFailures, setupInFlight,
|
|
679
|
+
boundAgents, dispositions, agentDispositions,
|
|
680
|
+
setupAgent, retryBinding, disposeAgent, startupFailure,
|
|
681
|
+
} = createBindingMachine({
|
|
682
|
+
ctx, sessionOf, GUARDED_TOOLS, options, registry, settled, writeScopes,
|
|
683
|
+
domainOf, servingRuntime,
|
|
684
|
+
// Late-bound wiring (ownAgent and the service object are declared below;
|
|
685
|
+
// the machine dereferences them only when an entry point actually calls).
|
|
686
|
+
ownAgent: (/** @type {any} */ agent) => ownAgent(agent),
|
|
687
|
+
installWriteScope: (/** @type {any} */ agent) => ({ owner: service, dispose: installEditLockWriteScope(agent, ctx, service, sandboxPolicyRef) }),
|
|
688
|
+
evidence, audit, deliverLocal,
|
|
689
|
+
pendingAutoResume, delegateResumes, turnClassifications, replyTools, expiry, settle,
|
|
690
|
+
isClosed: () => closed, platformFailure: () => platformFailure,
|
|
691
|
+
})
|
|
685
692
|
/** @param {any} agent @param {any} domain */
|
|
686
693
|
function armExpiry(agent, domain) {
|
|
687
694
|
const state = expiry.get(agent) ?? {}
|
|
@@ -707,27 +714,6 @@ return (ctx, config = {}) => {
|
|
|
707
714
|
state.timer?.unref?.()
|
|
708
715
|
}).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock expiry arm failed: ${error?.message ?? error}`))
|
|
709
716
|
}
|
|
710
|
-
/** Only this live Agent's diagnostic; no domain reads or startup side effects. @param {any} agent */
|
|
711
|
-
function startupFailure(agent) {
|
|
712
|
-
if (platformFailure) return { state: 'unavailable', reason: platformFailure.message, bindingFailure: null }
|
|
713
|
-
if (!agent || settled.has(agent) || !startFailures.has(agent)) return null
|
|
714
|
-
let eligible = false
|
|
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 */ }
|
|
726
|
-
if (!eligible) return null
|
|
727
|
-
const failure = bindingFailures.get(agent)
|
|
728
|
-
return { state: 'unavailable', reason: startFailures.get(agent),
|
|
729
|
-
bindingFailure: failure ? { ...failure, inFlight: setupInFlight.has(agent) } : null }
|
|
730
|
-
}
|
|
731
717
|
const service = Object.freeze({
|
|
732
718
|
startupFailure,
|
|
733
719
|
publish: route('publish'),
|
|
@@ -817,171 +803,11 @@ return (ctx, config = {}) => {
|
|
|
817
803
|
if (failure) return { kind: 'deny', reason: `${exec.name} refused: this session has no Edit Lock domain under the current preset generation (setup failed: ${failure}); an automatic re-bind was attempted and did not recover it. Recovery: restart DeepSeek Harness or start a new conversation.` }
|
|
818
804
|
return { kind: 'deny', reason: `${exec.name} is not routed through Edit Lock in this composition; unmanaged file mutation refused.` }
|
|
819
805
|
})
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
const dispositions = new Map()
|
|
826
|
-
const agentDispositions = new WeakMap()
|
|
827
|
-
function dispositionFor(agent) {
|
|
828
|
-
let record = agentDispositions.get(agent)
|
|
829
|
-
if (!record) {
|
|
830
|
-
record = Promise.withResolvers()
|
|
831
|
-
record.promise.catch(() => {}) // emit initiators have no awaiting caller
|
|
832
|
-
agentDispositions.set(agent, record)
|
|
833
|
-
dispositions.set(agent.id, record)
|
|
834
|
-
}
|
|
835
|
-
return record
|
|
836
|
-
}
|
|
837
|
-
/** Per-agent Edit Lock setup, shared by the creation listener, the
|
|
838
|
-
* preset-switch re-bind, the late-mount catch-up and the guard/acquire lazy
|
|
839
|
-
* re-bind, so the paths can never diverge. Root selection and successful
|
|
840
|
-
* startup are separate: explicit binding retry reuses the existing scope
|
|
841
|
-
* and root, while ordinary entry points never repeat a failed startup.
|
|
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) {
|
|
848
|
-
if (closed || settled.has(agent)) return
|
|
849
|
-
// No writer scope, root resolution, directory, reservation or session
|
|
850
|
-
// authority may be installed when the mount preflight failed.
|
|
851
|
-
if (platformFailure) return
|
|
852
|
-
if (!registry.rootOf(agent)) try {
|
|
853
|
-
// A previous generation's managed write survives in the agent's own
|
|
854
|
-
// scope layer (a preset rebind never disposes own-layer registrations);
|
|
855
|
-
// retire it first or the scope registry throws duplicate-registration
|
|
856
|
-
// and the re-bind dies here (design D3).
|
|
857
|
-
disposeWriteScope(writeScopes, agent)
|
|
858
|
-
writeScopes.set(agent, { owner: service, dispose: installEditLockWriteScope(agent, ctx, service, sandboxPolicyRef) })
|
|
859
|
-
registry.bind(agent, options.fixed?.root ?? agent?.session?.header?.cwd)
|
|
860
|
-
} catch (error) {
|
|
861
|
-
// The denial itself must never escape: restrict() throws on names the
|
|
862
|
-
// agent's composition does not know, and an escape from this setup path
|
|
863
|
-
// is fiber-fatal during a reload (2026-10-05 host crash). Deny only the
|
|
864
|
-
// guarded tools the agent actually has.
|
|
865
|
-
try {
|
|
866
|
-
const known = GUARDED_TOOLS.filter(name => ctx.tools?.get?.(name, agent) !== undefined)
|
|
867
|
-
if (known.length > 0) agent?.ctx?.tools?.restrict?.({ deny: [...known] })
|
|
868
|
-
} catch { /* the denial is best-effort; the records below are the visible account */ }
|
|
869
|
-
// Failure visibility (design D4): the same reason lands in the panel
|
|
870
|
-
// state, the maintenance evidence, an in-session notice and the durable
|
|
871
|
-
// audit; the warn stays as the host ring-buffer copy.
|
|
872
|
-
const reason = String(/** @type {any} */ (error)?.message ?? error)
|
|
873
|
-
startFailures.set(agent, reason)
|
|
874
|
-
try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
|
|
875
|
-
audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, { kind: 'setup-failed', sessionId: sessionOf(agent) ?? null, reason })
|
|
876
|
-
try { deliverLocal(agent, `Edit Lock is unavailable for this session (${reason}); managed file editing is disabled and the failed edit must not be retried. This failure is not repaired automatically: the user can restart DeepSeek Harness or start a new conversation.`) } catch { /* the audit record is the durable account */ }
|
|
877
|
-
ctx.logger?.warn?.(`edit lock scope failed; edit tools denied: ${reason}`)
|
|
878
|
-
return
|
|
879
|
-
}
|
|
880
|
-
try {
|
|
881
|
-
const domain = await domainOf(agent)
|
|
882
|
-
if (transfer) await domain.transferStart(agent)
|
|
883
|
-
else if (retry) await domain.retryStart(agent)
|
|
884
|
-
else await domain.start(agent)
|
|
885
|
-
if (closed || disposedAgents.has(agent) || (retry && (!ownAgent(agent) || (!transfer && servingRuntime(agent.id) !== agent)))) {
|
|
886
|
-
await domain.forget(agent)
|
|
887
|
-
throw new Error('Edit Lock mount changed during binding')
|
|
888
|
-
}
|
|
889
|
-
startFailures.delete(agent)
|
|
890
|
-
bindingFailures.delete(agent)
|
|
891
|
-
try { evidence?.clearSessionFailure?.(sessionOf(agent)) } catch { /* reporting cannot undo a successful bind */ }
|
|
892
|
-
// A genuine message from a turn that ran while binding was unavailable
|
|
893
|
-
// is not a Continue receipt for a later injected wake after connection retry.
|
|
894
|
-
if (retry) pendingAutoResume.delete(agent)
|
|
895
|
-
settled.set(agent, domain)
|
|
896
|
-
boundAgents.set(agent.id, agent)
|
|
897
|
-
dispositionFor(agent)
|
|
898
|
-
if (!retry) await delegateResumes.get(agent.id)?.resume(agent, domain)
|
|
899
|
-
} catch (error) {
|
|
900
|
-
// Kept so the panel can say WHY editing is unavailable instead of
|
|
901
|
-
// showing "starting" forever; the notice aligns this catch with the
|
|
902
|
-
// write-scope failure above.
|
|
903
|
-
const reason = String(/** @type {any} */ (error)?.message ?? error)
|
|
904
|
-
startFailures.set(agent, reason)
|
|
905
|
-
const failure = /** @type {any} */ (error)?.bindingFailure
|
|
906
|
-
if (failure?.code === 'binding-duplicate') bindingFailures.set(agent, failure)
|
|
907
|
-
else bindingFailures.delete(agent)
|
|
908
|
-
try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
|
|
909
|
-
try { deliverLocal(agent, bindingFailureNotice(reason, failure)) } catch { /* the panel record remains */ }
|
|
910
|
-
ctx.logger?.warn?.(`edit lock registration failed for ${sessionOf(agent)}: ${reason}`)
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
/** Concurrent entry points (agent/created, preset-switch, lazy re-bind)
|
|
914
|
-
* share one in-flight setup per agent; a completed setup short-circuits on
|
|
915
|
-
* the root binding. @param {any} agent @returns {Promise<void>} */
|
|
916
|
-
function setupAgent(agent) {
|
|
917
|
-
const pending = setupInFlight.get(agent)
|
|
918
|
-
if (pending) return pending
|
|
919
|
-
if (closed || settled.has(agent) || registry.rootOf(agent)) return Promise.resolve()
|
|
920
|
-
const inner = setupAgentOnce(agent)
|
|
921
|
-
const task = inner.finally(() => { if (setupInFlight.get(agent) === task) setupInFlight.delete(agent) })
|
|
922
|
-
setupInFlight.set(agent, task)
|
|
923
|
-
return task
|
|
924
|
-
}
|
|
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')
|
|
933
|
-
if (!registry.rootOf(agent) || bindingFailures.get(agent)?.code !== 'binding-duplicate') throw new Error('Edit Lock startup failure is not eligible for binding retry')
|
|
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
|
|
965
|
-
}
|
|
966
|
-
const failure = startFailures.get(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
|
-
}
|
|
983
|
-
return 'Edit Lock connection recovered. Editing remains interrupted; use Continue editing when ready. No failed edit was replayed.'
|
|
984
|
-
}
|
|
806
|
+
// The binding stores (boundAgents, dispositions, agentDispositions) and
|
|
807
|
+
// dispositionFor moved into binding-machine.js; delegateResumes and
|
|
808
|
+
// turnClassifications stay composition-owned and are declared above with it.
|
|
809
|
+
// setupAgentOnce / setupAgent / retryBinding moved into binding-machine.js
|
|
810
|
+
// (change edit-lock-deepening, design D3) — they arrive destructured above.
|
|
985
811
|
ctx.on('agent/created', (/** @type {any} */ { agent }) => setupAgent(agent))
|
|
986
812
|
|
|
987
813
|
// Late-mount catch-up (the same mount-order hazard the worktree-mode guard
|
|
@@ -1090,10 +916,8 @@ return (ctx, config = {}) => {
|
|
|
1090
916
|
/** Turn identity seen last per agent, so a new turn is detected exactly once.
|
|
1091
917
|
* @type {WeakMap<object, unknown>} */
|
|
1092
918
|
const lastTurn = new WeakMap()
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
* auto-resume D1/D6); WeakMap keys die with their agent. @type {WeakMap<object, boolean>} */
|
|
1096
|
-
const pendingAutoResume = new WeakMap()
|
|
919
|
+
// pendingAutoResume moved next to the binding machine (its retry path clears
|
|
920
|
+
// an armed auto-resume); it is declared above with it.
|
|
1097
921
|
/** A genuine user message also arms ONE bounded binding retry when this agent
|
|
1098
922
|
* has a recorded duplicate-binding failure (message-driven self-heal, design
|
|
1099
923
|
* D5): consumed at the next turn's first pre-step by awaiting the existing
|
|
@@ -1234,32 +1058,8 @@ return (ctx, config = {}) => {
|
|
|
1234
1058
|
|
|
1235
1059
|
// A reply tool may only disappear at a turn boundary, never mid-turn.
|
|
1236
1060
|
ctx.on('agent/turn-stopping', (/** @type {any} */ { agent }) => { retireReplyTool(agent) })
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
if (disposedAgents.has(agent)) return disposalTasks.get(agent) ?? Promise.resolve()
|
|
1240
|
-
if (boundAgents.get(agent.id) === agent) boundAgents.delete(agent.id)
|
|
1241
|
-
// Undispatched sweep work armed by this agent cancels here (an in-flight
|
|
1242
|
-
// authority transaction is never cancelled or reinterpreted).
|
|
1243
|
-
disposedAgents.add(agent)
|
|
1244
|
-
// The managed write lives on the agent's own scope layer, which dies with
|
|
1245
|
-
// the agent: retire the host-lifetime registration here (design D3).
|
|
1246
|
-
disposeWriteScope(writeScopes, agent)
|
|
1247
|
-
replyTools.get(agent)?.dispose()
|
|
1248
|
-
replyTools.delete(agent)
|
|
1249
|
-
const timer = expiry.get(agent)?.timer
|
|
1250
|
-
if (timer) clearTimeout(timer)
|
|
1251
|
-
expiry.delete(agent)
|
|
1252
|
-
settle.settled(agent)
|
|
1253
|
-
const domain = settled.get(agent)
|
|
1254
|
-
settled.delete(agent)
|
|
1255
|
-
// The emit hook must cancel timers/scopes/sweeps synchronously. Only the
|
|
1256
|
-
// final authority disposal waits for durable abnormal classification.
|
|
1257
|
-
const record = dispositionFor(agent)
|
|
1258
|
-
const task = Promise.resolve(turnClassifications.get(agent)).then(() => domain?.dispose(agent))
|
|
1259
|
-
task.then(record.resolve, record.reject)
|
|
1260
|
-
disposalTasks.set(agent, record.promise)
|
|
1261
|
-
return record.promise
|
|
1262
|
-
}
|
|
1061
|
+
// disposeAgent and its disposalTasks moved into binding-machine.js (change
|
|
1062
|
+
// edit-lock-deepening, design D3) — the destructured alias is shared below.
|
|
1263
1063
|
ctx.on('agent/disposed', ({ agent }) => disposeAgent(agent).catch(() => {}))
|
|
1264
1064
|
|
|
1265
1065
|
/** Cold-read fallback for the status view (design D1/D2): a session with no
|
package/src/edit-lock/inspect.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// supplies the parsed snapshot and the mount evidence. Nothing here opens a
|
|
5
5
|
// runtime, grants authority, or changes what admission would decide; an
|
|
6
6
|
// unresolved operation is reported, never settled.
|
|
7
|
+
import { dispositionFor } from './derived-facts.js'
|
|
8
|
+
import { recoveryConfirmation } from './admin-recovery.js'
|
|
9
|
+
import { LATE_WRITER_RISK } from './admin-ledger.js'
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* The switch state the maintenance panel renders. Detection is evidence-based:
|
|
@@ -52,7 +55,7 @@ export function summarizeAuthorityImage(snapshot) {
|
|
|
52
55
|
const unresolved = operations
|
|
53
56
|
.filter(op => op?.phase === 'publishing' || op?.phase === 'unknown')
|
|
54
57
|
.map(op => {
|
|
55
|
-
const disposition = state
|
|
58
|
+
const disposition = dispositionFor(state, op)
|
|
56
59
|
return { ...unresolvedRow(op), admissionBlocked: !disposition, administrativeRecoveryId: disposition?.recoveryId ?? null }
|
|
57
60
|
})
|
|
58
61
|
// Prepared operations never fence anything, but the online administrative
|
|
@@ -89,6 +92,115 @@ export function summarizeAuthorityImage(snapshot) {
|
|
|
89
92
|
}
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Server-computed online-recovery proposals over the SAME parsed image the
|
|
97
|
+
* summary above projects (change edit-lock-recovery-proposals, D1/D2). The
|
|
98
|
+
* client renders these verbatim and never re-derives eligibility or the
|
|
99
|
+
* confirmation: eligibility uses the host's own disposition rule, and the
|
|
100
|
+
* confirmation is produced by the very function the manager re-verifies at
|
|
101
|
+
* commit time — the proposal is a read-time suggestion, never an
|
|
102
|
+
* authorization. `unmet` entries carry stable reason CODES in the manager's
|
|
103
|
+
* refusal order (disposition recorded → owner unknown → not interrupted →
|
|
104
|
+
* prepared outstanding → publishing outstanding → no unresolved operations);
|
|
105
|
+
* the prose is the client's localization job. Pure: reading changes nothing.
|
|
106
|
+
* @param {string} root @param {{ revision: number, state: any }} snapshot
|
|
107
|
+
*/
|
|
108
|
+
export function recoveryProposals(root, snapshot) {
|
|
109
|
+
const state = snapshot.state
|
|
110
|
+
const revision = snapshot.revision
|
|
111
|
+
const operations = Array.isArray(state?.operations) ? state.operations : []
|
|
112
|
+
const sessionByOwner = new Map()
|
|
113
|
+
const interruptedByOwner = new Map()
|
|
114
|
+
for (const session of Array.isArray(state?.sessions) ? state.sessions : []) {
|
|
115
|
+
if (typeof session?.sessionId !== 'string') continue
|
|
116
|
+
sessionByOwner.set(session.sessionId, session)
|
|
117
|
+
interruptedByOwner.set(session.sessionId, session.interrupted === true)
|
|
118
|
+
}
|
|
119
|
+
/** Unresolved (publishing/unknown) operations by owner, in image order. */
|
|
120
|
+
const unresolvedByOwner = new Map()
|
|
121
|
+
const preparedOwners = new Set()
|
|
122
|
+
for (const op of operations) {
|
|
123
|
+
if (typeof op?.sessionId !== 'string') continue
|
|
124
|
+
if (op.phase === 'prepared') { preparedOwners.add(op.sessionId); continue }
|
|
125
|
+
if (op.phase !== 'publishing' && op.phase !== 'unknown') continue
|
|
126
|
+
const list = unresolvedByOwner.get(op.sessionId) ?? []
|
|
127
|
+
list.push(op)
|
|
128
|
+
unresolvedByOwner.set(op.sessionId, list)
|
|
129
|
+
}
|
|
130
|
+
// The panel's merged display rows: retained locks plus still-blocking file
|
|
131
|
+
// fences (one row per resource); wide fences and cross-owner file fences
|
|
132
|
+
// become named scope paths instead of rows.
|
|
133
|
+
const rowsByResource = new Map()
|
|
134
|
+
const scopePathsByOwner = new Map()
|
|
135
|
+
const blockedOwners = new Set()
|
|
136
|
+
/** @param {string} owner @param {unknown} path */
|
|
137
|
+
const addScopePath = (owner, path) => {
|
|
138
|
+
if (typeof path !== 'string') return
|
|
139
|
+
const paths = scopePathsByOwner.get(owner) ?? []
|
|
140
|
+
if (!paths.includes(path)) paths.push(path)
|
|
141
|
+
scopePathsByOwner.set(owner, paths)
|
|
142
|
+
}
|
|
143
|
+
for (const lock of Array.isArray(state?.locks) ? state.locks : []) {
|
|
144
|
+
if (lock?.status === 'active') continue
|
|
145
|
+
if (typeof lock?.resourceId !== 'string' || typeof lock?.owner !== 'string') continue
|
|
146
|
+
blockedOwners.add(lock.owner)
|
|
147
|
+
const row = rowsByResource.get(lock.resourceId) ?? { resourceId: lock.resourceId, owner: lock.owner, lockStatus: null, fence: false, interrupted: interruptedByOwner.get(lock.owner) ?? false }
|
|
148
|
+
row.lockStatus = row.lockStatus ?? (typeof lock.status === 'string' ? lock.status : null)
|
|
149
|
+
rowsByResource.set(lock.resourceId, row)
|
|
150
|
+
}
|
|
151
|
+
for (const [owner, ops] of unresolvedByOwner) {
|
|
152
|
+
for (const op of ops) {
|
|
153
|
+
if (dispositionFor(state, op)) continue // disposed: no longer blocks admission
|
|
154
|
+
blockedOwners.add(owner)
|
|
155
|
+
const scope = fenceScope(op?.fence)
|
|
156
|
+
if (scope.kind === 'file' && typeof scope.path === 'string') {
|
|
157
|
+
const existing = rowsByResource.get(scope.path)
|
|
158
|
+
if (existing) {
|
|
159
|
+
// Same owner locks AND fences the file (the incident shape): one row.
|
|
160
|
+
if (existing.owner === owner) existing.fence = true
|
|
161
|
+
else addScopePath(owner, scope.path)
|
|
162
|
+
} else {
|
|
163
|
+
rowsByResource.set(scope.path, { resourceId: scope.path, owner, lockStatus: null, fence: true, interrupted: interruptedByOwner.get(owner) ?? false })
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
addScopePath(owner, scope.path)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const rows = [...rowsByResource.values()]
|
|
171
|
+
// Eligibility: an interrupted owner whose every unresolved operation is a
|
|
172
|
+
// still-blocking unknown, with no prepared operation outstanding — exactly
|
|
173
|
+
// the preconditions the manager re-verifies at the FIFO execution point.
|
|
174
|
+
const eligible = []
|
|
175
|
+
for (const [owner, ops] of unresolvedByOwner) {
|
|
176
|
+
if (interruptedByOwner.get(owner) !== true) continue
|
|
177
|
+
if (preparedOwners.has(owner)) continue
|
|
178
|
+
if (!(ops.length > 0 && ops.every(op => op.phase === 'unknown' && !dispositionFor(state, op)))) continue
|
|
179
|
+
eligible.push({ owner, ops })
|
|
180
|
+
}
|
|
181
|
+
const candidateOwners = new Set(eligible.map(entry => entry.owner))
|
|
182
|
+
const blocked = rows.filter(row => candidateOwners.has(row.owner))
|
|
183
|
+
blocked.sort((a, b) => a.resourceId.localeCompare(b.resourceId))
|
|
184
|
+
const candidates = eligible.map(({ owner, ops }) => {
|
|
185
|
+
const scope = { root, owner, expectedRevision: revision, operationIds: ops.map(op => op.operationId).sort() }
|
|
186
|
+
return { owner, scope, confirmation: recoveryConfirmation(scope), risk: LATE_WRITER_RISK, rows: blocked.filter(row => row.owner === owner) }
|
|
187
|
+
})
|
|
188
|
+
const unmet = []
|
|
189
|
+
for (const owner of [...blockedOwners].sort()) {
|
|
190
|
+
if (candidateOwners.has(owner)) continue
|
|
191
|
+
const ops = unresolvedByOwner.get(owner) ?? []
|
|
192
|
+
const session = sessionByOwner.get(owner)
|
|
193
|
+
const reason = ops.some(op => dispositionFor(state, op)) ? 'disposition-recorded'
|
|
194
|
+
: !session ? 'owner-unknown'
|
|
195
|
+
: session.interrupted !== true ? 'not-interrupted'
|
|
196
|
+
: preparedOwners.has(owner) ? 'prepared-outstanding'
|
|
197
|
+
: ops.some(op => op.phase === 'publishing') ? 'publishing-outstanding'
|
|
198
|
+
: 'no-unresolved'
|
|
199
|
+
unmet.push({ owner, reason, rows: rows.filter(row => row.owner === owner), scopePaths: scopePathsByOwner.get(owner) ?? [] })
|
|
200
|
+
}
|
|
201
|
+
return { schemaVersion: 1, revision, candidates, blocked, unmet }
|
|
202
|
+
}
|
|
203
|
+
|
|
92
204
|
/**
|
|
93
205
|
* The profile-wide switch decision. `saved` is the value currently persisted
|
|
94
206
|
* in settings; `enabledAtMount` is what the edit-lock row actually did when it
|
|
@@ -227,10 +227,12 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
227
227
|
? (/** @type {any} */ (prior[0]).remote !== true && holderStatus(prior[0]) === 'idle' ? 'serving-idle' : 'serving')
|
|
228
228
|
: 'unknown'
|
|
229
229
|
const refusal = () => {
|
|
230
|
-
const
|
|
230
|
+
const pid = /** @type {any} */ (prior[0]).remote === true ? undefined : process.pid
|
|
231
|
+
const reason = bindingRefusalReason(pid, attestation)
|
|
232
|
+
const failure = bindingFailure(pid, attestation)
|
|
231
233
|
// Remote callers render after their retry budget; local callers have no wire.
|
|
232
|
-
return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId,
|
|
233
|
-
{ bindingFailure:
|
|
234
|
+
return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
|
|
235
|
+
{ bindingFailure: failure })
|
|
234
236
|
}
|
|
235
237
|
// Remote entries are channel proxies, NOT runtime identities. Only the
|
|
236
238
|
// endpoint's nonce attestation may reclaim them (including same-process).
|
|
@@ -294,8 +296,9 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
294
296
|
// re-attempts the transfer.
|
|
295
297
|
return forget(prior[0]).then(() => start(agent), () => {
|
|
296
298
|
const reason = bindingRefusalReason(process.pid, 'serving-idle')
|
|
297
|
-
|
|
298
|
-
|
|
299
|
+
const failure = bindingFailure(process.pid, 'serving-idle')
|
|
300
|
+
throw Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
|
|
301
|
+
{ bindingFailure: failure })
|
|
299
302
|
})
|
|
300
303
|
}
|
|
301
304
|
return start(agent)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Edit Lock retention policy: the defaults and the overlay resolver the
|
|
2
|
+
// lifecycle (edit-lock/index.js) consumes. Moved verbatim out of
|
|
3
|
+
// src/settings/sections.js (subsystem-seam-hygiene, D4) — the owner subsystem
|
|
4
|
+
// now holds the policy; settings' field-default parity consumes this module
|
|
5
|
+
// directly (the temporary sections.js re-export was removed by dead-export-cleanup).
|
|
6
|
+
|
|
7
|
+
/** Resolved Edit Lock retention policy. The flat keys are declared in the
|
|
8
|
+
* settings sections table; this is the ONE place that turns them into the
|
|
9
|
+
* values the lifecycle consumes, so defaults and validation cannot drift
|
|
10
|
+
* apart. */
|
|
11
|
+
export const EDIT_LOCK_DEFAULTS = Object.freeze({
|
|
12
|
+
holdDefaultMinutes: 30,
|
|
13
|
+
holdSingleMaxMinutes: 30,
|
|
14
|
+
holdCumulativeMaxMinutes: 120,
|
|
15
|
+
nudgeAttempts: 2,
|
|
16
|
+
nudgeFallback: 'release',
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** Overlay one Edit Lock section onto the defaults. A key the user never set
|
|
20
|
+
* falls back to its default; a key that is present but unusable (alone or in
|
|
21
|
+
* combination) throws with the flat settings key named, because silently clamping
|
|
22
|
+
* a retention cap would change how long other sessions stay blocked from those
|
|
23
|
+
* files. The Edit Lock composition refuses retention requests with that error and
|
|
24
|
+
* runs every other path (settling, status, release, stop, unlock) on defaults.
|
|
25
|
+
* @param {any} section */
|
|
26
|
+
export function editLockLimits(section) {
|
|
27
|
+
const value = section && typeof section === 'object' ? section : {}
|
|
28
|
+
/** @type {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'}} */
|
|
29
|
+
const limits = { ...EDIT_LOCK_DEFAULTS }
|
|
30
|
+
/** @param {'holdDefaultMinutes'|'holdSingleMaxMinutes'|'holdCumulativeMaxMinutes'} key @param {string} name */
|
|
31
|
+
const take = (key, name) => {
|
|
32
|
+
const input = value[key]
|
|
33
|
+
if (input === undefined) return
|
|
34
|
+
if (typeof input !== 'number' || !Number.isFinite(input) || input <= 0) {
|
|
35
|
+
throw new Error(`weir-settings: ${name} must be a positive number`)
|
|
36
|
+
}
|
|
37
|
+
limits[key] = input
|
|
38
|
+
}
|
|
39
|
+
take('holdDefaultMinutes', 'editLockHoldDefaultMinutes')
|
|
40
|
+
take('holdSingleMaxMinutes', 'editLockHoldSingleMaxMinutes')
|
|
41
|
+
take('holdCumulativeMaxMinutes', 'editLockHoldCumulativeMaxMinutes')
|
|
42
|
+
if (limits.holdDefaultMinutes > limits.holdSingleMaxMinutes) {
|
|
43
|
+
throw new Error('weir-settings: editLockHoldDefaultMinutes must not exceed editLockHoldSingleMaxMinutes')
|
|
44
|
+
}
|
|
45
|
+
if (limits.holdCumulativeMaxMinutes < limits.holdSingleMaxMinutes) {
|
|
46
|
+
throw new Error('weir-settings: editLockHoldCumulativeMaxMinutes must be at least editLockHoldSingleMaxMinutes')
|
|
47
|
+
}
|
|
48
|
+
if (value.nudgeAttempts !== undefined) {
|
|
49
|
+
if (!Number.isSafeInteger(value.nudgeAttempts) || value.nudgeAttempts < 0) {
|
|
50
|
+
throw new Error('weir-settings: editLockNudgeAttempts must be a non-negative integer')
|
|
51
|
+
}
|
|
52
|
+
limits.nudgeAttempts = value.nudgeAttempts
|
|
53
|
+
}
|
|
54
|
+
if (value.nudgeFallback !== undefined) {
|
|
55
|
+
if (value.nudgeFallback !== 'release' && value.nudgeFallback !== 'abnormal') {
|
|
56
|
+
throw new Error('weir-settings: editLockNudgeFallback must be "release" or "abnormal"')
|
|
57
|
+
}
|
|
58
|
+
limits.nudgeFallback = value.nudgeFallback
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze(limits)
|
|
61
|
+
}
|
|
@@ -6,7 +6,7 @@ import { join } from 'node:path'
|
|
|
6
6
|
import { AUTHORITY_DIR } from './domains.js'
|
|
7
7
|
import { reservationPathFor } from './reservation.js'
|
|
8
8
|
import { parseSnapshot } from './snapshot.js'
|
|
9
|
-
import { maintenanceState, summarizeAuthorityImage } from './inspect.js'
|
|
9
|
+
import { maintenanceState, summarizeAuthorityImage, recoveryProposals } from './inspect.js'
|
|
10
10
|
import { readSnapshotBytes, checkedPath, verifyPins, missing, errorMessage, MAX_SNAPSHOT_BYTES } from './read-authority.js'
|
|
11
11
|
import { recoverAuthority } from './admin-recovery.js'
|
|
12
12
|
import { createAudit, AUDIT_TYPES } from '../shared/audit.js'
|
|
@@ -141,7 +141,10 @@ export function inspectAuthority(root) {
|
|
|
141
141
|
if (read.presence === 'not-a-file') return { ...base, presence: 'not-a-file' }
|
|
142
142
|
if (read.presence !== 'valid') return { ...base, presence: 'unreadable', message: read.message }
|
|
143
143
|
try {
|
|
144
|
-
|
|
144
|
+
const parsed = parseSnapshot(read.bytes, root)
|
|
145
|
+
// Both projections derive from the SAME parsed image: the snapshot summary
|
|
146
|
+
// and the server-computed recovery proposals (additive, schemaVersion 1).
|
|
147
|
+
return { ...base, presence: 'valid', snapshot: summarizeAuthorityImage(parsed), recoveryProposals: recoveryProposals(root, parsed) }
|
|
145
148
|
} catch (error) {
|
|
146
149
|
return { ...base, presence: 'corrupt', message: errorMessage(error) }
|
|
147
150
|
}
|
package/src/edit-lock/manager.js
CHANGED
|
@@ -4,7 +4,8 @@ import { bindRequest } from './request-binding.js'
|
|
|
4
4
|
import { lookupOperation } from './operation-history.js'
|
|
5
5
|
import { canonicalRequestData } from './request-data.js'
|
|
6
6
|
import { admitMutation as admitHistory, publicationCandidate } from './admission.js'
|
|
7
|
-
import { administrativeState, AUTOMATIC_RECOVERY_ACTOR, digest,
|
|
7
|
+
import { administrativeState, AUTOMATIC_RECOVERY_ACTOR, digest, LATE_WRITER_RISK, ONLINE_RECOVERY_ACTOR } from './admin-ledger.js'
|
|
8
|
+
import { dispositionFor, revokedOwner } from './derived-facts.js'
|
|
8
9
|
import { canonical } from './snapshot.js'
|
|
9
10
|
import { incarnationProcess, withIncarnation } from './incarnations.js'
|
|
10
11
|
|
|
@@ -395,8 +396,11 @@ function managerCore(store, kernel, { processIdentity = null, root = null, write
|
|
|
395
396
|
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
396
397
|
// A revoked owner is NOT refused here: the resume commits the epoch
|
|
397
398
|
// advance that atomically ends the revocation (no ledger change).
|
|
399
|
+
// Admission runs in the resume mode: the epoch advance publishes nothing,
|
|
400
|
+
// so unresolved resource-level fences never block the trusted Continue;
|
|
401
|
+
// the fenced resources stay barred to every other ingress.
|
|
398
402
|
return draft.operations.resume(captured, requestId, receipt)
|
|
399
|
-
}, () => sessionResources(captured.sessionId)).then(async resumed => {
|
|
403
|
+
}, () => sessionResources(captured.sessionId), 'resume').then(async resumed => {
|
|
400
404
|
const cancellation = cancelled.get(captured.sessionId)
|
|
401
405
|
if (cancellation) {
|
|
402
406
|
await cancellation
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isAbsolute, relative } from 'node:path'
|
|
2
|
-
import { dispositionFor } from './
|
|
2
|
+
import { dispositionFor } from './derived-facts.js'
|
|
3
3
|
|
|
4
4
|
/** Historical assertions only: no authentication, live permission or FS publication.
|
|
5
5
|
* Digests MUST be computed by a future trusted publisher over canonical requests,
|