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.
Files changed (52) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.en.md +22 -16
  3. package/README.md +22 -16
  4. package/SECURITY.md +4 -1
  5. package/docs/README.md +30 -0
  6. package/docs/architecture/overview.md +3 -2
  7. package/docs/examples/first-mission.md +2 -1
  8. package/docs/faq.md +15 -6
  9. package/docs/guide/install-upgrade-migration.md +14 -5
  10. package/docs/guide/quickstart.md +12 -7
  11. package/docs/guide/scenarios-and-philosophy.md +150 -62
  12. package/docs/operations/security.md +23 -12
  13. package/docs/reference/blockers.md +11 -10
  14. package/docs/reference/config.md +8 -6
  15. package/docs/reference/errors.md +7 -1
  16. package/docs/reference/events.md +7 -4
  17. package/docs/reference/testing.md +2 -1
  18. package/docs/reference/tools.md +7 -6
  19. package/docs/zh/README.md +1 -1
  20. package/docs/zh/faq.md +12 -4
  21. package/docs/zh/guide/install-upgrade-migration.md +11 -4
  22. package/docs/zh/guide/quickstart.md +13 -8
  23. package/docs/zh/guide/scenarios-and-philosophy.md +108 -36
  24. package/docs/zh/operations/security.md +16 -6
  25. package/docs/zh/reference/blockers.md +10 -10
  26. package/docs/zh/reference/config.md +8 -6
  27. package/docs/zh/reference/errors.md +2 -1
  28. package/docs/zh/reference/events.md +6 -3
  29. package/docs/zh/reference/testing.md +3 -2
  30. package/docs/zh/reference/tools.md +7 -6
  31. package/index.js +26 -17
  32. package/lib/config.js +103 -6
  33. package/lib/doctor.js +11 -5
  34. package/lib/errors.js +14 -2
  35. package/lib/git.js +36 -2
  36. package/lib/modeStore.js +54 -20
  37. package/lib/store.js +65 -10
  38. package/lib/tests.js +35 -1
  39. package/lib/tools.js +257 -47
  40. package/lib/tower.js +281 -55
  41. package/lib/workspaceLock.js +44 -20
  42. package/package.json +3 -2
  43. package/scripts/check-docs.mjs +35 -0
  44. package/skills/tower-recovery.md +7 -5
  45. package/ui-bundle/README.md +111 -0
  46. package/ui-bundle/client.js +1980 -0
  47. package/ui-bundle/cordis.patch.yml +3 -0
  48. package/ui-bundle/icon.svg +6 -0
  49. package/ui-bundle/index.js +2 -0
  50. package/ui-bundle/locale/en.json +6 -0
  51. package/ui-bundle/locale/zh.json +6 -0
  52. 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', '-n', '100', baseTip])) ?? ''
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
- /** A killed git process can leave lock files behind; only used after a failed merge. */
125
- async function removeStaleGitLocks(commonDir) {
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
- await fsp.unlink(path.join(commonDir, name)).catch(() => {})
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
- if (!entry || typeof entry.name !== 'string' || typeof entry.agentId !== 'string') {
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 (agents.has(entry.agentId)) fail(`duplicate roster agent ${entry.agentId}${where}`)
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((entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state)).length
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
- validateState(state, this.paths.state)
1014
- await writeJsonAtomic(this.paths.state, state)
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
- if (state.roster.some((entry) => entry.name === input.name)) {
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
- state.roster.push(entry)
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 receipt = { ...input.receipt, recordedAt: Date.now() }
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
- async markRetired(agentIds) {
1304
- if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
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 (caller) {
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
- if (typeof input.breakGlassReason !== 'string' || input.breakGlassReason.trim().length === 0) {
1770
- fail('a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
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 (awaiting review) can be reviewed`)
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 && caller.spawnTip !== tip) {
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 (awaiting review) is required`)
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
- mission.reviewPolicy === 'none' &&
2108
- this.config.gates.allowReviewExemption === true &&
2109
- String(mission.reviewExemptionReason ?? '').trim().length > 0
2110
- if (mission.reviewPolicy === 'none' && this.config.gates.allowReviewExemption !== true) {
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) => row.missionId === mission.id && (row.state === 'stale' || row.stale === true),
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
- mission.reviewPolicy === 'none' &&
2256
- this.config.gates.allowReviewExemption === true &&
2257
- String(mission.reviewExemptionReason ?? '').trim().length > 0
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 && !reviewedLanded) return null
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 : reviewedLanded ? reviewedTip : branchTip
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', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
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
- await removeStaleGitLocks(this.commonDir)
2406
- await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
2407
- cleanup = 'reset'
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 unrelated changes untouched: ${dirtyAfter.slice(0, 5).join(', ')}`)
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
- delete state.mergeIntent
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).catch(() => {})
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: `unexpected merge parents [${parents.join(', ')}], expected [${baseTipBefore}, ${branchTip}]`,
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
- await git(this.root, ['branch', '-d', mission.worktree.branch]).catch(() => {})
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({ missionId: mission.id, action: 'removed', branch: mission.worktree.branch })
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