dsh-tower 2.0.3 → 2.0.4
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 +40 -0
- package/README.en.md +19 -13
- package/README.md +19 -13
- package/SECURITY.md +4 -1
- package/docs/README.md +30 -0
- package/docs/architecture/overview.md +3 -2
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/install-upgrade-migration.md +14 -5
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +18 -12
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.md +7 -6
- package/docs/reference/errors.md +7 -1
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +1 -1
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/install-upgrade-migration.md +11 -4
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +13 -6
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +7 -6
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +7 -6
- package/index.js +26 -17
- package/lib/config.js +100 -6
- package/lib/doctor.js +11 -5
- package/lib/errors.js +14 -2
- package/lib/git.js +36 -2
- package/lib/modeStore.js +54 -20
- package/lib/store.js +65 -10
- package/lib/tests.js +35 -1
- package/lib/tools.js +257 -47
- package/lib/tower.js +268 -45
- package/lib/workspaceLock.js +44 -20
- package/package.json +3 -2
- package/scripts/check-docs.mjs +35 -0
- package/skills/tower-recovery.md +7 -5
- 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/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { TowerToolError, classifyError } from './errors.js'
|
|
|
4
4
|
import { git, resolveRepo, hasAnyCommit } from './git.js'
|
|
5
5
|
import { runtimeConfig } from './config.js'
|
|
6
6
|
import { createHmrBridge } from './hmr.js'
|
|
7
|
+
import { readStateLock } from './store.js'
|
|
7
8
|
import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from './tests.js'
|
|
8
9
|
import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch, BatchError } from './batch.js'
|
|
9
10
|
import { acquireLease, releaseLease, inspectLeases } from './leases.js'
|
|
@@ -248,6 +249,10 @@ const SCHEMAS = {
|
|
|
248
249
|
['ok', 'scheduled'],
|
|
249
250
|
),
|
|
250
251
|
doctor: S.obj({ ok: S.bool, checks: S.arr(S.obj()), environmentContract: S.obj() }, ['ok', 'checks']),
|
|
252
|
+
recover: S.obj(
|
|
253
|
+
{ ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, error: S.str },
|
|
254
|
+
['ok', 'action'],
|
|
255
|
+
),
|
|
251
256
|
test: S.obj(
|
|
252
257
|
{
|
|
253
258
|
ok: S.bool,
|
|
@@ -298,26 +303,40 @@ function normalizeToolArgs(args, aliases = {}) {
|
|
|
298
303
|
return out
|
|
299
304
|
}
|
|
300
305
|
|
|
301
|
-
|
|
302
|
-
throw new Error(message)
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Resolve the calling agent to a roster role; unknown callers are the tower. */
|
|
306
|
+
/** Resolve the calling agent to a roster role; unknown callers are rejected. */
|
|
306
307
|
async function actorFor(tower, exec) {
|
|
307
308
|
const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
|
|
308
|
-
|
|
309
|
+
const unknown = { name: 'unknown', role: 'unknown', agentId }
|
|
310
|
+
if (!agentId) return unknown
|
|
311
|
+
let state
|
|
312
|
+
try {
|
|
313
|
+
state = await tower.load()
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (!/not initialized/i.test(String(error?.message ?? error))) throw error
|
|
316
|
+
// No readable state: only tower_init may claim ownership (handled in that tool).
|
|
317
|
+
return unknown
|
|
318
|
+
}
|
|
319
|
+
if (state.towerAgentId && String(state.towerAgentId) === agentId) {
|
|
320
|
+
return { name: 'tower', role: 'tower', agentId }
|
|
321
|
+
}
|
|
309
322
|
let view
|
|
310
323
|
try {
|
|
311
324
|
view = await tower.rosterView()
|
|
312
325
|
} catch {
|
|
313
|
-
|
|
314
|
-
return { name: 'tower', role: 'tower', agentId }
|
|
326
|
+
return unknown
|
|
315
327
|
}
|
|
316
328
|
const self = view.roster.find((entry) => entry.agentId === agentId)
|
|
317
|
-
if (!self) return
|
|
329
|
+
if (!self) return unknown
|
|
330
|
+
if (self.role === 'tower') return { name: self.name, role: 'tower', agentId }
|
|
318
331
|
return { name: self.name, role: self.role, agentId }
|
|
319
332
|
}
|
|
320
333
|
|
|
334
|
+
function requireKnownActor(actor, action) {
|
|
335
|
+
if (!actor || actor.role === 'unknown') {
|
|
336
|
+
throw new TowerToolError('not-authorized', `${action} requires a registered roster agent or the tower`)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
321
340
|
function requireTowerActor(actor, action) {
|
|
322
341
|
if (actor.role !== 'tower') {
|
|
323
342
|
throw new TowerToolError('not-authorized', `only the tower can ${action}; ${actor.name} is a ${actor.role}`)
|
|
@@ -366,6 +385,37 @@ function checkSendRate(config, repoRoot) {
|
|
|
366
385
|
bucket.count += 1
|
|
367
386
|
}
|
|
368
387
|
|
|
388
|
+
async function verifyBatchReceiptFiles(root, batch, args) {
|
|
389
|
+
const problems = []
|
|
390
|
+
const frozen = new Set(batch.receiptPaths ?? [])
|
|
391
|
+
const declared = new Map()
|
|
392
|
+
for (const [rawPath, rawHash] of Object.entries(args.receiptHashes ?? {})) {
|
|
393
|
+
declared.set(String(rawPath), String(rawHash).trim().toLowerCase())
|
|
394
|
+
}
|
|
395
|
+
for (const entry of args.receipts ?? []) {
|
|
396
|
+
if (entry && typeof entry.path === 'string' && typeof entry.hash === 'string') {
|
|
397
|
+
declared.set(entry.path, entry.hash.trim().toLowerCase())
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
for (const [rel, reported] of declared) {
|
|
401
|
+
if (frozen.size > 0 && !frozen.has(rel)) continue
|
|
402
|
+
let actual
|
|
403
|
+
try {
|
|
404
|
+
const abs = path.resolve(root, rel)
|
|
405
|
+
const relative = path.relative(root, abs)
|
|
406
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('path escapes the repository')
|
|
407
|
+
actual = crypto.createHash('sha256').update(await fsp.readFile(abs)).digest('hex')
|
|
408
|
+
} catch (error) {
|
|
409
|
+
problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" cannot be read: ${String(error?.message ?? error)}` })
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
if (reported && reported !== actual) {
|
|
413
|
+
problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" hash ${reported.slice(0, 12)} does not match the file (${actual.slice(0, 12)})` })
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return problems
|
|
417
|
+
}
|
|
418
|
+
|
|
369
419
|
async function interruptRosterAgent(ctx, tower, exec, input) {
|
|
370
420
|
const subagents = ctx.get('subagents')
|
|
371
421
|
if (!subagents || typeof subagents.interrupt !== 'function') {
|
|
@@ -459,7 +509,7 @@ function define(ctx, definition) {
|
|
|
459
509
|
}
|
|
460
510
|
if (typeof normalized.repoRoot === 'string') watchRepo(normalized.repoRoot)
|
|
461
511
|
const event = rest.name.startsWith('tower_') ? rest.name.slice('tower_'.length) : null
|
|
462
|
-
if (event
|
|
512
|
+
if (event) {
|
|
463
513
|
try {
|
|
464
514
|
ctx.emit?.(`tower/${event}`, {
|
|
465
515
|
tool: rest.name,
|
|
@@ -795,10 +845,29 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
795
845
|
const initConfig = tower.config.init ?? {}
|
|
796
846
|
const snapshotDirty =
|
|
797
847
|
args.snapshotDirty === true || (args.snapshotDirty === undefined && initConfig.snapshotDirty === true)
|
|
798
|
-
const
|
|
799
|
-
|
|
848
|
+
const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
|
|
849
|
+
if (!agentId) {
|
|
850
|
+
throw new TowerToolError('not-authorized', 'tower_init requires a caller agent id; refusing to treat an unidentified caller as the tower')
|
|
851
|
+
}
|
|
852
|
+
let state = null
|
|
853
|
+
try {
|
|
854
|
+
state = await tower.load()
|
|
855
|
+
} catch {
|
|
856
|
+
state = null
|
|
857
|
+
}
|
|
858
|
+
if (state?.towerAgentId) {
|
|
859
|
+
const actor = await actorFor(tower, exec)
|
|
860
|
+
requireTowerActor(actor, 'initialize Tower state')
|
|
861
|
+
} else if (state) {
|
|
862
|
+
// Legacy state predating owner binding: an explicit tower_init may
|
|
863
|
+
// claim ownership once. Other tools remain fail-closed until then.
|
|
864
|
+
const actor = await actorFor(tower, exec)
|
|
865
|
+
if (actor.role !== 'tower' && actor.role !== 'unknown') {
|
|
866
|
+
requireTowerActor(actor, 'claim Tower ownership')
|
|
867
|
+
}
|
|
868
|
+
}
|
|
800
869
|
const base = args.base ?? workspaceHook?.basePreference?.(exec) ?? undefined
|
|
801
|
-
return tower.init({ base, snapshotDirty, reanchor: args.reanchor })
|
|
870
|
+
return tower.init({ base, snapshotDirty, reanchor: args.reanchor, towerAgentId: agentId })
|
|
802
871
|
},
|
|
803
872
|
})
|
|
804
873
|
|
|
@@ -831,7 +900,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
831
900
|
reviewPolicy: {
|
|
832
901
|
type: 'string',
|
|
833
902
|
enum: ['required-clean', 'none'],
|
|
834
|
-
description: 'required-clean (default)
|
|
903
|
+
description: 'required-clean (mission default) means a clean review is required when review.requireReview is on; none needs reviewExemptionReason, and with the review gate on it also needs gates.allowReviewExemption.',
|
|
835
904
|
},
|
|
836
905
|
reviewExemptionReason: {
|
|
837
906
|
type: 'string',
|
|
@@ -898,6 +967,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
898
967
|
async execute(args, exec) {
|
|
899
968
|
const tower = await Tower.open(args.repoRoot)
|
|
900
969
|
const actor = await actorFor(tower, exec)
|
|
970
|
+
requireKnownActor(actor, 'tower_mission')
|
|
901
971
|
return tower.mission({
|
|
902
972
|
actor,
|
|
903
973
|
id: args.id,
|
|
@@ -1024,7 +1094,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1024
1094
|
const actor = await actorFor(tower, exec)
|
|
1025
1095
|
requireTowerActor(actor, 'spawn tower agents')
|
|
1026
1096
|
const role = args.role ?? 'worker'
|
|
1027
|
-
if (!ROLE_ENUM.includes(role))
|
|
1097
|
+
if (!ROLE_ENUM.includes(role)) throw new TowerToolError('invalid-argument', `role must be one of ${ROLE_ENUM.join('|')}`)
|
|
1028
1098
|
// Repository config (if any) already overrides the profile config here.
|
|
1029
1099
|
const config = tower.config
|
|
1030
1100
|
const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
|
|
@@ -1032,14 +1102,15 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1032
1102
|
const context = await tower.missionContext(args.missionId)
|
|
1033
1103
|
if (role === 'reviewer') {
|
|
1034
1104
|
if (!context.mission.worktree) {
|
|
1035
|
-
|
|
1105
|
+
throw new TowerToolError('reviewer-prerequisite', 'reviewer needs an existing mission worktree; spawn a worker first')
|
|
1036
1106
|
}
|
|
1037
1107
|
if (context.mission.status !== 'completed') {
|
|
1038
|
-
|
|
1108
|
+
throw new TowerToolError('reviewer-prerequisite', `reviewer requires a completed mission; ${context.mission.id} is ${context.mission.status}`)
|
|
1039
1109
|
}
|
|
1040
1110
|
}
|
|
1041
1111
|
if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
|
|
1042
|
-
|
|
1112
|
+
throw new TowerToolError(
|
|
1113
|
+
'invalid-transition',
|
|
1043
1114
|
`mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
|
|
1044
1115
|
)
|
|
1045
1116
|
}
|
|
@@ -1125,15 +1196,15 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1125
1196
|
// Service/provider/preset checks happen before provisioning, so a failed
|
|
1126
1197
|
// precheck cannot leave a worktree or roster revision behind.
|
|
1127
1198
|
const subagents = ctx.get('subagents')
|
|
1128
|
-
if (!subagents)
|
|
1199
|
+
if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
|
|
1129
1200
|
const providers = subagents.list()
|
|
1130
1201
|
const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
|
|
1131
|
-
if (!provider)
|
|
1202
|
+
if (!provider) throw new TowerToolError('provider-missing', 'no subagent provider is registered')
|
|
1132
1203
|
const presets = ctx.get('agentPresets')
|
|
1133
1204
|
if (presets && typeof presets.list === 'function') {
|
|
1134
1205
|
const available = await presets.list()
|
|
1135
1206
|
if (!available.some((entry) => String(entry.id) === preset)) {
|
|
1136
|
-
|
|
1207
|
+
throw new TowerToolError('preset-missing', 'agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
|
|
1137
1208
|
}
|
|
1138
1209
|
}
|
|
1139
1210
|
if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
|
|
@@ -1210,6 +1281,14 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1210
1281
|
let activation = null
|
|
1211
1282
|
let agentId = null
|
|
1212
1283
|
let entry = null
|
|
1284
|
+
await tower.reserveRoster({
|
|
1285
|
+
missionId: mission.id,
|
|
1286
|
+
name,
|
|
1287
|
+
role,
|
|
1288
|
+
parentSessionId: String(exec.agent.id),
|
|
1289
|
+
cwd: worktree.path,
|
|
1290
|
+
branch: worktree.branch,
|
|
1291
|
+
})
|
|
1213
1292
|
try {
|
|
1214
1293
|
activation = await subagents.startActivation({
|
|
1215
1294
|
provider,
|
|
@@ -1237,16 +1316,24 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1237
1316
|
takeover,
|
|
1238
1317
|
})
|
|
1239
1318
|
} catch (error) {
|
|
1240
|
-
// The child may already be running even when recordSpawn failed
|
|
1241
|
-
//
|
|
1242
|
-
// unobserved
|
|
1319
|
+
// The child may already be running even when recordSpawn failed: stop
|
|
1320
|
+
// it and mark the reserved entry failed so it cannot keep writing
|
|
1321
|
+
// unobserved.
|
|
1243
1322
|
if (agentId) {
|
|
1244
1323
|
try {
|
|
1245
|
-
subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent })
|
|
1324
|
+
await Promise.resolve(subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent }))
|
|
1246
1325
|
} catch {
|
|
1247
1326
|
// best effort; the error below is the actionable one
|
|
1248
1327
|
}
|
|
1249
1328
|
}
|
|
1329
|
+
await tower
|
|
1330
|
+
.recordSpawnOutcome({
|
|
1331
|
+
missionId: mission.id,
|
|
1332
|
+
name,
|
|
1333
|
+
state: 'failed',
|
|
1334
|
+
detail: `spawn failed${agentId ? ` after activation ${agentId}` : ' before activation'}: ${String(error?.message ?? error).slice(0, 300)}`,
|
|
1335
|
+
})
|
|
1336
|
+
.catch(() => {})
|
|
1250
1337
|
throw error
|
|
1251
1338
|
}
|
|
1252
1339
|
|
|
@@ -1330,7 +1417,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1330
1417
|
const tower = await Tower.open(args.repoRoot)
|
|
1331
1418
|
const view = await tower.rosterView()
|
|
1332
1419
|
const subagents = ctx.get('subagents')
|
|
1333
|
-
if (!subagents)
|
|
1420
|
+
if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot observe roster')
|
|
1334
1421
|
|
|
1335
1422
|
const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
|
|
1336
1423
|
const activity = new Map()
|
|
@@ -1444,7 +1531,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1444
1531
|
requireTowerActor(actor, 'adopt the roster')
|
|
1445
1532
|
const view = await tower.rosterView()
|
|
1446
1533
|
const subagents = ctx.get('subagents')
|
|
1447
|
-
if (!subagents)
|
|
1534
|
+
if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot adopt')
|
|
1448
1535
|
|
|
1449
1536
|
const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
|
|
1450
1537
|
const activity = new Map()
|
|
@@ -1460,11 +1547,19 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1460
1547
|
}
|
|
1461
1548
|
|
|
1462
1549
|
const retiredCandidates = []
|
|
1550
|
+
const presentIds = []
|
|
1551
|
+
const absentIds = []
|
|
1463
1552
|
for (const entry of view.roster) {
|
|
1464
1553
|
if (entry.state === 'retired') continue
|
|
1465
1554
|
if (parentStatus[entry.parentSessionId] !== 'ok') continue
|
|
1466
|
-
if (
|
|
1555
|
+
if (activity.has(entry.agentId)) {
|
|
1556
|
+
presentIds.push(entry.agentId)
|
|
1557
|
+
} else {
|
|
1558
|
+
absentIds.push(entry.agentId)
|
|
1559
|
+
}
|
|
1467
1560
|
}
|
|
1561
|
+
const observations = await tower.recordRosterObservations({ present: presentIds, absent: absentIds, confirmations: 2 })
|
|
1562
|
+
retiredCandidates.push(...observations.candidates)
|
|
1468
1563
|
const retireMissing = args.retireMissing !== false
|
|
1469
1564
|
const { retired } = retireMissing ? await tower.markRetired(retiredCandidates) : { retired: [] }
|
|
1470
1565
|
const { missing } = retireMissing ? { missing: [] } : await tower.markMissing(retiredCandidates)
|
|
@@ -1472,6 +1567,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1472
1567
|
return {
|
|
1473
1568
|
retired,
|
|
1474
1569
|
missing,
|
|
1570
|
+
unconfirmed: absentIds.filter((agentId) => !retiredCandidates.includes(agentId)),
|
|
1475
1571
|
skippedUnknownParents: Object.entries(parentStatus)
|
|
1476
1572
|
.filter(([, status]) => status !== 'ok')
|
|
1477
1573
|
.map(([parent]) => parent),
|
|
@@ -1511,11 +1607,33 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1511
1607
|
const actor = await actorFor(tower, exec)
|
|
1512
1608
|
const view = await tower.rosterView()
|
|
1513
1609
|
const from = actor.role === 'tower' ? 'tower' : actor.name
|
|
1610
|
+
if (actor.role === 'unknown') {
|
|
1611
|
+
throw new TowerToolError('not-authorized', 'tower_send requires a registered roster agent or the tower')
|
|
1612
|
+
}
|
|
1613
|
+
if (args.to === 'all' && actor.role !== 'tower') {
|
|
1614
|
+
throw new TowerToolError('not-authorized', 'only the tower may broadcast to "all"')
|
|
1615
|
+
}
|
|
1616
|
+
const sendConfig = tower.config.send ?? {}
|
|
1617
|
+
const interrupt =
|
|
1618
|
+
args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
|
|
1619
|
+
if (interrupt && actor.role !== 'tower') {
|
|
1620
|
+
throw new TowerToolError('not-authorized', 'only the tower may interrupt a recipient with tower_send')
|
|
1621
|
+
}
|
|
1622
|
+
if (actor.role !== 'tower') {
|
|
1623
|
+
const self = view.roster.find((entry) => entry.agentId === actor.agentId)
|
|
1624
|
+
const target = view.roster.find((entry) => entry.name === args.to)
|
|
1625
|
+
const allowed =
|
|
1626
|
+
args.to === 'tower' ||
|
|
1627
|
+
(self && target && target.agentId !== self.agentId && target.missionId === self.missionId)
|
|
1628
|
+
if (!allowed) {
|
|
1629
|
+
throw new TowerToolError('not-authorized', `${actor.name} may only message the tower or agents on the same mission`)
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1514
1632
|
if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
|
|
1515
|
-
|
|
1633
|
+
throw new TowerToolError('invalid-argument', 'tower_send requires a subject')
|
|
1516
1634
|
}
|
|
1517
1635
|
if (typeof args.body !== 'string' || args.body.trim().length === 0) {
|
|
1518
|
-
|
|
1636
|
+
throw new TowerToolError('invalid-argument', 'tower_send requires a body')
|
|
1519
1637
|
}
|
|
1520
1638
|
const message = await tower.recordMessage({
|
|
1521
1639
|
from,
|
|
@@ -1542,9 +1660,6 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1542
1660
|
// an unreadable parent catalog only means "do not wake it"
|
|
1543
1661
|
}
|
|
1544
1662
|
}
|
|
1545
|
-
const sendConfig = tower.config.send ?? {}
|
|
1546
|
-
const interrupt =
|
|
1547
|
-
args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
|
|
1548
1663
|
const targets =
|
|
1549
1664
|
args.to === 'all' ? roster.map((entry) => entry.name) : [args.to]
|
|
1550
1665
|
for (const targetName of targets) {
|
|
@@ -1566,6 +1681,10 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1566
1681
|
try {
|
|
1567
1682
|
subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
|
|
1568
1683
|
interrupted = true
|
|
1684
|
+
await tower.recordInterrupt({
|
|
1685
|
+
agentId: entry.agentId,
|
|
1686
|
+
reason: `tower_send${message?.id ? ` ${message.id}` : ''}: ${args.subject.trim().slice(0, 120)}`,
|
|
1687
|
+
})
|
|
1569
1688
|
} catch (error) {
|
|
1570
1689
|
results.push({ target: targetName, pushed: false, reason: `interrupt failed: ${String(error?.message ?? error)}` })
|
|
1571
1690
|
continue
|
|
@@ -1626,7 +1745,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1626
1745
|
define(ctx, {
|
|
1627
1746
|
name: 'tower_complete',
|
|
1628
1747
|
description:
|
|
1629
|
-
'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means
|
|
1748
|
+
'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means ready for preflight and optional review, not merged.',
|
|
1630
1749
|
parameters: {
|
|
1631
1750
|
type: 'object',
|
|
1632
1751
|
properties: {
|
|
@@ -1714,6 +1833,8 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1714
1833
|
output: output(SCHEMAS.finding),
|
|
1715
1834
|
async execute(args, exec) {
|
|
1716
1835
|
const tower = await Tower.open(args.repoRoot)
|
|
1836
|
+
const actor = await actorFor(tower, exec)
|
|
1837
|
+
requireKnownActor(actor, 'tower_finding')
|
|
1717
1838
|
const view = await tower.rosterView()
|
|
1718
1839
|
const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
|
|
1719
1840
|
return tower.addFinding({
|
|
@@ -1759,6 +1880,8 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1759
1880
|
output: output(SCHEMAS.findings),
|
|
1760
1881
|
async execute(args, exec) {
|
|
1761
1882
|
const tower = await Tower.open(args.repoRoot)
|
|
1883
|
+
const actor = await actorFor(tower, exec)
|
|
1884
|
+
requireKnownActor(actor, 'tower_findings')
|
|
1762
1885
|
const view = await tower.rosterView()
|
|
1763
1886
|
const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
|
|
1764
1887
|
return tower.findings({
|
|
@@ -1801,9 +1924,17 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1801
1924
|
output: output(SCHEMAS.review),
|
|
1802
1925
|
async execute(args, exec) {
|
|
1803
1926
|
const tower = await Tower.open(args.repoRoot)
|
|
1927
|
+
const actor = await actorFor(tower, exec)
|
|
1928
|
+
if (!['reviewer', 'tower'].includes(actor.role)) {
|
|
1929
|
+
throw new TowerToolError('not-authorized', `only an assigned reviewer or the tower may review; ${actor.name} is ${actor.role}`)
|
|
1930
|
+
}
|
|
1931
|
+
if (actor.role === 'tower' && (typeof args.breakGlassReason !== 'string' || args.breakGlassReason.trim().length === 0)) {
|
|
1932
|
+
throw new TowerToolError('not-authorized', 'a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
|
|
1933
|
+
}
|
|
1804
1934
|
return tower.submitReview({
|
|
1805
1935
|
missionId: args.missionId,
|
|
1806
1936
|
agentId: String(exec.agent.id),
|
|
1937
|
+
actor,
|
|
1807
1938
|
status: args.status,
|
|
1808
1939
|
merge: args.merge,
|
|
1809
1940
|
findings: args.findings,
|
|
@@ -1817,7 +1948,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1817
1948
|
define(ctx, {
|
|
1818
1949
|
name: 'tower_preflight',
|
|
1819
1950
|
description:
|
|
1820
|
-
'Read-only merge gate for one mission: returns ok plus stable blocker codes (branch-missing, deps-unmerged, read-only-survey, stale-base, no-review, review-hold, not-clean, review-not-approved, tip-moved, review-mission-mismatch
|
|
1951
|
+
'Read-only merge gate for one mission: returns ok plus stable blocker codes (branch-missing, deps-unmerged, read-only-survey, stale-base, out-of-scope, base-dirty, branch-owned-by-closed-missions, and mission/worker guards). Review blockers (no-review, review-hold, not-clean, review-not-approved, tip-moved, review-mission-mismatch) apply only when review.requireReview is on. Never changes anything.',
|
|
1821
1952
|
parameters: {
|
|
1822
1953
|
type: 'object',
|
|
1823
1954
|
properties: {
|
|
@@ -1963,10 +2094,72 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1963
2094
|
additionalProperties: false,
|
|
1964
2095
|
},
|
|
1965
2096
|
output: output(SCHEMAS.doctor),
|
|
2097
|
+
async execute(args, exec) {
|
|
2098
|
+
let tower
|
|
2099
|
+
let configError = null
|
|
2100
|
+
try {
|
|
2101
|
+
tower = await Tower.open(args.repoRoot)
|
|
2102
|
+
} catch (error) {
|
|
2103
|
+
const message = String(error?.message ?? error)
|
|
2104
|
+
if (!/config-(invalid|unknown-key)/.test(message) && error?.code !== 'config-invalid' && error?.code !== 'config-unknown-key') throw error
|
|
2105
|
+
configError = message
|
|
2106
|
+
const repo = await resolveRepo(args.repoRoot)
|
|
2107
|
+
tower = new Tower(repo, { ...runtimeConfig(), configPath: null, repoConfigPresent: true })
|
|
2108
|
+
}
|
|
2109
|
+
const report = await runDoctor({ tower, ctx, agent: exec.agent })
|
|
2110
|
+
if (!configError) return report
|
|
2111
|
+
return {
|
|
2112
|
+
...report,
|
|
2113
|
+
ok: false,
|
|
2114
|
+
checks: [
|
|
2115
|
+
{
|
|
2116
|
+
id: 'config',
|
|
2117
|
+
status: 'fail',
|
|
2118
|
+
detail: configError,
|
|
2119
|
+
fix: 'repair <git-common-dir>/dsh-tower/config.json and re-run tower_doctor',
|
|
2120
|
+
},
|
|
2121
|
+
...(report.checks ?? []),
|
|
2122
|
+
],
|
|
2123
|
+
}
|
|
2124
|
+
},
|
|
2125
|
+
})
|
|
2126
|
+
|
|
2127
|
+
define(ctx, {
|
|
2128
|
+
name: 'tower_recover',
|
|
2129
|
+
description:
|
|
2130
|
+
'Read-only recovery diagnostics and an explicit stale-state-lock repair. status reports the state lock owner; clear-stale-lock requires confirm:"CLEAR_STALE_LOCK" and only moves a lock whose PID is provably dead to a backup file. It never resets Tower state or worktrees.',
|
|
2131
|
+
parameters: {
|
|
2132
|
+
type: 'object',
|
|
2133
|
+
properties: {
|
|
2134
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2135
|
+
action: { type: 'string', enum: ['status', 'clear-stale-lock'], description: 'Recovery action.' },
|
|
2136
|
+
confirm: { type: 'string', description: 'Required literal CLEAR_STALE_LOCK for clear-stale-lock.' },
|
|
2137
|
+
},
|
|
2138
|
+
required: ['repoRoot', 'action'],
|
|
2139
|
+
additionalProperties: false,
|
|
2140
|
+
},
|
|
2141
|
+
output: output(SCHEMAS.recover),
|
|
1966
2142
|
async execute(args, exec) {
|
|
1967
2143
|
const tower = await Tower.open(args.repoRoot)
|
|
1968
|
-
await actorFor(tower, exec)
|
|
1969
|
-
|
|
2144
|
+
const actor = await actorFor(tower, exec)
|
|
2145
|
+
requireTowerActor(actor, 'recover Tower state')
|
|
2146
|
+
const lock = await readStateLock(tower.paths)
|
|
2147
|
+
const publicLock = lock.record
|
|
2148
|
+
? { pid: lock.record.pid ?? null, startedAt: lock.record.startedAt ?? null, at: lock.record.at ?? null, alive: lock.alive }
|
|
2149
|
+
: null
|
|
2150
|
+
if (args.action === 'status') {
|
|
2151
|
+
return { ok: true, action: 'status', stateLock: publicLock, cleared: false }
|
|
2152
|
+
}
|
|
2153
|
+
if (args.confirm !== 'CLEAR_STALE_LOCK') {
|
|
2154
|
+
throw new TowerToolError('invalid-argument', 'clear-stale-lock requires confirm:"CLEAR_STALE_LOCK"')
|
|
2155
|
+
}
|
|
2156
|
+
if (!lock.record) return { ok: true, action: 'clear-stale-lock', stateLock: null, cleared: false }
|
|
2157
|
+
if (lock.alive !== false) {
|
|
2158
|
+
throw new TowerToolError('state-locked', `state lock pid ${lock.record.pid ?? 'unknown'} is still alive; refusing to clear it`)
|
|
2159
|
+
}
|
|
2160
|
+
const backup = `${tower.paths.lock}.recovered-${Date.now()}`
|
|
2161
|
+
await fsp.rename(tower.paths.lock, backup)
|
|
2162
|
+
return { ok: true, action: 'clear-stale-lock', stateLock: publicLock, cleared: true, backup }
|
|
1970
2163
|
},
|
|
1971
2164
|
})
|
|
1972
2165
|
|
|
@@ -1990,11 +2183,12 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
1990
2183
|
async execute(args, exec) {
|
|
1991
2184
|
const tower = await Tower.open(args.repoRoot)
|
|
1992
2185
|
const actor = await actorFor(tower, exec)
|
|
2186
|
+
requireKnownActor(actor, 'tower_test')
|
|
1993
2187
|
const context = await tower.missionContext(args.missionId)
|
|
1994
|
-
if (!context.mission.worktree)
|
|
1995
|
-
if (actor.role === 'reviewer')
|
|
2188
|
+
if (!context.mission.worktree) throw new TowerToolError('invalid-transition', `mission ${args.missionId} has no worktree`)
|
|
2189
|
+
if (actor.role === 'reviewer') throw new TowerToolError('not-authorized', 'reviewers may not run test discovery')
|
|
1996
2190
|
if (actor.role === 'worker' && context.mission.owner && context.mission.owner !== actor.name) {
|
|
1997
|
-
|
|
2191
|
+
throw new TowerToolError('not-authorized', `worker ${actor.name} does not own mission ${args.missionId}`)
|
|
1998
2192
|
}
|
|
1999
2193
|
await tower.assertWorktree(context.mission.worktree)
|
|
2000
2194
|
const discovery = await runMissionDiscovery(tower, context.mission)
|
|
@@ -2053,7 +2247,7 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
2053
2247
|
const batch = args.batchId
|
|
2054
2248
|
? (state.batches ?? []).find((entry) => entry.id === args.batchId)
|
|
2055
2249
|
: null
|
|
2056
|
-
if (args.batchId && !batch)
|
|
2250
|
+
if (args.batchId && !batch) throw new TowerToolError('invalid-argument', `unknown batch "${args.batchId}"`)
|
|
2057
2251
|
return {
|
|
2058
2252
|
ok: true,
|
|
2059
2253
|
action: 'status',
|
|
@@ -2068,12 +2262,20 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
2068
2262
|
return { ok: true, action: 'create', batch, batches: (await tower.batchStatus()).batches }
|
|
2069
2263
|
}
|
|
2070
2264
|
if (typeof args.batchId !== 'string' || args.batchId.trim().length === 0) {
|
|
2071
|
-
|
|
2265
|
+
throw new TowerToolError('invalid-argument', `tower_batch ${args.action} requires batchId`)
|
|
2072
2266
|
}
|
|
2073
2267
|
if (args.action === 'abandon') {
|
|
2074
2268
|
const result = await tower.abandonBatch(args.batchId.trim(), actor.name, args.reason)
|
|
2075
2269
|
return { ok: true, action: 'abandon', batch: result.batch, blockers: [] }
|
|
2076
2270
|
}
|
|
2271
|
+
const closeState = await tower.load()
|
|
2272
|
+
const closeBatch = (closeState.batches ?? []).find((entry) => entry.id === args.batchId.trim())
|
|
2273
|
+
if (closeBatch) {
|
|
2274
|
+
const receiptProblems = await verifyBatchReceiptFiles(tower.root, closeBatch, args)
|
|
2275
|
+
if (receiptProblems.length > 0) {
|
|
2276
|
+
return { ok: false, action: 'close', batch: closeBatch, blockers: receiptProblems, checks: [] }
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2077
2279
|
const result = await tower.closeBatch(args.batchId.trim(), {
|
|
2078
2280
|
checks: args.checks,
|
|
2079
2281
|
treeTip: args.treeTip,
|
|
@@ -2116,12 +2318,16 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
2116
2318
|
async execute(args, exec) {
|
|
2117
2319
|
const tower = await Tower.open(args.repoRoot)
|
|
2118
2320
|
const actor = await actorFor(tower, exec)
|
|
2321
|
+
const redact = (leases) => leases.map(({ token, ...lease }) => lease)
|
|
2119
2322
|
if (args.action === 'status') {
|
|
2120
2323
|
const info = await inspectLeases(tower.commonDir)
|
|
2121
|
-
|
|
2324
|
+
const leases = redact(info.leases ?? [])
|
|
2325
|
+
return { ok: true, action: 'status', ...info, leases }
|
|
2122
2326
|
}
|
|
2123
2327
|
requireTowerActor(actor, args.action === 'acquire' ? 'acquire a lease' : 'release a lease')
|
|
2124
|
-
if (typeof args.name !== 'string' || args.name.trim().length === 0)
|
|
2328
|
+
if (typeof args.name !== 'string' || args.name.trim().length === 0) {
|
|
2329
|
+
throw new TowerToolError('invalid-argument', 'tower_resource requires name')
|
|
2330
|
+
}
|
|
2125
2331
|
const name = args.name.trim()
|
|
2126
2332
|
const ttlMs = Number(args.ttlMs ?? tower.config.leases?.ttlMs ?? 900000)
|
|
2127
2333
|
if (args.action === 'acquire') {
|
|
@@ -2135,9 +2341,11 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
2135
2341
|
return { ok: true, action: 'acquire', lease }
|
|
2136
2342
|
}
|
|
2137
2343
|
const token = args.token
|
|
2138
|
-
if (typeof token !== 'string' || token.length === 0)
|
|
2344
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
2345
|
+
throw new TowerToolError('invalid-argument', 'tower_resource release requires token')
|
|
2346
|
+
}
|
|
2139
2347
|
const released = await releaseLease(tower.commonDir, { name, token, force: args.force === true })
|
|
2140
|
-
return { ok: true, action: 'release', released, leases: (await inspectLeases(tower.commonDir)).leases }
|
|
2348
|
+
return { ok: true, action: 'release', released, leases: redact((await inspectLeases(tower.commonDir)).leases) }
|
|
2141
2349
|
},
|
|
2142
2350
|
})
|
|
2143
2351
|
|
|
@@ -2196,7 +2404,9 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
2196
2404
|
}
|
|
2197
2405
|
requireTowerActor(actor, 'map a legacy mission')
|
|
2198
2406
|
const validation = validateLegacyMap(state, args)
|
|
2199
|
-
if (validation.ok !== true)
|
|
2407
|
+
if (validation.ok !== true) {
|
|
2408
|
+
throw new TowerToolError('invalid-argument', validation.error ?? 'invalid legacy mapping')
|
|
2409
|
+
}
|
|
2200
2410
|
const map = { ...validation.map, by: actor.name, at: Date.now() }
|
|
2201
2411
|
const recorded = await tower.recordLegacyMap(map)
|
|
2202
2412
|
return { ok: true, map: recorded.map, maps: [...(state.legacyMaps ?? []), recorded.map] }
|