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,144 +0,0 @@
1
- /** Turn-end settling of still-held locks (design D2/D3).
2
- *
3
- * A finished turn that leaves locks unreleased is not an error, it is unfinished
4
- * business: the model decides whether each file is really done with. So the
5
- * session is continued once with a notice that asks for exactly one of two
6
- * things — release the lock, or ask explicitly to keep it for a bounded period.
7
- *
8
- * Only the durable `completed` reason enters this path. A provider error keeps its
9
- * cleanup-only recovery and a user stop keeps its latch: neither is nudged, and
10
- * classification still reads nothing but the turn/end reason (AGENTS §3.5).
11
- *
12
- * The notice count is bounded and configurable, and the disposition after it runs
13
- * out is configurable too (`release` or `abnormal`). Both are policy supplied by
14
- * the caller, so this module holds no configuration and no clock.
15
- */
16
-
17
- /** @param {{reason?: {kind?: string}}[]} locks @param {number} attempt @param {number} maxAttempts @param {{defaultMinutes: number, singleMaxMinutes: number, cumulativeMaxMinutes: number, remainingMinutes: number, unavailable?: boolean}} budget */
18
- export function settlementPrompt(locks, attempt, maxAttempts, budget) {
19
- const lines = locks.map(lock => `- ${lock.resourceId}`)
20
- // With the batch budget gone there is only one honest instruction left, so the
21
- // hold option is not mentioned at all rather than offered and then refused.
22
- const option = budget.unavailable
23
- ? 'Retention is unavailable because its settings conflict, so no reservation can be made: release each file (edit_lock_release).'
24
- : budget.remainingMinutes <= 0
25
- ? `This batch has used its whole retention budget (${budget.cumulativeMaxMinutes} minutes), so no new reservation is available: release each file (edit_lock_release).`
26
- : `For each file, either release it because you are done with it (edit_lock_release), or ask to keep ownership for a bounded time (edit_lock_hold, at most ${budget.singleMaxMinutes} minutes per request and ${budget.remainingMinutes} minutes left in this batch).`
27
- return [
28
- `Edit Lock: the turn ended while this session still held ${locks.length} file(s):`,
29
- ...lines,
30
- option,
31
- `Do not edit other files to work around this. A file you leave unresolved after ${maxAttempts} notice(s) is handled for you.`,
32
- `This is notice ${attempt} of ${maxAttempts}.`,
33
- ].join('\n')
34
- }
35
-
36
- /** @param {{
37
- * domainFor: (agent: object) => Promise<any> | undefined,
38
- * followup: (agent: object, text: string) => void,
39
- * notify: (agent: object, text: string) => void,
40
- * limits: () => {holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'},
41
- * now?: () => number,
42
- * onSettled?: (agent: object) => void,
43
- * warn?: (message: string) => void,
44
- * }} options */
45
- export function createSettlementDriver(options) {
46
- const now = options.now ?? Date.now
47
- let closed = false
48
- /** Notices already sent for the current batch of one agent. In-memory on
49
- * purpose: a restart restores the session as interrupted and takes the
50
- * cleanup route instead, so a lost counter cannot loop. @type {WeakMap<object, number>} */
51
- const notices = new WeakMap()
52
- /** @type {Set<Promise<unknown>>} */
53
- const running = new Set()
54
- /** Agents whose next turn is the settle follow-up itself. @type {WeakSet<object>} */
55
- const nudged = new WeakSet()
56
-
57
- /** The notice counter is keyed by the live agent object, so a non-object key can
58
- * never be remembered. Making that explicit turns a caller mistake into a clear
59
- * failure instead of a silent "nothing happened". @param {unknown} agent */
60
- function remember(agent) {
61
- if (!agent || (typeof agent !== 'object' && typeof agent !== 'function')) {
62
- throw new Error('settlement requires a live agent object')
63
- }
64
- }
65
- /** @param {object} agent */
66
- function forget(agent) {
67
- if (!agent || typeof agent !== 'object') return
68
- notices.delete(agent)
69
- nudged.delete(agent)
70
- }
71
-
72
- /** @param {object} agent @param {any} domain @param {any[]} locks */
73
- async function dispose(agent, domain, locks) {
74
- const limits = options.limits()
75
- if (limits.nudgeFallback === 'abnormal') {
76
- // The team asked for human handling: keep the locks, never silently free them.
77
- const marked = await domain.classifyAbnormal(agent, 'unsettled-after-notices')
78
- options.notify(agent, `Edit Lock: ${marked.length} file(s) stayed locked and are now flagged for the user to sort out in the Edit Lock panel.`)
79
- } else {
80
- const { released } = await domain.releaseHeld(agent)
81
- options.notify(agent, released.length
82
- ? `Edit Lock: ${released.length} file(s) stayed locked after ${limits.nudgeAttempts} notice(s), so they were released for other sessions.`
83
- : 'Edit Lock: no file is held any more.')
84
- }
85
- forget(agent)
86
- }
87
-
88
- /** @param {object} agent @param {any} domain */
89
- async function settle(agent, domain) {
90
- remember(agent)
91
- const status = await domain.status(agent)
92
- // Only ordinary ownership counts. Abnormal and interrupted locks have their
93
- // own paths, and a pending confirmation already requires a human.
94
- const held = status.locks.filter(lock => lock.status === 'active')
95
- if (held.length === 0) { forget(agent); return }
96
- const limits = options.limits()
97
- const retention = status.retention ?? { held: false, remainingMs: 0, holdCumulativeMs: 0 }
98
- // Already reserved and still inside its period: nothing to ask, and the
99
- // expiry timer owns what happens next.
100
- if (retention.held && retention.remainingMs > 0) return
101
- const attempts = notices.get(agent) ?? 0
102
- if (attempts >= limits.nudgeAttempts) { await dispose(agent, domain, held); return }
103
- notices.set(agent, attempts + 1)
104
- // The follow-up starts a turn of its own; that turn must not count as the
105
- // holder getting back to work, or the count would reset forever.
106
- nudged.add(agent)
107
- const remainingMinutes = Math.max(0, Math.floor((limits.holdCumulativeMaxMinutes * 60_000 - retention.holdCumulativeMs) / 60_000))
108
- options.followup(agent, settlementPrompt(held, attempts + 1, limits.nudgeAttempts, {
109
- defaultMinutes: limits.holdDefaultMinutes,
110
- singleMaxMinutes: limits.holdSingleMaxMinutes,
111
- cumulativeMaxMinutes: limits.holdCumulativeMaxMinutes,
112
- remainingMinutes,
113
- unavailable: limits.holdUnavailable === true,
114
- }))
115
- }
116
-
117
- return Object.freeze({
118
- /** Durable turn/end feed for one agent. @param {object} agent @param {any} reason */
119
- onTurnEnd(agent, reason) {
120
- if (closed || reason?.kind !== 'completed') return
121
- const domain = options.domainFor(agent)
122
- if (!domain) return
123
- const job = Promise.resolve(domain)
124
- .then(resolved => (resolved ? settle(agent, resolved) : undefined))
125
- .catch(error => options.warn?.(`edit lock settlement failed: ${error?.message ?? error}`))
126
- .finally(() => running.delete(job))
127
- running.add(job)
128
- },
129
- /** The holder started a new turn, so the batch is in use again: the notice
130
- * budget restarts. Without this a long session would run out of notices over
131
- * unrelated turns. @param {object} agent */
132
- turnStarted(agent) {
133
- if (agent && typeof agent === 'object' && nudged.delete(agent)) return
134
- forget(agent)
135
- },
136
- /** The expiry timer released the batch: nothing left to settle. @param {object} agent */
137
- settled(agent) { forget(agent) },
138
- /** @param {object} agent */
139
- noticeCount(agent) { return notices.get(agent) ?? 0 },
140
- /** Host shutdown waits for in-flight settlement instead of dropping it. */
141
- async drain() { await Promise.allSettled([...running]) },
142
- close() { closed = true },
143
- })
144
- }
@@ -1,175 +0,0 @@
1
- // Pure durable-image validation shared by the authority store and the
2
- // read-only maintenance inspector. Everything here is a function of its
3
- // arguments: no filesystem access, no clock, no ctx. `parseSnapshot` performs
4
- // EXACTLY the checks the store's recover path always performed (same order,
5
- // same messages) so an inspection verdict can never diverge from what a real
6
- // recover would accept — corruption is reported, never repaired.
7
- import { createHash } from 'node:crypto'
8
- import { validateOperations } from './operation-history.js'
9
- import { validateAdminLedger } from './admin-ledger.js'
10
- import { validateIncarnations } from './incarnations.js'
11
-
12
- /**
13
- * @typedef {import('./store.js').AuthorityImage} AuthorityImage
14
- * @typedef {{ revision: number, state: AuthorityImage }} Snapshot
15
- */
16
-
17
- /** @param {unknown} condition @param {string} message @returns {asserts condition} */
18
- function valid(condition, message) {
19
- if (!condition) throw new Error(`invalid ${message}`)
20
- }
21
- /** @param {unknown} value @returns {value is string} */
22
- function id(value) { return typeof value === 'string' && value.length > 0 }
23
- /** @param {unknown} value @param {number} [minimum] @returns {value is number} */
24
- function integer(value, minimum = 0) { return typeof value === 'number' && Number.isSafeInteger(value) && !Object.is(value, -0) && value >= minimum }
25
- /** @param {unknown} value @param {string[]} keys @returns {asserts value is Record<string, unknown>} */
26
- function shape(value, keys) {
27
- valid(value !== null && typeof value === 'object' && !Array.isArray(value), 'object')
28
- valid(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null, 'object prototype')
29
- valid(Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)), 'schema keys')
30
- for (const key of keys) {
31
- const descriptor = Object.getOwnPropertyDescriptor(value, key)
32
- valid(descriptor && 'value' in descriptor && descriptor.enumerable, 'data property')
33
- }
34
- }
35
-
36
- /** Deterministic JSON: UTF-16 key order, JSON scalar spelling, no whitespace.
37
- * Array order is retained (including the historical image's ordered collections).
38
- * @param {unknown} value @returns {string} */
39
- export function canonical(value) {
40
- if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
41
- if (value !== null && typeof value === 'object') {
42
- const object = /** @type {Record<string, unknown>} */ (value)
43
- return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonical(object[key])}`).join(',')}}`
44
- }
45
- return JSON.stringify(value)
46
- }
47
-
48
- /** Lossless v2 -> v3 upgrade: add one empty retention row per known session.
49
- * @param {any} state @returns {any} */
50
- export function upgradeFromV2(state) {
51
- valid(state && typeof state === 'object' && !Array.isArray(state) && !Object.hasOwn(state, 'holds') && state.version === 2, 'v2 image')
52
- valid(Array.isArray(state.sessions), 'v2 sessions')
53
- const holds = state.sessions.map((/** @type {any} */ session) => ({ sessionId: session?.sessionId, holding: false, holdUntil: null, holdCumulativeMs: 0 }))
54
- const { version: _v, operations, ...rest } = state
55
- return { ...rest, version: 3, holds, operations }
56
- }
57
-
58
- /** Lossless v3 -> v4: historical assertions remain inert, never upgraded to proof.
59
- * @param {any} state @returns {AuthorityImage} */
60
- export function upgradeFromV3(state) {
61
- valid(state?.version === 3 && Array.isArray(state.operations), 'v3 image')
62
- for (const operation of state.operations) {
63
- valid(Array.isArray(operation.closeouts), 'v3 closeouts')
64
- for (const closeout of operation.closeouts) shape(closeout, ['kind', 'assertionId'])
65
- }
66
- return { ...state, version: 4 }
67
- }
68
-
69
- /** @param {AuthorityImage} state */
70
- export function validateImage(state) {
71
- shape(state, ['version', 'managerIncarnation', 'sessions', 'generations', 'locks', 'issuedRequests', 'recovery', 'holds', 'operations', ...(state.version === 5 ? ['adminRecoveries'] : []), ...(state.version === 6 ? ['adminRecoveries', 'incarnations'] : [])])
72
- valid(state.version === 4 || state.version === 5 || state.version === 6, `image version ${state.version}; expected 4, 5 or 6`)
73
- valid(state.managerIncarnation === null || id(state.managerIncarnation), 'incarnation')
74
- for (const collection of [state.sessions, state.generations, state.locks, state.issuedRequests, state.recovery, state.holds]) {
75
- valid(Array.isArray(collection) && Object.getPrototypeOf(collection) === Array.prototype, 'collection')
76
- valid(Reflect.ownKeys(collection).length === collection.length + 1, 'dense collection keys')
77
- for (let i = 0; i < collection.length; i++) {
78
- const descriptor = Object.getOwnPropertyDescriptor(collection, i)
79
- valid(descriptor && 'value' in descriptor && descriptor.enumerable, 'collection data property')
80
- }
81
- }
82
- valid(state.managerIncarnation !== null || [state.sessions, state.generations, state.locks, state.issuedRequests, state.recovery, state.holds, state.operations].every(list => list.length === 0), 'null incarnation with history')
83
- const sessions = new Map(state.sessions.map(session => {
84
- shape(session, ['sessionId', 'executionEpoch', 'interrupted'])
85
- valid(id(session.sessionId) && integer(session.executionEpoch, 1) && typeof session.interrupted === 'boolean', 'session')
86
- return [session.sessionId, session]
87
- }))
88
- valid(sessions.size === state.sessions.length, 'duplicate session')
89
- // Retention is a session-level budget (design D1): one row per session, only for
90
- // sessions the image knows, and the cumulative allowance is a lifetime charge
91
- // that is never refunded.
92
- const holds = new Map()
93
- for (const held of state.holds) {
94
- shape(held, ['sessionId', 'holding', 'holdUntil', 'holdCumulativeMs'])
95
- valid(sessions.has(held.sessionId) && !holds.has(held.sessionId), 'hold reference')
96
- valid(typeof held.holding === 'boolean' && integer(held.holdCumulativeMs), 'hold flags')
97
- valid(held.holdUntil === null || integer(held.holdUntil, 1), 'hold expiry')
98
- valid(held.holding ? held.holdUntil !== null : held.holdUntil === null, 'hold expiry coherence')
99
- holds.set(held.sessionId, held)
100
- }
101
- for (const session of state.sessions) valid(holds.has(session.sessionId), 'missing hold row')
102
- const generations = new Map(state.generations.map(entry => {
103
- shape(entry, ['resourceId', 'generation'])
104
- valid(id(entry.resourceId) && integer(entry.generation, 1), 'generation')
105
- return [entry.resourceId, entry.generation]
106
- }))
107
- valid(generations.size === state.generations.length, 'duplicate generation')
108
- // A session whose last lock is gone has no batch left: the row stays (one row per
109
- // session) but must be back at zero, so a stale allowance cannot survive a batch.
110
- for (const held of state.holds) {
111
- if (state.locks.some(lock => lock.owner === held.sessionId)) continue
112
- valid(!held.holding && held.holdUntil === null && held.holdCumulativeMs === 0, 'hold without a lock')
113
- }
114
- const resources = new Set()
115
- for (const lock of state.locks) {
116
- shape(lock, lock.status === 'abnormal' ? ['resourceId', 'owner', 'generation', 'status', 'reason'] : ['resourceId', 'owner', 'generation', 'status'])
117
- valid(id(lock.resourceId) && id(lock.owner) && integer(lock.generation, 1), 'lock')
118
- const session = sessions.get(lock.owner)
119
- valid(session && generations.get(lock.resourceId) === lock.generation && !resources.has(lock.resourceId), 'lock reference')
120
- resources.add(lock.resourceId)
121
- valid(['active', 'user-interrupted', 'pending-confirmation', 'abnormal'].includes(lock.status), 'lock status')
122
- if (lock.status === 'abnormal') valid(id(lock.reason), 'abnormal reason')
123
- else valid(session.interrupted === (lock.status === 'user-interrupted'), 'lock interruption')
124
- }
125
- const requests = new Set()
126
- for (const request of state.issuedRequests) {
127
- shape(request, ['sessionId', 'requestId'])
128
- valid(sessions.has(request.sessionId) && id(request.requestId), 'issued request')
129
- const key = canonical([request.sessionId, request.requestId])
130
- valid(!requests.has(key), 'duplicate request')
131
- requests.add(key)
132
- }
133
- const recoveries = new Set()
134
- for (const recovery of state.recovery) {
135
- shape(recovery, ['sessionId', 'attempts', 'elapsedMs', 'pauseMs'])
136
- valid(sessions.has(recovery.sessionId) && !recoveries.has(recovery.sessionId), 'recovery reference')
137
- valid(integer(recovery.attempts) && integer(recovery.elapsedMs) && integer(recovery.pauseMs), 'recovery counters')
138
- recoveries.add(recovery.sessionId)
139
- }
140
- validateAdminLedger(state)
141
- validateOperations(state)
142
- if (state.version === 6) validateIncarnations(state)
143
- }
144
-
145
- /**
146
- * Parse and validate one committed snapshot's raw bytes for `domainId`.
147
- * Byte-for-byte the store recover path's validation, in the same order with
148
- * the same refusal messages; the caller supplies the bytes, so a read-only
149
- * inspection runs no store, no recovery and no reservation. Throws on any
150
- * deviation — the caller decides how corruption is surfaced; nothing here
151
- * repairs, rewrites or upgrades the file on disk.
152
- * @param {Buffer} bytes @param {string} domainId @returns {Snapshot}
153
- */
154
- export function parseSnapshot(bytes, domainId) {
155
- const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
156
- const envelope = JSON.parse(text)
157
- // Exact byte equality rejects duplicate keys at every depth as well as
158
- // alternate number/escape spellings, whitespace and reordered keys.
159
- valid(Buffer.from(canonical(envelope)).equals(bytes), 'noncanonical snapshot')
160
- shape(envelope, ['payload', 'checksum'])
161
- const payload = envelope.payload
162
- shape(payload, ['version', 'domainId', 'revision', 'state'])
163
- valid(typeof payload.version === 'number' && [2, 3, 4, 5, 6].includes(payload.version), `snapshot version ${payload.version}; supported: 2, 3, 4, 5, 6; recover with a newer build or restore a pre-upgrade snapshot`)
164
- valid(payload.domainId === domainId, 'snapshot domain')
165
- valid(payload.state !== null && typeof payload.state === 'object' && 'version' in payload.state && payload.state.version === payload.version, 'snapshot/image version mismatch')
166
- valid(integer(payload.revision), 'snapshot revision')
167
- valid(typeof envelope.checksum === 'string' && envelope.checksum === createHash('sha256').update(canonical(payload)).digest('hex'), 'snapshot checksum')
168
- // Verify legacy closeout shapes before upgrading: new semantics must never
169
- // be smuggled into an old image. Integrity above is against the original bytes.
170
- const legacy = payload.version === 2 ? upgradeFromV2(payload.state) : payload.state
171
- const state = /** @type {AuthorityImage} */ (payload.version < 4 ? upgradeFromV3(legacy) : legacy)
172
- validateImage(state)
173
- for (const row of state.adminRecoveries ?? []) valid(row.root === domainId && row.committedRevision <= payload.revision, 'administrative domain/revision')
174
- return { revision: payload.revision, state }
175
- }
@@ -1,120 +0,0 @@
1
- // Message-triggered stale-lock sweep: the missing-target classifier and the
2
- // per-domain detached scheduler. Plain ESM, no ctx, no @deepseek-ai imports;
3
- // the scheduler's job, clock and timer are injected so the composition and
4
- // the unit tests share one core.
5
- import { lstatSync } from 'node:fs'
6
- import { dirname } from 'node:path'
7
-
8
- /** Missing-target classification for one stored canonical resource id. ONLY
9
- * ENOENT counts: a successful lstat — including of a dangling symlink — is
10
- * not missing, and any other error (EACCES, ENOTDIR, symlink loops) skips the
11
- * row. Never follows a moved file, never resolves aliases into a replacement
12
- * identity, never fabricates a resource key for a missing path.
13
- * @param {string} resourceId */
14
- export function isMissingTarget(resourceId) {
15
- if (typeof resourceId !== 'string' || resourceId.length === 0) return false
16
- try {
17
- lstatSync(resourceId)
18
- return false
19
- } catch (error) {
20
- const code = /** @type {any} */ (error)?.code
21
- if (code !== 'ENOENT') return false
22
- // Windows reports a path through a regular-file component as ENOENT as
23
- // well (there is no ENOTDIR): preserve the conservative POSIX
24
- // classification by walking up — a non-directory ancestor means the path
25
- // is BLOCKED (skip the row), not missing.
26
- if (process.platform === 'win32') {
27
- for (let parent = dirname(resourceId); parent !== dirname(parent); parent = dirname(parent)) {
28
- let stat
29
- try { stat = lstatSync(parent) } catch (ancestorError) {
30
- if (/** @type {any} */ (ancestorError)?.code === 'ENOENT') continue
31
- return false
32
- }
33
- return stat.isDirectory()
34
- }
35
- }
36
- return true
37
- }
38
- }
39
-
40
- /** Cooldown between two sweeps of one management domain. */
41
- export const STALE_SWEEP_COOLDOWN_MS = 60_000
42
-
43
- /** Per-key detached sweep scheduler. At most one in-flight sweep and one
44
- * cooldown window per key (the management-domain ROOT, not a session), so
45
- * message volume across sessions never scales the aggregate scan cost. Idle
46
- * entries whose cooldown window expired are RECLAIMED — on each trigger pass
47
- * and when a job completes — and close() empties the registry, so the map
48
- * never grows unboundedly with the number of domains a process has touched.
49
- *
50
- * Dispatch happens on a zero-delay timer and is NEVER awaited by the caller:
51
- * the turn proceeds immediately (unlike auto-resume, which must precede the
52
- * first step). close() cancels undispatched work; an already-running job
53
- * always settles on its own — an already-submitted authority transaction is
54
- * never cancelled or reinterpreted. A failed job warns once through the
55
- * injected sink and still opens the cooldown window.
56
- * @param {{cooldownMs?: number, now?: () => number,
57
- * setTimer?: (fn: () => void) => unknown, clearTimer?: (handle: unknown) => void,
58
- * warn?: (message: string) => void}} [options] */
59
- export function createStaleSweepScheduler({ cooldownMs = STALE_SWEEP_COOLDOWN_MS, now = () => Date.now(), setTimer, clearTimer, warn = () => {} } = {}) {
60
- const arm = setTimer ?? ((fn) => globalThis.setTimeout(fn, 0))
61
- const disarm = clearTimer ?? ((handle) => globalThis.clearTimeout(handle))
62
- /** @type {Map<string, {inFlight: Promise<unknown> | null, coolingUntil: number, handle: unknown}>} */
63
- const entries = new Map()
64
- let closed = false
65
- /** Reclaim entries that are neither dispatched nor in flight and whose
66
- * cooldown window expired. @param {unknown} [except] */
67
- function prune(except = undefined) {
68
- const at = now()
69
- for (const [key, entry] of entries) {
70
- if (entry === except) continue
71
- if (entry.inFlight === null && entry.handle === null && at >= entry.coolingUntil) entries.delete(key)
72
- }
73
- }
74
- return Object.freeze({
75
- /** Arm one sweep for key. Returns false when the key is closed, one is
76
- * already dispatched or in flight, or the cooldown window is still open.
77
- * @param {string} key @param {() => unknown} job */
78
- trigger(key, job) {
79
- if (closed || typeof key !== 'string' || key.length === 0 || typeof job !== 'function') return false
80
- prune()
81
- let entry = entries.get(key)
82
- if (!entry) {
83
- entry = { inFlight: null, coolingUntil: 0, handle: null }
84
- entries.set(key, entry)
85
- }
86
- if (entry.inFlight !== null || entry.handle !== null || now() < entry.coolingUntil) return false
87
- entry.handle = arm(() => {
88
- entry.handle = null
89
- if (closed) return
90
- const pending = Promise.resolve()
91
- .then(job)
92
- .then(
93
- () => {},
94
- (error) => warn(`edit lock stale sweep failed: ${/** @type {any} */ (error)?.message ?? error}`),
95
- )
96
- .then(() => {
97
- entry.inFlight = null
98
- entry.coolingUntil = now() + cooldownMs
99
- // Completion is the other reclamation point: peers whose windows
100
- // lapsed while this job ran are dropped here.
101
- prune(entry)
102
- })
103
- entry.inFlight = pending
104
- })
105
- return true
106
- },
107
- /** Entry count, for diagnostics and tests. */
108
- get size() { return entries.size },
109
- /** Cancel every undispatched sweep and forget every key. In-flight jobs
110
- * settle untouched. */
111
- close() {
112
- closed = true
113
- for (const entry of entries.values()) {
114
- if (entry.handle !== null) disarm(entry.handle)
115
- entry.handle = null
116
- }
117
- entries.clear()
118
- },
119
- })
120
- }