weir-harness 1.2.3 → 2.0.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.
Files changed (81) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.file-lease-badge.js +307 -0
  3. package/lib/client.js +20 -30
  4. package/lib/client.settings-page.js +10 -34
  5. package/package.json +2 -2
  6. package/src/capabilities/store/checked-contract.d.ts +2 -2
  7. package/src/capabilities/store/fs-adapter.js +2 -3
  8. package/src/context-guard/index.js +2 -2
  9. package/src/delegate/supervision-mount.js +5 -5
  10. package/src/delegate/tool.js +2 -10
  11. package/src/file-lease/client.js +1066 -0
  12. package/src/file-lease/index.js +30 -0
  13. package/src/file-lease/lineage.js +207 -0
  14. package/src/file-lease/node-contracts.d.ts +42 -0
  15. package/src/file-lease/plugin.js +789 -0
  16. package/src/file-lease/singleton.js +155 -0
  17. package/src/file-lease/spawn.js +62 -0
  18. package/src/file-lease/tools.js +459 -0
  19. package/src/file-lease/view.js +144 -0
  20. package/src/hashline-edit/index.js +76 -42
  21. package/src/hashline-edit/write-tool.js +90 -0
  22. package/src/lease-broker/entry.js +98 -0
  23. package/src/lease-broker/node-contracts.d.ts +63 -0
  24. package/src/lease-broker/protocol.js +236 -0
  25. package/src/lease-broker/server.js +399 -0
  26. package/src/lease-broker/state.js +425 -0
  27. package/src/lsp/tools.js +137 -27
  28. package/src/settings/index.js +2 -44
  29. package/src/settings/sections.js +2 -24
  30. package/src/shared/audit.js +6 -1
  31. package/src/todo-driver/index.js +1 -6
  32. package/src/worktree/cards.js +6 -21
  33. package/src/worktree/lane-core.js +1 -19
  34. package/src/worktree/lane-workflows.js +11 -32
  35. package/lib/client.edit-lock-maintenance.js +0 -592
  36. package/lib/client.edit-lock-panel.js +0 -405
  37. package/src/edit-lock/adapter.js +0 -57
  38. package/src/edit-lock/admin-ledger.js +0 -149
  39. package/src/edit-lock/admin-recovery.js +0 -151
  40. package/src/edit-lock/admission.js +0 -82
  41. package/src/edit-lock/binding-attestation.js +0 -29
  42. package/src/edit-lock/binding-guidance.js +0 -72
  43. package/src/edit-lock/binding-machine.js +0 -312
  44. package/src/edit-lock/call-context.js +0 -65
  45. package/src/edit-lock/derived-facts.js +0 -67
  46. package/src/edit-lock/domain.js +0 -165
  47. package/src/edit-lock/domains.js +0 -170
  48. package/src/edit-lock/host.js +0 -149
  49. package/src/edit-lock/incarnations.js +0 -150
  50. package/src/edit-lock/index.js +0 -1303
  51. package/src/edit-lock/inspect.js +0 -219
  52. package/src/edit-lock/lifecycle.js +0 -579
  53. package/src/edit-lock/limits.js +0 -61
  54. package/src/edit-lock/maintenance.js +0 -310
  55. package/src/edit-lock/manager.js +0 -875
  56. package/src/edit-lock/negotiation.js +0 -132
  57. package/src/edit-lock/operation-history.js +0 -214
  58. package/src/edit-lock/peer-client.js +0 -86
  59. package/src/edit-lock/peer-transport.js +0 -76
  60. package/src/edit-lock/peer.js +0 -87
  61. package/src/edit-lock/publisher.js +0 -191
  62. package/src/edit-lock/read-authority.js +0 -101
  63. package/src/edit-lock/recovery.js +0 -139
  64. package/src/edit-lock/remote.js +0 -362
  65. package/src/edit-lock/request-binding.js +0 -42
  66. package/src/edit-lock/request-data.js +0 -40
  67. package/src/edit-lock/reservation.js +0 -301
  68. package/src/edit-lock/reserved-runtime.js +0 -45
  69. package/src/edit-lock/resource-identity.js +0 -130
  70. package/src/edit-lock/runtime.js +0 -145
  71. package/src/edit-lock/settle.js +0 -144
  72. package/src/edit-lock/snapshot.js +0 -175
  73. package/src/edit-lock/stale-sweep.js +0 -120
  74. package/src/edit-lock/state.js +0 -462
  75. package/src/edit-lock/store.js +0 -327
  76. package/src/edit-lock/tool-scope.js +0 -29
  77. package/src/edit-lock/view.js +0 -170
  78. package/src/edit-lock/write-scopes.js +0 -48
  79. package/src/edit-lock/write-tool.js +0 -45
  80. package/src/shared/delegate-edit-lifecycle.js +0 -36
  81. package/src/worktree/authority.js +0 -56
@@ -1,151 +0,0 @@
1
- // Trusted offline admin API. Never opens a runtime or invokes a publisher.
2
- import { constants, lstatSync, realpathSync } from 'node:fs'
3
- import { open } from 'node:fs/promises'
4
- import { join, resolve, parse, relative, sep } from 'node:path'
5
- import { createHash } from 'node:crypto'
6
- import { reservePublisher } from './reservation.js'
7
- import { openEditLockStore } from './store.js'
8
- import { canonical, parseSnapshot, validateImage } from './snapshot.js'
9
- import { administrativeState, digest, LATE_WRITER_RISK } from './admin-ledger.js'
10
- import { dispositionFor } from './derived-facts.js'
11
-
12
- export const MAX_RECOVERY_BYTES = 16 * 1024 * 1024
13
- const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
14
- function fail(code, message) { throw Object.assign(new Error(message), { code: `weir-edit-lock/${code}` }) }
15
- function check(condition, message) { if (!condition) fail('invalid-recovery', message) }
16
- function scope(input) { return { root: input.root, owner: input.owner, expectedRevision: input.expectedRevision, operationIds: [...input.operationIds].sort(), risk: LATE_WRITER_RISK } }
17
- export function recoveryConfirmation(input) { return `ADMIN OVERRIDE ${digest(scope(input))}` }
18
- function validateInput(input) {
19
- check(input && typeof input === 'object' && !Array.isArray(input), 'Recovery object required')
20
- const keys = ['root', 'owner', 'expectedRevision', 'operationIds', 'recoveryId', 'reason', 'acceptLateWriterRisk', 'confirmation']
21
- check(Object.keys(input).length === keys.length && keys.every(k => Object.hasOwn(input, k)), 'Exact recovery fields required')
22
- for (const key of ['root', 'owner', 'recoveryId', 'reason']) check(typeof input[key] === 'string' && input[key].trim() && input[key].length <= (key === 'root' ? 4096 : key === 'reason' ? 2000 : key === 'recoveryId' ? 128 : 512) && !input[key].includes('\0'), `Invalid ${key}`)
23
- check(Number.isSafeInteger(input.expectedRevision) && input.expectedRevision >= 0, 'Expected revision required')
24
- check(Array.isArray(input.operationIds) && input.operationIds.length > 0 && input.operationIds.length <= 10000 && input.operationIds.every(id => typeof id === 'string' && id.trim() && id.length <= 1024), 'Operation IDs required')
25
- check(new Set(input.operationIds).size === input.operationIds.length, 'Duplicate operation ID')
26
- check(input.acceptLateWriterRisk === true && input.confirmation === recoveryConfirmation(input), 'Explicit scoped ADMIN OVERRIDE and late-writer risk acceptance required')
27
- }
28
- function pinsFor(directory) {
29
- let current = parse(directory).root
30
- const pins = []
31
- for (const part of relative(current, directory).split(sep).filter(Boolean)) {
32
- current = join(current, part)
33
- const stat = lstatSync(current)
34
- check(stat.isDirectory() && !stat.isSymbolicLink(), 'Authority ancestors must be nonsymlink directories')
35
- pins.push({ path: current, stat })
36
- }
37
- return pins
38
- }
39
- const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode
40
- function verify(pins) { for (const pin of pins) check(same(pin.stat, lstatSync(pin.path)), 'Authority ancestor changed') }
41
- async function boundedRead(path, pins) {
42
- verify(pins)
43
- const before = lstatSync(path)
44
- check(before.isFile() && !before.isSymbolicLink() && before.nlink === 1 && before.size <= MAX_RECOVERY_BYTES, 'Bounded single-link regular authority file required')
45
- // win32: O_NOFOLLOW/O_NONBLOCK are undefined; the lstat above refused a symlink
46
- // leaf and the post-open identity check below bounds the swap (read-authority.js).
47
- const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
48
- try {
49
- const stat = await file.stat()
50
- check(same(before, stat) && stat.isFile() && stat.nlink === 1 && stat.size <= MAX_RECOVERY_BYTES, 'Opened file identity or bound changed')
51
- const bytes = Buffer.alloc(stat.size + 1)
52
- let length = 0
53
- while (length < bytes.length) {
54
- const result = await file.read(bytes, length, bytes.length - length, length)
55
- if (!result.bytesRead) break
56
- length += result.bytesRead
57
- }
58
- const after = await file.stat()
59
- verify(pins)
60
- check(same(stat, lstatSync(path)) && after.size === stat.size && after.mtimeMs === stat.mtimeMs && after.ctimeMs === stat.ctimeMs && length === stat.size, 'Authority changed during read')
61
- return bytes.subarray(0, length)
62
- } finally { await file.close() }
63
- }
64
- async function syncDirectory(directory) {
65
- // Directory fsync is POSIX-only (EPERM on win32, reservation.js SYNC_SUPPORTED).
66
- if (process.platform !== 'darwin' && process.platform !== 'linux') return
67
- const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
68
- try { await handle.sync() } finally { await handle.close() }
69
- }
70
-
71
- /** Caller must authenticate settings-plane access and exact-match a server-derived
72
- * root BEFORE calling. Test checkpoints surround real IO, never replace it.
73
- * @param {any} input
74
- * @param {{ checkpoint?: (point: string) => void|Promise<void> }} [testing] */
75
- export async function recoverAuthority(input, testing = {}) {
76
- validateInput(input)
77
- // Capture before the first await: the operator request cannot change in flight.
78
- input = structuredClone(input)
79
- check(resolve(input.root) === input.root && realpathSync.native(input.root) === input.root, 'Canonical root required')
80
- const directory = join(input.root, '.weir/edit-lock')
81
- const pins = pinsFor(directory)
82
- const reservation = await reservePublisher(directory)
83
- let store
84
- try {
85
- await testing.checkpoint?.('reserved')
86
- const assertExclusive = async () => { verify(pins); await reservation.assertExclusive(); verify(pins) }
87
- await assertExclusive()
88
- const path = join(directory, 'snapshot.json')
89
- const bytes = await boundedRead(path, pins)
90
- const before = parseSnapshot(bytes, input.root)
91
- const existing = before.state.adminRecoveries?.find(row => row.recoveryId === input.recoveryId)
92
- if (existing) {
93
- check(existing.confirmation === digest(scope(input)) && existing.reason === input.reason, 'Recovery ID reused with different request')
94
- const backup = await boundedRead(join(directory, existing.backup.file), pins)
95
- check(sha256(backup) === existing.backup.sha256 && backup.length === existing.backup.bytes, 'Recovery backup no longer validates')
96
- const original = parseSnapshot(backup, input.root)
97
- check(original.revision === existing.expectedRevision, 'Backup revision mismatch')
98
- await syncDirectory(directory)
99
- await assertExclusive()
100
- return { revision: before.revision, idempotent: true, record: existing }
101
- }
102
- if (before.revision !== input.expectedRevision) fail('revision-conflict', 'Authority revision changed; inspect and confirm again')
103
- const owner = before.state.sessions.find(s => s.sessionId === input.owner)
104
- if (!owner || !owner.interrupted) throw new Error('Only an interrupted historical owner can be overridden')
105
- // Operations an earlier disposition of the same owner already covers stay
106
- // disposed: a re-revocation after repair names only genuinely new unknowns.
107
- const unresolved = before.state.operations.filter(op => op.sessionId === input.owner && ['prepared', 'publishing', 'unknown'].includes(op.phase) && !dispositionFor(before.state, op))
108
- check(unresolved.length > 0 && unresolved.every(op => op.phase === 'unknown'), 'Recover to stable unknown history before administrative override')
109
- check(canonical(unresolved.map(op => op.operationId).sort()) === canonical([...input.operationIds].sort()), 'Confirm every unresolved operation for this owner')
110
- const backup = { file: `admin-backup-${digest([input.root, input.recoveryId])}.json`, sha256: sha256(bytes), bytes: bytes.length }
111
- const record = { recoveryId: input.recoveryId, root: input.root, owner: input.owner,
112
- expectedRevision: before.revision, committedRevision: before.revision + 1, at: Date.now(), actor: 'authenticated-settings-administrator',
113
- reason: input.reason, risk: LATE_WRITER_RISK, confirmation: digest(scope(input)), backup,
114
- operations: unresolved.map(op => ({ operationId: op.operationId, sha256: digest(op) })),
115
- releasedLocks: before.state.locks.filter(lock => lock.owner === input.owner), revokedEpoch: owner.executionEpoch + 1 }
116
- const nextState = administrativeState(before.state, record)
117
- validateImage(nextState)
118
- check(Buffer.byteLength(canonical(nextState)) + 1024 <= MAX_RECOVERY_BYTES, 'Resulting authority exceeds recovery byte limit')
119
- await testing.checkpoint?.('before-backup')
120
- await assertExclusive()
121
- let file
122
- try { file = await open(join(directory, backup.file), 'wx', 0o600) }
123
- catch (error) { if (/** @type {{code?: string}} */ (error).code !== 'EEXIST') throw error }
124
- if (file) {
125
- try { await file.writeFile(bytes); await testing.checkpoint?.('backup-written'); await file.sync() }
126
- finally { await file.close() }
127
- }
128
- const savedBackup = await boundedRead(join(directory, backup.file), pins)
129
- check(savedBackup.equals(bytes), 'Existing backup does not match exact authority bytes')
130
- parseSnapshot(savedBackup, input.root)
131
- // Also sync an existing backup when retrying a crash before its first fsync.
132
- // fsync needs a writable handle on Windows (EPERM on O_RDONLY): open the
133
- // existing backup read-write there so the re-sync stays a real fsync.
134
- const backupHandle = await open(join(directory, backup.file), process.platform === 'win32' ? 'r+' : constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
135
- try { await backupHandle.sync() } finally { await backupHandle.close() }
136
- await syncDirectory(directory)
137
- await testing.checkpoint?.('backup-durable')
138
- await assertExclusive()
139
- check((await boundedRead(path, pins)).equals(bytes), 'Authority changed after backup')
140
- store = await openEditLockStore({ directory, domainId: input.root, mode: 'recover', maxSnapshotBytes: MAX_RECOVERY_BYTES }, { checkpoint: async point => { await testing.checkpoint?.(`store:${point}`); await assertExclusive() } })
141
- check(canonical(store.snapshot()) === canonical(before), 'Authority changed before commit')
142
- const saved = await store.recordAdministrativeRecovery({ expectedRevision: before.revision, nextState })
143
- await assertExclusive()
144
- return { revision: saved.revision, idempotent: false, record }
145
- } finally {
146
- await store?.close()
147
- // This operation dispatched no file publisher; it may release ONLY its own
148
- // reservation. A pre-existing runtime/stale reservation is never removed.
149
- await reservation.releaseAfterQuiescence()
150
- }
151
- }
@@ -1,82 +0,0 @@
1
- import { posix, win32 } from 'node:path'
2
-
3
- /** Candidates describe effects, not merely explicit API arguments. Session-wide
4
- * operations must enumerate affected ownership at their FIFO position.
5
- * Modes: 'normal' covers every mutating ingress; 'release' is pure removal and
6
- * must retain the ownership unresolved updates require; 'revocation' is strictly
7
- * subtractive and crosses fences. 'resume' is reserved for session-level
8
- * recovery transactions (the trusted Continue): it publishes nothing, so an
9
- * unresolved resource-level fence never denies it, while domain fences and
10
- * every shape denial bind it exactly as they bind 'normal'. It must never be
11
- * used for a transaction that publishes, confirms, acquires or releases.
12
- * @typedef {{kind: 'resources', resourceIds: string[]} | {kind: 'scope', ancestor: string} | {kind: 'none'}} Candidate
13
- * @typedef {'normal'|'release'|'revocation'|'resume'} Mode
14
- */
15
-
16
- /** Shape check only, NOT filesystem proof. The trusted manager ingress already
17
- * requires canonical, single-link resource identity and exclusive topology control.
18
- * Opaque kernel test IDs remain valid without fences but cannot prove non-overlap.
19
- * @param {string} resourceId */
20
- function canonicalResource(resourceId) {
21
- if (typeof resourceId !== 'string' || resourceId.includes('\0')) return false
22
- // Shape check only, NOT filesystem proof. The POSIX arm is every POSIX row
23
- // plus the platform-neutral synthetic ids historical records may carry; the
24
- // win32 arm is the NTFS-native shape the resource-identity win32 row
25
- // produces. Real producers emit exactly one shape per platform; relative,
26
- // root, trailing-slash and non-normalized spellings stay refused.
27
- const posixCanonical = resourceId.startsWith('/') && resourceId !== '/' &&
28
- !resourceId.endsWith('/') && posix.normalize(resourceId) === resourceId
29
- if (process.platform !== 'win32') return posixCanonical
30
- return posixCanonical ||
31
- (/^[A-Za-z]:\\/.test(resourceId) && resourceId.length > 3 &&
32
- !resourceId.endsWith('\\') && !resourceId.includes('/') &&
33
- win32.normalize(resourceId) === resourceId)
34
- }
35
-
36
- /** Pure denial check. Historical closeout assertions grant no authority.
37
- * v3 stores no ancestor continuity witness. Neither relative() nor resolving an
38
- * ancestor again proves its historical topology: scope relations stay undecidable.
39
- * @param {import('./operation-history.js').Operation[]} operations
40
- * @param {Candidate} candidate
41
- * @param {Mode} [mode]
42
- */
43
- export function admitMutation(operations, candidate, mode = 'normal') {
44
- if (mode === 'revocation') return
45
- for (const operation of operations) {
46
- if (operation.phase !== 'publishing' && operation.phase !== 'unknown') continue
47
- const fence = operation.fence
48
- const deny = (/** @type {string} */ reason) => {
49
- // Tagged so trusted maintenance (the stale-lock sweep) can tell this
50
- // expected refusal from a persistence/poison/unexpected failure.
51
- throw Object.assign(new Error(`unresolved publication fence: ${reason}`), { admissionRefusal: true })
52
- }
53
- if (!candidate || !fence) deny('scope-unproved')
54
- // Pure removal cannot publish or grant authority, but MUST retain the exact
55
- // ownership required by unresolved updates, even under historical fences.
56
- if (mode === 'release') {
57
- if (candidate.kind !== 'resources' || !candidate.resourceIds.every(canonicalResource)) deny('identity-unproved')
58
- const target = operation.binding.target
59
- if (target.kind === 'update' && candidate.kind === 'resources' && candidate.resourceIds.includes(target.resourceId)) deny('retained-ownership')
60
- continue
61
- }
62
- if (fence?.kind === 'domain') deny('domain')
63
- if (candidate.kind === 'none') continue
64
- if (candidate.kind !== 'resources') deny('scope-continuity-unproved')
65
- if (candidate.kind === 'resources') {
66
- for (const resourceId of candidate.resourceIds) {
67
- if (!canonicalResource(resourceId)) deny('identity-unproved')
68
- if (fence?.kind !== 'resource') deny('scope-continuity-unproved')
69
- // 'resume' publishes nothing: a resource-level fence never bars the
70
- // session-level recovery transaction; it keeps barring the resource
71
- // itself on every other mode until administrative settlement.
72
- if (mode !== 'resume' && fence?.kind === 'resource' && (!canonicalResource(fence.resourceId) || fence.resourceId === resourceId)) deny('resource-conflict')
73
- }
74
- }
75
- }
76
- }
77
-
78
- /** @param {import('./operation-history.js').Target} target @returns {Candidate} */
79
- export function publicationCandidate(target) {
80
- return target.kind === 'update' ? { kind: 'resources', resourceIds: [target.resourceId] }
81
- : { kind: 'scope', ancestor: target.ancestor }
82
- }
@@ -1,29 +0,0 @@
1
- /** Same-bundle-instance bridge. Tokens identify channel runtime objects, never
2
- * confer authority. An absent registration is UNKNOWN, not evidence of death. */
3
- /** @type {Map<string, {agent: object, sessionId: string}>} */
4
- const registrations = new Map()
5
-
6
- /** @param {string} nonce @param {object} agent @param {string} sessionId */
7
- export function registerBindingRuntime(nonce, agent, sessionId) {
8
- if (registrations.has(nonce)) throw new Error('duplicate binding nonce')
9
- const entry = Object.freeze({ agent, sessionId })
10
- registrations.set(nonce, entry)
11
- return () => { if (registrations.get(nonce) === entry) registrations.delete(nonce) }
12
- }
13
-
14
- /** @param {string} nonce */
15
- export function bindingRuntime(nonce) { return registrations.get(nonce) }
16
-
17
- /** Undefined is a verified absence ONLY after a successful registry lookup.
18
- * Missing services and lookup failures must throw (and become unknown).
19
- * @param {(sessionId: string) => object | undefined} servingRuntime */
20
- export function createBindingAttester(servingRuntime) {
21
- /** @param {string} sessionId @param {string} nonce
22
- * @returns {'serving'|'stale'|'unknown'} */
23
- return (sessionId, nonce) => {
24
- const holder = bindingRuntime(nonce)
25
- if (!holder || holder.sessionId !== sessionId) return 'unknown'
26
- try { return servingRuntime(sessionId) === holder.agent ? 'serving' : 'stale' }
27
- catch { return 'unknown' }
28
- }
29
- }
@@ -1,72 +0,0 @@
1
- /** Publisher-only classification; pid is compared with the publisher, never the client.
2
- * @param {number | undefined} pid @param {string} attestation */
3
- export function bindingRefusalReason(pid, attestation) {
4
- const holder = attestation === 'serving' ? 'serving'
5
- : attestation === 'serving-idle' ? 'serving-idle'
6
- : pid === process.pid ? 'same-process-unattestable'
7
- : pid !== undefined ? 'cross-process-pid' : 'unknown'
8
- return `session already bound: holder=${holder}${pid !== undefined ? `; pid=${pid}` : ''}`
9
- }
10
-
11
- /** The wire/minted holder classes. */
12
- const HOLDER_KINDS = Object.freeze(['revocation-failed', 'serving', 'transferable', 'cross-process', 'unknown'])
13
-
14
- /** Validate a structured binding refusal — exactly the minted four-field
15
- * shape crosses the wire or reaches a classifier; anything else degrades to
16
- * the text channel (display only, never a decision input).
17
- * @param {unknown} value @returns {{code: 'binding-duplicate', retryable: true, holderKind: string, holderPid: number | null} | undefined} */
18
- export function asBindingFailure(value) {
19
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
20
- if (Object.keys(value).sort().join(',') !== 'code,holderKind,holderPid,retryable') return undefined
21
- const { code, retryable, holderKind, holderPid } = /** @type {any} */ (value)
22
- if (code !== 'binding-duplicate' || retryable !== true || !HOLDER_KINDS.includes(holderKind)) return undefined
23
- if (holderPid !== null && !(Number.isSafeInteger(holderPid) && holderPid > 0)) return undefined
24
- return { code, retryable, holderKind, holderPid }
25
- }
26
-
27
- /** Render only publisher-classified facts — the STRUCTURED bindingFailure,
28
- * never a re-parse of the display text (spec: presentation must not infer
29
- * state by matching human-readable text; change edit-lock-deepening D2).
30
- * Unrecognized or missing structure renders as unknown.
31
- * @param {string} sessionId @param {any} failure */
32
- export function bindingGuidance(sessionId, failure) {
33
- const classification = failure?.code === 'binding-duplicate' ? failure?.holderKind : undefined
34
- const pid = typeof failure?.holderPid === 'number' ? failure.holderPid : undefined
35
- let holder
36
- if (classification === 'transferable') {
37
- // Transferable (change edit-lock-binding-transfer): an idle same-process
38
- // holder is taken over by the next driving ingress — calm copy, no pid,
39
- // no window hunt, and NEVER the publisher-reservation warning.
40
- holder = `session ${sessionId} is held by an inactive attachment of this same session. Editing attaches here automatically when you next send a message; /edit-lock retry also takes it over now. Retry does not Continue editing.`
41
- } else if (classification === 'serving') {
42
- // The holder is provably live and not transferable (mid-turn or a live
43
- // channel proxy): name what is happening and when to retry. No window
44
- // hunt (single-instance hosts exist) and no process management.
45
- holder = `session ${sessionId} is being edited in another attachment right now. Wait for that turn to finish, then use /edit-lock retry in this session; retry does not Continue editing.`
46
- } else if (classification === 'unknown' && pid !== undefined) {
47
- holder = `session ${sessionId} has a binding in this same process (pid ${pid}) that cannot be safely reclaimed. A stale runtime incarnation may still hold its channel. Wait for the original holder to disconnect or for confirmed process shutdown.`
48
- } else if (classification === 'cross-process' && pid !== undefined) {
49
- holder = `session ${sessionId} has a binding held by process pid ${pid}. Quit that holder process or disconnect the original holder, and wait for confirmation.`
50
- } else {
51
- holder = `session ${sessionId} has a binding whose holder cannot be attested. Wait for the original holder to disconnect or for confirmed process shutdown.`
52
- }
53
- if (classification === 'transferable' || classification === 'serving') return holder
54
- return `${holder} Then use /edit-lock retry in the original session when available; retry does not Continue editing. Do NOT remove the publisher reservation (.weir/.edit-lock.publisher-reservation) while its process is alive — removing it would split the edit authority.`
55
- }
56
-
57
- /** Structured publisher fact; never classify a retry from prose.
58
- * @param {number | undefined} pid @param {string} attestation
59
- * @param {boolean} [revocationFailed] */
60
- export function bindingFailure(pid, attestation, revocationFailed = false) {
61
- return { code: 'binding-duplicate', retryable: true,
62
- holderKind: revocationFailed ? 'revocation-failed' : attestation === 'serving' ? 'serving'
63
- : attestation === 'serving-idle' ? 'transferable'
64
- : pid !== undefined && pid !== process.pid ? 'cross-process' : 'unknown',
65
- holderPid: pid ?? null }
66
- }
67
- /** @param {number | undefined} pid @param {string} attestation @param {boolean} [revocationFailed] */
68
- export function bindingRefusal(pid, attestation, revocationFailed = false) {
69
- const reason = bindingRefusalReason(pid, attestation)
70
- return Object.assign(new Error(revocationFailed ? `${reason}; durable revocation was not acknowledged. Inspect authority; no replacement was admitted.` : reason),
71
- { bindingFailure: bindingFailure(pid, attestation, revocationFailed) })
72
- }