weir-harness 1.2.2 → 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 (89) 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/intent-gate/matcher.js +27 -0
  23. package/src/lease-broker/entry.js +98 -0
  24. package/src/lease-broker/node-contracts.d.ts +63 -0
  25. package/src/lease-broker/protocol.js +236 -0
  26. package/src/lease-broker/server.js +399 -0
  27. package/src/lease-broker/state.js +425 -0
  28. package/src/lsp/admin.js +24 -13
  29. package/src/lsp/index.js +5 -2
  30. package/src/lsp/registry.js +83 -17
  31. package/src/lsp/tools.js +137 -27
  32. package/src/notify/commands.js +15 -2
  33. package/src/notify/index.js +48 -10
  34. package/src/notify/web-channel.js +26 -3
  35. package/src/process/index.js +11 -0
  36. package/src/settings/index.js +2 -44
  37. package/src/settings/sections.js +29 -28
  38. package/src/shared/audit.js +6 -1
  39. package/src/todo-driver/index.js +1 -6
  40. package/src/worktree/cards.js +6 -21
  41. package/src/worktree/lane-core.js +1 -19
  42. package/src/worktree/lane-workflows.js +11 -32
  43. package/lib/client.edit-lock-maintenance.js +0 -592
  44. package/lib/client.edit-lock-panel.js +0 -405
  45. package/src/edit-lock/adapter.js +0 -57
  46. package/src/edit-lock/admin-ledger.js +0 -149
  47. package/src/edit-lock/admin-recovery.js +0 -151
  48. package/src/edit-lock/admission.js +0 -82
  49. package/src/edit-lock/binding-attestation.js +0 -29
  50. package/src/edit-lock/binding-guidance.js +0 -72
  51. package/src/edit-lock/binding-machine.js +0 -312
  52. package/src/edit-lock/call-context.js +0 -65
  53. package/src/edit-lock/derived-facts.js +0 -67
  54. package/src/edit-lock/domain.js +0 -165
  55. package/src/edit-lock/domains.js +0 -170
  56. package/src/edit-lock/host.js +0 -149
  57. package/src/edit-lock/incarnations.js +0 -150
  58. package/src/edit-lock/index.js +0 -1303
  59. package/src/edit-lock/inspect.js +0 -219
  60. package/src/edit-lock/lifecycle.js +0 -579
  61. package/src/edit-lock/limits.js +0 -61
  62. package/src/edit-lock/maintenance.js +0 -310
  63. package/src/edit-lock/manager.js +0 -875
  64. package/src/edit-lock/negotiation.js +0 -132
  65. package/src/edit-lock/operation-history.js +0 -214
  66. package/src/edit-lock/peer-client.js +0 -86
  67. package/src/edit-lock/peer-transport.js +0 -76
  68. package/src/edit-lock/peer.js +0 -87
  69. package/src/edit-lock/publisher.js +0 -191
  70. package/src/edit-lock/read-authority.js +0 -101
  71. package/src/edit-lock/recovery.js +0 -139
  72. package/src/edit-lock/remote.js +0 -362
  73. package/src/edit-lock/request-binding.js +0 -42
  74. package/src/edit-lock/request-data.js +0 -40
  75. package/src/edit-lock/reservation.js +0 -301
  76. package/src/edit-lock/reserved-runtime.js +0 -45
  77. package/src/edit-lock/resource-identity.js +0 -130
  78. package/src/edit-lock/runtime.js +0 -145
  79. package/src/edit-lock/settle.js +0 -144
  80. package/src/edit-lock/snapshot.js +0 -175
  81. package/src/edit-lock/stale-sweep.js +0 -120
  82. package/src/edit-lock/state.js +0 -462
  83. package/src/edit-lock/store.js +0 -327
  84. package/src/edit-lock/tool-scope.js +0 -29
  85. package/src/edit-lock/view.js +0 -170
  86. package/src/edit-lock/write-scopes.js +0 -48
  87. package/src/edit-lock/write-tool.js +0 -45
  88. package/src/shared/delegate-edit-lifecycle.js +0 -36
  89. package/src/worktree/authority.js +0 -56
@@ -1,191 +0,0 @@
1
- import { lstatSync } from 'node:fs'
2
- import { dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
3
- import { createResourceIdentity } from './resource-identity.js'
4
- import { canonicalRequestData } from './request-data.js'
5
-
6
- /** Bind the original host filesystem once; never substitute a Node write fallback.
7
- * The trusted host owns policy provenance, execution authentication, exclusive
8
- * manager lifetime and exclusion of external topology writers.
9
- * @param {{manager: ReturnType<typeof import('./manager.js').createEditLockManager>,
10
- * fs: {resolve: (path: string, options: {cwd: string}) => Promise<any>, writeText: (...args: any[]) => Promise<{version: string}>}, root: string, excluded?: string[], assertExclusive?: () => void}} options */
11
- export function createPublisher({ manager, fs, root, excluded = [], assertExclusive = () => {} }) {
12
- if (!isAbsolute(root)) throw new Error('absolute domain root required')
13
- const resolve = fs.resolve.bind(fs)
14
- const writeText = fs.writeText.bind(fs)
15
- const identity = createResourceIdentity()
16
- /** @type {WeakMap<object, () => void>} */
17
- const listeners = new WeakMap()
18
- /** @param {string} path */
19
- function contained(path) {
20
- const suffix = relative(root, path)
21
- if (suffix === '..' || suffix.startsWith('../') || isAbsolute(suffix)) throw new Error('outside management domain')
22
- for (const protectedPath of excluded) {
23
- const inner = relative(protectedPath, path)
24
- if (inner === '' || (!inner.startsWith('..') && !isAbsolute(inner))) throw new Error('Edit Lock authority files are not editable')
25
- }
26
- }
27
- return Object.freeze({
28
- /** Canonical identity of one existing in-domain regular file; never a
29
- * creation key and never a permission. @param {string} path @param {string} cwd */
30
- resource(path, cwd) {
31
- assertExclusive()
32
- const observation = identity.resolve(path, { cwd })
33
- if (observation.kind !== 'file') throw new Error('lock target must be an existing regular file')
34
- contained(observation.resourceId)
35
- return observation.resourceId
36
- },
37
- /** Release observations are subtractive only; never publication authority.
38
- * @param {string} path @param {string} cwd */
39
- releaseResource(path, cwd) {
40
- assertExclusive()
41
- const observation = identity.resolve(path, { cwd })
42
- const resourceId = observation.kind === 'file' ? observation.resourceId : resolvePath(cwd, path)
43
- contained(resourceId)
44
- if (observation.kind === 'missing' &&
45
- (path.split(/[\\/]/).includes('..') || resourceId !== join(observation.ancestor, observation.suffix) || !missingTarget(resourceId))) {
46
- throw new Error('Edit Lock release refused: missing target is not proven')
47
- }
48
- return { resourceId, observation }
49
- },
50
- /** Explicit owner release: preserve ordinary admission, but recheck missing
51
- * evidence inside the manager FIFO rather than trusting a preflight scan.
52
- * @param {import('./state.js').Ownership} token
53
- * @param {ReturnType<typeof identity.resolve>} observation */
54
- async release(token, observation) {
55
- assertExclusive()
56
- token = { ...token }
57
- if (observation.kind === 'file') return manager.release(token)
58
- const status = manager.status()
59
- if (token.managerIncarnation !== status.managerIncarnation) throw new Error('stale incarnation')
60
- const lock = status.locks.find(item => item.resourceId === token.resourceId)
61
- if (!lock || lock.owner !== token.sessionId) throw new Error('lock not owned by this session')
62
- const result = await manager.releaseStale([{
63
- resourceId: token.resourceId, owner: token.sessionId, generation: token.generation,
64
- executionEpoch: token.executionEpoch, status: lock.status,
65
- }], resourceId => {
66
- assertExclusive()
67
- identity.revalidate(observation)
68
- return missingTarget(resourceId)
69
- })
70
- const refusal = [...result.skipped, ...result.failed]
71
- if (refusal.length || result.released.length !== 1) {
72
- throw new Error(`Edit Lock release refused: ${refusal.map(item => item.reason).join('; ') || 'release not confirmed'}`)
73
- }
74
- },
75
- /** Resolve the entire existing-file set before atomic ownership acquisition.
76
- * @param {import('./state.js').Execution} execution
77
- * @param {string[]} paths @param {string} cwd */
78
- acquireBatch(execution, paths, cwd) {
79
- assertExclusive()
80
- const observations = paths.map(path => {
81
- const observation = identity.resolve(path, { cwd })
82
- if (observation.kind !== 'file') throw new Error('rename requires existing regular files')
83
- contained(observation.resourceId)
84
- return observation
85
- })
86
- if (new Set(observations.map(item => item.resourceId)).size !== paths.length) throw new Error('duplicate canonical rename target')
87
- return manager.acquireMany(execution, observations.map(item => item.resourceId)).then(ownership => ({
88
- ...ownership, ordered: observations.map(item => ownership.tokens.find(/** @param {import('./state.js').Ownership} token */ token => token.resourceId === item.resourceId)),
89
- }))
90
- },
91
- /** Trusted adapter only: content is the fully synthesized original tool payload.
92
- * @param {import('./state.js').Execution} execution
93
- * @param {{operationId: string, tool: 'write'|'hash_edit'|'lsp_rename', filePath: string, cwd: string,
94
- * args: unknown, content: string, effectivePolicy: any,
95
- * batchOwnership?: import('./state.js').Ownership,
96
- * expected: {kind: 'createIfAbsent'}|{kind: 'replaceIfVersion', version: string}}} request
97
- * @param {AbortSignal} signal */
98
- async prepare(execution, request, signal) {
99
- const data = JSON.parse(canonicalRequestData(request))
100
- execution = { ...execution }
101
- const historical = manager.history(execution.sessionId, data.operationId)
102
- if (historical) {
103
- if (canonicalRequestData(data.expected) !== canonicalRequestData(historical.binding.target.policy)) throw new Error('ID_REUSE: original guard differs')
104
- return manager.prepare(execution, { ...data, target: historical.binding.target }, {
105
- validate() { throw new Error('historical operation cannot dispatch') },
106
- publish() { throw new Error('historical operation cannot dispatch') },
107
- identify() { throw new Error('historical operation cannot dispatch') },
108
- })
109
- }
110
- const abort = () => {
111
- try { void manager.cancel(execution).catch(() => {}) } catch { /* stale execution cannot revoke a newer one */ }
112
- }
113
- signal.addEventListener('abort', abort, { once: true })
114
- if (signal.aborted) abort()
115
- const detach = () => signal.removeEventListener('abort', abort)
116
- try {
117
- if (signal.aborted) throw new Error('call aborted')
118
- if (!['workspace-write', 'danger-full-access'].includes(data.effectivePolicy?.mode)) throw new Error('writable effective policy required')
119
- const observation = identity.resolve(data.filePath, { cwd: data.cwd })
120
- contained(observation.kind === 'file' ? observation.resourceId : observation.ancestor)
121
- // The lexical creation target too: a missing protected subtree is not an escape.
122
- if (observation.kind === 'missing') contained(join(observation.ancestor, observation.suffix))
123
- const target = await resolve(data.filePath, { cwd: data.cwd })
124
- let descriptor
125
- if (observation.kind === 'missing') {
126
- if (data.tool !== 'write' || data.expected.kind !== 'createIfAbsent') throw new Error('creation requires explicit write intent')
127
- descriptor = { kind: 'create', ancestor: observation.ancestor, suffix: observation.suffix, policy: data.expected }
128
- } else {
129
- if (data.expected.kind !== 'replaceIfVersion') throw new Error('existing resource requires original version guard')
130
- let ownership = data.batchOwnership
131
- if (data.tool === 'lsp_rename') {
132
- if (!ownership || ownership.resourceId !== observation.resourceId || ownership.sessionId !== execution.sessionId ||
133
- ownership.executionEpoch !== execution.executionEpoch || ownership.managerIncarnation !== execution.managerIncarnation) throw new Error('rename batch ownership changed')
134
- manager.checkWrite(ownership)
135
- } else {
136
- const acquired = await manager.acquireMany(execution, [observation.resourceId])
137
- ownership = acquired.tokens[0]
138
- }
139
- descriptor = { kind: 'update', resourceId: observation.resourceId, generation: ownership.generation, policy: data.expected }
140
- }
141
- const validate = () => {
142
- assertExclusive()
143
- if (signal.aborted) throw new Error('call aborted')
144
- if (data.effectivePolicy?.mode === 'read-only') throw new Error('read-only policy')
145
- identity.revalidate(observation)
146
- }
147
- const ready = await manager.prepare(execution, { ...data, target: descriptor }, {
148
- validate,
149
- // Admission still observes the turn signal. Once invoked, join the host
150
- // commit independently: Stop revokes future work, not this dispatched write.
151
- publish: () => writeText(target, data.content, data.expected, new AbortController().signal, data.effectivePolicy),
152
- identify: () => {
153
- const current = identity.resolve(data.filePath, { cwd: data.cwd })
154
- if (current.kind !== 'file') throw new Error('published resource missing')
155
- contained(current.resourceId)
156
- if (observation.kind === 'missing') {
157
- const suffix = relative(observation.ancestor, current.resourceId)
158
- if (suffix === '..' || suffix.startsWith('../') || isAbsolute(suffix)) throw new Error('published resource escaped fence')
159
- }
160
- return current.resourceId
161
- },
162
- })
163
- if (ready.kind === 'ready' && ready.submission) listeners.set(ready.submission, detach)
164
- else detach()
165
- return ready
166
- } catch (error) { detach(); throw error }
167
- },
168
- /** @param {object} submission */
169
- async commit(submission) {
170
- try { return await manager.commit(submission) }
171
- finally { listeners.get(submission)?.(); listeners.delete(submission) }
172
- },
173
- })
174
- }
175
-
176
- /** ENOENT alone is insufficient through a symlink or non-directory ancestor.
177
- * Walk every surviving component without following links; all ambiguity refuses.
178
- * @param {string} resourceId */
179
- function missingTarget(resourceId) {
180
- try { lstatSync(resourceId); return false } catch (error) {
181
- if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error
182
- }
183
- for (let parent = dirname(resourceId); ; parent = dirname(parent)) {
184
- try {
185
- if (!lstatSync(parent).isDirectory()) return false
186
- } catch (error) {
187
- if (/** @type {any} */ (error)?.code !== 'ENOENT' || parent === dirname(parent)) throw error
188
- }
189
- if (parent === dirname(parent)) return true
190
- }
191
- }
@@ -1,101 +0,0 @@
1
- // Read-only authority snapshot bytes: the one bounded, symlink-pinned,
2
- // identity-checked read shared by the maintenance inspector and the cold
3
- // status view (design D1). No runtime open, no reservation, no mutation —
4
- // the file, the reservation and every history are left byte-identical.
5
- import { lstatSync, openSync, closeSync, fstatSync, readSync, constants } from 'node:fs'
6
- import { join, resolve, parse, relative, sep } from 'node:path'
7
-
8
- /** Same ceiling the maintenance inspector enforces (16 MiB). */
9
- export const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
10
-
11
- // Pin every component, including the trusted root's ancestors. Node exposes no
12
- // portable openat: compare identities before/after opening and before/after read.
13
- // NOFOLLOW protects the leaf; NONBLOCK prevents swapped FIFO/device hangs.
14
- // These checks fail closed on observed races, not an atomic hostile-rename proof.
15
- export function checkedPath(path) {
16
- const absolute = resolve(path)
17
- let current = parse(absolute).root
18
- const pins = []
19
- for (const part of relative(current, absolute).split(sep).filter(Boolean)) {
20
- current = join(current, part)
21
- const stat = lstatSync(current)
22
- if (stat.isSymbolicLink()) throw new Error('symlink authority component refused')
23
- pins.push({ path: current, stat })
24
- }
25
- return pins
26
- }
27
- /** @param {import('node:fs').Stats} a @param {import('node:fs').Stats} b */
28
- export function sameNode(a, b) { return a.dev === b.dev && a.ino === b.ino && a.mode === b.mode }
29
- /** @param {{ path: string, stat: import('node:fs').Stats }[]} pins */
30
- export function verifyPins(pins) {
31
- for (const pin of pins) if (!sameNode(pin.stat, lstatSync(pin.path))) throw new Error('authority path changed during inspection')
32
- }
33
- /** @param {unknown} error */
34
- export function missing(error) { return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' }
35
- /** @param {unknown} error */
36
- export function errorMessage(error) { return error instanceof Error ? error.message : String(error) }
37
-
38
- /**
39
- * @typedef {{ presence: 'valid', bytes: Buffer }
40
- * | { presence: 'no-authority' }
41
- * | { presence: 'no-snapshot' }
42
- * | { presence: 'not-a-file' }
43
- * | { presence: 'unreadable', message: string }} SnapshotRead
44
- */
45
-
46
- /**
47
- * Read `<authorityDir>/snapshot.json` with the full read-only discipline:
48
- * ancestor pins, O_NOFOLLOW leaf, bounded read, before/after identity checks.
49
- * The bytes are validated nowhere here — the caller runs `parseSnapshot`.
50
- *
51
- * Presence facts, never inferred: 'valid' (bytes returned), 'no-authority'
52
- * (a path component or the directory itself is absent), 'no-snapshot' (the
53
- * directory stands but no snapshot leaf exists; history indeterminate),
54
- * 'not-a-file', 'unreadable' (IO failure or an observed race, message kept).
55
- * @param {string} authorityDir @returns {SnapshotRead}
56
- */
57
- export function readSnapshotBytes(authorityDir) {
58
- let parents
59
- try {
60
- parents = checkedPath(authorityDir)
61
- } catch (error) {
62
- if (missing(error)) return { presence: 'no-authority' }
63
- return { presence: 'unreadable', message: errorMessage(error) }
64
- }
65
- let fd
66
- try {
67
- const path = join(authorityDir, 'snapshot.json')
68
- let leaf
69
- try { leaf = lstatSync(path) } catch (error) { if (!missing(error)) throw error }
70
- if (!leaf) {
71
- verifyPins(parents)
72
- return { presence: 'no-snapshot' }
73
- }
74
- if (!leaf.isFile() || leaf.isSymbolicLink()) return { presence: 'not-a-file' }
75
- // Windows exposes no O_NOFOLLOW/O_NONBLOCK (constants undefined): the leaf
76
- // lstat above already refused a symlink and the post-open fstat identity
77
- // check below bounds the swap window (symlink creation needs privilege on
78
- // Windows; the residual TOCTOU gap is documented in edit-lock.md).
79
- fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
80
- const opened = fstatSync(fd)
81
- if (!opened.isFile() || !sameNode(leaf, opened)) throw new Error('opened snapshot identity changed')
82
- verifyPins(parents)
83
- if (!sameNode(opened, lstatSync(path))) throw new Error('snapshot replaced before read')
84
- if (opened.size > MAX_SNAPSHOT_BYTES) throw new Error('snapshot exceeds inspection byte limit')
85
- const buffer = Buffer.alloc(Math.min(opened.size + 1, MAX_SNAPSHOT_BYTES + 1))
86
- let length = 0
87
- while (length < buffer.length) {
88
- const count = readSync(fd, buffer, length, buffer.length - length, length)
89
- if (!count) break
90
- length += count
91
- }
92
- const after = fstatSync(fd)
93
- verifyPins(parents)
94
- if (!sameNode(opened, lstatSync(path)) || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || length !== opened.size) {
95
- throw new Error('snapshot changed during bounded read')
96
- }
97
- return { presence: 'valid', bytes: buffer.subarray(0, length) }
98
- } catch (error) {
99
- return { presence: 'unreadable', message: errorMessage(error) }
100
- } finally { if (fd !== undefined) closeSync(fd) }
101
- }
@@ -1,139 +0,0 @@
1
- /** Cleanup-only automatic recovery for Edit Lock (design D4).
2
- *
3
- * Classification uses only the durable turn/end reason. A provider error that
4
- * ends a turn while the session holds locks marks them abnormal; the session
5
- * may then only release, answer transfer requests or pause. Attempts, elapsed
6
- * recovery time and pause time are charged durably and never refunded by a
7
- * restart. Expiry re-evaluates; it never releases or extends. Exhaustion hands
8
- * the locks to a human. Business authority returns only via /edit-lock resume.
9
- */
10
- export const RECOVERY_LIMITS = Object.freeze({
11
- attempts: 3,
12
- elapsedMs: 5 * 60_000,
13
- backoffMs: Object.freeze([15_000, 30_000, 60_000]),
14
- singlePauseMs: 15 * 60_000,
15
- cumulativePauseMs: 30 * 60_000,
16
- })
17
-
18
- /** @param {{attempts: number, elapsedMs: number}} usage @param {number} pendingElapsedMs */
19
- export function recoveryBudgetLeft(usage, pendingElapsedMs = 0) {
20
- return usage.attempts < RECOVERY_LIMITS.attempts && usage.elapsedMs + pendingElapsedMs < RECOVERY_LIMITS.elapsedMs
21
- }
22
-
23
- /** @param {{resourceId: string, status: string, reason?: string}[]} locks @param {number} attempt */
24
- export function cleanupPrompt(locks, attempt) {
25
- const lines = locks.filter(lock => lock.status === 'abnormal').map(lock => `- ${lock.resourceId} (${lock.reason ?? 'abnormal'})`)
26
- return [
27
- `Edit Lock cleanup-only recovery (attempt ${attempt} of ${RECOVERY_LIMITS.attempts}). The previous turn failed while this session held file ownership, so these locks are now abnormal:`,
28
- ...lines,
29
- 'Business edits and new ownership are denied in this state. You may only: release a lock you no longer need (edit_lock_release), answer a pending ownership request (edit_lock_reply), or request a bounded pause while an external problem clears (edit_lock_pause). Do not attempt other file changes.',
30
- 'When cleanup is done, stop. The user resumes normal editing from the Edit Lock panel.',
31
- ].join('\n')
32
- }
33
-
34
- /** @param {{
35
- * domainFor: (agent: object) => Promise<any> | undefined,
36
- * followup: (agent: object, text: string) => void,
37
- * notify: (agent: object, text: string) => void,
38
- * now?: () => number,
39
- * setTimer?: (fn: () => void, ms: number) => any, clearTimer?: (handle: any) => void,
40
- * warn?: (message: string) => void,
41
- * }} options */
42
- export function createRecoveryDriver(options) {
43
- const now = options.now ?? Date.now
44
- const setTimer = options.setTimer ?? ((fn, ms) => { const handle = setTimeout(fn, ms); handle.unref?.(); return handle })
45
- const clearTimer = options.clearTimer ?? (handle => clearTimeout(handle))
46
- /** @typedef {{since: number, timer?: any, pausedUntil?: number, done?: boolean}} Recovery */
47
- /** @type {WeakMap<object, Recovery>} */
48
- const active = new WeakMap()
49
- let closed = false
50
-
51
- /** Charge time spent recovering since the last mark (pauses excluded).
52
- * @param {any} domain @param {object} agent @param {Recovery} recovery @param {{attempts?: number}} [extra] */
53
- async function chargeElapsed(domain, agent, recovery, extra = {}) {
54
- const elapsedMs = Math.max(0, now() - recovery.since)
55
- recovery.since = now()
56
- return domain.chargeRecovery(agent, { ...extra, elapsedMs })
57
- }
58
- /** @param {object} agent @param {Recovery} recovery */
59
- function cancel(agent, recovery) {
60
- if (recovery.timer) clearTimer(recovery.timer)
61
- recovery.timer = undefined
62
- recovery.done = true
63
- active.delete(agent)
64
- }
65
- /** @param {object} agent @param {Recovery} recovery @param {number} delayMs */
66
- function schedule(agent, recovery, delayMs) {
67
- if (recovery.timer) clearTimer(recovery.timer)
68
- recovery.timer = setTimer(() => { recovery.timer = undefined; void attempt(agent, recovery) }, delayMs)
69
- }
70
- /** @param {object} agent @param {Recovery} recovery */
71
- async function attempt(agent, recovery) {
72
- if (closed || recovery.done || active.get(agent) !== recovery) return
73
- try {
74
- const domain = await options.domainFor(agent)
75
- const status = await domain.status(agent)
76
- if (status.state !== 'recovering') { cancel(agent, recovery); return }
77
- if (!status.locks.some(/** @param {any} lock */ lock => lock.status === 'abnormal')) {
78
- cancel(agent, recovery)
79
- options.notify(agent, 'Edit Lock cleanup complete: no abnormal locks remain. Editing stays paused until the user chooses Continue editing.')
80
- return
81
- }
82
- const usage = await domain.recoveryUsage(agent)
83
- if (!recoveryBudgetLeft(usage, now() - recovery.since)) {
84
- await chargeElapsed(domain, agent, recovery)
85
- cancel(agent, recovery)
86
- options.notify(agent, `Edit Lock automatic cleanup stopped after ${RECOVERY_LIMITS.attempts} attempts or ${RECOVERY_LIMITS.elapsedMs / 60_000} minutes. The remaining files stay locked for the user to release or unlock from the Edit Lock panel.`)
87
- return
88
- }
89
- const charged = await chargeElapsed(domain, agent, recovery, { attempts: 1 })
90
- options.followup(agent, cleanupPrompt(status.locks, charged.attempts))
91
- } catch (error) {
92
- options.warn?.(`edit lock recovery attempt failed: ${/** @type {any} */ (error)?.message ?? error}`)
93
- }
94
- }
95
-
96
- return Object.freeze({
97
- /** Durable turn/end feed for one agent. @param {object} agent @param {any} reason */
98
- async onTurnEnd(agent, reason) {
99
- if (closed) return
100
- const domain = await options.domainFor(agent)
101
- if (!domain) return
102
- let recovery = active.get(agent)
103
- if (reason?.kind === 'aborted') { if (recovery) cancel(agent, recovery); return }
104
- if (!recovery) {
105
- if (reason?.kind !== 'error') return
106
- const marked = await domain.classifyAbnormal(agent, 'provider-error')
107
- if (!marked.length) return
108
- recovery = { since: now() }
109
- active.set(agent, recovery)
110
- options.notify(agent, `Edit Lock: the turn failed while this session held files; ${marked.length} lock(s) are now abnormal and editing is limited to cleanup.`)
111
- }
112
- if (recovery.pausedUntil && recovery.pausedUntil > now()) return
113
- const usage = await domain.recoveryUsage(agent)
114
- schedule(agent, recovery, RECOVERY_LIMITS.backoffMs[Math.min(usage.attempts, RECOVERY_LIMITS.backoffMs.length - 1)])
115
- },
116
- /** Pause request from the cleanup turn; expiry re-evaluates.
117
- * @param {object} agent @param {number} minutes */
118
- async pause(agent, minutes) {
119
- const recovery = active.get(agent)
120
- if (!recovery) throw new Error('no automatic recovery is in progress for this session')
121
- const domain = await options.domainFor(agent)
122
- const result = await domain.pause(agent, minutes)
123
- await chargeElapsed(domain, agent, recovery)
124
- recovery.pausedUntil = now() + result.pausedMs
125
- if (recovery.timer) clearTimer(recovery.timer)
126
- // Elapsed recovery time restarts after the pause, not during it.
127
- recovery.timer = setTimer(() => {
128
- recovery.timer = undefined
129
- recovery.since = now()
130
- recovery.pausedUntil = undefined
131
- void attempt(agent, recovery)
132
- }, result.pausedMs)
133
- return result
134
- },
135
- /** @param {object} agent */
136
- state(agent) { const recovery = active.get(agent); return recovery ? { pausedUntil: recovery.pausedUntil ?? null } : null },
137
- close() { closed = true },
138
- })
139
- }