dsh-tower 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +22 -8
- package/README.md +20 -8
- package/cordis.patch.yml +10 -0
- package/docs/README.md +8 -4
- package/docs/architecture/overview.md +13 -9
- package/docs/faq.md +33 -2
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +11 -0
- package/docs/guide/quickstart.md +33 -1
- package/docs/operations/runbook.md +23 -0
- package/docs/operations/security.md +23 -0
- package/docs/reference/blockers.md +10 -2
- package/docs/reference/config.md +65 -2
- package/docs/reference/doctor.md +31 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +52 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +65 -23
- package/docs/zh/README.md +58 -0
- package/docs/zh/faq.md +72 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +30 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +48 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +261 -13
- package/lib/batch.js +578 -0
- package/lib/config.js +270 -8
- package/lib/doctor.js +165 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +28 -1
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1554 -182
- package/lib/tower.js +957 -102
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +4 -1
- package/reviewer.js +1 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-review.md +4 -2
- package/skills/tower-worker-protocol.md +3 -1
- package/worker.js +4 -2
package/lib/tower.js
CHANGED
|
@@ -17,18 +17,21 @@ import {
|
|
|
17
17
|
isAncestor,
|
|
18
18
|
tryGit,
|
|
19
19
|
} from './git.js'
|
|
20
|
+
import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch } from './batch.js'
|
|
21
|
+
import { assertOwnership, canonicalPath } from './worktreeOwnership.js'
|
|
20
22
|
import {
|
|
21
23
|
pathsFor,
|
|
22
24
|
exists,
|
|
23
25
|
ensureDir,
|
|
24
26
|
readJson,
|
|
25
27
|
writeJsonAtomic,
|
|
28
|
+
renameWithRetry,
|
|
26
29
|
appendJsonl,
|
|
27
30
|
readActivityTail,
|
|
28
31
|
withLock,
|
|
29
32
|
shortHash,
|
|
30
33
|
} from './store.js'
|
|
31
|
-
import { runtimeConfig } from './config.js'
|
|
34
|
+
import { runtimeConfig, resolveRepoConfig } from './config.js'
|
|
32
35
|
|
|
33
36
|
export const SCHEMA_VERSION = 1
|
|
34
37
|
export const MISSION_STATUSES = [
|
|
@@ -41,6 +44,14 @@ export const MISSION_STATUSES = [
|
|
|
41
44
|
'abandoned',
|
|
42
45
|
]
|
|
43
46
|
const TERMINAL = new Set(['merged', 'abandoned'])
|
|
47
|
+
/** Roster states that no longer represent a live writer. `inactive` is an idle
|
|
48
|
+
* child whose turn finished: it must not block a new owner or consume a worker
|
|
49
|
+
* slot. `unknown` is deliberately NOT included because it cannot be proven
|
|
50
|
+
* stopped. */
|
|
51
|
+
export const ROSTER_INACTIVE_STATES = ['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive']
|
|
52
|
+
const ROSTER_INACTIVE = new Set(ROSTER_INACTIVE_STATES)
|
|
53
|
+
const SNAPSHOT_REF_PREFIX = 'refs/tower/snapshot/'
|
|
54
|
+
const PAUSE_CAUSES = ['user', 'failure', 'resource', 'dependency']
|
|
44
55
|
const TRANSITIONS = {
|
|
45
56
|
planned: ['active', 'abandoned'],
|
|
46
57
|
active: ['completed', 'blocked', 'paused', 'abandoned'],
|
|
@@ -63,15 +74,22 @@ function slugify(title) {
|
|
|
63
74
|
|
|
64
75
|
export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
|
|
65
76
|
export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
|
|
77
|
+
export const FINDING_STATUSES = ['open', 'triaged', 'fixed', 'wontfix']
|
|
78
|
+
export const FINDING_TRANSITIONS = {
|
|
79
|
+
open: ['triaged', 'fixed', 'wontfix'],
|
|
80
|
+
triaged: ['open', 'fixed', 'wontfix'],
|
|
81
|
+
fixed: ['open'],
|
|
82
|
+
wontfix: ['open'],
|
|
83
|
+
}
|
|
66
84
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
67
85
|
const MAX_REVIEW_ROUNDS = 5
|
|
68
86
|
const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
|
|
69
87
|
const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
|
|
70
88
|
const MERGE_MAX_BUFFER = 64 * 1024 * 1024
|
|
71
89
|
|
|
72
|
-
function mergeTimeoutMs(value) {
|
|
90
|
+
function mergeTimeoutMs(value, config) {
|
|
73
91
|
if (value === undefined || value === null) {
|
|
74
|
-
const configured = runtimeConfig().merge.timeoutMs
|
|
92
|
+
const configured = (config ?? runtimeConfig()).merge.timeoutMs
|
|
75
93
|
return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
|
|
76
94
|
}
|
|
77
95
|
const ms = Number(value)
|
|
@@ -284,7 +302,26 @@ function normalizeMissionInput(input, index) {
|
|
|
284
302
|
}
|
|
285
303
|
}
|
|
286
304
|
const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
|
|
287
|
-
|
|
305
|
+
const references = []
|
|
306
|
+
if (input.references !== undefined) {
|
|
307
|
+
if (!Array.isArray(input.references)) fail(`missions[${index}].references must be an array of pointers`)
|
|
308
|
+
if (input.references.length > 20) fail(`missions[${index}].references supports at most 20 pointers`)
|
|
309
|
+
for (const reference of input.references) {
|
|
310
|
+
if (typeof reference !== 'string' || reference.trim().length === 0) {
|
|
311
|
+
fail(`missions[${index}].references entries must be non-empty strings`)
|
|
312
|
+
}
|
|
313
|
+
if (!references.includes(reference.trim())) references.push(reference.trim())
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const reviewPolicy = input.reviewPolicy === undefined ? 'required-clean' : input.reviewPolicy
|
|
317
|
+
if (!['required-clean', 'none'].includes(reviewPolicy)) {
|
|
318
|
+
fail(`missions[${index}].reviewPolicy must be "required-clean" or "none"`)
|
|
319
|
+
}
|
|
320
|
+
const reviewExemptionReason = typeof input.reviewExemptionReason === 'string' ? input.reviewExemptionReason.trim() : ''
|
|
321
|
+
if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
|
|
322
|
+
fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
|
|
323
|
+
}
|
|
324
|
+
return { title, kind, scope, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
|
|
288
325
|
}
|
|
289
326
|
|
|
290
327
|
function newMission(id, input, now) {
|
|
@@ -296,6 +333,9 @@ function newMission(id, input, now) {
|
|
|
296
333
|
tasks: input.tasks,
|
|
297
334
|
deps: input.deps,
|
|
298
335
|
context: input.context,
|
|
336
|
+
references: input.references ?? [],
|
|
337
|
+
reviewPolicy: input.reviewPolicy ?? 'required-clean',
|
|
338
|
+
reviewExemptionReason: input.reviewExemptionReason ?? '',
|
|
299
339
|
status: 'planned',
|
|
300
340
|
owner: null,
|
|
301
341
|
blockers: [],
|
|
@@ -324,6 +364,24 @@ export function validateState(state, statePath) {
|
|
|
324
364
|
}
|
|
325
365
|
if (!Array.isArray(mission.tasks)) fail(`mission ${mission.id} has no tasks array${where}`)
|
|
326
366
|
if (!Array.isArray(mission.deps)) fail(`mission ${mission.id} has no deps array${where}`)
|
|
367
|
+
if (mission.references !== undefined && !Array.isArray(mission.references)) {
|
|
368
|
+
fail(`mission ${mission.id} has no references array${where}`)
|
|
369
|
+
}
|
|
370
|
+
if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
|
|
371
|
+
fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
|
|
372
|
+
}
|
|
373
|
+
if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
|
|
374
|
+
fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
|
|
375
|
+
}
|
|
376
|
+
if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
|
|
377
|
+
fail(`mission ${mission.id} has no testRuns array${where}`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const key of ['batches', 'legacySnapshots', 'legacyMaps', 'deliveries']) {
|
|
381
|
+
if (state[key] !== undefined && !Array.isArray(state[key])) fail(`tower state ${key} is not an array${where}`)
|
|
382
|
+
}
|
|
383
|
+
if (state.mergeIntent !== undefined && state.mergeIntent !== null && !isPlainObject(state.mergeIntent)) {
|
|
384
|
+
fail(`tower state mergeIntent is not an object${where}`)
|
|
327
385
|
}
|
|
328
386
|
if (state.roster !== undefined) {
|
|
329
387
|
if (!Array.isArray(state.roster)) fail(`tower state roster is not an array${where}`)
|
|
@@ -358,6 +416,9 @@ export function validateState(state, statePath) {
|
|
|
358
416
|
if (!finding || typeof finding.id !== 'string') fail(`invalid finding entry${where}`)
|
|
359
417
|
if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
|
|
360
418
|
ids.add(finding.id)
|
|
419
|
+
if (finding.status !== undefined && !FINDING_STATUSES.includes(finding.status)) {
|
|
420
|
+
fail(`finding ${finding.id} has unknown status ${finding.status}${where}`)
|
|
421
|
+
}
|
|
361
422
|
}
|
|
362
423
|
}
|
|
363
424
|
if (state.reviews !== undefined) {
|
|
@@ -407,6 +468,9 @@ export function missionSummary(mission) {
|
|
|
407
468
|
blockers: mission.blockers ?? [],
|
|
408
469
|
tasks: taskCounts(mission.tasks ?? []),
|
|
409
470
|
scopeGeneration: mission.scopeGeneration ?? 1,
|
|
471
|
+
ownerGeneration: mission.ownerGeneration ?? 0,
|
|
472
|
+
pauseCause: mission.pauseCause ?? null,
|
|
473
|
+
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
410
474
|
mergedCommit: mission.mergedCommit ?? null,
|
|
411
475
|
mergedAt: mission.mergedAt ?? null,
|
|
412
476
|
updatedAt: mission.updatedAt,
|
|
@@ -418,20 +482,96 @@ function fullMission(mission) {
|
|
|
418
482
|
...missionSummary(mission),
|
|
419
483
|
tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
|
|
420
484
|
notes: mission.notes ?? [],
|
|
485
|
+
completionReport: mission.completionReport ?? null,
|
|
486
|
+
authorizationRef: mission.authorizationRef ?? null,
|
|
487
|
+
references: mission.references ?? [],
|
|
488
|
+
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
489
|
+
reviewExemptionReason: mission.reviewExemptionReason ?? '',
|
|
490
|
+
testRuns: mission.testRuns ?? [],
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function findingSummary(finding) {
|
|
495
|
+
return {
|
|
496
|
+
id: finding.id,
|
|
497
|
+
seq: finding.seq ?? null,
|
|
498
|
+
type: finding.type,
|
|
499
|
+
title: finding.title,
|
|
500
|
+
severity: finding.severity ?? null,
|
|
501
|
+
status: finding.status ?? 'open',
|
|
502
|
+
missionId: finding.missionId ?? null,
|
|
503
|
+
branch: finding.branch ?? null,
|
|
504
|
+
reportedBy: finding.reportedBy ?? null,
|
|
505
|
+
at: finding.at ?? null,
|
|
506
|
+
updatedAt: finding.updatedAt ?? null,
|
|
507
|
+
closedAt: finding.closedAt ?? null,
|
|
421
508
|
}
|
|
422
509
|
}
|
|
423
510
|
|
|
511
|
+
function countFindings(findings) {
|
|
512
|
+
const counts = {}
|
|
513
|
+
for (const status of FINDING_STATUSES) counts[status] = 0
|
|
514
|
+
for (const finding of findings) {
|
|
515
|
+
const status = finding.status ?? 'open'
|
|
516
|
+
counts[status] = (counts[status] ?? 0) + 1
|
|
517
|
+
}
|
|
518
|
+
return counts
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function liveWorkerEntry(state, missionId) {
|
|
522
|
+
return (state.roster ?? []).find(
|
|
523
|
+
(entry) => entry.missionId === missionId && entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state),
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function workerEntries(state, missionId) {
|
|
528
|
+
return (state.roster ?? []).filter((entry) => entry.missionId === missionId && entry.role === 'worker')
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function countLiveWorkers(state) {
|
|
532
|
+
return (state.roster ?? []).filter((entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state)).length
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function normalizePauseCause(value) {
|
|
536
|
+
if (value === undefined || value === null || value === '') return 'user'
|
|
537
|
+
if (!PAUSE_CAUSES.includes(value)) fail(`pause_cause must be one of ${PAUSE_CAUSES.join('|')}`)
|
|
538
|
+
return value
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function normalizeChecks(checks) {
|
|
542
|
+
if (checks === undefined) return []
|
|
543
|
+
if (!Array.isArray(checks)) fail('checks must be an array')
|
|
544
|
+
if (checks.length > 20) fail('checks may contain at most 20 entries')
|
|
545
|
+
return checks.map((check, index) => {
|
|
546
|
+
if (!isPlainObject(check)) fail(`checks[${index}] must be an object`)
|
|
547
|
+
const name = typeof check.name === 'string' ? check.name.trim() : ''
|
|
548
|
+
if (!name) fail(`checks[${index}].name must be a non-empty string`)
|
|
549
|
+
const entry = { name }
|
|
550
|
+
if (typeof check.command === 'string' && check.command.trim()) entry.command = check.command.trim()
|
|
551
|
+
if (check.exitCode !== undefined && check.exitCode !== null) {
|
|
552
|
+
if (!Number.isInteger(check.exitCode)) fail(`checks[${index}].exitCode must be an integer`)
|
|
553
|
+
entry.exitCode = check.exitCode
|
|
554
|
+
}
|
|
555
|
+
if (typeof check.evidence === 'string' && check.evidence.trim()) entry.evidence = check.evidence.trim().slice(0, 4000)
|
|
556
|
+
return entry
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
424
560
|
export class Tower {
|
|
425
|
-
constructor(repo) {
|
|
561
|
+
constructor(repo, config) {
|
|
426
562
|
this.root = repo.root
|
|
427
563
|
this.commonDir = repo.commonDir
|
|
428
564
|
this.branch = repo.branch
|
|
429
565
|
this.paths = pathsFor(repo.commonDir)
|
|
566
|
+
// Effective config for this repository: code defaults < profile config <
|
|
567
|
+
// repo config file. Per-call tool arguments are applied by the caller.
|
|
568
|
+
this.config = config ?? { ...runtimeConfig(), configPath: null, repoConfigPresent: false }
|
|
430
569
|
}
|
|
431
570
|
|
|
432
571
|
static async open(repoRoot) {
|
|
433
572
|
const repo = await resolveRepo(repoRoot)
|
|
434
|
-
|
|
573
|
+
const config = await resolveRepoConfig(repo)
|
|
574
|
+
return new Tower(repo, config)
|
|
435
575
|
}
|
|
436
576
|
|
|
437
577
|
async load() {
|
|
@@ -445,8 +585,52 @@ export class Tower {
|
|
|
445
585
|
async init(input = {}) {
|
|
446
586
|
await ensureDir(this.paths.dir)
|
|
447
587
|
return withLock(this.paths, async () => {
|
|
588
|
+
const now = Date.now()
|
|
589
|
+
const dirty = !(await isClean(this.root))
|
|
590
|
+
const committed = await hasAnyCommit(this.root)
|
|
591
|
+
const resolveBase = async () => {
|
|
592
|
+
let base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null
|
|
593
|
+
if (base) {
|
|
594
|
+
if (/^refs\//.test(base) || base.startsWith('origin/')) {
|
|
595
|
+
fail(`base must be a local branch name, not ${base}`)
|
|
596
|
+
}
|
|
597
|
+
if (committed && !(await localBranchExists(this.root, base))) {
|
|
598
|
+
fail(`base branch "${base}" does not exist locally`)
|
|
599
|
+
}
|
|
600
|
+
return base
|
|
601
|
+
}
|
|
602
|
+
base = this.branch
|
|
603
|
+
if (!base && !committed) base = 'main'
|
|
604
|
+
if (!base) fail('repository is in detached HEAD; pass an explicit local base branch')
|
|
605
|
+
return base
|
|
606
|
+
}
|
|
607
|
+
|
|
448
608
|
if (await exists(this.paths.state)) {
|
|
449
609
|
const state = await this.load()
|
|
610
|
+
let reanchored = false
|
|
611
|
+
if (input.reanchor === true) {
|
|
612
|
+
const active = state.missions.filter((mission) => !TERMINAL.has(mission.status))
|
|
613
|
+
if (active.length > 0) {
|
|
614
|
+
fail(`cannot reanchor base while ${active.length} mission(s) are not merged or abandoned`)
|
|
615
|
+
}
|
|
616
|
+
const base = await resolveBase()
|
|
617
|
+
if (base !== state.base) {
|
|
618
|
+
const refs = (await tryGit(this.root, ['for-each-ref', '--format=%(refname)', SNAPSHOT_REF_PREFIX])) ?? ''
|
|
619
|
+
for (const ref of refs.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
|
620
|
+
await git(this.root, ['update-ref', '-d', ref]).catch(() => {})
|
|
621
|
+
}
|
|
622
|
+
state.base = base
|
|
623
|
+
state.baseChangedAt = now
|
|
624
|
+
delete state.snapshotCommit
|
|
625
|
+
delete state.snapshotRef
|
|
626
|
+
reanchored = true
|
|
627
|
+
state.revision += 1
|
|
628
|
+
state.updatedAt = now
|
|
629
|
+
validateState(state, this.paths.state)
|
|
630
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
631
|
+
await appendJsonl(this.paths.activity, { at: now, type: 'init', text: `base reanchored to ${base}` })
|
|
632
|
+
}
|
|
633
|
+
}
|
|
450
634
|
return {
|
|
451
635
|
created: false,
|
|
452
636
|
repoRoot: state.repoRoot,
|
|
@@ -455,23 +639,24 @@ export class Tower {
|
|
|
455
639
|
base: state.base,
|
|
456
640
|
revision: state.revision,
|
|
457
641
|
missions: state.missions.length,
|
|
642
|
+
reanchored,
|
|
643
|
+
dirty,
|
|
644
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
458
645
|
}
|
|
459
646
|
}
|
|
460
647
|
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
let
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (committed && !(await localBranchExists(this.root, base))) {
|
|
469
|
-
fail(`base branch "${base}" does not exist locally`)
|
|
648
|
+
const base = await resolveBase()
|
|
649
|
+
let snapshotCommit = null
|
|
650
|
+
let snapshotRef = null
|
|
651
|
+
if (input.snapshotDirty === true && dirty) {
|
|
652
|
+
snapshotCommit = ((await tryGit(this.root, ['stash', 'create'])) ?? '').trim() || null
|
|
653
|
+
if (!snapshotCommit) {
|
|
654
|
+
fail('snapshotDirty was requested but no snapshot commit could be created; commit or stash the working tree first')
|
|
470
655
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
656
|
+
// A dangling commit can be pruned by git gc; keep a ref so the mission
|
|
657
|
+
// baseline stays reachable for the life of the state.
|
|
658
|
+
snapshotRef = `${SNAPSHOT_REF_PREFIX}${snapshotCommit}`
|
|
659
|
+
await git(this.root, ['update-ref', snapshotRef, snapshotCommit])
|
|
475
660
|
}
|
|
476
661
|
|
|
477
662
|
const state = {
|
|
@@ -482,11 +667,17 @@ export class Tower {
|
|
|
482
667
|
revision: 0,
|
|
483
668
|
createdAt: now,
|
|
484
669
|
updatedAt: now,
|
|
670
|
+
dirtyBase: dirty,
|
|
671
|
+
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
485
672
|
missions: [],
|
|
486
673
|
}
|
|
487
674
|
validateState(state, this.paths.state)
|
|
488
675
|
await writeJsonAtomic(this.paths.state, state)
|
|
489
|
-
await appendJsonl(this.paths.activity, {
|
|
676
|
+
await appendJsonl(this.paths.activity, {
|
|
677
|
+
at: now,
|
|
678
|
+
type: 'init',
|
|
679
|
+
text: `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}`,
|
|
680
|
+
})
|
|
490
681
|
return {
|
|
491
682
|
created: true,
|
|
492
683
|
repoRoot: this.root,
|
|
@@ -495,6 +686,10 @@ export class Tower {
|
|
|
495
686
|
base,
|
|
496
687
|
revision: 0,
|
|
497
688
|
missions: 0,
|
|
689
|
+
reanchored: false,
|
|
690
|
+
dirty,
|
|
691
|
+
snapshotCommit,
|
|
692
|
+
snapshotRef,
|
|
498
693
|
}
|
|
499
694
|
})
|
|
500
695
|
}
|
|
@@ -540,11 +735,36 @@ export class Tower {
|
|
|
540
735
|
}
|
|
541
736
|
|
|
542
737
|
async mission(input = {}) {
|
|
543
|
-
|
|
738
|
+
const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
|
|
739
|
+
if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
|
|
544
740
|
return withLock(this.paths, async () => {
|
|
545
741
|
const state = await this.load()
|
|
546
|
-
const mission = state.missions.find((entry) => entry.id ===
|
|
547
|
-
if (!mission) fail(`unknown mission "${
|
|
742
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
743
|
+
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
744
|
+
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
745
|
+
const workerRequest = actor.role === 'worker'
|
|
746
|
+
if (actor.role === 'reviewer') {
|
|
747
|
+
fail('only the tower or a worker may update a mission; reviewers submit tower_review')
|
|
748
|
+
}
|
|
749
|
+
if (workerRequest && mission.owner && mission.owner !== actor.name) {
|
|
750
|
+
fail(`worker ${actor.name} does not own mission ${mission.id} (owner: ${mission.owner})`)
|
|
751
|
+
}
|
|
752
|
+
if (workerRequest) {
|
|
753
|
+
if (input.status !== undefined) {
|
|
754
|
+
fail('only the tower can change mission status directly; workers report completion with tower_complete')
|
|
755
|
+
}
|
|
756
|
+
if (input.scope !== undefined) fail('only the tower can change mission scope')
|
|
757
|
+
if (input.clear_blockers === true) fail('only the tower can clear blockers')
|
|
758
|
+
if (input.pause_cause !== undefined || input.authorization !== undefined) {
|
|
759
|
+
fail('only the tower can record pause/authorization metadata')
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (input.pause_cause !== undefined && input.status !== 'paused') {
|
|
763
|
+
fail('pause_cause is only valid together with status "paused"')
|
|
764
|
+
}
|
|
765
|
+
if (input.status === 'abandoned' && (typeof input.note !== 'string' || input.note.trim().length === 0)) {
|
|
766
|
+
fail('abandoning a mission requires a non-empty note (reason)')
|
|
767
|
+
}
|
|
548
768
|
const now = Date.now()
|
|
549
769
|
const changes = []
|
|
550
770
|
|
|
@@ -557,6 +777,20 @@ export class Tower {
|
|
|
557
777
|
if (!allowed.includes(input.status)) {
|
|
558
778
|
fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`)
|
|
559
779
|
}
|
|
780
|
+
if (input.status === 'paused') {
|
|
781
|
+
mission.pauseCause = normalizePauseCause(input.pause_cause)
|
|
782
|
+
}
|
|
783
|
+
if (mission.status === 'paused' && input.status === 'active') {
|
|
784
|
+
const requireAuthorization = this.config.mode.requireResumeAuthorization === true
|
|
785
|
+
if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
|
|
786
|
+
if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
|
|
787
|
+
fail('resuming a user-paused mission requires a non-empty authorization reference')
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (typeof input.authorization === 'string' && input.authorization.trim()) {
|
|
791
|
+
mission.authorizationRef = input.authorization.trim()
|
|
792
|
+
}
|
|
793
|
+
}
|
|
560
794
|
mission.status = input.status
|
|
561
795
|
changes.push(`status=${input.status}`)
|
|
562
796
|
}
|
|
@@ -590,6 +824,9 @@ export class Tower {
|
|
|
590
824
|
if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
|
|
591
825
|
fail('blocker must be a non-empty string')
|
|
592
826
|
}
|
|
827
|
+
if (TERMINAL.has(mission.status)) {
|
|
828
|
+
fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
|
|
829
|
+
}
|
|
593
830
|
mission.blockers.push(input.blocker.trim())
|
|
594
831
|
mission.status = 'blocked'
|
|
595
832
|
changes.push('blocker added')
|
|
@@ -661,20 +898,17 @@ export class Tower {
|
|
|
661
898
|
|
|
662
899
|
async assertWorktree(worktree) {
|
|
663
900
|
if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (branch !== worktree.branch) {
|
|
673
|
-
fail(`worktree ${wtPath} is on ${branch ?? 'detached HEAD'}, expected ${worktree.branch}`)
|
|
674
|
-
}
|
|
901
|
+
const owned = await assertOwnership({
|
|
902
|
+
storedPath: worktree.path,
|
|
903
|
+
storedRealPath: worktree.realPath,
|
|
904
|
+
branch: worktree.branch,
|
|
905
|
+
commonDir: this.commonDir,
|
|
906
|
+
missionId: worktree.missionId,
|
|
907
|
+
})
|
|
908
|
+
worktree.realPath = owned.realPath
|
|
675
909
|
const registered = await listWorktrees(this.root)
|
|
676
|
-
if (!registered.some((entry) => path.resolve(entry.path) ===
|
|
677
|
-
fail(`worktree ${
|
|
910
|
+
if (!registered.some((entry) => path.resolve(entry.path) === path.resolve(owned.path))) {
|
|
911
|
+
fail(`worktree ${owned.path} is not registered with git`)
|
|
678
912
|
}
|
|
679
913
|
return worktree
|
|
680
914
|
}
|
|
@@ -703,6 +937,34 @@ export class Tower {
|
|
|
703
937
|
})
|
|
704
938
|
}
|
|
705
939
|
|
|
940
|
+
/** Record that a live agent was interrupted by the tower. */
|
|
941
|
+
async recordInterrupt(input) {
|
|
942
|
+
return withLock(this.paths, async () => {
|
|
943
|
+
const state = await this.load()
|
|
944
|
+
const entry = (state.roster ?? []).find(
|
|
945
|
+
(row) =>
|
|
946
|
+
(input.agentId !== undefined && row.agentId === input.agentId) ||
|
|
947
|
+
(input.name !== undefined && row.name === input.name),
|
|
948
|
+
)
|
|
949
|
+
if (!entry) fail(`unknown roster agent "${input.name ?? input.agentId}"`)
|
|
950
|
+
const now = Date.now()
|
|
951
|
+
entry.state = 'interrupted'
|
|
952
|
+
entry.outcome = String(input.reason ?? 'interrupted by tower').slice(0, 1000)
|
|
953
|
+
entry.outcomeAt = now
|
|
954
|
+
state.revision += 1
|
|
955
|
+
state.updatedAt = now
|
|
956
|
+
validateState(state, this.paths.state)
|
|
957
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
958
|
+
await appendJsonl(this.paths.activity, {
|
|
959
|
+
at: now,
|
|
960
|
+
type: 'spawn',
|
|
961
|
+
missionId: entry.missionId,
|
|
962
|
+
text: `${entry.name} interrupted: ${entry.outcome}`,
|
|
963
|
+
})
|
|
964
|
+
return entry
|
|
965
|
+
})
|
|
966
|
+
}
|
|
967
|
+
|
|
706
968
|
async provision(input = {}) {
|
|
707
969
|
const missionId = input.missionId
|
|
708
970
|
if (typeof missionId !== 'string' || missionId.trim().length === 0) {
|
|
@@ -717,12 +979,19 @@ export class Tower {
|
|
|
717
979
|
return { mission, worktree: mission.worktree, created: false }
|
|
718
980
|
}
|
|
719
981
|
|
|
720
|
-
const baseCommit =
|
|
982
|
+
const baseCommit = input.baseCommit
|
|
983
|
+
? String(input.baseCommit)
|
|
984
|
+
: input.includeDirtyBase === false
|
|
985
|
+
? await revParse(this.root, state.base)
|
|
986
|
+
: state.snapshotCommit && (await tryGit(this.root, ['cat-file', '-e', `${state.snapshotCommit}^{commit}`])) !== null
|
|
987
|
+
? state.snapshotCommit
|
|
988
|
+
: await revParse(this.root, state.base)
|
|
721
989
|
const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
|
|
990
|
+
const configuredRoot = this.config.worktrees.root
|
|
722
991
|
const root = input.worktreeRoot
|
|
723
992
|
? path.resolve(input.worktreeRoot)
|
|
724
|
-
:
|
|
725
|
-
? path.resolve(
|
|
993
|
+
: configuredRoot
|
|
994
|
+
? path.resolve(configuredRoot)
|
|
726
995
|
: path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
|
|
727
996
|
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
728
997
|
this.assertOutside(root)
|
|
@@ -734,7 +1003,7 @@ export class Tower {
|
|
|
734
1003
|
|
|
735
1004
|
await addWorktree(this.root, { path: wtPath, branch, commit: baseCommit })
|
|
736
1005
|
const now = Date.now()
|
|
737
|
-
const worktree = { path: wtPath, branch, baseCommit, createdAt: now }
|
|
1006
|
+
const worktree = { path: wtPath, branch, baseCommit, createdAt: now, realPath: await canonicalPath(wtPath) }
|
|
738
1007
|
mission.worktree = worktree
|
|
739
1008
|
mission.baseCommit = baseCommit
|
|
740
1009
|
mission.updatedAt = now
|
|
@@ -758,9 +1027,37 @@ export class Tower {
|
|
|
758
1027
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
759
1028
|
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
760
1029
|
const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
|
|
1030
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1031
|
+
const reviews = branch
|
|
1032
|
+
? (state.reviews ?? [])
|
|
1033
|
+
.filter((review) => review.branch === branch)
|
|
1034
|
+
.slice()
|
|
1035
|
+
.sort((a, b) => b.round - a.round)
|
|
1036
|
+
.slice(0, 3)
|
|
1037
|
+
: []
|
|
1038
|
+
const findings = (state.findings ?? [])
|
|
1039
|
+
.filter((finding) => finding.missionId === mission.id)
|
|
1040
|
+
.slice()
|
|
1041
|
+
.sort((a, b) => b.seq - a.seq)
|
|
1042
|
+
.slice(0, 10)
|
|
761
1043
|
return {
|
|
762
1044
|
base: state.base,
|
|
763
1045
|
mission: fullMission(mission),
|
|
1046
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
1047
|
+
completionReport: mission.completionReport ?? null,
|
|
1048
|
+
reviews: reviews.map((review) => ({
|
|
1049
|
+
id: review.id,
|
|
1050
|
+
round: review.round,
|
|
1051
|
+
status: review.status,
|
|
1052
|
+
mergeVerdict: review.mergeVerdict,
|
|
1053
|
+
reviewer: review.reviewer,
|
|
1054
|
+
findings: review.findings,
|
|
1055
|
+
checks: review.checks ?? [],
|
|
1056
|
+
decision: review.decision,
|
|
1057
|
+
reviewedCommit: review.reviewedCommit,
|
|
1058
|
+
at: review.at,
|
|
1059
|
+
})),
|
|
1060
|
+
findings: findings.map((finding) => findingSummary(finding)),
|
|
764
1061
|
deps: (mission.deps ?? []).map((id) => {
|
|
765
1062
|
const dep = byId.get(id)
|
|
766
1063
|
return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
|
|
@@ -795,11 +1092,35 @@ export class Tower {
|
|
|
795
1092
|
state: 'active',
|
|
796
1093
|
spawnedAt: now,
|
|
797
1094
|
}
|
|
798
|
-
state.roster.push(entry)
|
|
799
1095
|
if (input.role === 'worker') {
|
|
1096
|
+
const previous = workerEntries(state, mission.id)
|
|
1097
|
+
const live = previous.find((entry) => !ROSTER_INACTIVE.has(entry.state))
|
|
1098
|
+
if (live && !input.takeover) {
|
|
1099
|
+
fail(
|
|
1100
|
+
`mission ${mission.id} already has a live worker "${live.name}"; stop it with tower_interrupt, or pass takeover:true after it is no longer running`,
|
|
1101
|
+
)
|
|
1102
|
+
}
|
|
1103
|
+
// A new owner always retires the mission's older worker entries, including
|
|
1104
|
+
// idle `inactive` ones; otherwise the next spawn would see several entries
|
|
1105
|
+
// for the same mission and the old idle worker would block forever.
|
|
1106
|
+
for (const old of previous) {
|
|
1107
|
+
if (old.state === 'retired') continue
|
|
1108
|
+
old.state = 'retired'
|
|
1109
|
+
old.outcome = `replaced by ${input.name}${input.takeover ? ' (takeover)' : ''}`
|
|
1110
|
+
old.outcomeAt = now
|
|
1111
|
+
}
|
|
1112
|
+
const liveCount = countLiveWorkers(state)
|
|
1113
|
+
const max = Number(this.config.spawn.maxLiveWorkers) || 4
|
|
1114
|
+
if (liveCount >= max) {
|
|
1115
|
+
fail(`live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`)
|
|
1116
|
+
}
|
|
1117
|
+
const generation = (mission.ownerGeneration ?? 0) + 1
|
|
1118
|
+
entry.generation = generation
|
|
1119
|
+
mission.ownerGeneration = generation
|
|
800
1120
|
mission.owner = input.name
|
|
801
1121
|
if (mission.status === 'planned') mission.status = 'active'
|
|
802
1122
|
}
|
|
1123
|
+
state.roster.push(entry)
|
|
803
1124
|
mission.updatedAt = now
|
|
804
1125
|
state.revision += 1
|
|
805
1126
|
state.updatedAt = now
|
|
@@ -836,6 +1157,129 @@ export class Tower {
|
|
|
836
1157
|
}
|
|
837
1158
|
}
|
|
838
1159
|
|
|
1160
|
+
async recordTestRun(input = {}) {
|
|
1161
|
+
return withLock(this.paths, async () => {
|
|
1162
|
+
const state = await this.load()
|
|
1163
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1164
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1165
|
+
if (!mission.testRuns) mission.testRuns = []
|
|
1166
|
+
const receipt = { ...input.receipt, recordedAt: Date.now() }
|
|
1167
|
+
mission.testRuns.push(receipt)
|
|
1168
|
+
if (mission.testRuns.length > 50) mission.testRuns = mission.testRuns.slice(-50)
|
|
1169
|
+
state.revision += 1
|
|
1170
|
+
state.updatedAt = Date.now()
|
|
1171
|
+
validateState(state, this.paths.state)
|
|
1172
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1173
|
+
await appendJsonl(this.paths.activity, {
|
|
1174
|
+
at: Date.now(),
|
|
1175
|
+
type: 'test',
|
|
1176
|
+
missionId: mission.id,
|
|
1177
|
+
text: receipt.ok === true ? `passed ${receipt.tests?.length ?? 0} test(s)` : `failed: ${String(receipt.error ?? 'unknown').slice(0, 200)}`,
|
|
1178
|
+
})
|
|
1179
|
+
return receipt
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async recordLegacySnapshot(snapshot) {
|
|
1184
|
+
return withLock(this.paths, async () => {
|
|
1185
|
+
const state = await this.load()
|
|
1186
|
+
if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
|
|
1187
|
+
const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
|
|
1188
|
+
if (existing) return { snapshot: existing, duplicate: true }
|
|
1189
|
+
state.legacySnapshots.push(snapshot)
|
|
1190
|
+
state.revision += 1
|
|
1191
|
+
state.updatedAt = Date.now()
|
|
1192
|
+
validateState(state, this.paths.state)
|
|
1193
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1194
|
+
return { snapshot, duplicate: false }
|
|
1195
|
+
})
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async recordLegacyMap(map) {
|
|
1199
|
+
return withLock(this.paths, async () => {
|
|
1200
|
+
const state = await this.load()
|
|
1201
|
+
if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
|
|
1202
|
+
const existing = state.legacyMaps.find(
|
|
1203
|
+
(entry) => entry.sourceHash === map.sourceHash && entry.oldId === map.oldId,
|
|
1204
|
+
)
|
|
1205
|
+
if (existing) return { map: existing, duplicate: true }
|
|
1206
|
+
state.legacyMaps.push(map)
|
|
1207
|
+
state.revision += 1
|
|
1208
|
+
state.updatedAt = Date.now()
|
|
1209
|
+
validateState(state, this.paths.state)
|
|
1210
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1211
|
+
return { map, duplicate: false }
|
|
1212
|
+
})
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async createBatch(input = {}, actor = 'tower') {
|
|
1216
|
+
return withLock(this.paths, async () => {
|
|
1217
|
+
const state = await this.load()
|
|
1218
|
+
const normalized = validateBatchCreate(state, input)
|
|
1219
|
+
if (!Array.isArray(state.batches)) state.batches = []
|
|
1220
|
+
const max = state.batches.reduce((value, batch) => {
|
|
1221
|
+
const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
|
|
1222
|
+
return match ? Math.max(value, Number(match[1])) : value
|
|
1223
|
+
}, 0)
|
|
1224
|
+
const batch = {
|
|
1225
|
+
id: `B${max + 1}`,
|
|
1226
|
+
status: 'open',
|
|
1227
|
+
...normalized,
|
|
1228
|
+
createdBy: actor,
|
|
1229
|
+
createdAt: Date.now(),
|
|
1230
|
+
}
|
|
1231
|
+
state.batches.push(batch)
|
|
1232
|
+
state.revision += 1
|
|
1233
|
+
state.updatedAt = batch.createdAt
|
|
1234
|
+
validateState(state, this.paths.state)
|
|
1235
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1236
|
+
await appendJsonl(this.paths.activity, {
|
|
1237
|
+
at: batch.createdAt,
|
|
1238
|
+
type: 'batch',
|
|
1239
|
+
text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
|
|
1240
|
+
})
|
|
1241
|
+
return batch
|
|
1242
|
+
})
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async closeBatch(batchId, options = {}, actor = 'tower') {
|
|
1246
|
+
return withLock(this.paths, async () => {
|
|
1247
|
+
const state = await this.load()
|
|
1248
|
+
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
1249
|
+
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
1250
|
+
const result = validateBatchClose(state, batch, { ...options, state })
|
|
1251
|
+
const blockers = Array.isArray(result.blockers) ? result.blockers : []
|
|
1252
|
+
if (blockers.length > 0) return { ok: false, batch, blockers, checks: result.checks ?? [] }
|
|
1253
|
+
batch.status = 'closed'
|
|
1254
|
+
batch.closedAt = Date.now()
|
|
1255
|
+
batch.closedBy = actor
|
|
1256
|
+
batch.close = {
|
|
1257
|
+
checks: options.checks ?? [],
|
|
1258
|
+
treeTip: options.treeTip ?? null,
|
|
1259
|
+
receiptHashes: options.receiptHashes ?? {},
|
|
1260
|
+
at: batch.closedAt,
|
|
1261
|
+
}
|
|
1262
|
+
state.revision += 1
|
|
1263
|
+
state.updatedAt = batch.closedAt
|
|
1264
|
+
validateState(state, this.paths.state)
|
|
1265
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1266
|
+
await appendJsonl(this.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
|
|
1267
|
+
return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
|
|
1268
|
+
})
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async batchStatus(batchId) {
|
|
1272
|
+
const state = await this.load()
|
|
1273
|
+
const batch = batchId
|
|
1274
|
+
? (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
1275
|
+
: null
|
|
1276
|
+
if (batchId && !batch) fail(`unknown batch "${batchId}"`)
|
|
1277
|
+
return {
|
|
1278
|
+
batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
|
|
1279
|
+
...(batch ? { batch: summarizeBatch(state, batch) } : {}),
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
839
1283
|
async markRetired(agentIds) {
|
|
840
1284
|
if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
841
1285
|
return withLock(this.paths, async () => {
|
|
@@ -864,6 +1308,77 @@ export class Tower {
|
|
|
864
1308
|
})
|
|
865
1309
|
}
|
|
866
1310
|
|
|
1311
|
+
async markMissing(agentIds) {
|
|
1312
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
|
|
1313
|
+
return withLock(this.paths, async () => {
|
|
1314
|
+
const state = await this.load()
|
|
1315
|
+
const missing = []
|
|
1316
|
+
const now = Date.now()
|
|
1317
|
+
for (const entry of state.roster ?? []) {
|
|
1318
|
+
if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
|
|
1319
|
+
entry.state = 'missing'
|
|
1320
|
+
entry.missingSince = now
|
|
1321
|
+
missing.push(entry.agentId)
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
if (missing.length > 0) {
|
|
1325
|
+
state.revision += 1
|
|
1326
|
+
state.updatedAt = now
|
|
1327
|
+
validateState(state, this.paths.state)
|
|
1328
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1329
|
+
await appendJsonl(this.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
|
|
1330
|
+
}
|
|
1331
|
+
return { missing }
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Mark live roster entries whose catalog activity disappeared after staleMs.
|
|
1336
|
+
* `authoritative` must be true only when the parent catalog was readable;
|
|
1337
|
+
* unknown state is never treated as stopped. */
|
|
1338
|
+
async sweepRoster(input = {}) {
|
|
1339
|
+
const staleMs = Number(input.staleMs) || 0
|
|
1340
|
+
if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
|
|
1341
|
+
const activity = input.activity ?? {}
|
|
1342
|
+
return withLock(this.paths, async () => {
|
|
1343
|
+
const state = await this.load()
|
|
1344
|
+
const now = Date.now()
|
|
1345
|
+
const marked = []
|
|
1346
|
+
const idled = []
|
|
1347
|
+
for (const entry of state.roster ?? []) {
|
|
1348
|
+
if (ROSTER_INACTIVE.has(entry.state)) continue
|
|
1349
|
+
const observed = activity[entry.agentId]
|
|
1350
|
+
if (observed === 'inactive') {
|
|
1351
|
+
entry.state = 'inactive'
|
|
1352
|
+
entry.outcome = entry.outcome ?? 'idle (catalog)'
|
|
1353
|
+
entry.outcomeAt = now
|
|
1354
|
+
idled.push(entry.name)
|
|
1355
|
+
continue
|
|
1356
|
+
}
|
|
1357
|
+
if (observed === 'running') continue
|
|
1358
|
+
if (observed !== undefined) continue
|
|
1359
|
+
const age = now - Number(entry.spawnedAt ?? now)
|
|
1360
|
+
if (age > staleMs) {
|
|
1361
|
+
entry.state = 'stale'
|
|
1362
|
+
entry.stale = true
|
|
1363
|
+
entry.staleAt = now
|
|
1364
|
+
marked.push(entry.name)
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (marked.length > 0 || idled.length > 0) {
|
|
1368
|
+
state.revision += 1
|
|
1369
|
+
state.updatedAt = now
|
|
1370
|
+
validateState(state, this.paths.state)
|
|
1371
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1372
|
+
await appendJsonl(this.paths.activity, {
|
|
1373
|
+
at: now,
|
|
1374
|
+
type: 'watchdog',
|
|
1375
|
+
text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
|
|
1376
|
+
})
|
|
1377
|
+
}
|
|
1378
|
+
return { marked, idled }
|
|
1379
|
+
})
|
|
1380
|
+
}
|
|
1381
|
+
|
|
867
1382
|
unreadFor(state, name) {
|
|
868
1383
|
return (state.messages ?? []).filter((message) => {
|
|
869
1384
|
if (message.from === name) return false
|
|
@@ -893,6 +1408,7 @@ export class Tower {
|
|
|
893
1408
|
subject: input.subject,
|
|
894
1409
|
body: input.body,
|
|
895
1410
|
scope: input.scope ?? null,
|
|
1411
|
+
consentRef: input.consentRef ?? null,
|
|
896
1412
|
at: now,
|
|
897
1413
|
readBy: [],
|
|
898
1414
|
delivery: [],
|
|
@@ -941,6 +1457,7 @@ export class Tower {
|
|
|
941
1457
|
for (const message of page) {
|
|
942
1458
|
if (!(message.readBy ?? []).includes(reader)) {
|
|
943
1459
|
message.readBy = [...(message.readBy ?? []), reader]
|
|
1460
|
+
message.readAt = { ...(message.readAt ?? {}), [reader]: Date.now() }
|
|
944
1461
|
changed = true
|
|
945
1462
|
}
|
|
946
1463
|
}
|
|
@@ -961,6 +1478,8 @@ export class Tower {
|
|
|
961
1478
|
body: message.body,
|
|
962
1479
|
scope: message.scope ?? null,
|
|
963
1480
|
at: message.at,
|
|
1481
|
+
delivery: message.delivery ?? [],
|
|
1482
|
+
ackAt: message.readAt?.[reader] ?? null,
|
|
964
1483
|
})),
|
|
965
1484
|
pageSize: page.length,
|
|
966
1485
|
unreadRemaining,
|
|
@@ -968,50 +1487,64 @@ export class Tower {
|
|
|
968
1487
|
})
|
|
969
1488
|
}
|
|
970
1489
|
|
|
1490
|
+
/** Read-only completion preconditions shared by tower_complete and the tool
|
|
1491
|
+
* layer's test-discovery preflight. Throws on the first failed condition. */
|
|
1492
|
+
async checkCompletion(input) {
|
|
1493
|
+
const state = await this.load()
|
|
1494
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1495
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1496
|
+
const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
1497
|
+
if (!worker || worker.missionId !== mission.id) {
|
|
1498
|
+
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
1499
|
+
}
|
|
1500
|
+
if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
1501
|
+
if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
|
|
1502
|
+
fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
|
|
1503
|
+
}
|
|
1504
|
+
if (mission.owner && mission.owner !== worker.name) {
|
|
1505
|
+
fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
|
|
1506
|
+
}
|
|
1507
|
+
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
1508
|
+
if (!['active', 'planned'].includes(mission.status)) {
|
|
1509
|
+
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
1510
|
+
}
|
|
1511
|
+
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
1512
|
+
fail('tower_complete requires a non-empty report')
|
|
1513
|
+
}
|
|
1514
|
+
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
1515
|
+
if (openTasks.length > 0) {
|
|
1516
|
+
fail(
|
|
1517
|
+
`mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
|
|
1518
|
+
.map((task) => task.text)
|
|
1519
|
+
.join('; ')}`,
|
|
1520
|
+
)
|
|
1521
|
+
}
|
|
1522
|
+
if ((mission.blockers ?? []).length > 0) {
|
|
1523
|
+
fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
|
|
1524
|
+
}
|
|
1525
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
1526
|
+
await this.assertWorktree(mission.worktree)
|
|
1527
|
+
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
1528
|
+
if (commits < 1) {
|
|
1529
|
+
fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
|
|
1530
|
+
}
|
|
1531
|
+
const unread = this.unreadFor(state, worker.name)
|
|
1532
|
+
if (unread.length > 0) {
|
|
1533
|
+
fail(
|
|
1534
|
+
`mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
|
|
1535
|
+
)
|
|
1536
|
+
}
|
|
1537
|
+
return { state, mission, worker, commits }
|
|
1538
|
+
}
|
|
1539
|
+
|
|
971
1540
|
async complete(input) {
|
|
972
1541
|
return withLock(this.paths, async () => {
|
|
973
|
-
const state = await this.
|
|
974
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
975
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
976
|
-
const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
977
|
-
if (!worker || worker.missionId !== mission.id) {
|
|
978
|
-
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
979
|
-
}
|
|
980
|
-
if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
981
|
-
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
982
|
-
if (!['active', 'planned'].includes(mission.status)) {
|
|
983
|
-
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
984
|
-
}
|
|
985
|
-
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
986
|
-
fail('tower_complete requires a non-empty report')
|
|
987
|
-
}
|
|
988
|
-
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
989
|
-
if (openTasks.length > 0) {
|
|
990
|
-
fail(
|
|
991
|
-
`mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
|
|
992
|
-
.map((task) => task.text)
|
|
993
|
-
.join('; ')}`,
|
|
994
|
-
)
|
|
995
|
-
}
|
|
996
|
-
if ((mission.blockers ?? []).length > 0) {
|
|
997
|
-
fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
|
|
998
|
-
}
|
|
999
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
1000
|
-
await this.assertWorktree(mission.worktree)
|
|
1001
|
-
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
1002
|
-
if (commits < 1) {
|
|
1003
|
-
fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
|
|
1004
|
-
}
|
|
1005
|
-
const unread = this.unreadFor(state, worker.name)
|
|
1006
|
-
if (unread.length > 0) {
|
|
1007
|
-
fail(
|
|
1008
|
-
`mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
|
|
1009
|
-
)
|
|
1010
|
-
}
|
|
1542
|
+
const { state, mission, worker, commits } = await this.checkCompletion(input)
|
|
1011
1543
|
|
|
1544
|
+
const checks = normalizeChecks(input.checks)
|
|
1012
1545
|
const now = Date.now()
|
|
1013
1546
|
mission.status = 'completed'
|
|
1014
|
-
mission.completionReport = { report: input.report.trim(), by: worker.name, at: now }
|
|
1547
|
+
mission.completionReport = { report: input.report.trim(), by: worker.name, at: now, checks }
|
|
1015
1548
|
mission.updatedAt = now
|
|
1016
1549
|
state.revision += 1
|
|
1017
1550
|
state.updatedAt = now
|
|
@@ -1087,6 +1620,111 @@ export class Tower {
|
|
|
1087
1620
|
})
|
|
1088
1621
|
}
|
|
1089
1622
|
|
|
1623
|
+
/** List or read findings. Read-only paths never take the writer lock. */
|
|
1624
|
+
async readFindings(input = {}) {
|
|
1625
|
+
const state = await this.load()
|
|
1626
|
+
const all = state.findings ?? []
|
|
1627
|
+
const counts = countFindings(all)
|
|
1628
|
+
if (input.id !== undefined && input.id !== null && input.id !== '') {
|
|
1629
|
+
const finding = all.find((entry) => entry.id === input.id)
|
|
1630
|
+
if (!finding) fail(`unknown finding "${input.id}"`)
|
|
1631
|
+
return { finding: { ...finding }, counts, total: all.length, filtered: 1, action: 'read' }
|
|
1632
|
+
}
|
|
1633
|
+
if (input.status !== undefined && input.status !== 'all') {
|
|
1634
|
+
if (!FINDING_STATUSES.includes(input.status)) {
|
|
1635
|
+
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
let visible = all
|
|
1639
|
+
if (input.status !== undefined && input.status !== 'all') {
|
|
1640
|
+
visible = visible.filter((finding) => (finding.status ?? 'open') === input.status)
|
|
1641
|
+
}
|
|
1642
|
+
if (input.type !== undefined) {
|
|
1643
|
+
if (!FINDING_TYPES.includes(input.type)) fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
|
|
1644
|
+
visible = visible.filter((finding) => finding.type === input.type)
|
|
1645
|
+
}
|
|
1646
|
+
if (input.severity !== undefined) {
|
|
1647
|
+
if (!FINDING_SEVERITIES.includes(input.severity)) {
|
|
1648
|
+
fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
|
|
1649
|
+
}
|
|
1650
|
+
visible = visible.filter((finding) => finding.severity === input.severity)
|
|
1651
|
+
}
|
|
1652
|
+
if (input.missionId !== undefined && input.missionId !== null && input.missionId !== '') {
|
|
1653
|
+
visible = visible.filter((finding) => finding.missionId === input.missionId)
|
|
1654
|
+
}
|
|
1655
|
+
if (input.open === true) {
|
|
1656
|
+
visible = visible.filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
1657
|
+
}
|
|
1658
|
+
const limit = Math.max(1, Math.min(200, Number.isInteger(input.limit) ? input.limit : 50))
|
|
1659
|
+
const page = visible.slice(-limit).reverse()
|
|
1660
|
+
return {
|
|
1661
|
+
findings: page.map((finding) => findingSummary(finding)),
|
|
1662
|
+
counts,
|
|
1663
|
+
total: all.length,
|
|
1664
|
+
filtered: visible.length,
|
|
1665
|
+
action: 'list',
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* List, read, or transition findings. Listing and reading are lock-free;
|
|
1671
|
+
* only a transition takes the writer lock. Reviewers may only read.
|
|
1672
|
+
*/
|
|
1673
|
+
async findings(input = {}) {
|
|
1674
|
+
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
1675
|
+
if (typeof actor.role !== 'string') fail('finding actor role must be a string')
|
|
1676
|
+
const hasId = input.id !== undefined && input.id !== null && input.id !== ''
|
|
1677
|
+
const wantsUpdate = hasId && (input.status !== undefined || input.note !== undefined)
|
|
1678
|
+
if (!wantsUpdate) return this.readFindings(input)
|
|
1679
|
+
|
|
1680
|
+
return withLock(this.paths, async () => {
|
|
1681
|
+
const state = await this.load()
|
|
1682
|
+
const all = state.findings ?? []
|
|
1683
|
+
const finding = all.find((entry) => entry.id === input.id)
|
|
1684
|
+
if (!finding) fail(`unknown finding "${input.id}"`)
|
|
1685
|
+
if (actor.role === 'reviewer') {
|
|
1686
|
+
fail('reviewers may list and read findings, but only the tower or a worker may change finding status')
|
|
1687
|
+
}
|
|
1688
|
+
const now = Date.now()
|
|
1689
|
+
if (input.status !== undefined) {
|
|
1690
|
+
if (input.status === 'all') {
|
|
1691
|
+
fail('finding status "all" is only a list filter; pass a concrete status when updating')
|
|
1692
|
+
}
|
|
1693
|
+
if (!FINDING_STATUSES.includes(input.status)) {
|
|
1694
|
+
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
1695
|
+
}
|
|
1696
|
+
const current = finding.status ?? 'open'
|
|
1697
|
+
const allowed = FINDING_TRANSITIONS[current] ?? []
|
|
1698
|
+
if (!allowed.includes(input.status)) {
|
|
1699
|
+
fail(`finding ${finding.id} cannot move from ${current} to ${input.status}`)
|
|
1700
|
+
}
|
|
1701
|
+
finding.status = input.status
|
|
1702
|
+
if (input.status === 'fixed' || input.status === 'wontfix') finding.closedAt = now
|
|
1703
|
+
else delete finding.closedAt
|
|
1704
|
+
}
|
|
1705
|
+
if (input.note !== undefined) {
|
|
1706
|
+
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
1707
|
+
fail('finding note must be a non-empty string')
|
|
1708
|
+
}
|
|
1709
|
+
if (!Array.isArray(finding.notes)) finding.notes = []
|
|
1710
|
+
finding.notes.push({ by: actor.name, at: now, text: input.note.trim() })
|
|
1711
|
+
}
|
|
1712
|
+
finding.updatedAt = now
|
|
1713
|
+
state.revision += 1
|
|
1714
|
+
state.updatedAt = now
|
|
1715
|
+
validateState(state, this.paths.state)
|
|
1716
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1717
|
+
await appendJsonl(this.paths.activity, {
|
|
1718
|
+
at: now,
|
|
1719
|
+
type: 'finding',
|
|
1720
|
+
missionId: finding.missionId,
|
|
1721
|
+
text: `${finding.id} ${finding.status}${input.note !== undefined ? ' +note' : ''} (${actor.name})`,
|
|
1722
|
+
})
|
|
1723
|
+
// Counts must reflect the transition just recorded.
|
|
1724
|
+
return { finding: { ...finding }, counts: countFindings(all), total: all.length, filtered: 1, action: 'updated' }
|
|
1725
|
+
})
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1090
1728
|
async submitReview(input) {
|
|
1091
1729
|
return withLock(this.paths, async () => {
|
|
1092
1730
|
const state = await this.load()
|
|
@@ -1150,7 +1788,7 @@ export class Tower {
|
|
|
1150
1788
|
}
|
|
1151
1789
|
const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1152
1790
|
const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
|
|
1153
|
-
const maxReviewRounds = Number(
|
|
1791
|
+
const maxReviewRounds = Number(this.config.review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
|
|
1154
1792
|
if (reviewerRounds >= maxReviewRounds) {
|
|
1155
1793
|
fail(
|
|
1156
1794
|
`reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
|
|
@@ -1211,9 +1849,105 @@ export class Tower {
|
|
|
1211
1849
|
})
|
|
1212
1850
|
}
|
|
1213
1851
|
|
|
1214
|
-
|
|
1852
|
+
/** Full detail for one mission, including reviews, findings and recent activity. */
|
|
1853
|
+
async missionDetail(missionId) {
|
|
1854
|
+
const state = await this.load()
|
|
1855
|
+
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
1856
|
+
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
1857
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1858
|
+
const reviews = (state.reviews ?? [])
|
|
1859
|
+
.filter((review) => review.missionId === mission.id || (branch && review.branch === branch))
|
|
1860
|
+
.slice()
|
|
1861
|
+
.sort((a, b) => b.round - a.round)
|
|
1862
|
+
const findings = (state.findings ?? []).filter((finding) => finding.missionId === mission.id)
|
|
1863
|
+
const activity = (await readActivityTail(this.paths.activity, 200)).filter(
|
|
1864
|
+
(entry) => entry.missionId === mission.id,
|
|
1865
|
+
)
|
|
1866
|
+
return {
|
|
1867
|
+
mission: fullMission(mission),
|
|
1868
|
+
reviews: reviews.slice(0, 10),
|
|
1869
|
+
findings: findings.slice(-10).map((finding) => findingSummary(finding)),
|
|
1870
|
+
activity: activity.slice(-20),
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/** Human-readable mission board (markdown) for the current state. */
|
|
1875
|
+
boardMarkdown(state) {
|
|
1876
|
+
const lines = []
|
|
1877
|
+
lines.push(`# Tower missions — ${state.repoRoot}`)
|
|
1878
|
+
lines.push('')
|
|
1879
|
+
lines.push(`Base: \`${state.base}\` · revision ${state.revision} · updated ${new Date(state.updatedAt ?? Date.now()).toISOString()}`)
|
|
1880
|
+
const counts = {}
|
|
1881
|
+
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
1882
|
+
for (const mission of state.missions) counts[mission.status] += 1
|
|
1883
|
+
lines.push('')
|
|
1884
|
+
lines.push(
|
|
1885
|
+
`Missions: ${state.missions.length} · ` +
|
|
1886
|
+
MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
|
|
1887
|
+
)
|
|
1888
|
+
const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
1889
|
+
lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
|
|
1890
|
+
lines.push('')
|
|
1891
|
+
lines.push('| Mission | Status | Kind | Title | Owner | Tasks (done/open/dropped) | Latest review | Merged |')
|
|
1892
|
+
lines.push('|---|---|---|---|---|---|---|---|')
|
|
1893
|
+
for (const mission of state.missions) {
|
|
1894
|
+
const tasks = taskCounts(mission.tasks ?? [])
|
|
1895
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1896
|
+
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
1897
|
+
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
1898
|
+
const reviewText = latest ? `R${latest.round} ${latest.status}/${latest.mergeVerdict}` : '—'
|
|
1899
|
+
lines.push(
|
|
1900
|
+
`| ${mission.id} | ${mission.status} | ${mission.kind} | ${String(mission.title).replaceAll('|', '\\|')} | ${
|
|
1901
|
+
mission.owner ?? '—'
|
|
1902
|
+
} | ${tasks.done}/${tasks.open}/${tasks.dropped} | ${reviewText} | ${
|
|
1903
|
+
mission.mergedCommit ? mission.mergedCommit.slice(0, 8) : '—'
|
|
1904
|
+
} |`,
|
|
1905
|
+
)
|
|
1906
|
+
}
|
|
1907
|
+
if ((state.roster ?? []).length > 0) {
|
|
1908
|
+
lines.push('')
|
|
1909
|
+
lines.push('## Roster')
|
|
1910
|
+
lines.push('')
|
|
1911
|
+
lines.push('| Name | Role | Mission | State | Agent |')
|
|
1912
|
+
lines.push('|---|---|---|---|---|')
|
|
1913
|
+
for (const entry of state.roster) {
|
|
1914
|
+
lines.push(
|
|
1915
|
+
`| ${entry.name} | ${entry.role} | ${entry.missionId} | ${entry.state} | \`${entry.agentId}\` |`,
|
|
1916
|
+
)
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
lines.push('')
|
|
1920
|
+
return lines.join('\n')
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/** Write the board when docs.board is enabled; failures never break a mutation. */
|
|
1924
|
+
async writeBoard() {
|
|
1925
|
+
if (this.config.docs?.board === false) return null
|
|
1926
|
+
try {
|
|
1927
|
+
const state = await this.load()
|
|
1928
|
+
const file = this.config.docs?.boardLocation === 'repo'
|
|
1929
|
+
? path.join(this.root, 'MISSIONS.md')
|
|
1930
|
+
: path.join(this.paths.dir, 'MISSIONS.md')
|
|
1931
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
1932
|
+
await fsp.writeFile(tmp, this.boardMarkdown(state), 'utf8')
|
|
1933
|
+
await renameWithRetry(tmp, file)
|
|
1934
|
+
return file
|
|
1935
|
+
} catch {
|
|
1936
|
+
return null
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
boardFile() {
|
|
1941
|
+
return this.config.docs?.boardLocation === 'repo'
|
|
1942
|
+
? path.join(this.root, 'MISSIONS.md')
|
|
1943
|
+
: path.join(this.paths.dir, 'MISSIONS.md')
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
async enrichedMissions(state, onlyIds) {
|
|
1947
|
+
const wanted = onlyIds ? new Set(onlyIds) : null
|
|
1215
1948
|
const out = []
|
|
1216
1949
|
for (const mission of state.missions) {
|
|
1950
|
+
if (wanted && !wanted.has(mission.id)) continue
|
|
1217
1951
|
const summary = missionSummary(mission)
|
|
1218
1952
|
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1219
1953
|
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
@@ -1275,6 +2009,18 @@ export class Tower {
|
|
|
1275
2009
|
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
1276
2010
|
if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
|
|
1277
2011
|
if ((mission.blockers ?? []).length > 0) push('blockers-present', mission.blockers.join('; '))
|
|
2012
|
+
if (this.config.gates.requireChecks === true) {
|
|
2013
|
+
const checks = mission.completionReport?.checks ?? []
|
|
2014
|
+
if (checks.length === 0) {
|
|
2015
|
+
push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
|
|
2019
|
+
push(
|
|
2020
|
+
'merge-intent-pending',
|
|
2021
|
+
`a merge intent for ${state.mergeIntent.missionId} was recorded at ${new Date(Number(state.mergeIntent.startedAt) || 0).toISOString()}; run tower_merge to reconcile it`,
|
|
2022
|
+
)
|
|
2023
|
+
}
|
|
1278
2024
|
|
|
1279
2025
|
const worktree = mission.worktree ?? null
|
|
1280
2026
|
let branchTip = null
|
|
@@ -1329,9 +2075,16 @@ export class Tower {
|
|
|
1329
2075
|
const branch = worktree ? worktree.branch : null
|
|
1330
2076
|
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
1331
2077
|
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
1332
|
-
|
|
2078
|
+
const exempt =
|
|
2079
|
+
mission.reviewPolicy === 'none' &&
|
|
2080
|
+
this.config.gates.allowReviewExemption === true &&
|
|
2081
|
+
String(mission.reviewExemptionReason ?? '').trim().length > 0
|
|
2082
|
+
if (mission.reviewPolicy === 'none' && this.config.gates.allowReviewExemption !== true) {
|
|
2083
|
+
push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
|
|
2084
|
+
}
|
|
2085
|
+
if (!exempt && !latest) {
|
|
1333
2086
|
push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first`)
|
|
1334
|
-
} else {
|
|
2087
|
+
} else if (!exempt) {
|
|
1335
2088
|
const waived = this.rebaseWaived(mission, latest)
|
|
1336
2089
|
if (latest.missionId !== mission.id) {
|
|
1337
2090
|
push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
|
|
@@ -1347,8 +2100,26 @@ export class Tower {
|
|
|
1347
2100
|
}
|
|
1348
2101
|
}
|
|
1349
2102
|
|
|
1350
|
-
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && row.state
|
|
2103
|
+
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
|
|
1351
2104
|
if (activity[entry.agentId] === 'running') push('worker-busy', `agent ${entry.name} is still running`)
|
|
2105
|
+
else if (input.parentStatus && entry.parentSessionId && input.parentStatus[entry.parentSessionId] !== 'ok') {
|
|
2106
|
+
push(
|
|
2107
|
+
'worker-status-unknown',
|
|
2108
|
+
`cannot prove agent ${entry.name} is stopped: its parent catalog is unavailable`,
|
|
2109
|
+
)
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
const staleWorkers = (state.roster ?? []).filter(
|
|
2113
|
+
(row) => row.missionId === mission.id && (row.state === 'stale' || row.stale === true),
|
|
2114
|
+
)
|
|
2115
|
+
if (staleWorkers.length > 0) {
|
|
2116
|
+
push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
|
|
2117
|
+
}
|
|
2118
|
+
if (this.config.gates.requireTests === true) {
|
|
2119
|
+
const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
|
|
2120
|
+
if (runs.length === 0) {
|
|
2121
|
+
push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
|
|
2122
|
+
}
|
|
1352
2123
|
}
|
|
1353
2124
|
|
|
1354
2125
|
const checkoutBranch = await currentBranch(this.root)
|
|
@@ -1406,14 +2177,60 @@ export class Tower {
|
|
|
1406
2177
|
* exists and either the branch tip or the reviewed commit is already an
|
|
1407
2178
|
* ancestor of the base.
|
|
1408
2179
|
*/
|
|
2180
|
+
/** Clear a stale merge intent only when the crash left no merge in progress
|
|
2181
|
+
* or no merge commit. Returns true when the intent was consumed. */
|
|
2182
|
+
async reconcileMergeIntent(state, mission) {
|
|
2183
|
+
const intent = state.mergeIntent
|
|
2184
|
+
if (!intent || intent.missionId !== mission.id || !mission.worktree) return false
|
|
2185
|
+
let head = null
|
|
2186
|
+
try {
|
|
2187
|
+
head = await revParse(this.root, 'HEAD')
|
|
2188
|
+
} catch {
|
|
2189
|
+
return false
|
|
2190
|
+
}
|
|
2191
|
+
const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
|
|
2192
|
+
const inProgress = await exists(mergeHeadPath)
|
|
2193
|
+
if (inProgress) return false
|
|
2194
|
+
let landed = null
|
|
2195
|
+
if (head === intent.baseTip) {
|
|
2196
|
+
landed = null // no merge commit was created; the intent is safe to drop
|
|
2197
|
+
} else {
|
|
2198
|
+
landed = await findLandedMerge(
|
|
2199
|
+
this.root,
|
|
2200
|
+
head,
|
|
2201
|
+
new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
|
|
2202
|
+
).catch(() => null)
|
|
2203
|
+
if (!landed) return false
|
|
2204
|
+
}
|
|
2205
|
+
const now = Date.now()
|
|
2206
|
+
delete state.mergeIntent
|
|
2207
|
+
state.revision += 1
|
|
2208
|
+
state.updatedAt = now
|
|
2209
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
2210
|
+
await appendJsonl(this.paths.activity, {
|
|
2211
|
+
at: now,
|
|
2212
|
+
type: 'merge',
|
|
2213
|
+
missionId: mission.id,
|
|
2214
|
+
text: landed
|
|
2215
|
+
? `reconciled merge intent; found landed commit ${String(landed.commit).slice(0, 7)}`
|
|
2216
|
+
: 'cleared a merge intent that created no commit',
|
|
2217
|
+
})
|
|
2218
|
+
return true
|
|
2219
|
+
}
|
|
2220
|
+
|
|
1409
2221
|
async reconcileLandedMerge(state, mission) {
|
|
1410
2222
|
if (!['completed', 'blocked'].includes(mission.status)) return null
|
|
1411
2223
|
if (!mission.worktree) return null
|
|
1412
2224
|
const branch = mission.worktree.branch
|
|
1413
2225
|
const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
2226
|
+
const exempt =
|
|
2227
|
+
mission.reviewPolicy === 'none' &&
|
|
2228
|
+
this.config.gates.allowReviewExemption === true &&
|
|
2229
|
+
String(mission.reviewExemptionReason ?? '').trim().length > 0
|
|
2230
|
+
if (reviews.length === 0 && !exempt) return null
|
|
2231
|
+
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
2232
|
+
if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
|
|
2233
|
+
if (!latest && !exempt) return null
|
|
1417
2234
|
let baseTip
|
|
1418
2235
|
let branchTip
|
|
1419
2236
|
try {
|
|
@@ -1422,7 +2239,7 @@ export class Tower {
|
|
|
1422
2239
|
} catch {
|
|
1423
2240
|
return null
|
|
1424
2241
|
}
|
|
1425
|
-
const reviewedTip = latest
|
|
2242
|
+
const reviewedTip = latest?.reviewedCommit ?? null
|
|
1426
2243
|
const branchLanded = await isAncestor(this.root, branchTip, baseTip)
|
|
1427
2244
|
const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
|
|
1428
2245
|
if (!branchLanded && !reviewedLanded) return null
|
|
@@ -1430,6 +2247,7 @@ export class Tower {
|
|
|
1430
2247
|
const found = await findLandedMerge(this.root, baseTip, tips)
|
|
1431
2248
|
const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
|
|
1432
2249
|
const now = Date.now()
|
|
2250
|
+
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
1433
2251
|
mission.status = 'merged'
|
|
1434
2252
|
mission.mergedAt = now
|
|
1435
2253
|
mission.mergedCommit = mergedCommit
|
|
@@ -1458,13 +2276,15 @@ export class Tower {
|
|
|
1458
2276
|
baseTip,
|
|
1459
2277
|
branchTip,
|
|
1460
2278
|
parents: found ? found.parents : null,
|
|
1461
|
-
review:
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2279
|
+
review: latest
|
|
2280
|
+
? {
|
|
2281
|
+
round: latest.round,
|
|
2282
|
+
status: latest.status,
|
|
2283
|
+
mergeVerdict: latest.mergeVerdict,
|
|
2284
|
+
reviewer: latest.reviewer,
|
|
2285
|
+
reviewedCommit: reviewedTip,
|
|
2286
|
+
}
|
|
2287
|
+
: null,
|
|
1468
2288
|
}
|
|
1469
2289
|
}
|
|
1470
2290
|
|
|
@@ -1475,6 +2295,7 @@ export class Tower {
|
|
|
1475
2295
|
if (!mission) {
|
|
1476
2296
|
return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
|
|
1477
2297
|
}
|
|
2298
|
+
await this.reconcileMergeIntent(state, mission)
|
|
1478
2299
|
const baseTip = await revParse(this.root, state.base)
|
|
1479
2300
|
if (mission.status === 'merged') {
|
|
1480
2301
|
return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
|
|
@@ -1487,13 +2308,25 @@ export class Tower {
|
|
|
1487
2308
|
const worktree = mission.worktree
|
|
1488
2309
|
const baseTipBefore = await revParse(this.root, 'HEAD')
|
|
1489
2310
|
const branchTip = await revParse(worktree.path, 'HEAD')
|
|
1490
|
-
|
|
2311
|
+
// Record the intent before touching Git; a crash between merge and state
|
|
2312
|
+
// write can be reconciled by the next tower_merge/preflight instead of
|
|
2313
|
+
// leaving an unexplained base tip.
|
|
2314
|
+
state.mergeIntent = {
|
|
2315
|
+
missionId: mission.id,
|
|
2316
|
+
baseTip: baseTipBefore,
|
|
2317
|
+
branchTip,
|
|
2318
|
+
startedAt: Date.now(),
|
|
2319
|
+
}
|
|
2320
|
+
state.revision += 1
|
|
2321
|
+
state.updatedAt = state.mergeIntent.startedAt
|
|
2322
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
2323
|
+
const timeoutMs = mergeTimeoutMs(input.timeoutMs, this.config)
|
|
1491
2324
|
let failure = null
|
|
1492
2325
|
try {
|
|
1493
2326
|
await git(
|
|
1494
2327
|
this.root,
|
|
1495
2328
|
['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
|
|
1496
|
-
{ timeoutMs, maxBuffer:
|
|
2329
|
+
{ timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
|
|
1497
2330
|
)
|
|
1498
2331
|
} catch (error) {
|
|
1499
2332
|
failure = error
|
|
@@ -1563,6 +2396,7 @@ export class Tower {
|
|
|
1563
2396
|
}
|
|
1564
2397
|
const detail = describeGitFailure(failure).slice(0, 2000)
|
|
1565
2398
|
const now = Date.now()
|
|
2399
|
+
delete state.mergeIntent
|
|
1566
2400
|
if (conflict) {
|
|
1567
2401
|
mission.status = 'blocked'
|
|
1568
2402
|
mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
|
|
@@ -1590,6 +2424,10 @@ export class Tower {
|
|
|
1590
2424
|
}
|
|
1591
2425
|
|
|
1592
2426
|
if (!expectedShape) {
|
|
2427
|
+
delete state.mergeIntent
|
|
2428
|
+
state.revision += 1
|
|
2429
|
+
state.updatedAt = Date.now()
|
|
2430
|
+
await writeJsonAtomic(this.paths.state, state).catch(() => {})
|
|
1593
2431
|
return {
|
|
1594
2432
|
ok: false,
|
|
1595
2433
|
blockers: [
|
|
@@ -1602,6 +2440,7 @@ export class Tower {
|
|
|
1602
2440
|
}
|
|
1603
2441
|
|
|
1604
2442
|
const now = Date.now()
|
|
2443
|
+
delete state.mergeIntent
|
|
1605
2444
|
mission.status = 'merged'
|
|
1606
2445
|
mission.mergedAt = now
|
|
1607
2446
|
mission.mergedCommit = newTip
|
|
@@ -1635,7 +2474,7 @@ export class Tower {
|
|
|
1635
2474
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1636
2475
|
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
|
|
1637
2476
|
const activity = input.activity ?? {}
|
|
1638
|
-
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && row.state
|
|
2477
|
+
for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
|
|
1639
2478
|
if (activity[entry.agentId] === 'running') {
|
|
1640
2479
|
return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${entry.name} is running; rebase refused` }] }
|
|
1641
2480
|
}
|
|
@@ -1714,7 +2553,7 @@ export class Tower {
|
|
|
1714
2553
|
continue
|
|
1715
2554
|
}
|
|
1716
2555
|
const live = (state.roster ?? []).some(
|
|
1717
|
-
(entry) => entry.missionId === mission.id && entry.state
|
|
2556
|
+
(entry) => entry.missionId === mission.id && !ROSTER_INACTIVE.has(entry.state) && activity[entry.agentId] === 'running',
|
|
1718
2557
|
)
|
|
1719
2558
|
if (live) {
|
|
1720
2559
|
decisions.push({ missionId: mission.id, action: 'keep', reason: 'live agent' })
|
|
@@ -1767,13 +2606,19 @@ export class Tower {
|
|
|
1767
2606
|
})
|
|
1768
2607
|
}
|
|
1769
2608
|
|
|
1770
|
-
async status() {
|
|
2609
|
+
async status(input = {}) {
|
|
1771
2610
|
const state = await this.load()
|
|
1772
2611
|
const activity = await readActivityTail(this.paths.activity, 12)
|
|
1773
2612
|
const counts = {}
|
|
1774
2613
|
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
1775
2614
|
for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
|
|
1776
2615
|
const openTasks = state.missions.reduce((sum, mission) => sum + taskCounts(mission.tasks).open, 0)
|
|
2616
|
+
const findingsByStatus = {}
|
|
2617
|
+
for (const status of FINDING_STATUSES) findingsByStatus[status] = 0
|
|
2618
|
+
for (const finding of state.findings ?? []) {
|
|
2619
|
+
const status = finding.status ?? 'open'
|
|
2620
|
+
findingsByStatus[status] = (findingsByStatus[status] ?? 0) + 1
|
|
2621
|
+
}
|
|
1777
2622
|
return {
|
|
1778
2623
|
repoRoot: state.repoRoot,
|
|
1779
2624
|
commonDir: state.commonDir,
|
|
@@ -1786,17 +2631,27 @@ export class Tower {
|
|
|
1786
2631
|
messages: (state.messages ?? []).length,
|
|
1787
2632
|
unreadTower: this.unreadFor(state, 'tower').length,
|
|
1788
2633
|
findings: (state.findings ?? []).length,
|
|
2634
|
+
findingsByStatus,
|
|
1789
2635
|
reviews: (state.reviews ?? []).length,
|
|
2636
|
+
config: {
|
|
2637
|
+
path: this.config.configPath ?? null,
|
|
2638
|
+
repoConfigPresent: this.config.repoConfigPresent === true,
|
|
2639
|
+
spawn: {
|
|
2640
|
+
workerPreset: this.config.spawn?.workerPreset ?? null,
|
|
2641
|
+
reviewerPreset: this.config.spawn?.reviewerPreset ?? null,
|
|
2642
|
+
},
|
|
2643
|
+
},
|
|
1790
2644
|
recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
|
|
1791
2645
|
id: finding.id,
|
|
1792
2646
|
type: finding.type,
|
|
1793
2647
|
severity: finding.severity,
|
|
2648
|
+
status: finding.status ?? 'open',
|
|
1794
2649
|
title: finding.title,
|
|
1795
2650
|
missionId: finding.missionId,
|
|
1796
2651
|
reportedBy: finding.reportedBy,
|
|
1797
2652
|
at: finding.at,
|
|
1798
2653
|
})),
|
|
1799
|
-
missions: await this.enrichedMissions(state),
|
|
2654
|
+
missions: await this.enrichedMissions(state, input.missionIds),
|
|
1800
2655
|
activity,
|
|
1801
2656
|
}
|
|
1802
2657
|
}
|