dsh-tower 2.0.4 → 2.1.0-beta.1

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 (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,349 @@
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/presets.js ADDED
@@ -0,0 +1,35 @@
1
+ // Cross-field preset validation (#64): fail before work starts, not at spawn.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+
4
+ export function presetIds(config = {}) {
5
+ return {
6
+ worker: String(config.spawn?.workerPreset ?? 'tower-worker'),
7
+ reviewer: String(config.spawn?.reviewerPreset ?? 'tower-reviewer'),
8
+ }
9
+ }
10
+
11
+ function rowId(preset) {
12
+ return preset === 'tower-worker' ? 'preset-tower-worker' : preset === 'tower-reviewer' ? 'preset-tower-reviewer' : preset
13
+ }
14
+
15
+ /**
16
+ * Resolve both presets against the profile's agentPresets service. Fail closed
17
+ * with preset-missing before mode entry/planning when a required preset is not
18
+ * registered. If the service is unavailable, doctor reports that separately and
19
+ * this check cannot make a claim either way.
20
+ */
21
+ export async function assertPresetsAvailable(ctx, config = {}) {
22
+ const service = ctx?.get?.('agentPresets')
23
+ if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
24
+ const presets = presetIds(config)
25
+ const available = new Set((await service.list()).map((entry) => String(entry.id)))
26
+ const missing = [presets.worker, presets.reviewer].filter((id) => !available.has(id))
27
+ if (missing.length > 0) {
28
+ const rows = [...new Set(missing.map(rowId))]
29
+ throw new TowerToolError(
30
+ 'preset-missing',
31
+ `required Tower agent preset(s) missing: ${missing.join(', ')}; re-enable ${rows.join(', ')} in the plugin page, then restart DSH`,
32
+ )
33
+ }
34
+ return { checked: true, missing: [] }
35
+ }
@@ -0,0 +1,168 @@
1
+ // tower_spawn prechecks (#56 P3c): roster naming, mission preconditions, and
2
+ // service/provider/lease validation. Split out of tools/spawn.js; no I/O.
3
+ import { TowerToolError } from '../domain/errors.js'
4
+ import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
5
+ import { inspectLeases } from './leases.js'
6
+
7
+ /**
8
+ * Pick a unique roster name before any activation.
9
+ * @param {{ role: string, missionId: string | undefined, requested: string | undefined, roster: any[] }} input
10
+ */
11
+ export function resolveRosterName({ role, missionId, requested, roster }) {
12
+ if (typeof requested === 'string' && requested.trim()) {
13
+ const name = requested.trim()
14
+ if (roster.some((entry) => entry.name === name)) {
15
+ throw new TowerToolError('invalid-argument', 'roster name already used: ' + name)
16
+ }
17
+ return name
18
+ }
19
+ const base = role + '-' + String(missionId).toLowerCase()
20
+ let candidate = base
21
+ let suffix = 2
22
+ while (roster.some((entry) => entry.name === candidate)) {
23
+ candidate = base + '-' + suffix
24
+ suffix += 1
25
+ }
26
+ return candidate
27
+ }
28
+
29
+ /**
30
+ * Role/mission preconditions checked before any provisioning.
31
+ * @param {{ role: string, context: any }} input
32
+ */
33
+ export function assertSpawnableMission({ role, context }) {
34
+ if (role === 'reviewer') {
35
+ if (!context.mission.worktree) {
36
+ throw new TowerToolError('reviewer-prerequisite', 'reviewer needs an existing mission worktree; spawn a worker first')
37
+ }
38
+ if (context.mission.status !== 'completed') {
39
+ throw new TowerToolError('reviewer-prerequisite', `reviewer requires a completed mission; ${context.mission.id} is ${context.mission.status}`)
40
+ }
41
+ }
42
+ if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
43
+ throw new TowerToolError(
44
+ 'invalid-transition',
45
+ `mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
46
+ )
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Single-owner and live-worker rules, checked before provisioning or starting
52
+ * a child so a rejection cannot leak an activation or worktree.
53
+ * @param {{
54
+ * role: string,
55
+ * tower: any,
56
+ * ctx: any,
57
+ * view: any,
58
+ * missionId: string | undefined,
59
+ * takeover: boolean | undefined,
60
+ * maxLiveWorkers: number | undefined,
61
+ * budget: number,
62
+ * observeActivity: any,
63
+ * }} input
64
+ */
65
+ export async function precheckWorkerOwnership({
66
+ role,
67
+ tower,
68
+ ctx,
69
+ view,
70
+ missionId,
71
+ takeover,
72
+ maxLiveWorkers,
73
+ budget,
74
+ observeActivity,
75
+ }) {
76
+ let effectiveTakeover = takeover === true
77
+ if (role !== 'worker') return effectiveTakeover
78
+ const liveWorkers = view.roster.filter(
79
+ (entry) => entry.role === 'worker' && !ROSTER_INACTIVE_STATES.includes(entry.state),
80
+ )
81
+ const sameMission = liveWorkers.filter((entry) => entry.missionId === missionId)
82
+ if (sameMission.length > 1) {
83
+ throw new TowerToolError(
84
+ 'invalid-transition',
85
+ 'mission ' + missionId + ' has ' + sameMission.length + ' live worker entries; inspect tower_roster before spawning',
86
+ )
87
+ }
88
+ if (sameMission.length === 1 && !effectiveTakeover) {
89
+ throw new TowerToolError(
90
+ 'invalid-transition',
91
+ 'mission ' + missionId + ' already has a live worker "' + sameMission[0].name + '"; stop it with tower_interrupt, or pass takeover:true after it is no longer running',
92
+ )
93
+ }
94
+ if (effectiveTakeover) {
95
+ if (sameMission.length === 1) {
96
+ const worker = sameMission[0]
97
+ const observed = await observeActivity(ctx, tower)
98
+ if (observed.parentStatus[worker.parentSessionId] !== 'ok') {
99
+ throw new TowerToolError(
100
+ 'takeover-refused',
101
+ 'cannot verify that worker ' + worker.name + ' stopped: its parent catalog is unavailable',
102
+ )
103
+ }
104
+ const activity = observed.activity[worker.agentId]
105
+ if (activity === 'running') {
106
+ throw new TowerToolError(
107
+ 'takeover-refused',
108
+ 'worker ' + worker.name + ' is still running; call tower_interrupt (or wait) before takeover',
109
+ )
110
+ }
111
+ if (activity === undefined) {
112
+ throw new TowerToolError(
113
+ 'takeover-refused',
114
+ 'worker ' + worker.name + ' is not in the live catalog; run tower_adopt before takeover',
115
+ )
116
+ }
117
+ } else {
118
+ effectiveTakeover = false
119
+ }
120
+ }
121
+ const replacing = effectiveTakeover ? sameMission : []
122
+ const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
123
+ const max = Math.min(Number(maxLiveWorkers) || 4, budget)
124
+ if (remaining.length >= max) {
125
+ throw new TowerToolError(
126
+ 'worker-limit',
127
+ 'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
128
+ )
129
+ }
130
+ return effectiveTakeover
131
+ }
132
+
133
+ /**
134
+ * Service/provider/preset/lease checks, run before provisioning.
135
+ * @param {{ tower: any, ctx: any, config: any, preset: string, actor: any, exec: any }} input
136
+ */
137
+ export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec }) {
138
+ const subagents = ctx.get('subagents')
139
+ if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
140
+ const providers = subagents.list()
141
+ const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
142
+ if (!provider) throw new TowerToolError('provider-missing', 'no subagent provider is registered')
143
+ const presets = ctx.get('agentPresets')
144
+ if (presets && typeof presets.list === 'function') {
145
+ const available = await presets.list()
146
+ if (!available.some((entry) => String(entry.id) === preset)) {
147
+ throw new TowerToolError('preset-missing', 'agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
148
+ }
149
+ }
150
+ if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
151
+ const requiredLease = config.spawn.requireLease.trim()
152
+ const info = await inspectLeases(tower.commonDir)
153
+ const lease = info.leases.find((entry) => entry.name === requiredLease && entry.expired !== true)
154
+ if (!lease) {
155
+ throw new TowerToolError(
156
+ 'lease-busy',
157
+ `spawn.requireLease="${requiredLease}" is not held; acquire it with tower_resource before spawning`,
158
+ )
159
+ }
160
+ if (lease.holder !== actor.name && lease.holder !== String(exec.agent.id)) {
161
+ throw new TowerToolError(
162
+ 'lease-busy',
163
+ `spawn.requireLease="${requiredLease}" is held by "${lease.holder}", not by this tower`,
164
+ )
165
+ }
166
+ }
167
+ return { subagents, provider }
168
+ }
@@ -0,0 +1,79 @@
1
+ // tower_spawn support (#56 P3c): briefing assembly, agent options, and the
2
+ // wait/result settling path. Split out of tools/spawn.js; no I/O of its own.
3
+ import { workerBriefing, reviewerBriefing } from './briefing.js'
4
+
5
+ /**
6
+ * @param {any} model
7
+ */
8
+ export function buildAgentOptions(model) {
9
+ const agentOptions = {}
10
+ for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
11
+ if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
12
+ }
13
+ return agentOptions
14
+ }
15
+
16
+ /**
17
+ * @param {{ role: string, towerRoot: string, context: any, worktree: any, instructions: string | undefined }} input
18
+ */
19
+ export function buildSpawnBriefing({ role, towerRoot, context, worktree, instructions }) {
20
+ const mission = {
21
+ ...context.mission,
22
+ worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
23
+ }
24
+ const briefing = {
25
+ repoRoot: towerRoot,
26
+ base: context.base,
27
+ mission,
28
+ deps: context.deps,
29
+ worktree,
30
+ instructions,
31
+ reviews: context.reviews,
32
+ findings: context.findings,
33
+ completionReport: context.completionReport,
34
+ }
35
+ return role === 'worker' ? workerBriefing(briefing) : reviewerBriefing(briefing)
36
+ }
37
+
38
+ /**
39
+ * Wait for the activation result when wait=true, or forward the eventual
40
+ * outcome when wait=false.
41
+ * @param {{
42
+ * activation: any,
43
+ * report: any,
44
+ * recordOutcome: (state: string, detail: any) => Promise<any>,
45
+ * maxWaitMs: number | undefined,
46
+ * requestedWaitMs: number | undefined,
47
+ * textOf: any,
48
+ * }} input
49
+ */
50
+ export async function waitForSpawnResult({ activation, report, recordOutcome, maxWaitMs, requestedWaitMs, textOf }) {
51
+ const waitMs = Math.max(1000, Math.min(maxWaitMs ?? 90000, requestedWaitMs ?? 180000))
52
+ let timer
53
+ const timeout = new Promise((resolve) => {
54
+ timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
55
+ })
56
+ let result
57
+ try {
58
+ result = await Promise.race([activation.result, timeout])
59
+ } catch (error) {
60
+ report.stopReason = 'failed'
61
+ report.diagnostic = String(error?.message ?? error)
62
+ await recordOutcome('failed', report.diagnostic)
63
+ throw error
64
+ } finally {
65
+ clearTimeout(timer)
66
+ }
67
+ if (result && result.__timeout) {
68
+ report.timedOut = true
69
+ recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
70
+ } else {
71
+ report.stopReason = result?.stopReason ?? null
72
+ report.output = textOf(result?.output)
73
+ report.diagnostic = result?.diagnostic ?? null
74
+ const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
75
+ if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
76
+ await recordOutcome(state, report.diagnostic ?? null)
77
+ }
78
+ return report
79
+ }