dsh-tower 2.1.0-beta.1 → 2.3.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 (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
@@ -1,349 +0,0 @@
1
- // Shared durable-persistence contract (#56 P2b).
2
- //
3
- // state.json (lib/store.js) and modes.json (lib/modeStore.js) both persist a
4
- // whole JSON document behind a token-owned writer lock. They must agree on:
5
- //
6
- // - atomic replacement: serialise completely, write a temp file, rename;
7
- // - dead-PID reclamation: a provably dead owner is moved to a quarantine
8
- // name, verified, and only then removed; a live/foreign lock is never
9
- // stolen or deleted;
10
- // - bounded waiting: a held lock is reported after a timeout, not stolen.
11
- //
12
- // Keeping the primitives here means a protocol fix applies to both contracts
13
- // at once. This module is the only lib/ file besides store.js that talks to
14
- // the fs adapter; it never imports node:fs directly (#50).
15
- import { fs, fsp } from '../adapters/store-fs.js'
16
- import path from 'node:path'
17
- import crypto from 'node:crypto'
18
- import { TowerToolError } from '../domain/errors.js'
19
-
20
- export const DEFAULT_LOCK_TIMEOUT_MS = 5000
21
- export const DEFAULT_LOCK_POLL_MS = 25
22
-
23
- const RENAME_ATTEMPTS = 12
24
- const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
25
- const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
26
-
27
- export function sleep(ms) {
28
- return new Promise((resolve) => setTimeout(resolve, ms))
29
- }
30
-
31
- function sleepSync(ms) {
32
- try {
33
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
34
- } catch {
35
- // best effort; a busy loop would be worse
36
- }
37
- }
38
-
39
- // ---------------------------------------------------------------------------
40
- // JSON document I/O
41
- // ---------------------------------------------------------------------------
42
-
43
- function parseJson(file, text) {
44
- try {
45
- return JSON.parse(text)
46
- } catch (error) {
47
- throw new TowerToolError('state-corrupt', `corrupt tower state at ${file}: ${error.message}`)
48
- }
49
- }
50
-
51
- export async function readJson(file) {
52
- return parseJson(file, await fsp.readFile(file, 'utf8'))
53
- }
54
-
55
- /** Synchronous counterpart for the mode store's short critical section. */
56
- export function readJsonSync(file) {
57
- return parseJson(file, fs.readFileSync(file, 'utf8'))
58
- }
59
-
60
- export async function writeJsonAtomic(file, value) {
61
- const dir = path.dirname(file)
62
- const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
63
- const handle = await fsp.open(tmp, 'w')
64
- try {
65
- await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
66
- await handle.sync()
67
- } finally {
68
- await handle.close()
69
- }
70
- await renameWithRetry(tmp, file)
71
- }
72
-
73
- /**
74
- * Windows can reject a rename while an indexer, antivirus, or a concurrent
75
- * reader still holds the destination; retry briefly instead of failing a
76
- * successful mutation. The temp file is removed when the rename never lands.
77
- */
78
- export async function renameWithRetry(tmp, file) {
79
- for (let attempt = 0; ; attempt += 1) {
80
- try {
81
- await fsp.rename(tmp, file)
82
- return
83
- } catch (error) {
84
- if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
85
- await fsp.unlink(tmp).catch(() => {})
86
- throw error
87
- }
88
- await sleep(10 + attempt * 15)
89
- }
90
- }
91
- }
92
-
93
- /** Synchronous counterpart used by the mode store's short critical section. */
94
- export function writeJsonAtomicSync(file, value) {
95
- const dir = path.dirname(file)
96
- const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
97
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
98
- for (let attempt = 0; ; attempt += 1) {
99
- try {
100
- fs.renameSync(tmp, file)
101
- return
102
- } catch (error) {
103
- if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
104
- try {
105
- fs.unlinkSync(tmp)
106
- } catch {
107
- // best effort
108
- }
109
- throw error
110
- }
111
- sleepSync(10 + attempt * 15)
112
- }
113
- }
114
- }
115
-
116
- export async function appendJsonl(file, value) {
117
- await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
118
- }
119
-
120
- // ---------------------------------------------------------------------------
121
- // Lock records
122
- // ---------------------------------------------------------------------------
123
-
124
- /**
125
- * true = process exists, false = provably gone, null = the record carries no
126
- * usable PID. The tri-state matters: a malformed state lock must be reported,
127
- * not silently reclaimed as if its owner were dead.
128
- */
129
- export function processAlive(pid) {
130
- const value = Number(pid)
131
- if (!Number.isInteger(value) || value <= 0) return null
132
- try {
133
- process.kill(value, 0)
134
- return true
135
- } catch (error) {
136
- if (error?.code === 'ESRCH') return false
137
- // EPERM means the process exists but belongs to another user.
138
- return true
139
- }
140
- }
141
-
142
- export function lockRecord(token, extra = {}) {
143
- return { token, pid: process.pid, ...extra, at: Date.now() }
144
- }
145
-
146
- function isDeadLock(record, options) {
147
- const alive = processAlive(record?.pid)
148
- if (alive === false) return true
149
- // The mode store historically treated a lock without a usable PID as dead;
150
- // preserve that where callers opt in, while the state lock stays defensive.
151
- return alive === null && options.invalidPidIsDead === true
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Lock inspection / reclamation
156
- // ---------------------------------------------------------------------------
157
-
158
- export async function readLock(lockPath) {
159
- try {
160
- const raw = await fsp.readFile(lockPath, 'utf8')
161
- const record = JSON.parse(raw)
162
- return { path: lockPath, record, alive: processAlive(record?.pid), raw }
163
- } catch (error) {
164
- if (error?.code === 'ENOENT') return { path: lockPath, record: null, alive: null, raw: null }
165
- return { path: lockPath, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
166
- }
167
- }
168
-
169
- export function readLockSync(lockPath) {
170
- try {
171
- const raw = fs.readFileSync(lockPath, 'utf8')
172
- const record = JSON.parse(raw)
173
- return { path: lockPath, record, alive: processAlive(record?.pid), raw }
174
- } catch (error) {
175
- if (error?.code === 'ENOENT') return { path: lockPath, record: null, alive: null, raw: null }
176
- return { path: lockPath, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
177
- }
178
- }
179
-
180
- export async function reclaimDeadLock(lockPath, options = {}) {
181
- let original
182
- try {
183
- original = await fsp.readFile(lockPath, 'utf8')
184
- } catch {
185
- return false
186
- }
187
- let parsed
188
- try {
189
- parsed = JSON.parse(original)
190
- } catch {
191
- return false
192
- }
193
- if (!isDeadLock(parsed, options)) return false
194
- // Move, never delete: a concurrent writer may have replaced the record
195
- // between the read and the rename, so verify the quarantined bytes first.
196
- const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
197
- try {
198
- await fsp.rename(lockPath, quarantine)
199
- } catch {
200
- return false
201
- }
202
- const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
203
- if (moved === null || moved !== original) {
204
- if (moved !== null) await fsp.rename(quarantine, lockPath).catch(() => {})
205
- return false
206
- }
207
- await fsp.unlink(quarantine).catch(() => {})
208
- return true
209
- }
210
-
211
- export function reclaimDeadLockSync(lockPath, options = {}) {
212
- let original
213
- try {
214
- original = fs.readFileSync(lockPath, 'utf8')
215
- } catch {
216
- return false
217
- }
218
- let parsed
219
- try {
220
- parsed = JSON.parse(original)
221
- } catch {
222
- return false
223
- }
224
- if (!isDeadLock(parsed, options)) return false
225
- const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`
226
- try {
227
- fs.renameSync(lockPath, quarantine)
228
- } catch {
229
- return false
230
- }
231
- let moved = null
232
- try {
233
- moved = fs.readFileSync(quarantine, 'utf8')
234
- } catch {
235
- moved = null
236
- }
237
- if (moved === null || moved !== original) {
238
- if (moved !== null) {
239
- try {
240
- fs.renameSync(quarantine, lockPath)
241
- } catch {
242
- // the newer record could not be restored; never delete it silently
243
- }
244
- }
245
- return false
246
- }
247
- try {
248
- fs.unlinkSync(quarantine)
249
- } catch {
250
- // the lock is already reclaimed; a leftover quarantine is inert
251
- }
252
- return true
253
- }
254
-
255
- // ---------------------------------------------------------------------------
256
- // Lock acquire / token-checked release
257
- // ---------------------------------------------------------------------------
258
-
259
- function handleFor(lockPath, token, record) {
260
- return {
261
- path: lockPath,
262
- token,
263
- record,
264
- async release() {
265
- return releaseLock(lockPath, token)
266
- },
267
- }
268
- }
269
-
270
- function syncHandleFor(lockPath, token, record) {
271
- return {
272
- path: lockPath,
273
- token,
274
- record,
275
- release() {
276
- return releaseLockSync(lockPath, token)
277
- },
278
- }
279
- }
280
-
281
- /**
282
- * Acquire a token-owned lock with a bounded wait. Returns null when another
283
- * writer still holds a live lock at the deadline; callers translate that into
284
- * their own [tower:state-locked] message (or a false return for modes).
285
- */
286
- export async function acquireLock(lockPath, options = {}) {
287
- const token = options.token ?? crypto.randomUUID()
288
- const record = options.record ?? lockRecord(token)
289
- const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
290
- const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
291
- const deadline = Date.now() + Math.max(0, timeoutMs)
292
- for (;;) {
293
- try {
294
- await fsp.writeFile(lockPath, JSON.stringify(record), { flag: 'wx' })
295
- return handleFor(lockPath, token, record)
296
- } catch (error) {
297
- if (error?.code !== 'EEXIST') throw error
298
- if (await reclaimDeadLock(lockPath, options)) continue
299
- if (Date.now() > deadline) return null
300
- await sleep(pollMs)
301
- }
302
- }
303
- }
304
-
305
- export function acquireLockSync(lockPath, options = {}) {
306
- const token = options.token ?? crypto.randomUUID()
307
- const record = options.record ?? lockRecord(token)
308
- const timeoutMs = Number(options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
309
- const pollMs = Number(options.pollMs ?? DEFAULT_LOCK_POLL_MS)
310
- const deadline = Date.now() + Math.max(0, timeoutMs)
311
- for (;;) {
312
- try {
313
- fs.writeFileSync(lockPath, JSON.stringify(record), { flag: 'wx' })
314
- return syncHandleFor(lockPath, token, record)
315
- } catch (error) {
316
- if (error?.code !== 'EEXIST') throw error
317
- if (reclaimDeadLockSync(lockPath, options)) continue
318
- if (Date.now() > deadline) return null
319
- sleepSync(pollMs)
320
- }
321
- }
322
- }
323
-
324
- export async function releaseLock(lockPath, token) {
325
- try {
326
- const current = await fsp.readFile(lockPath, 'utf8').catch(() => null)
327
- if (current === null) return false
328
- const parsed = JSON.parse(current)
329
- if (!parsed || parsed.token !== token) return false
330
- await fsp.unlink(lockPath)
331
- return true
332
- } catch {
333
- // a foreign or unreadable lock is not ours to remove
334
- return false
335
- }
336
- }
337
-
338
- export function releaseLockSync(lockPath, token) {
339
- try {
340
- const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'))
341
- if (!parsed || parsed.token !== token) return false
342
- fs.unlinkSync(lockPath)
343
- return true
344
- } catch {
345
- return false
346
- }
347
- }
348
-
349
- export { PROCESS_STARTED_AT }
package/lib/store.js DELETED
@@ -1,118 +0,0 @@
1
- import { TowerToolError } from '../domain/errors.js'
2
- // Durable repo-scoped store: exclusive lock + atomic JSON + append-only activity.
3
- // The atomic/lock/JSON primitives are shared with the mode store (#56 P2b).
4
- import { fsp } from '../adapters/store-fs.js'
5
- import {
6
- PROCESS_STARTED_AT,
7
- acquireLock,
8
- lockRecord,
9
- readLock,
10
- } from './persistence.js'
11
- import path from 'node:path'
12
- import crypto from 'node:crypto'
13
-
14
- export {
15
- readJson,
16
- writeJsonAtomic,
17
- writeJsonAtomicSync,
18
- renameWithRetry,
19
- appendJsonl,
20
- } from './persistence.js'
21
-
22
- export function pathsFor(commonDir) {
23
- const dir = path.join(commonDir, 'dsh-tower')
24
- return {
25
- dir,
26
- state: path.join(dir, 'state.json'),
27
- lock: path.join(dir, 'state.lock'),
28
- gitLock: path.join(dir, 'git.lock'),
29
- activity: path.join(dir, 'activity.jsonl'),
30
- }
31
- }
32
-
33
- export async function exists(file) {
34
- try {
35
- await fsp.access(file)
36
- return true
37
- } catch {
38
- return false
39
- }
40
- }
41
-
42
- export async function ensureDir(dir) {
43
- await fsp.mkdir(dir, { recursive: true })
44
- }
45
-
46
- export function shortHash(text) {
47
- return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
48
- }
49
-
50
- export async function readActivityTail(file, limit = 12) {
51
- const text = await fsp.readFile(file, 'utf8').catch(() => '')
52
- const lines = text.split('\n').filter(Boolean)
53
- const tail = lines.slice(Math.max(0, lines.length - limit))
54
- const out = []
55
- for (const line of tail) {
56
- try {
57
- out.push(JSON.parse(line))
58
- } catch {
59
- // keep going: a truncated activity line must not hide the rest
60
- }
61
- }
62
- return out
63
- }
64
-
65
- export async function readStateLock(paths) {
66
- return readLock(paths.lock)
67
- }
68
-
69
- /**
70
- * Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
71
- * provably dead is reclaimed atomically; a live lock is reported, never stolen.
72
- */
73
- export async function withLock(paths, fn, options = {}) {
74
- const timeoutMs = options.timeoutMs ?? 5000
75
- const pollMs = options.pollMs ?? 25
76
- const token = crypto.randomUUID()
77
- const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT })
78
- const held = await acquireLock(paths.lock, { token, record, timeoutMs, pollMs })
79
- if (!held) {
80
- const owner = await readStateLock(paths)
81
- const ownerRecord = owner.record
82
- const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
83
- throw new TowerToolError(
84
- 'state-locked',
85
- `tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
86
- )
87
- }
88
- const startedAt = Date.now()
89
- try {
90
- return await fn(paths)
91
- } finally {
92
- await held.release()
93
- await fsp
94
- .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
95
- .catch(() => {})
96
- }
97
- }
98
-
99
- /** Serialize git mutations without holding the state lock. */
100
- export async function withGitLock(paths, fn, options = {}) {
101
- const timeoutMs = options.timeoutMs ?? 60000
102
- const pollMs = options.pollMs ?? 25
103
- const lock = paths.gitLock
104
- const token = crypto.randomUUID()
105
- const startedAt = Date.now()
106
- const held = await acquireLock(lock, { token, record: lockRecord(token, { kind: 'git' }), timeoutMs, pollMs })
107
- if (!held) {
108
- throw new TowerToolError('state-locked', 'another git mutation is in progress for this repository; retry after it finishes')
109
- }
110
- try {
111
- return await fn(paths)
112
- } finally {
113
- await held.release()
114
- await fsp
115
- .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, phase: 'git' })}\n`, 'utf8')
116
- .catch(() => {})
117
- }
118
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes