dsh-tower 2.0.3 → 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.
- package/CHANGELOG.md +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +44 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /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
|
+
}
|