dsh-tower 2.0.3 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.en.md +19 -13
- package/README.md +19 -13
- package/SECURITY.md +4 -1
- package/docs/README.md +30 -0
- package/docs/architecture/overview.md +3 -2
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/install-upgrade-migration.md +14 -5
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +18 -12
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.md +7 -6
- package/docs/reference/errors.md +7 -1
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +1 -1
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/install-upgrade-migration.md +11 -4
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +13 -6
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +7 -6
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +7 -6
- package/index.js +26 -17
- package/lib/config.js +100 -6
- package/lib/doctor.js +11 -5
- package/lib/errors.js +14 -2
- package/lib/git.js +36 -2
- package/lib/modeStore.js +54 -20
- package/lib/store.js +65 -10
- package/lib/tests.js +35 -1
- package/lib/tools.js +257 -47
- package/lib/tower.js +268 -45
- package/lib/workspaceLock.js +44 -20
- package/package.json +3 -2
- package/scripts/check-docs.mjs +35 -0
- package/skills/tower-recovery.md +7 -5
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
package/lib/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('; ')}`)
|
|
@@ -2140,7 +2291,9 @@ export class Tower {
|
|
|
2140
2291
|
}
|
|
2141
2292
|
}
|
|
2142
2293
|
const staleWorkers = (state.roster ?? []).filter(
|
|
2143
|
-
(row) =>
|
|
2294
|
+
(row) =>
|
|
2295
|
+
row.missionId === mission.id &&
|
|
2296
|
+
(row.state === 'stale' || (row.stale === true && !ROSTER_INACTIVE.has(row.state))),
|
|
2144
2297
|
)
|
|
2145
2298
|
if (staleWorkers.length > 0) {
|
|
2146
2299
|
push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
|
|
@@ -2273,10 +2426,24 @@ export class Tower {
|
|
|
2273
2426
|
const reviewedTip = latest?.reviewedCommit ?? null
|
|
2274
2427
|
const branchLanded = await isAncestor(this.root, branchTip, baseTip)
|
|
2275
2428
|
const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
|
|
2276
|
-
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
|
+
}
|
|
2277
2444
|
const tips = new Set([branchTip, reviewedTip].filter(Boolean))
|
|
2278
2445
|
const found = await findLandedMerge(this.root, baseTip, tips)
|
|
2279
|
-
const mergedCommit = found ? found.commit :
|
|
2446
|
+
const mergedCommit = found ? found.commit : branchTip
|
|
2280
2447
|
const now = Date.now()
|
|
2281
2448
|
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
2282
2449
|
mission.status = 'merged'
|
|
@@ -2339,6 +2506,20 @@ export class Tower {
|
|
|
2339
2506
|
const worktree = mission.worktree
|
|
2340
2507
|
const baseTipBefore = await revParse(this.root, 'HEAD')
|
|
2341
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
|
+
}
|
|
2342
2523
|
// Record the intent before touching Git; a crash between merge and state
|
|
2343
2524
|
// write can be reconciled by the next tower_merge/preflight instead of
|
|
2344
2525
|
// leaving an unexplained base tip.
|
|
@@ -2346,6 +2527,7 @@ export class Tower {
|
|
|
2346
2527
|
missionId: mission.id,
|
|
2347
2528
|
baseTip: baseTipBefore,
|
|
2348
2529
|
branchTip,
|
|
2530
|
+
gateTip: gate.branchTip ?? branchTip,
|
|
2349
2531
|
startedAt: Date.now(),
|
|
2350
2532
|
}
|
|
2351
2533
|
state.revision += 1
|
|
@@ -2354,9 +2536,11 @@ export class Tower {
|
|
|
2354
2536
|
const timeoutMs = mergeTimeoutMs(input.timeoutMs, this.config)
|
|
2355
2537
|
let failure = null
|
|
2356
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.
|
|
2357
2541
|
await git(
|
|
2358
2542
|
this.root,
|
|
2359
|
-
['merge', '--no-ff',
|
|
2543
|
+
['merge', '--no-ff', branchTip, '-m', `tower: merge ${mission.id} — ${mission.title}`],
|
|
2360
2544
|
{ timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
|
|
2361
2545
|
)
|
|
2362
2546
|
} catch (error) {
|
|
@@ -2405,9 +2589,11 @@ export class Tower {
|
|
|
2405
2589
|
}
|
|
2406
2590
|
}
|
|
2407
2591
|
} else if (newTip !== baseTipBefore) {
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
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')
|
|
2411
2597
|
}
|
|
2412
2598
|
// A killed git process can leave the index staged even without
|
|
2413
2599
|
// MERGE_HEAD. Restore only when every dirty path belongs to this merge.
|
|
@@ -2417,12 +2603,12 @@ export class Tower {
|
|
|
2417
2603
|
.map((line) => line.slice(3).trim())
|
|
2418
2604
|
if (dirtyAfter.length > 0) {
|
|
2419
2605
|
const changed = new Set(gate.changedFiles ?? [])
|
|
2420
|
-
if (dirtyAfter.every((file) => changed.has(file))) {
|
|
2606
|
+
if (newTip === baseTipBefore && dirtyAfter.every((file) => changed.has(file))) {
|
|
2421
2607
|
await removeStaleGitLocks(this.commonDir)
|
|
2422
2608
|
await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
|
|
2423
2609
|
cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
|
|
2424
2610
|
} else {
|
|
2425
|
-
warnings.push(`cleanup left
|
|
2611
|
+
warnings.push(`cleanup left changes for manual review: ${dirtyAfter.slice(0, 5).join(', ')}`)
|
|
2426
2612
|
}
|
|
2427
2613
|
}
|
|
2428
2614
|
const detail = describeGitFailure(failure).slice(0, 2000)
|
|
@@ -2455,18 +2641,33 @@ export class Tower {
|
|
|
2455
2641
|
}
|
|
2456
2642
|
|
|
2457
2643
|
if (!expectedShape) {
|
|
2458
|
-
|
|
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() }
|
|
2459
2649
|
state.revision += 1
|
|
2460
2650
|
state.updatedAt = Date.now()
|
|
2461
|
-
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
|
+
})
|
|
2462
2658
|
return {
|
|
2463
2659
|
ok: false,
|
|
2660
|
+
missionId: mission.id,
|
|
2464
2661
|
blockers: [
|
|
2465
2662
|
{
|
|
2466
2663
|
code: 'merge-shape',
|
|
2467
|
-
detail
|
|
2664
|
+
detail,
|
|
2665
|
+
cleanup: 'manual-required',
|
|
2468
2666
|
},
|
|
2469
2667
|
],
|
|
2668
|
+
newTip,
|
|
2669
|
+
parents,
|
|
2670
|
+
intentRetained: true,
|
|
2470
2671
|
}
|
|
2471
2672
|
}
|
|
2472
2673
|
|
|
@@ -2594,6 +2795,16 @@ export class Tower {
|
|
|
2594
2795
|
decisions.push({ missionId: mission.id, action: 'keep', reason: `mission is ${mission.status}` })
|
|
2595
2796
|
continue
|
|
2596
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
|
+
}
|
|
2597
2808
|
let dirty = true
|
|
2598
2809
|
try {
|
|
2599
2810
|
dirty = !(await isClean(mission.worktree.path))
|
|
@@ -2614,16 +2825,28 @@ export class Tower {
|
|
|
2614
2825
|
decisions.push({ missionId: mission.id, action: 'keep', reason: `remove failed: ${String(error?.message ?? error)}` })
|
|
2615
2826
|
continue
|
|
2616
2827
|
}
|
|
2617
|
-
|
|
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
|
+
}
|
|
2618
2834
|
mission.worktreeRemovedAt = now
|
|
2619
2835
|
mission.updatedAt = now
|
|
2836
|
+
if (branchDeleteError) mission.branchDeleteError = branchDeleteError
|
|
2837
|
+
else delete mission.branchDeleteError
|
|
2620
2838
|
removed.push(mission.id)
|
|
2621
|
-
decisions.push({
|
|
2839
|
+
decisions.push({
|
|
2840
|
+
missionId: mission.id,
|
|
2841
|
+
action: 'removed',
|
|
2842
|
+
branch: mission.worktree.branch,
|
|
2843
|
+
...(branchDeleteError ? { branchRetained: true, branchDeleteError } : {}),
|
|
2844
|
+
})
|
|
2622
2845
|
await appendJsonl(this.paths.activity, {
|
|
2623
2846
|
at: now,
|
|
2624
2847
|
type: 'teardown',
|
|
2625
2848
|
missionId: mission.id,
|
|
2626
|
-
text: mission.worktree.branch
|
|
2849
|
+
text: `${mission.worktree.branch}${branchDeleteError ? ' (branch retained)' : ''}`,
|
|
2627
2850
|
})
|
|
2628
2851
|
}
|
|
2629
2852
|
|