dsh-tower 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.en.md +22 -16
- package/README.md +22 -16
- package/SECURITY.md +4 -1
- package/docs/README.md +30 -0
- package/docs/architecture/overview.md +3 -2
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +15 -6
- package/docs/guide/install-upgrade-migration.md +14 -5
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/scenarios-and-philosophy.md +150 -62
- package/docs/operations/security.md +23 -12
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.md +8 -6
- package/docs/reference/errors.md +7 -1
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +1 -1
- package/docs/zh/faq.md +12 -4
- package/docs/zh/guide/install-upgrade-migration.md +11 -4
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +108 -36
- package/docs/zh/operations/security.md +16 -6
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +8 -6
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +7 -6
- package/index.js +26 -17
- package/lib/config.js +103 -6
- package/lib/doctor.js +11 -5
- package/lib/errors.js +14 -2
- package/lib/git.js +36 -2
- package/lib/modeStore.js +54 -20
- package/lib/store.js +65 -10
- package/lib/tests.js +35 -1
- package/lib/tools.js +257 -47
- package/lib/tower.js +281 -55
- package/lib/workspaceLock.js +44 -20
- package/package.json +3 -2
- package/scripts/check-docs.mjs +35 -0
- package/skills/tower-recovery.md +7 -5
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
package/lib/tower.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isClean,
|
|
17
17
|
isAncestor,
|
|
18
18
|
tryGit,
|
|
19
|
+
setGitTimeout,
|
|
19
20
|
} from './git.js'
|
|
20
21
|
import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch } from './batch.js'
|
|
21
22
|
import { assertOwnership, canonicalPath } from './worktreeOwnership.js'
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
shortHash,
|
|
33
34
|
} from './store.js'
|
|
34
35
|
import { runtimeConfig, resolveRepoConfig } from './config.js'
|
|
36
|
+
import { TowerToolError } from './errors.js'
|
|
35
37
|
|
|
36
38
|
export const SCHEMA_VERSION = 1
|
|
37
39
|
export const MISSION_STATUSES = [
|
|
@@ -108,7 +110,7 @@ async function commitParents(cwd, sha) {
|
|
|
108
110
|
* the concrete evidence that a mission's work already landed in the base.
|
|
109
111
|
*/
|
|
110
112
|
async function findLandedMerge(cwd, baseTip, tips) {
|
|
111
|
-
const revs = (await tryGit(cwd, ['rev-list', '--merges',
|
|
113
|
+
const revs = (await tryGit(cwd, ['rev-list', '--merges', baseTip])) ?? ''
|
|
112
114
|
for (const sha of revs.split('\n').filter(Boolean)) {
|
|
113
115
|
const parents = await commitParents(cwd, sha)
|
|
114
116
|
if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
|
|
@@ -116,16 +118,32 @@ async function findLandedMerge(cwd, baseTip, tips) {
|
|
|
116
118
|
return null
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
function clearStale(entry) {
|
|
122
|
+
delete entry.stale
|
|
123
|
+
delete entry.staleAt
|
|
124
|
+
}
|
|
125
|
+
|
|
119
126
|
function describeGitFailure(error) {
|
|
120
127
|
const prefix = error?.timedOut ? 'timed out: ' : error?.bufferOverflow ? 'output buffer overflow: ' : ''
|
|
121
128
|
return `${prefix}${String(error?.message ?? error)}`
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
131
|
+
/** Remove only lock files that are provably old. A fresh lock may belong to a
|
|
132
|
+
* concurrent Git process; deleting it would let two writers into the index. */
|
|
133
|
+
async function removeStaleGitLocks(commonDir, maxAgeMs = 60000) {
|
|
134
|
+
const removed = []
|
|
126
135
|
for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
|
|
127
|
-
|
|
136
|
+
const file = path.join(commonDir, name)
|
|
137
|
+
try {
|
|
138
|
+
const stat = await fsp.stat(file)
|
|
139
|
+
if (Date.now() - stat.mtimeMs < maxAgeMs) continue
|
|
140
|
+
await fsp.unlink(file)
|
|
141
|
+
removed.push(name)
|
|
142
|
+
} catch {
|
|
143
|
+
// absent or in use; never force it
|
|
144
|
+
}
|
|
128
145
|
}
|
|
146
|
+
return removed
|
|
129
147
|
}
|
|
130
148
|
|
|
131
149
|
function globToRegExp(pattern) {
|
|
@@ -388,13 +406,16 @@ export function validateState(state, statePath) {
|
|
|
388
406
|
const names = new Set()
|
|
389
407
|
const agents = new Set()
|
|
390
408
|
for (const entry of state.roster) {
|
|
391
|
-
|
|
409
|
+
const reserved = entry?.state === 'reserved'
|
|
410
|
+
if (!entry || typeof entry.name !== 'string' || (!reserved && typeof entry.agentId !== 'string')) {
|
|
392
411
|
fail(`invalid roster entry${where}`)
|
|
393
412
|
}
|
|
394
413
|
if (names.has(entry.name)) fail(`duplicate roster name ${entry.name}${where}`)
|
|
395
|
-
if (
|
|
414
|
+
if (typeof entry.agentId === 'string') {
|
|
415
|
+
if (agents.has(entry.agentId)) fail(`duplicate roster agent ${entry.agentId}${where}`)
|
|
416
|
+
agents.add(entry.agentId)
|
|
417
|
+
}
|
|
396
418
|
names.add(entry.name)
|
|
397
|
-
agents.add(entry.agentId)
|
|
398
419
|
}
|
|
399
420
|
}
|
|
400
421
|
if (state.messages !== undefined) {
|
|
@@ -529,7 +550,9 @@ function workerEntries(state, missionId) {
|
|
|
529
550
|
}
|
|
530
551
|
|
|
531
552
|
function countLiveWorkers(state) {
|
|
532
|
-
return (state.roster ?? []).filter(
|
|
553
|
+
return (state.roster ?? []).filter(
|
|
554
|
+
(entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state) && entry.state !== 'reserved',
|
|
555
|
+
).length
|
|
533
556
|
}
|
|
534
557
|
|
|
535
558
|
function normalizePauseCause(value) {
|
|
@@ -566,6 +589,7 @@ export class Tower {
|
|
|
566
589
|
// Effective config for this repository: code defaults < profile config <
|
|
567
590
|
// repo config file. Per-call tool arguments are applied by the caller.
|
|
568
591
|
this.config = config ?? { ...runtimeConfig(), configPath: null, repoConfigPresent: false }
|
|
592
|
+
setGitTimeout(this.config.git?.timeoutMs)
|
|
569
593
|
}
|
|
570
594
|
|
|
571
595
|
static async open(repoRoot) {
|
|
@@ -607,6 +631,19 @@ export class Tower {
|
|
|
607
631
|
|
|
608
632
|
if (await exists(this.paths.state)) {
|
|
609
633
|
const state = await this.load()
|
|
634
|
+
const owner = state.towerAgentId ? String(state.towerAgentId) : null
|
|
635
|
+
const caller = input.towerAgentId ? String(input.towerAgentId) : null
|
|
636
|
+
if (owner && caller && owner !== caller) {
|
|
637
|
+
fail(`tower state is owned by another agent; ${caller} is not the tower`)
|
|
638
|
+
}
|
|
639
|
+
if (!owner && caller) {
|
|
640
|
+
state.towerAgentId = caller
|
|
641
|
+
state.revision += 1
|
|
642
|
+
state.updatedAt = now
|
|
643
|
+
validateState(state, this.paths.state)
|
|
644
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
645
|
+
await appendJsonl(this.paths.activity, { at: now, type: 'init', text: `tower owner claimed by ${caller}` })
|
|
646
|
+
}
|
|
610
647
|
let reanchored = false
|
|
611
648
|
if (input.reanchor === true) {
|
|
612
649
|
const active = state.missions.filter((mission) => !TERMINAL.has(mission.status))
|
|
@@ -668,6 +705,7 @@ export class Tower {
|
|
|
668
705
|
createdAt: now,
|
|
669
706
|
updatedAt: now,
|
|
670
707
|
dirtyBase: dirty,
|
|
708
|
+
...(input.towerAgentId ? { towerAgentId: String(input.towerAgentId) } : {}),
|
|
671
709
|
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
672
710
|
missions: [],
|
|
673
711
|
}
|
|
@@ -923,6 +961,7 @@ export class Tower {
|
|
|
923
961
|
entry.state = input.state ?? 'failed'
|
|
924
962
|
entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
|
|
925
963
|
entry.outcomeAt = now
|
|
964
|
+
clearStale(entry)
|
|
926
965
|
state.revision += 1
|
|
927
966
|
state.updatedAt = now
|
|
928
967
|
validateState(state, this.paths.state)
|
|
@@ -951,6 +990,7 @@ export class Tower {
|
|
|
951
990
|
entry.state = 'interrupted'
|
|
952
991
|
entry.outcome = String(input.reason ?? 'interrupted by tower').slice(0, 1000)
|
|
953
992
|
entry.outcomeAt = now
|
|
993
|
+
clearStale(entry)
|
|
954
994
|
state.revision += 1
|
|
955
995
|
state.updatedAt = now
|
|
956
996
|
validateState(state, this.paths.state)
|
|
@@ -1010,14 +1050,22 @@ export class Tower {
|
|
|
1010
1050
|
state.revision += 1
|
|
1011
1051
|
state.updatedAt = now
|
|
1012
1052
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1053
|
+
try {
|
|
1054
|
+
validateState(state, this.paths.state)
|
|
1055
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
// Roll back Git resources if state persistence failed, otherwise the
|
|
1058
|
+
// mission is wedged forever with an untracked worktree/branch.
|
|
1059
|
+
await git(this.root, ['worktree', 'remove', '--force', wtPath]).catch(() => {})
|
|
1060
|
+
await git(this.root, ['branch', '-D', branch]).catch(() => {})
|
|
1061
|
+
throw error
|
|
1062
|
+
}
|
|
1015
1063
|
await appendJsonl(this.paths.activity, {
|
|
1016
1064
|
at: now,
|
|
1017
1065
|
type: 'worktree',
|
|
1018
1066
|
missionId,
|
|
1019
1067
|
text: `${branch} at ${wtPath}`,
|
|
1020
|
-
})
|
|
1068
|
+
}).catch(() => {})
|
|
1021
1069
|
return { mission, worktree, created: true }
|
|
1022
1070
|
})
|
|
1023
1071
|
}
|
|
@@ -1065,16 +1113,55 @@ export class Tower {
|
|
|
1065
1113
|
}
|
|
1066
1114
|
}
|
|
1067
1115
|
|
|
1116
|
+
/** Reserve a roster name before activation so a failed spawn is recoverable. */
|
|
1117
|
+
async reserveRoster(input) {
|
|
1118
|
+
return withLock(this.paths, async () => {
|
|
1119
|
+
const state = await this.load()
|
|
1120
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1121
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1122
|
+
if (!Array.isArray(state.roster)) state.roster = []
|
|
1123
|
+
if (state.roster.some((entry) => entry.name === input.name)) fail(`roster name already used: ${input.name}`)
|
|
1124
|
+
const now = Date.now()
|
|
1125
|
+
const entry = {
|
|
1126
|
+
name: input.name,
|
|
1127
|
+
role: input.role,
|
|
1128
|
+
agentId: null,
|
|
1129
|
+
sessionId: null,
|
|
1130
|
+
parentSessionId: input.parentSessionId,
|
|
1131
|
+
missionId: input.missionId,
|
|
1132
|
+
cwd: input.cwd ?? null,
|
|
1133
|
+
branch: input.branch ?? null,
|
|
1134
|
+
spawnTip: null,
|
|
1135
|
+
state: 'reserved',
|
|
1136
|
+
reservedAt: now,
|
|
1137
|
+
spawnedAt: now,
|
|
1138
|
+
}
|
|
1139
|
+
state.roster.push(entry)
|
|
1140
|
+
state.revision += 1
|
|
1141
|
+
state.updatedAt = now
|
|
1142
|
+
validateState(state, this.paths.state)
|
|
1143
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1144
|
+
await appendJsonl(this.paths.activity, {
|
|
1145
|
+
at: now,
|
|
1146
|
+
type: 'spawn',
|
|
1147
|
+
missionId: input.missionId,
|
|
1148
|
+
text: `reserved ${input.role} ${input.name}`,
|
|
1149
|
+
})
|
|
1150
|
+
return entry
|
|
1151
|
+
})
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1068
1154
|
async recordSpawn(input) {
|
|
1069
1155
|
return withLock(this.paths, async () => {
|
|
1070
1156
|
const state = await this.load()
|
|
1071
1157
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1072
1158
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1073
1159
|
if (!Array.isArray(state.roster)) state.roster = []
|
|
1074
|
-
|
|
1160
|
+
const reserved = state.roster.find((entry) => entry.name === input.name && entry.state === 'reserved' && !entry.agentId)
|
|
1161
|
+
if (!reserved && state.roster.some((entry) => entry.name === input.name)) {
|
|
1075
1162
|
fail(`roster name already used: ${input.name}`)
|
|
1076
1163
|
}
|
|
1077
|
-
if (state.roster.some((entry) => entry.agentId === input.agentId)) {
|
|
1164
|
+
if (state.roster.some((entry) => entry.agentId && entry.agentId === input.agentId)) {
|
|
1078
1165
|
fail(`agent ${input.agentId} is already in the roster`)
|
|
1079
1166
|
}
|
|
1080
1167
|
const now = Date.now()
|
|
@@ -1094,7 +1181,7 @@ export class Tower {
|
|
|
1094
1181
|
}
|
|
1095
1182
|
if (input.role === 'worker') {
|
|
1096
1183
|
const previous = workerEntries(state, mission.id)
|
|
1097
|
-
const live = previous.find((entry) => !ROSTER_INACTIVE.has(entry.state))
|
|
1184
|
+
const live = previous.find((entry) => !ROSTER_INACTIVE.has(entry.state) && entry.state !== 'reserved')
|
|
1098
1185
|
if (live && !input.takeover) {
|
|
1099
1186
|
fail(
|
|
1100
1187
|
`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`,
|
|
@@ -1104,10 +1191,11 @@ export class Tower {
|
|
|
1104
1191
|
// idle `inactive` ones; otherwise the next spawn would see several entries
|
|
1105
1192
|
// for the same mission and the old idle worker would block forever.
|
|
1106
1193
|
for (const old of previous) {
|
|
1107
|
-
if (old.state === 'retired') continue
|
|
1194
|
+
if (old.state === 'retired' || old.state === 'reserved') continue
|
|
1108
1195
|
old.state = 'retired'
|
|
1109
1196
|
old.outcome = `replaced by ${input.name}${input.takeover ? ' (takeover)' : ''}`
|
|
1110
1197
|
old.outcomeAt = now
|
|
1198
|
+
clearStale(old)
|
|
1111
1199
|
}
|
|
1112
1200
|
const liveCount = countLiveWorkers(state)
|
|
1113
1201
|
const max = Number(this.config.spawn.maxLiveWorkers) || 4
|
|
@@ -1120,7 +1208,8 @@ export class Tower {
|
|
|
1120
1208
|
mission.owner = input.name
|
|
1121
1209
|
if (mission.status === 'planned') mission.status = 'active'
|
|
1122
1210
|
}
|
|
1123
|
-
|
|
1211
|
+
if (reserved) Object.assign(reserved, entry)
|
|
1212
|
+
else state.roster.push(entry)
|
|
1124
1213
|
mission.updatedAt = now
|
|
1125
1214
|
state.revision += 1
|
|
1126
1215
|
state.updatedAt = now
|
|
@@ -1133,7 +1222,7 @@ export class Tower {
|
|
|
1133
1222
|
missionId: input.missionId,
|
|
1134
1223
|
text: `${input.role} ${input.name} (${input.agentId})`,
|
|
1135
1224
|
})
|
|
1136
|
-
return entry
|
|
1225
|
+
return reserved ?? entry
|
|
1137
1226
|
})
|
|
1138
1227
|
}
|
|
1139
1228
|
|
|
@@ -1163,9 +1252,18 @@ export class Tower {
|
|
|
1163
1252
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1164
1253
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1165
1254
|
if (!mission.testRuns) mission.testRuns = []
|
|
1166
|
-
const
|
|
1255
|
+
const rawReceipt = { ...input.receipt, recordedAt: Date.now() }
|
|
1256
|
+
// Keep state bounded even when a repository reports a huge suite.
|
|
1257
|
+
const tests = Array.isArray(rawReceipt.tests) ? rawReceipt.tests : []
|
|
1258
|
+
const receipt = tests.length > 100
|
|
1259
|
+
? { ...rawReceipt, tests: tests.slice(0, 100), testsTotal: rawReceipt.count ?? tests.length, testsTruncated: true }
|
|
1260
|
+
: rawReceipt
|
|
1167
1261
|
mission.testRuns.push(receipt)
|
|
1168
1262
|
if (mission.testRuns.length > 50) mission.testRuns = mission.testRuns.slice(-50)
|
|
1263
|
+
const MAX_RUNS_BYTES = 512 * 1024
|
|
1264
|
+
while (mission.testRuns.length > 1 && Buffer.byteLength(JSON.stringify(mission.testRuns), 'utf8') > MAX_RUNS_BYTES) {
|
|
1265
|
+
mission.testRuns.shift()
|
|
1266
|
+
}
|
|
1169
1267
|
state.revision += 1
|
|
1170
1268
|
state.updatedAt = Date.now()
|
|
1171
1269
|
validateState(state, this.paths.state)
|
|
@@ -1300,8 +1398,41 @@ export class Tower {
|
|
|
1300
1398
|
}
|
|
1301
1399
|
}
|
|
1302
1400
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1401
|
+
/** Track repeated absence observations before declaring a roster row gone. */
|
|
1402
|
+
async recordRosterObservations({ present = [], absent = [], confirmations = 2 }) {
|
|
1403
|
+
const presentIds = new Set(present)
|
|
1404
|
+
const absentIds = new Set(absent)
|
|
1405
|
+
return withLock(this.paths, async () => {
|
|
1406
|
+
const state = await this.load()
|
|
1407
|
+
const candidates = []
|
|
1408
|
+
const now = Date.now()
|
|
1409
|
+
let changed = false
|
|
1410
|
+
for (const entry of state.roster ?? []) {
|
|
1411
|
+
if (presentIds.has(entry.agentId)) {
|
|
1412
|
+
if (entry.absentObservations !== undefined) {
|
|
1413
|
+
delete entry.absentObservations
|
|
1414
|
+
delete entry.absentSince
|
|
1415
|
+
changed = true
|
|
1416
|
+
}
|
|
1417
|
+
continue
|
|
1418
|
+
}
|
|
1419
|
+
if (!absentIds.has(entry.agentId)) continue
|
|
1420
|
+
entry.absentObservations = (entry.absentObservations ?? 0) + 1
|
|
1421
|
+
entry.absentSince ??= now
|
|
1422
|
+
changed = true
|
|
1423
|
+
if (entry.absentObservations >= Math.max(2, Number(confirmations) || 2)) candidates.push(entry.agentId)
|
|
1424
|
+
}
|
|
1425
|
+
if (changed) {
|
|
1426
|
+
state.revision += 1
|
|
1427
|
+
state.updatedAt = now
|
|
1428
|
+
validateState(state, this.paths.state)
|
|
1429
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1430
|
+
}
|
|
1431
|
+
return { candidates }
|
|
1432
|
+
})
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
async markRetired(agentIds) { if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
1305
1436
|
return withLock(this.paths, async () => {
|
|
1306
1437
|
const state = await this.load()
|
|
1307
1438
|
const retired = []
|
|
@@ -1310,6 +1441,7 @@ export class Tower {
|
|
|
1310
1441
|
if (entry.state !== 'retired' && agentIds.includes(entry.agentId)) {
|
|
1311
1442
|
entry.state = 'retired'
|
|
1312
1443
|
entry.retiredAt = now
|
|
1444
|
+
clearStale(entry)
|
|
1313
1445
|
retired.push(entry.agentId)
|
|
1314
1446
|
}
|
|
1315
1447
|
}
|
|
@@ -1338,6 +1470,7 @@ export class Tower {
|
|
|
1338
1470
|
if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
|
|
1339
1471
|
entry.state = 'missing'
|
|
1340
1472
|
entry.missingSince = now
|
|
1473
|
+
clearStale(entry)
|
|
1341
1474
|
missing.push(entry.agentId)
|
|
1342
1475
|
}
|
|
1343
1476
|
}
|
|
@@ -1548,6 +1681,16 @@ export class Tower {
|
|
|
1548
1681
|
if (commits < 1) {
|
|
1549
1682
|
fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
|
|
1550
1683
|
}
|
|
1684
|
+
if (!(await isClean(mission.worktree.path))) {
|
|
1685
|
+
const dirty = (await git(mission.worktree.path, ['status', '--porcelain']))
|
|
1686
|
+
.split('\n')
|
|
1687
|
+
.filter(Boolean)
|
|
1688
|
+
.map((line) => line.slice(3).trim())
|
|
1689
|
+
throw new TowerToolError(
|
|
1690
|
+
'worktree-dirty',
|
|
1691
|
+
`mission ${mission.id} worktree has uncommitted changes (${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ', …' : ''}); commit, remove, or record them before tower_complete`,
|
|
1692
|
+
)
|
|
1693
|
+
}
|
|
1551
1694
|
const unread = this.unreadFor(state, worker.name)
|
|
1552
1695
|
if (unread.length > 0) {
|
|
1553
1696
|
fail(
|
|
@@ -1753,9 +1896,16 @@ export class Tower {
|
|
|
1753
1896
|
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to review`)
|
|
1754
1897
|
|
|
1755
1898
|
const caller = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
1899
|
+
const actorRole = input.actor?.role
|
|
1756
1900
|
let reviewerName
|
|
1757
1901
|
let reviewerRole
|
|
1758
|
-
if (
|
|
1902
|
+
if (actorRole === 'tower') {
|
|
1903
|
+
if (typeof input.breakGlassReason !== 'string' || input.breakGlassReason.trim().length === 0) {
|
|
1904
|
+
fail('a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
|
|
1905
|
+
}
|
|
1906
|
+
reviewerName = 'tower'
|
|
1907
|
+
reviewerRole = 'tower'
|
|
1908
|
+
} else if (caller) {
|
|
1759
1909
|
if (caller.role !== 'reviewer') {
|
|
1760
1910
|
fail(`agent ${caller.name} is a ${caller.role}; only an assigned reviewer or the tower (break-glass) may review`)
|
|
1761
1911
|
}
|
|
@@ -1766,15 +1916,13 @@ export class Tower {
|
|
|
1766
1916
|
reviewerName = caller.name
|
|
1767
1917
|
reviewerRole = 'reviewer'
|
|
1768
1918
|
} else {
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
}
|
|
1772
|
-
reviewerName = 'tower'
|
|
1773
|
-
reviewerRole = 'tower'
|
|
1919
|
+
// Fail closed: an unknown caller is not the tower, and a missing
|
|
1920
|
+
// roster row is not evidence of tower identity.
|
|
1921
|
+
fail(`agent ${input.agentId ?? '(unknown)'} is not a registered reviewer or the tower`)
|
|
1774
1922
|
}
|
|
1775
1923
|
|
|
1776
1924
|
if (mission.status !== 'completed') {
|
|
1777
|
-
fail(`mission ${mission.id} is ${mission.status}; only a completed mission
|
|
1925
|
+
fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
|
|
1778
1926
|
}
|
|
1779
1927
|
if (!REVIEW_STATUS_RE.test(String(input.status ?? ''))) {
|
|
1780
1928
|
fail('status must be "clean", "p1-Nitems", or "p2-Nitems"')
|
|
@@ -1801,9 +1949,12 @@ export class Tower {
|
|
|
1801
1949
|
await this.assertWorktree(mission.worktree)
|
|
1802
1950
|
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
1803
1951
|
const branch = mission.worktree.branch
|
|
1804
|
-
if (caller && caller.role === 'reviewer' && caller.spawnTip
|
|
1952
|
+
if (caller && caller.role === 'reviewer' && !String(caller.spawnTip ?? '').trim()) {
|
|
1953
|
+
fail(`reviewer ${caller.name} has no recorded spawn tip; re-spawn the reviewer before reviewing`)
|
|
1954
|
+
}
|
|
1955
|
+
if (caller && caller.role === 'reviewer' && caller.spawnTip !== tip) {
|
|
1805
1956
|
fail(
|
|
1806
|
-
`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`,
|
|
1957
|
+
`reviewer ${caller.name} modified the mission branch after it was spawned (${String(caller.spawnTip).slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
|
|
1807
1958
|
)
|
|
1808
1959
|
}
|
|
1809
1960
|
const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
@@ -2032,7 +2183,7 @@ export class Tower {
|
|
|
2032
2183
|
)
|
|
2033
2184
|
}
|
|
2034
2185
|
if (mission.status !== 'completed') {
|
|
2035
|
-
push('mission-not-completed', `mission ${mission.id} is ${mission.status}; completion
|
|
2186
|
+
push('mission-not-completed', `mission ${mission.id} is ${mission.status}; completion is required`)
|
|
2036
2187
|
}
|
|
2037
2188
|
const openTasks = mission.tasks.filter((task) => task.status === 'open')
|
|
2038
2189
|
if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
|
|
@@ -2103,16 +2254,18 @@ export class Tower {
|
|
|
2103
2254
|
const branch = worktree ? worktree.branch : null
|
|
2104
2255
|
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
2105
2256
|
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
2257
|
+
const requireReview = this.config.review?.requireReview !== false
|
|
2106
2258
|
const exempt =
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2259
|
+
!requireReview ||
|
|
2260
|
+
(mission.reviewPolicy === 'none' &&
|
|
2261
|
+
this.config.gates.allowReviewExemption === true &&
|
|
2262
|
+
String(mission.reviewExemptionReason ?? '').trim().length > 0)
|
|
2263
|
+
if (requireReview && mission.reviewPolicy === 'none' && this.config.gates.allowReviewExemption !== true) {
|
|
2111
2264
|
push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
|
|
2112
2265
|
}
|
|
2113
|
-
if (!exempt && !latest) {
|
|
2114
|
-
push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first`)
|
|
2115
|
-
} else if (!exempt) {
|
|
2266
|
+
if (requireReview && !exempt && !latest) {
|
|
2267
|
+
push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.requireReview:false)`)
|
|
2268
|
+
} else if (requireReview && !exempt) {
|
|
2116
2269
|
const waived = this.rebaseWaived(mission, latest)
|
|
2117
2270
|
if (latest.missionId !== mission.id) {
|
|
2118
2271
|
push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
|
|
@@ -2138,7 +2291,9 @@ export class Tower {
|
|
|
2138
2291
|
}
|
|
2139
2292
|
}
|
|
2140
2293
|
const staleWorkers = (state.roster ?? []).filter(
|
|
2141
|
-
(row) =>
|
|
2294
|
+
(row) =>
|
|
2295
|
+
row.missionId === mission.id &&
|
|
2296
|
+
(row.state === 'stale' || (row.stale === true && !ROSTER_INACTIVE.has(row.state))),
|
|
2142
2297
|
)
|
|
2143
2298
|
if (staleWorkers.length > 0) {
|
|
2144
2299
|
push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
|
|
@@ -2252,9 +2407,10 @@ export class Tower {
|
|
|
2252
2407
|
const branch = mission.worktree.branch
|
|
2253
2408
|
const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
2254
2409
|
const exempt =
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2410
|
+
this.config.review?.requireReview === false ||
|
|
2411
|
+
(mission.reviewPolicy === 'none' &&
|
|
2412
|
+
this.config.gates.allowReviewExemption === true &&
|
|
2413
|
+
String(mission.reviewExemptionReason ?? '').trim().length > 0)
|
|
2258
2414
|
if (reviews.length === 0 && !exempt) return null
|
|
2259
2415
|
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
2260
2416
|
if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
|
|
@@ -2270,10 +2426,24 @@ export class Tower {
|
|
|
2270
2426
|
const reviewedTip = latest?.reviewedCommit ?? null
|
|
2271
2427
|
const branchLanded = await isAncestor(this.root, branchTip, baseTip)
|
|
2272
2428
|
const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
|
|
2273
|
-
if (!branchLanded
|
|
2429
|
+
if (!branchLanded) {
|
|
2430
|
+
if (reviewedLanded) {
|
|
2431
|
+
return {
|
|
2432
|
+
ok: false,
|
|
2433
|
+
missionId: mission.id,
|
|
2434
|
+
blockers: [
|
|
2435
|
+
{
|
|
2436
|
+
code: 'review-stale',
|
|
2437
|
+
detail: `reviewed commit ${reviewedTip.slice(0, 7)} is contained in ${state.base}, but branch tip ${branchTip.slice(0, 7)} is not; the branch was partially landed or moved — re-review or repair manually`,
|
|
2438
|
+
},
|
|
2439
|
+
],
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
return null
|
|
2443
|
+
}
|
|
2274
2444
|
const tips = new Set([branchTip, reviewedTip].filter(Boolean))
|
|
2275
2445
|
const found = await findLandedMerge(this.root, baseTip, tips)
|
|
2276
|
-
const mergedCommit = found ? found.commit :
|
|
2446
|
+
const mergedCommit = found ? found.commit : branchTip
|
|
2277
2447
|
const now = Date.now()
|
|
2278
2448
|
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
2279
2449
|
mission.status = 'merged'
|
|
@@ -2336,6 +2506,20 @@ export class Tower {
|
|
|
2336
2506
|
const worktree = mission.worktree
|
|
2337
2507
|
const baseTipBefore = await revParse(this.root, 'HEAD')
|
|
2338
2508
|
const branchTip = await revParse(worktree.path, 'HEAD')
|
|
2509
|
+
if (gate.baseTip && gate.baseTip !== baseTipBefore) {
|
|
2510
|
+
return {
|
|
2511
|
+
ok: false,
|
|
2512
|
+
missionId: mission.id,
|
|
2513
|
+
blockers: [{ code: 'stale-base', detail: `base moved after gate (${gate.baseTip.slice(0, 7)} -> ${baseTipBefore.slice(0, 7)}); re-run preflight` }],
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
if (gate.branchTip && gate.branchTip !== branchTip) {
|
|
2517
|
+
return {
|
|
2518
|
+
ok: false,
|
|
2519
|
+
missionId: mission.id,
|
|
2520
|
+
blockers: [{ code: 'tip-moved', detail: `branch moved after gate (${gate.branchTip.slice(0, 7)} -> ${branchTip.slice(0, 7)}); re-run preflight` }],
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2339
2523
|
// Record the intent before touching Git; a crash between merge and state
|
|
2340
2524
|
// write can be reconciled by the next tower_merge/preflight instead of
|
|
2341
2525
|
// leaving an unexplained base tip.
|
|
@@ -2343,6 +2527,7 @@ export class Tower {
|
|
|
2343
2527
|
missionId: mission.id,
|
|
2344
2528
|
baseTip: baseTipBefore,
|
|
2345
2529
|
branchTip,
|
|
2530
|
+
gateTip: gate.branchTip ?? branchTip,
|
|
2346
2531
|
startedAt: Date.now(),
|
|
2347
2532
|
}
|
|
2348
2533
|
state.revision += 1
|
|
@@ -2351,9 +2536,11 @@ export class Tower {
|
|
|
2351
2536
|
const timeoutMs = mergeTimeoutMs(input.timeoutMs, this.config)
|
|
2352
2537
|
let failure = null
|
|
2353
2538
|
try {
|
|
2539
|
+
// Merge the exact reviewed commit, never a mutable branch name. A
|
|
2540
|
+
// branch move after the gate must not smuggle extra commits into base.
|
|
2354
2541
|
await git(
|
|
2355
2542
|
this.root,
|
|
2356
|
-
['merge', '--no-ff',
|
|
2543
|
+
['merge', '--no-ff', branchTip, '-m', `tower: merge ${mission.id} — ${mission.title}`],
|
|
2357
2544
|
{ timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
|
|
2358
2545
|
)
|
|
2359
2546
|
} catch (error) {
|
|
@@ -2402,9 +2589,11 @@ export class Tower {
|
|
|
2402
2589
|
}
|
|
2403
2590
|
}
|
|
2404
2591
|
} else if (newTip !== baseTipBefore) {
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2592
|
+
// HEAD moved without Tower's expected merge shape. This may be a
|
|
2593
|
+
// concurrent commit; never hard-reset it away. Leave a visible
|
|
2594
|
+
// manual-recovery state instead.
|
|
2595
|
+
cleanup = 'manual-required'
|
|
2596
|
+
warnings.push('HEAD moved unexpectedly during a failed merge; left in place for manual recovery')
|
|
2408
2597
|
}
|
|
2409
2598
|
// A killed git process can leave the index staged even without
|
|
2410
2599
|
// MERGE_HEAD. Restore only when every dirty path belongs to this merge.
|
|
@@ -2414,12 +2603,12 @@ export class Tower {
|
|
|
2414
2603
|
.map((line) => line.slice(3).trim())
|
|
2415
2604
|
if (dirtyAfter.length > 0) {
|
|
2416
2605
|
const changed = new Set(gate.changedFiles ?? [])
|
|
2417
|
-
if (dirtyAfter.every((file) => changed.has(file))) {
|
|
2606
|
+
if (newTip === baseTipBefore && dirtyAfter.every((file) => changed.has(file))) {
|
|
2418
2607
|
await removeStaleGitLocks(this.commonDir)
|
|
2419
2608
|
await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
|
|
2420
2609
|
cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
|
|
2421
2610
|
} else {
|
|
2422
|
-
warnings.push(`cleanup left
|
|
2611
|
+
warnings.push(`cleanup left changes for manual review: ${dirtyAfter.slice(0, 5).join(', ')}`)
|
|
2423
2612
|
}
|
|
2424
2613
|
}
|
|
2425
2614
|
const detail = describeGitFailure(failure).slice(0, 2000)
|
|
@@ -2452,18 +2641,33 @@ export class Tower {
|
|
|
2452
2641
|
}
|
|
2453
2642
|
|
|
2454
2643
|
if (!expectedShape) {
|
|
2455
|
-
|
|
2644
|
+
// Keep the intent and the observed tip. A shape mismatch means Tower
|
|
2645
|
+
// cannot prove what landed; deleting the intent here would make the
|
|
2646
|
+
// moved base unreconcilable and could discard a real commit.
|
|
2647
|
+
const detail = `unexpected merge parents [${parents.join(', ')}], expected [${baseTipBefore}, ${branchTip}]`
|
|
2648
|
+
state.mergeIntent.problem = { code: 'merge-shape', newTip, parents, at: Date.now() }
|
|
2456
2649
|
state.revision += 1
|
|
2457
2650
|
state.updatedAt = Date.now()
|
|
2458
|
-
await writeJsonAtomic(this.paths.state, state)
|
|
2651
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
2652
|
+
await appendJsonl(this.paths.activity, {
|
|
2653
|
+
at: state.updatedAt,
|
|
2654
|
+
type: 'merge',
|
|
2655
|
+
missionId: mission.id,
|
|
2656
|
+
text: `shape-mismatch newTip=${newTip.slice(0, 12)} parents=${parents.length}`,
|
|
2657
|
+
})
|
|
2459
2658
|
return {
|
|
2460
2659
|
ok: false,
|
|
2660
|
+
missionId: mission.id,
|
|
2461
2661
|
blockers: [
|
|
2462
2662
|
{
|
|
2463
2663
|
code: 'merge-shape',
|
|
2464
|
-
detail
|
|
2664
|
+
detail,
|
|
2665
|
+
cleanup: 'manual-required',
|
|
2465
2666
|
},
|
|
2466
2667
|
],
|
|
2668
|
+
newTip,
|
|
2669
|
+
parents,
|
|
2670
|
+
intentRetained: true,
|
|
2467
2671
|
}
|
|
2468
2672
|
}
|
|
2469
2673
|
|
|
@@ -2591,6 +2795,16 @@ export class Tower {
|
|
|
2591
2795
|
decisions.push({ missionId: mission.id, action: 'keep', reason: `mission is ${mission.status}` })
|
|
2592
2796
|
continue
|
|
2593
2797
|
}
|
|
2798
|
+
try {
|
|
2799
|
+
await this.assertWorktree(mission.worktree)
|
|
2800
|
+
} catch (error) {
|
|
2801
|
+
decisions.push({
|
|
2802
|
+
missionId: mission.id,
|
|
2803
|
+
action: 'keep',
|
|
2804
|
+
reason: `worktree ownership/registration unverified: ${String(error?.message ?? error).slice(0, 240)}`,
|
|
2805
|
+
})
|
|
2806
|
+
continue
|
|
2807
|
+
}
|
|
2594
2808
|
let dirty = true
|
|
2595
2809
|
try {
|
|
2596
2810
|
dirty = !(await isClean(mission.worktree.path))
|
|
@@ -2611,16 +2825,28 @@ export class Tower {
|
|
|
2611
2825
|
decisions.push({ missionId: mission.id, action: 'keep', reason: `remove failed: ${String(error?.message ?? error)}` })
|
|
2612
2826
|
continue
|
|
2613
2827
|
}
|
|
2614
|
-
|
|
2828
|
+
let branchDeleteError = null
|
|
2829
|
+
try {
|
|
2830
|
+
await git(this.root, ['branch', '-d', mission.worktree.branch])
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
branchDeleteError = String(error?.message ?? error).slice(0, 240)
|
|
2833
|
+
}
|
|
2615
2834
|
mission.worktreeRemovedAt = now
|
|
2616
2835
|
mission.updatedAt = now
|
|
2836
|
+
if (branchDeleteError) mission.branchDeleteError = branchDeleteError
|
|
2837
|
+
else delete mission.branchDeleteError
|
|
2617
2838
|
removed.push(mission.id)
|
|
2618
|
-
decisions.push({
|
|
2839
|
+
decisions.push({
|
|
2840
|
+
missionId: mission.id,
|
|
2841
|
+
action: 'removed',
|
|
2842
|
+
branch: mission.worktree.branch,
|
|
2843
|
+
...(branchDeleteError ? { branchRetained: true, branchDeleteError } : {}),
|
|
2844
|
+
})
|
|
2619
2845
|
await appendJsonl(this.paths.activity, {
|
|
2620
2846
|
at: now,
|
|
2621
2847
|
type: 'teardown',
|
|
2622
2848
|
missionId: mission.id,
|
|
2623
|
-
text: mission.worktree.branch
|
|
2849
|
+
text: `${mission.worktree.branch}${branchDeleteError ? ' (branch retained)' : ''}`,
|
|
2624
2850
|
})
|
|
2625
2851
|
}
|
|
2626
2852
|
|