dsh-tower 1.2.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 +96 -0
- package/CONTRIBUTING.md +36 -0
- package/README.en.md +60 -23
- package/README.md +54 -21
- package/SECURITY.md +20 -0
- package/cordis.patch.yml +32 -0
- package/docs/README.md +17 -0
- package/docs/architecture/overview.md +70 -0
- package/docs/examples/first-mission.md +58 -0
- package/docs/faq.md +74 -0
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +59 -0
- package/docs/guide/quickstart.md +97 -0
- package/docs/operations/runbook.md +80 -0
- package/docs/operations/security.md +62 -0
- package/docs/reference/blockers.md +58 -0
- package/docs/reference/config.md +114 -0
- 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 +75 -0
- 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 +293 -49
- package/lib/batch.js +578 -0
- package/lib/config.js +381 -0
- 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 +47 -16
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1594 -160
- package/lib/tower.js +1025 -101
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +21 -4
- package/reviewer.js +40 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-recovery.md +40 -0
- package/skills/tower-review.md +16 -0
- package/skills/tower-worker-protocol.md +15 -0
- package/skills.js +63 -0
- package/worker.js +7 -4
package/lib/tower.js
CHANGED
|
@@ -17,17 +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'
|
|
34
|
+
import { runtimeConfig, resolveRepoConfig } from './config.js'
|
|
31
35
|
|
|
32
36
|
export const SCHEMA_VERSION = 1
|
|
33
37
|
export const MISSION_STATUSES = [
|
|
@@ -40,6 +44,14 @@ export const MISSION_STATUSES = [
|
|
|
40
44
|
'abandoned',
|
|
41
45
|
]
|
|
42
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']
|
|
43
55
|
const TRANSITIONS = {
|
|
44
56
|
planned: ['active', 'abandoned'],
|
|
45
57
|
active: ['completed', 'blocked', 'paused', 'abandoned'],
|
|
@@ -62,14 +74,24 @@ function slugify(title) {
|
|
|
62
74
|
|
|
63
75
|
export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
|
|
64
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
|
+
}
|
|
65
84
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
66
85
|
const MAX_REVIEW_ROUNDS = 5
|
|
67
86
|
const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
|
|
68
87
|
const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
|
|
69
88
|
const MERGE_MAX_BUFFER = 64 * 1024 * 1024
|
|
70
89
|
|
|
71
|
-
function mergeTimeoutMs(value) {
|
|
72
|
-
if (value === undefined || value === null)
|
|
90
|
+
function mergeTimeoutMs(value, config) {
|
|
91
|
+
if (value === undefined || value === null) {
|
|
92
|
+
const configured = (config ?? runtimeConfig()).merge.timeoutMs
|
|
93
|
+
return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
|
|
94
|
+
}
|
|
73
95
|
const ms = Number(value)
|
|
74
96
|
if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
|
|
75
97
|
return ms
|
|
@@ -280,7 +302,26 @@ function normalizeMissionInput(input, index) {
|
|
|
280
302
|
}
|
|
281
303
|
}
|
|
282
304
|
const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
|
|
283
|
-
|
|
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 }
|
|
284
325
|
}
|
|
285
326
|
|
|
286
327
|
function newMission(id, input, now) {
|
|
@@ -292,6 +333,9 @@ function newMission(id, input, now) {
|
|
|
292
333
|
tasks: input.tasks,
|
|
293
334
|
deps: input.deps,
|
|
294
335
|
context: input.context,
|
|
336
|
+
references: input.references ?? [],
|
|
337
|
+
reviewPolicy: input.reviewPolicy ?? 'required-clean',
|
|
338
|
+
reviewExemptionReason: input.reviewExemptionReason ?? '',
|
|
295
339
|
status: 'planned',
|
|
296
340
|
owner: null,
|
|
297
341
|
blockers: [],
|
|
@@ -320,6 +364,24 @@ export function validateState(state, statePath) {
|
|
|
320
364
|
}
|
|
321
365
|
if (!Array.isArray(mission.tasks)) fail(`mission ${mission.id} has no tasks array${where}`)
|
|
322
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}`)
|
|
323
385
|
}
|
|
324
386
|
if (state.roster !== undefined) {
|
|
325
387
|
if (!Array.isArray(state.roster)) fail(`tower state roster is not an array${where}`)
|
|
@@ -354,6 +416,9 @@ export function validateState(state, statePath) {
|
|
|
354
416
|
if (!finding || typeof finding.id !== 'string') fail(`invalid finding entry${where}`)
|
|
355
417
|
if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
|
|
356
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
|
+
}
|
|
357
422
|
}
|
|
358
423
|
}
|
|
359
424
|
if (state.reviews !== undefined) {
|
|
@@ -403,6 +468,9 @@ export function missionSummary(mission) {
|
|
|
403
468
|
blockers: mission.blockers ?? [],
|
|
404
469
|
tasks: taskCounts(mission.tasks ?? []),
|
|
405
470
|
scopeGeneration: mission.scopeGeneration ?? 1,
|
|
471
|
+
ownerGeneration: mission.ownerGeneration ?? 0,
|
|
472
|
+
pauseCause: mission.pauseCause ?? null,
|
|
473
|
+
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
406
474
|
mergedCommit: mission.mergedCommit ?? null,
|
|
407
475
|
mergedAt: mission.mergedAt ?? null,
|
|
408
476
|
updatedAt: mission.updatedAt,
|
|
@@ -414,20 +482,96 @@ function fullMission(mission) {
|
|
|
414
482
|
...missionSummary(mission),
|
|
415
483
|
tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
|
|
416
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,
|
|
417
508
|
}
|
|
418
509
|
}
|
|
419
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
|
+
|
|
420
560
|
export class Tower {
|
|
421
|
-
constructor(repo) {
|
|
561
|
+
constructor(repo, config) {
|
|
422
562
|
this.root = repo.root
|
|
423
563
|
this.commonDir = repo.commonDir
|
|
424
564
|
this.branch = repo.branch
|
|
425
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 }
|
|
426
569
|
}
|
|
427
570
|
|
|
428
571
|
static async open(repoRoot) {
|
|
429
572
|
const repo = await resolveRepo(repoRoot)
|
|
430
|
-
|
|
573
|
+
const config = await resolveRepoConfig(repo)
|
|
574
|
+
return new Tower(repo, config)
|
|
431
575
|
}
|
|
432
576
|
|
|
433
577
|
async load() {
|
|
@@ -441,8 +585,52 @@ export class Tower {
|
|
|
441
585
|
async init(input = {}) {
|
|
442
586
|
await ensureDir(this.paths.dir)
|
|
443
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
|
+
|
|
444
608
|
if (await exists(this.paths.state)) {
|
|
445
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
|
+
}
|
|
446
634
|
return {
|
|
447
635
|
created: false,
|
|
448
636
|
repoRoot: state.repoRoot,
|
|
@@ -451,23 +639,24 @@ export class Tower {
|
|
|
451
639
|
base: state.base,
|
|
452
640
|
revision: state.revision,
|
|
453
641
|
missions: state.missions.length,
|
|
642
|
+
reanchored,
|
|
643
|
+
dirty,
|
|
644
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
454
645
|
}
|
|
455
646
|
}
|
|
456
647
|
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
let
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (committed && !(await localBranchExists(this.root, base))) {
|
|
465
|
-
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')
|
|
466
655
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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])
|
|
471
660
|
}
|
|
472
661
|
|
|
473
662
|
const state = {
|
|
@@ -478,11 +667,17 @@ export class Tower {
|
|
|
478
667
|
revision: 0,
|
|
479
668
|
createdAt: now,
|
|
480
669
|
updatedAt: now,
|
|
670
|
+
dirtyBase: dirty,
|
|
671
|
+
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
481
672
|
missions: [],
|
|
482
673
|
}
|
|
483
674
|
validateState(state, this.paths.state)
|
|
484
675
|
await writeJsonAtomic(this.paths.state, state)
|
|
485
|
-
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
|
+
})
|
|
486
681
|
return {
|
|
487
682
|
created: true,
|
|
488
683
|
repoRoot: this.root,
|
|
@@ -491,6 +686,10 @@ export class Tower {
|
|
|
491
686
|
base,
|
|
492
687
|
revision: 0,
|
|
493
688
|
missions: 0,
|
|
689
|
+
reanchored: false,
|
|
690
|
+
dirty,
|
|
691
|
+
snapshotCommit,
|
|
692
|
+
snapshotRef,
|
|
494
693
|
}
|
|
495
694
|
})
|
|
496
695
|
}
|
|
@@ -536,11 +735,36 @@ export class Tower {
|
|
|
536
735
|
}
|
|
537
736
|
|
|
538
737
|
async mission(input = {}) {
|
|
539
|
-
|
|
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')
|
|
540
740
|
return withLock(this.paths, async () => {
|
|
541
741
|
const state = await this.load()
|
|
542
|
-
const mission = state.missions.find((entry) => entry.id ===
|
|
543
|
-
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
|
+
}
|
|
544
768
|
const now = Date.now()
|
|
545
769
|
const changes = []
|
|
546
770
|
|
|
@@ -553,6 +777,20 @@ export class Tower {
|
|
|
553
777
|
if (!allowed.includes(input.status)) {
|
|
554
778
|
fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`)
|
|
555
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
|
+
}
|
|
556
794
|
mission.status = input.status
|
|
557
795
|
changes.push(`status=${input.status}`)
|
|
558
796
|
}
|
|
@@ -586,6 +824,9 @@ export class Tower {
|
|
|
586
824
|
if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
|
|
587
825
|
fail('blocker must be a non-empty string')
|
|
588
826
|
}
|
|
827
|
+
if (TERMINAL.has(mission.status)) {
|
|
828
|
+
fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
|
|
829
|
+
}
|
|
589
830
|
mission.blockers.push(input.blocker.trim())
|
|
590
831
|
mission.status = 'blocked'
|
|
591
832
|
changes.push('blocker added')
|
|
@@ -657,24 +898,73 @@ export class Tower {
|
|
|
657
898
|
|
|
658
899
|
async assertWorktree(worktree) {
|
|
659
900
|
if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
if (branch !== worktree.branch) {
|
|
669
|
-
fail(`worktree ${wtPath} is on ${branch ?? 'detached HEAD'}, expected ${worktree.branch}`)
|
|
670
|
-
}
|
|
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
|
|
671
909
|
const registered = await listWorktrees(this.root)
|
|
672
|
-
if (!registered.some((entry) => path.resolve(entry.path) ===
|
|
673
|
-
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`)
|
|
674
912
|
}
|
|
675
913
|
return worktree
|
|
676
914
|
}
|
|
677
915
|
|
|
916
|
+
/** Record that a spawned agent failed or went idle, so roster state is truthful. */
|
|
917
|
+
async recordSpawnOutcome(input) {
|
|
918
|
+
return withLock(this.paths, async () => {
|
|
919
|
+
const state = await this.load()
|
|
920
|
+
const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
|
|
921
|
+
if (!entry) return null
|
|
922
|
+
const now = Date.now()
|
|
923
|
+
entry.state = input.state ?? 'failed'
|
|
924
|
+
entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
|
|
925
|
+
entry.outcomeAt = now
|
|
926
|
+
state.revision += 1
|
|
927
|
+
state.updatedAt = now
|
|
928
|
+
validateState(state, this.paths.state)
|
|
929
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
930
|
+
await appendJsonl(this.paths.activity, {
|
|
931
|
+
at: now,
|
|
932
|
+
type: 'spawn',
|
|
933
|
+
missionId: input.missionId,
|
|
934
|
+
text: `${entry.name} ${entry.state}${entry.outcome ? `: ${entry.outcome.slice(0, 160)}` : ''}`,
|
|
935
|
+
})
|
|
936
|
+
return entry
|
|
937
|
+
})
|
|
938
|
+
}
|
|
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
|
+
|
|
678
968
|
async provision(input = {}) {
|
|
679
969
|
const missionId = input.missionId
|
|
680
970
|
if (typeof missionId !== 'string' || missionId.trim().length === 0) {
|
|
@@ -689,11 +979,20 @@ export class Tower {
|
|
|
689
979
|
return { mission, worktree: mission.worktree, created: false }
|
|
690
980
|
}
|
|
691
981
|
|
|
692
|
-
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)
|
|
693
989
|
const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
|
|
990
|
+
const configuredRoot = this.config.worktrees.root
|
|
694
991
|
const root = input.worktreeRoot
|
|
695
992
|
? path.resolve(input.worktreeRoot)
|
|
696
|
-
:
|
|
993
|
+
: configuredRoot
|
|
994
|
+
? path.resolve(configuredRoot)
|
|
995
|
+
: path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
|
|
697
996
|
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
698
997
|
this.assertOutside(root)
|
|
699
998
|
await ensureDir(root)
|
|
@@ -704,7 +1003,7 @@ export class Tower {
|
|
|
704
1003
|
|
|
705
1004
|
await addWorktree(this.root, { path: wtPath, branch, commit: baseCommit })
|
|
706
1005
|
const now = Date.now()
|
|
707
|
-
const worktree = { path: wtPath, branch, baseCommit, createdAt: now }
|
|
1006
|
+
const worktree = { path: wtPath, branch, baseCommit, createdAt: now, realPath: await canonicalPath(wtPath) }
|
|
708
1007
|
mission.worktree = worktree
|
|
709
1008
|
mission.baseCommit = baseCommit
|
|
710
1009
|
mission.updatedAt = now
|
|
@@ -728,9 +1027,37 @@ export class Tower {
|
|
|
728
1027
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
729
1028
|
if (!mission) fail(`unknown mission "${missionId}"`)
|
|
730
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)
|
|
731
1043
|
return {
|
|
732
1044
|
base: state.base,
|
|
733
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)),
|
|
734
1061
|
deps: (mission.deps ?? []).map((id) => {
|
|
735
1062
|
const dep = byId.get(id)
|
|
736
1063
|
return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
|
|
@@ -751,6 +1078,7 @@ export class Tower {
|
|
|
751
1078
|
fail(`agent ${input.agentId} is already in the roster`)
|
|
752
1079
|
}
|
|
753
1080
|
const now = Date.now()
|
|
1081
|
+
const spawnTip = input.cwd ? ((await tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
|
|
754
1082
|
const entry = {
|
|
755
1083
|
name: input.name,
|
|
756
1084
|
role: input.role,
|
|
@@ -760,14 +1088,39 @@ export class Tower {
|
|
|
760
1088
|
missionId: input.missionId,
|
|
761
1089
|
cwd: input.cwd ?? null,
|
|
762
1090
|
branch: input.branch ?? null,
|
|
1091
|
+
spawnTip,
|
|
763
1092
|
state: 'active',
|
|
764
1093
|
spawnedAt: now,
|
|
765
1094
|
}
|
|
766
|
-
state.roster.push(entry)
|
|
767
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
|
|
768
1120
|
mission.owner = input.name
|
|
769
1121
|
if (mission.status === 'planned') mission.status = 'active'
|
|
770
1122
|
}
|
|
1123
|
+
state.roster.push(entry)
|
|
771
1124
|
mission.updatedAt = now
|
|
772
1125
|
state.revision += 1
|
|
773
1126
|
state.updatedAt = now
|
|
@@ -804,6 +1157,129 @@ export class Tower {
|
|
|
804
1157
|
}
|
|
805
1158
|
}
|
|
806
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
|
+
|
|
807
1283
|
async markRetired(agentIds) {
|
|
808
1284
|
if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
809
1285
|
return withLock(this.paths, async () => {
|
|
@@ -832,6 +1308,77 @@ export class Tower {
|
|
|
832
1308
|
})
|
|
833
1309
|
}
|
|
834
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
|
+
|
|
835
1382
|
unreadFor(state, name) {
|
|
836
1383
|
return (state.messages ?? []).filter((message) => {
|
|
837
1384
|
if (message.from === name) return false
|
|
@@ -861,6 +1408,7 @@ export class Tower {
|
|
|
861
1408
|
subject: input.subject,
|
|
862
1409
|
body: input.body,
|
|
863
1410
|
scope: input.scope ?? null,
|
|
1411
|
+
consentRef: input.consentRef ?? null,
|
|
864
1412
|
at: now,
|
|
865
1413
|
readBy: [],
|
|
866
1414
|
delivery: [],
|
|
@@ -909,6 +1457,7 @@ export class Tower {
|
|
|
909
1457
|
for (const message of page) {
|
|
910
1458
|
if (!(message.readBy ?? []).includes(reader)) {
|
|
911
1459
|
message.readBy = [...(message.readBy ?? []), reader]
|
|
1460
|
+
message.readAt = { ...(message.readAt ?? {}), [reader]: Date.now() }
|
|
912
1461
|
changed = true
|
|
913
1462
|
}
|
|
914
1463
|
}
|
|
@@ -929,6 +1478,8 @@ export class Tower {
|
|
|
929
1478
|
body: message.body,
|
|
930
1479
|
scope: message.scope ?? null,
|
|
931
1480
|
at: message.at,
|
|
1481
|
+
delivery: message.delivery ?? [],
|
|
1482
|
+
ackAt: message.readAt?.[reader] ?? null,
|
|
932
1483
|
})),
|
|
933
1484
|
pageSize: page.length,
|
|
934
1485
|
unreadRemaining,
|
|
@@ -936,50 +1487,64 @@ export class Tower {
|
|
|
936
1487
|
})
|
|
937
1488
|
}
|
|
938
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
|
+
|
|
939
1540
|
async complete(input) {
|
|
940
1541
|
return withLock(this.paths, async () => {
|
|
941
|
-
const state = await this.
|
|
942
|
-
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
943
|
-
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
944
|
-
const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
945
|
-
if (!worker || worker.missionId !== mission.id) {
|
|
946
|
-
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
947
|
-
}
|
|
948
|
-
if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
949
|
-
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
950
|
-
if (!['active', 'planned'].includes(mission.status)) {
|
|
951
|
-
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
952
|
-
}
|
|
953
|
-
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
954
|
-
fail('tower_complete requires a non-empty report')
|
|
955
|
-
}
|
|
956
|
-
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
957
|
-
if (openTasks.length > 0) {
|
|
958
|
-
fail(
|
|
959
|
-
`mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
|
|
960
|
-
.map((task) => task.text)
|
|
961
|
-
.join('; ')}`,
|
|
962
|
-
)
|
|
963
|
-
}
|
|
964
|
-
if ((mission.blockers ?? []).length > 0) {
|
|
965
|
-
fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
|
|
966
|
-
}
|
|
967
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
968
|
-
await this.assertWorktree(mission.worktree)
|
|
969
|
-
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
970
|
-
if (commits < 1) {
|
|
971
|
-
fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
|
|
972
|
-
}
|
|
973
|
-
const unread = this.unreadFor(state, worker.name)
|
|
974
|
-
if (unread.length > 0) {
|
|
975
|
-
fail(
|
|
976
|
-
`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`,
|
|
977
|
-
)
|
|
978
|
-
}
|
|
1542
|
+
const { state, mission, worker, commits } = await this.checkCompletion(input)
|
|
979
1543
|
|
|
1544
|
+
const checks = normalizeChecks(input.checks)
|
|
980
1545
|
const now = Date.now()
|
|
981
1546
|
mission.status = 'completed'
|
|
982
|
-
mission.completionReport = { report: input.report.trim(), by: worker.name, at: now }
|
|
1547
|
+
mission.completionReport = { report: input.report.trim(), by: worker.name, at: now, checks }
|
|
983
1548
|
mission.updatedAt = now
|
|
984
1549
|
state.revision += 1
|
|
985
1550
|
state.updatedAt = now
|
|
@@ -1055,6 +1620,111 @@ export class Tower {
|
|
|
1055
1620
|
})
|
|
1056
1621
|
}
|
|
1057
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
|
+
|
|
1058
1728
|
async submitReview(input) {
|
|
1059
1729
|
return withLock(this.paths, async () => {
|
|
1060
1730
|
const state = await this.load()
|
|
@@ -1111,11 +1781,17 @@ export class Tower {
|
|
|
1111
1781
|
await this.assertWorktree(mission.worktree)
|
|
1112
1782
|
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
1113
1783
|
const branch = mission.worktree.branch
|
|
1784
|
+
if (caller && caller.role === 'reviewer' && caller.spawnTip && caller.spawnTip !== tip) {
|
|
1785
|
+
fail(
|
|
1786
|
+
`reviewer ${caller.name} modified the mission branch after it was spawned (${caller.spawnTip.slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
|
|
1787
|
+
)
|
|
1788
|
+
}
|
|
1114
1789
|
const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1115
1790
|
const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
|
|
1116
|
-
|
|
1791
|
+
const maxReviewRounds = Number(this.config.review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
|
|
1792
|
+
if (reviewerRounds >= maxReviewRounds) {
|
|
1117
1793
|
fail(
|
|
1118
|
-
`reviewer ${reviewerName} reached the ${
|
|
1794
|
+
`reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
|
|
1119
1795
|
)
|
|
1120
1796
|
}
|
|
1121
1797
|
const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
|
|
@@ -1173,9 +1849,105 @@ export class Tower {
|
|
|
1173
1849
|
})
|
|
1174
1850
|
}
|
|
1175
1851
|
|
|
1176
|
-
|
|
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
|
|
1177
1948
|
const out = []
|
|
1178
1949
|
for (const mission of state.missions) {
|
|
1950
|
+
if (wanted && !wanted.has(mission.id)) continue
|
|
1179
1951
|
const summary = missionSummary(mission)
|
|
1180
1952
|
const branch = mission.worktree ? mission.worktree.branch : null
|
|
1181
1953
|
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
@@ -1237,6 +2009,18 @@ export class Tower {
|
|
|
1237
2009
|
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
1238
2010
|
if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
|
|
1239
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
|
+
}
|
|
1240
2024
|
|
|
1241
2025
|
const worktree = mission.worktree ?? null
|
|
1242
2026
|
let branchTip = null
|
|
@@ -1291,9 +2075,16 @@ export class Tower {
|
|
|
1291
2075
|
const branch = worktree ? worktree.branch : null
|
|
1292
2076
|
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
1293
2077
|
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
1294
|
-
|
|
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) {
|
|
1295
2086
|
push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first`)
|
|
1296
|
-
} else {
|
|
2087
|
+
} else if (!exempt) {
|
|
1297
2088
|
const waived = this.rebaseWaived(mission, latest)
|
|
1298
2089
|
if (latest.missionId !== mission.id) {
|
|
1299
2090
|
push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
|
|
@@ -1309,8 +2100,26 @@ export class Tower {
|
|
|
1309
2100
|
}
|
|
1310
2101
|
}
|
|
1311
2102
|
|
|
1312
|
-
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))) {
|
|
1313
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
|
+
}
|
|
1314
2123
|
}
|
|
1315
2124
|
|
|
1316
2125
|
const checkoutBranch = await currentBranch(this.root)
|
|
@@ -1330,6 +2139,37 @@ export class Tower {
|
|
|
1330
2139
|
}
|
|
1331
2140
|
}
|
|
1332
2141
|
|
|
2142
|
+
/** Read-only mission diff for reviewers: branch changes against the merge base. */
|
|
2143
|
+
async diff(input = {}) {
|
|
2144
|
+
const state = await this.load()
|
|
2145
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
2146
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
2147
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
|
|
2148
|
+
await this.assertWorktree(mission.worktree)
|
|
2149
|
+
const baseTip = await revParse(this.root, state.base)
|
|
2150
|
+
const branchTip = await revParse(mission.worktree.path, 'HEAD')
|
|
2151
|
+
const mergeBase = (await git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
|
|
2152
|
+
const files = (await git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
|
|
2153
|
+
.split('\n')
|
|
2154
|
+
.filter(Boolean)
|
|
2155
|
+
const commits = (await git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
|
|
2156
|
+
.split('\n')
|
|
2157
|
+
.filter(Boolean)
|
|
2158
|
+
const fullPatch = await git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
|
|
2159
|
+
const limit = Number.isInteger(input.maxBytes) && input.maxBytes > 0 ? input.maxBytes : 64 * 1024
|
|
2160
|
+
return {
|
|
2161
|
+
missionId: mission.id,
|
|
2162
|
+
branch: mission.worktree.branch,
|
|
2163
|
+
baseTip,
|
|
2164
|
+
branchTip,
|
|
2165
|
+
mergeBase,
|
|
2166
|
+
files,
|
|
2167
|
+
commits,
|
|
2168
|
+
truncated: fullPatch.length > limit,
|
|
2169
|
+
patch: fullPatch.slice(0, limit),
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
1333
2173
|
/**
|
|
1334
2174
|
* Record a merge that already landed in Git without being recorded here:
|
|
1335
2175
|
* a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
|
|
@@ -1337,14 +2177,60 @@ export class Tower {
|
|
|
1337
2177
|
* exists and either the branch tip or the reviewed commit is already an
|
|
1338
2178
|
* ancestor of the base.
|
|
1339
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
|
+
|
|
1340
2221
|
async reconcileLandedMerge(state, mission) {
|
|
1341
2222
|
if (!['completed', 'blocked'].includes(mission.status)) return null
|
|
1342
2223
|
if (!mission.worktree) return null
|
|
1343
2224
|
const branch = mission.worktree.branch
|
|
1344
2225
|
const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
|
1348
2234
|
let baseTip
|
|
1349
2235
|
let branchTip
|
|
1350
2236
|
try {
|
|
@@ -1353,7 +2239,7 @@ export class Tower {
|
|
|
1353
2239
|
} catch {
|
|
1354
2240
|
return null
|
|
1355
2241
|
}
|
|
1356
|
-
const reviewedTip = latest
|
|
2242
|
+
const reviewedTip = latest?.reviewedCommit ?? null
|
|
1357
2243
|
const branchLanded = await isAncestor(this.root, branchTip, baseTip)
|
|
1358
2244
|
const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
|
|
1359
2245
|
if (!branchLanded && !reviewedLanded) return null
|
|
@@ -1361,6 +2247,7 @@ export class Tower {
|
|
|
1361
2247
|
const found = await findLandedMerge(this.root, baseTip, tips)
|
|
1362
2248
|
const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
|
|
1363
2249
|
const now = Date.now()
|
|
2250
|
+
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
1364
2251
|
mission.status = 'merged'
|
|
1365
2252
|
mission.mergedAt = now
|
|
1366
2253
|
mission.mergedCommit = mergedCommit
|
|
@@ -1389,13 +2276,15 @@ export class Tower {
|
|
|
1389
2276
|
baseTip,
|
|
1390
2277
|
branchTip,
|
|
1391
2278
|
parents: found ? found.parents : null,
|
|
1392
|
-
review:
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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,
|
|
1399
2288
|
}
|
|
1400
2289
|
}
|
|
1401
2290
|
|
|
@@ -1406,6 +2295,7 @@ export class Tower {
|
|
|
1406
2295
|
if (!mission) {
|
|
1407
2296
|
return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
|
|
1408
2297
|
}
|
|
2298
|
+
await this.reconcileMergeIntent(state, mission)
|
|
1409
2299
|
const baseTip = await revParse(this.root, state.base)
|
|
1410
2300
|
if (mission.status === 'merged') {
|
|
1411
2301
|
return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
|
|
@@ -1418,13 +2308,25 @@ export class Tower {
|
|
|
1418
2308
|
const worktree = mission.worktree
|
|
1419
2309
|
const baseTipBefore = await revParse(this.root, 'HEAD')
|
|
1420
2310
|
const branchTip = await revParse(worktree.path, 'HEAD')
|
|
1421
|
-
|
|
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)
|
|
1422
2324
|
let failure = null
|
|
1423
2325
|
try {
|
|
1424
2326
|
await git(
|
|
1425
2327
|
this.root,
|
|
1426
2328
|
['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
|
|
1427
|
-
{ timeoutMs, maxBuffer: MERGE_MAX_BUFFER },
|
|
2329
|
+
{ timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
|
|
1428
2330
|
)
|
|
1429
2331
|
} catch (error) {
|
|
1430
2332
|
failure = error
|
|
@@ -1494,6 +2396,7 @@ export class Tower {
|
|
|
1494
2396
|
}
|
|
1495
2397
|
const detail = describeGitFailure(failure).slice(0, 2000)
|
|
1496
2398
|
const now = Date.now()
|
|
2399
|
+
delete state.mergeIntent
|
|
1497
2400
|
if (conflict) {
|
|
1498
2401
|
mission.status = 'blocked'
|
|
1499
2402
|
mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
|
|
@@ -1521,6 +2424,10 @@ export class Tower {
|
|
|
1521
2424
|
}
|
|
1522
2425
|
|
|
1523
2426
|
if (!expectedShape) {
|
|
2427
|
+
delete state.mergeIntent
|
|
2428
|
+
state.revision += 1
|
|
2429
|
+
state.updatedAt = Date.now()
|
|
2430
|
+
await writeJsonAtomic(this.paths.state, state).catch(() => {})
|
|
1524
2431
|
return {
|
|
1525
2432
|
ok: false,
|
|
1526
2433
|
blockers: [
|
|
@@ -1533,6 +2440,7 @@ export class Tower {
|
|
|
1533
2440
|
}
|
|
1534
2441
|
|
|
1535
2442
|
const now = Date.now()
|
|
2443
|
+
delete state.mergeIntent
|
|
1536
2444
|
mission.status = 'merged'
|
|
1537
2445
|
mission.mergedAt = now
|
|
1538
2446
|
mission.mergedCommit = newTip
|
|
@@ -1566,7 +2474,7 @@ export class Tower {
|
|
|
1566
2474
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1567
2475
|
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
|
|
1568
2476
|
const activity = input.activity ?? {}
|
|
1569
|
-
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))) {
|
|
1570
2478
|
if (activity[entry.agentId] === 'running') {
|
|
1571
2479
|
return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${entry.name} is running; rebase refused` }] }
|
|
1572
2480
|
}
|
|
@@ -1645,7 +2553,7 @@ export class Tower {
|
|
|
1645
2553
|
continue
|
|
1646
2554
|
}
|
|
1647
2555
|
const live = (state.roster ?? []).some(
|
|
1648
|
-
(entry) => entry.missionId === mission.id && entry.state
|
|
2556
|
+
(entry) => entry.missionId === mission.id && !ROSTER_INACTIVE.has(entry.state) && activity[entry.agentId] === 'running',
|
|
1649
2557
|
)
|
|
1650
2558
|
if (live) {
|
|
1651
2559
|
decisions.push({ missionId: mission.id, action: 'keep', reason: 'live agent' })
|
|
@@ -1698,13 +2606,19 @@ export class Tower {
|
|
|
1698
2606
|
})
|
|
1699
2607
|
}
|
|
1700
2608
|
|
|
1701
|
-
async status() {
|
|
2609
|
+
async status(input = {}) {
|
|
1702
2610
|
const state = await this.load()
|
|
1703
2611
|
const activity = await readActivityTail(this.paths.activity, 12)
|
|
1704
2612
|
const counts = {}
|
|
1705
2613
|
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
1706
2614
|
for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
|
|
1707
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
|
+
}
|
|
1708
2622
|
return {
|
|
1709
2623
|
repoRoot: state.repoRoot,
|
|
1710
2624
|
commonDir: state.commonDir,
|
|
@@ -1717,17 +2631,27 @@ export class Tower {
|
|
|
1717
2631
|
messages: (state.messages ?? []).length,
|
|
1718
2632
|
unreadTower: this.unreadFor(state, 'tower').length,
|
|
1719
2633
|
findings: (state.findings ?? []).length,
|
|
2634
|
+
findingsByStatus,
|
|
1720
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
|
+
},
|
|
1721
2644
|
recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
|
|
1722
2645
|
id: finding.id,
|
|
1723
2646
|
type: finding.type,
|
|
1724
2647
|
severity: finding.severity,
|
|
2648
|
+
status: finding.status ?? 'open',
|
|
1725
2649
|
title: finding.title,
|
|
1726
2650
|
missionId: finding.missionId,
|
|
1727
2651
|
reportedBy: finding.reportedBy,
|
|
1728
2652
|
at: finding.at,
|
|
1729
2653
|
})),
|
|
1730
|
-
missions: await this.enrichedMissions(state),
|
|
2654
|
+
missions: await this.enrichedMissions(state, input.missionIds),
|
|
1731
2655
|
activity,
|
|
1732
2656
|
}
|
|
1733
2657
|
}
|