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.
Files changed (52) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.en.md +19 -13
  3. package/README.md +19 -13
  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 +11 -7
  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 +86 -69
  12. package/docs/operations/security.md +18 -12
  13. package/docs/reference/blockers.md +11 -10
  14. package/docs/reference/config.md +7 -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 +9 -6
  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 +55 -50
  24. package/docs/zh/operations/security.md +13 -6
  25. package/docs/zh/reference/blockers.md +10 -10
  26. package/docs/zh/reference/config.md +7 -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 +100 -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 +268 -45
  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('; ')}`)
@@ -2140,7 +2291,9 @@ export class Tower {
2140
2291
  }
2141
2292
  }
2142
2293
  const staleWorkers = (state.roster ?? []).filter(
2143
- (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))),
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 && !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
+ }
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 : reviewedLanded ? reviewedTip : branchTip
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', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
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
- await removeStaleGitLocks(this.commonDir)
2409
- await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
2410
- 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')
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 unrelated changes untouched: ${dirtyAfter.slice(0, 5).join(', ')}`)
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
- 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() }
2459
2649
  state.revision += 1
2460
2650
  state.updatedAt = Date.now()
2461
- 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
+ })
2462
2658
  return {
2463
2659
  ok: false,
2660
+ missionId: mission.id,
2464
2661
  blockers: [
2465
2662
  {
2466
2663
  code: 'merge-shape',
2467
- detail: `unexpected merge parents [${parents.join(', ')}], expected [${baseTipBefore}, ${branchTip}]`,
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
- 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
+ }
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({ 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
+ })
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