dsh-tower 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +9 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,72 @@
1
+ // State recording use cases for test receipts and imported legacy snapshots.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+ import { validateState } from '../domain/state.js'
4
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
5
+
6
+ function fail(message, code = 'invalid-argument') {
7
+ throw new TowerToolError(code, message)
8
+ }
9
+
10
+ export async function recordTestRun(tower, input = {}) {
11
+ return withLock(tower.paths, async () => {
12
+ const state = await tower.load()
13
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
14
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
15
+ if (!mission.testRuns) mission.testRuns = []
16
+ const rawReceipt = { ...input.receipt, recordedAt: Date.now() }
17
+ // Keep state bounded even when a repository reports a huge suite.
18
+ const tests = Array.isArray(rawReceipt.tests) ? rawReceipt.tests : []
19
+ const receipt = tests.length > 100
20
+ ? { ...rawReceipt, tests: tests.slice(0, 100), testsTotal: rawReceipt.count ?? tests.length, testsTruncated: true }
21
+ : rawReceipt
22
+ mission.testRuns.push(receipt)
23
+ if (mission.testRuns.length > 50) mission.testRuns = mission.testRuns.slice(-50)
24
+ const MAX_RUNS_BYTES = 512 * 1024
25
+ while (mission.testRuns.length > 1 && Buffer.byteLength(JSON.stringify(mission.testRuns), 'utf8') > MAX_RUNS_BYTES) {
26
+ mission.testRuns.shift()
27
+ }
28
+ state.revision += 1
29
+ state.updatedAt = Date.now()
30
+ validateState(state, tower.paths.state)
31
+ await writeJsonAtomic(tower.paths.state, state)
32
+ await appendJsonl(tower.paths.activity, {
33
+ at: Date.now(),
34
+ type: 'test',
35
+ missionId: mission.id,
36
+ text: receipt.ok === true ? `passed ${receipt.tests?.length ?? 0} test(s)` : `failed: ${String(receipt.error ?? 'unknown').slice(0, 200)}`,
37
+ })
38
+ return receipt
39
+ })
40
+ }
41
+
42
+ export async function recordLegacySnapshot(tower, snapshot) {
43
+ return withLock(tower.paths, async () => {
44
+ const state = await tower.load()
45
+ if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
46
+ const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
47
+ if (existing) return { snapshot: existing, duplicate: true }
48
+ state.legacySnapshots.push(snapshot)
49
+ state.revision += 1
50
+ state.updatedAt = Date.now()
51
+ validateState(state, tower.paths.state)
52
+ await writeJsonAtomic(tower.paths.state, state)
53
+ return { snapshot, duplicate: false }
54
+ })
55
+ }
56
+
57
+ export async function recordLegacyMap(tower, map) {
58
+ return withLock(tower.paths, async () => {
59
+ const state = await tower.load()
60
+ if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
61
+ const existing = state.legacyMaps.find(
62
+ (entry) => entry.sourceHash === map.sourceHash && entry.oldId === map.oldId,
63
+ )
64
+ if (existing) return { map: existing, duplicate: true }
65
+ state.legacyMaps.push(map)
66
+ state.revision += 1
67
+ state.updatedAt = Date.now()
68
+ validateState(state, tower.paths.state)
69
+ await writeJsonAtomic(tower.paths.state, state)
70
+ return { map, duplicate: false }
71
+ })
72
+ }
package/app/review.js ADDED
@@ -0,0 +1,147 @@
1
+ // Independent review submission use case.
2
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
+ import { sweepArchive } from './archive.js'
4
+ import { validateState } from '../domain/state.js'
5
+ import { TowerToolError } from '../domain/errors.js'
6
+ import {
7
+ assertReviewableMission,
8
+ resolveReviewActor,
9
+ normalizeReviewVerdict,
10
+ assertReviewerSpawnTip,
11
+ reviewRound,
12
+ reviewOutcome,
13
+ reviewRecord,
14
+ } from '../domain/missions/review.js'
15
+
16
+ function fail(message, code = 'invalid-argument') {
17
+ throw new TowerToolError(code, message)
18
+ }
19
+
20
+ /**
21
+ * Phase 1 (#51): load state and capture everything the review needs — actor
22
+ * checks, worktree ownership, branch tip, and round counters — without holding
23
+ * the state lock. No git call may run inside the lock.
24
+ */
25
+ async function prepareReview(tower, input) {
26
+ const state = await tower.load()
27
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
28
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
29
+ assertReviewableMission(mission)
30
+
31
+ const caller = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
32
+ const { reviewerName, reviewerRole } = resolveReviewActor({
33
+ mission,
34
+ caller,
35
+ actorRole: input.actor?.role,
36
+ agentId: input.agentId,
37
+ breakGlassReason: input.breakGlassReason,
38
+ })
39
+
40
+ if (mission.status !== 'completed') {
41
+ fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
42
+ }
43
+ const verdict = normalizeReviewVerdict(input)
44
+
45
+ await tower.assertWorktree(mission.worktree)
46
+ const tip = await tower.revParse(mission.worktree.path, 'HEAD')
47
+ const branch = mission.worktree.branch
48
+ assertReviewerSpawnTip(caller, tip)
49
+ const { reviewerRounds, round } = reviewRound({
50
+ reviews: state.reviews,
51
+ branch,
52
+ reviewerName,
53
+ maxRoundsPerReviewer: tower.config.review.maxRoundsPerReviewer,
54
+ })
55
+
56
+ return {
57
+ stateRevision: state.revision,
58
+ missionId: mission.id,
59
+ branch,
60
+ tip,
61
+ status: verdict.status,
62
+ merge: verdict.merge,
63
+ findings: verdict.findings,
64
+ checks: verdict.checks,
65
+ decision: verdict.decision,
66
+ reviewerName,
67
+ reviewerRole,
68
+ reviewerRounds,
69
+ round,
70
+ breakGlassReason: reviewerRole === 'tower' ? input.breakGlassReason.trim() : undefined,
71
+ }
72
+ }
73
+
74
+ export async function submitReview(tower, input) {
75
+ // Two-phase (#51): the lock is held only for the state reload, revision
76
+ // revalidation, and write; every git call happens in prepareReview.
77
+ for (let attempt = 0; attempt < 2; attempt += 1) {
78
+ const prepared = await prepareReview(tower, input)
79
+ try {
80
+ return await withLock(tower.paths, async () => {
81
+ const state = await tower.load()
82
+ if (state.revision !== prepared.stateRevision) {
83
+ throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
84
+ }
85
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
86
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
87
+
88
+ const now = Date.now()
89
+ const seq = (state.reviewSeq ?? 0) + 1
90
+ state.reviewSeq = seq
91
+ const review = reviewRecord({
92
+ seq,
93
+ missionId: mission.id,
94
+ branch: prepared.branch,
95
+ tip: prepared.tip,
96
+ verdict: {
97
+ status: prepared.status,
98
+ merge: prepared.merge,
99
+ findings: prepared.findings,
100
+ checks: prepared.checks,
101
+ decision: prepared.decision,
102
+ },
103
+ reviewerName: prepared.reviewerName,
104
+ reviewerRole: prepared.reviewerRole,
105
+ round: prepared.round,
106
+ now,
107
+ breakGlassReason: prepared.breakGlassReason,
108
+ })
109
+ if (!Array.isArray(state.reviews)) state.reviews = []
110
+ state.reviews.push(review)
111
+
112
+ const outcome = reviewOutcome({
113
+ status: prepared.status,
114
+ merge: prepared.merge,
115
+ round: prepared.round,
116
+ reviewerName: prepared.reviewerName,
117
+ })
118
+ if (outcome.returnToActive) mission.status = 'active'
119
+ mission.notes.push(outcome.note)
120
+ mission.updatedAt = now
121
+ await sweepArchive(tower, state, { now })
122
+ state.revision += 1
123
+ state.updatedAt = now
124
+
125
+ validateState(state, tower.paths.state)
126
+ await writeJsonAtomic(tower.paths.state, state)
127
+ await appendJsonl(tower.paths.activity, {
128
+ at: now,
129
+ type: 'review',
130
+ missionId: mission.id,
131
+ text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
132
+ })
133
+ return {
134
+ review,
135
+ branchTip: prepared.tip,
136
+ missionStatus: mission.status,
137
+ round: prepared.round,
138
+ reviewerRounds: prepared.reviewerRounds + 1,
139
+ }
140
+ })
141
+ } catch (error) {
142
+ if (error.code === 'stale-base' && attempt === 0) continue
143
+ throw error
144
+ }
145
+ }
146
+ throw new TowerToolError('stale-base', 'tower state kept changing while the review was being prepared; retry')
147
+ }
package/app/roster.js ADDED
@@ -0,0 +1,123 @@
1
+ // Roster observation and retirement use cases.
2
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
+ import { validateState } from '../domain/state.js'
4
+ import {
5
+ recordRosterObservations as recordObservations,
6
+ reconcileObservedInactive as reconcileInactive,
7
+ retireEntries,
8
+ markMissingEntries,
9
+ sweepRosterState,
10
+ } from '../domain/collab/roster.js'
11
+ import { unreadMessages } from '../domain/collab/messages.js'
12
+
13
+ export async function recordRosterObservations(tower, { present = [], absent = [], confirmations = 2 }) {
14
+ return withLock(tower.paths, async () => {
15
+ const state = await tower.load()
16
+ const now = Date.now()
17
+ const { candidates, changed } = recordObservations(
18
+ state.roster ?? [],
19
+ { present, absent, confirmations },
20
+ now,
21
+ )
22
+ if (changed) {
23
+ state.revision += 1
24
+ state.updatedAt = now
25
+ validateState(state, tower.paths.state)
26
+ await writeJsonAtomic(tower.paths.state, state)
27
+ }
28
+ return { candidates }
29
+ })
30
+ }
31
+
32
+ /** Release live roster slots whose catalog activity is explicitly inactive. */
33
+ export async function reconcileObservedInactive(tower, activity = {}) {
34
+ return withLock(tower.paths, async () => {
35
+ const state = await tower.load()
36
+ const now = Date.now()
37
+ const { released } = reconcileInactive(state.roster ?? [], activity, now)
38
+ if (released.length > 0) {
39
+ state.revision += 1
40
+ state.updatedAt = now
41
+ validateState(state, tower.paths.state)
42
+ await writeJsonAtomic(tower.paths.state, state)
43
+ await appendJsonl(tower.paths.activity, {
44
+ at: now,
45
+ type: 'roster',
46
+ text: `released inactive slots: ${released.join(', ')}`,
47
+ })
48
+ }
49
+ return { released }
50
+ })
51
+ }
52
+
53
+ export async function markRetired(tower, agentIds) {
54
+ if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
55
+ return withLock(tower.paths, async () => {
56
+ const state = await tower.load()
57
+ const now = Date.now()
58
+ const { retired } = retireEntries(state.roster ?? [], agentIds, now)
59
+ if (retired.length > 0) {
60
+ state.revision += 1
61
+ state.updatedAt = now
62
+ validateState(state, tower.paths.state)
63
+ await writeJsonAtomic(tower.paths.state, state)
64
+ await appendJsonl(tower.paths.activity, {
65
+ at: now,
66
+ type: 'adopt',
67
+ text: `retired ${retired.join(', ')}`,
68
+ })
69
+ }
70
+ return { retired }
71
+ })
72
+ }
73
+
74
+ export async function markMissing(tower, agentIds) {
75
+ if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
76
+ return withLock(tower.paths, async () => {
77
+ const state = await tower.load()
78
+ const now = Date.now()
79
+ const { missing } = markMissingEntries(state.roster ?? [], agentIds, now)
80
+ if (missing.length > 0) {
81
+ state.revision += 1
82
+ state.updatedAt = now
83
+ validateState(state, tower.paths.state)
84
+ await writeJsonAtomic(tower.paths.state, state)
85
+ await appendJsonl(tower.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
86
+ }
87
+ return { missing }
88
+ })
89
+ }
90
+
91
+ /** Mark live roster entries whose catalog activity disappeared after staleMs.
92
+ * `authoritative` must be true only when the parent catalog was readable;
93
+ * unknown state is never treated as stopped. */
94
+ export async function sweepRoster(tower, input = {}) {
95
+ const staleMs = Number(input.staleMs) || 0
96
+ if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
97
+ const activity = input.activity ?? {}
98
+ return withLock(tower.paths, async () => {
99
+ const state = await tower.load()
100
+ const now = Date.now()
101
+ const { marked, idled } = sweepRosterState(
102
+ state.roster ?? [],
103
+ { activity, staleMs, authoritative: input.authoritative },
104
+ now,
105
+ )
106
+ if (marked.length > 0 || idled.length > 0) {
107
+ state.revision += 1
108
+ state.updatedAt = now
109
+ validateState(state, tower.paths.state)
110
+ await writeJsonAtomic(tower.paths.state, state)
111
+ await appendJsonl(tower.paths.activity, {
112
+ at: now,
113
+ type: 'watchdog',
114
+ text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
115
+ })
116
+ }
117
+ return { marked, idled }
118
+ })
119
+ }
120
+
121
+ export function unreadFor(tower, state, name) {
122
+ return /** @type {any} */ (unreadMessages(state, name))
123
+ }
package/app/spawn.js ADDED
@@ -0,0 +1,125 @@
1
+ // Spawn reservation/outcome and roster recording use cases.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+ import {
4
+ applySpawnOutcome,
5
+ applyInterrupt,
6
+ reserveRosterEntry,
7
+ recordSpawnEntry,
8
+ rosterProjection,
9
+ } from '../domain/collab/spawn.js'
10
+ import { validateState } from '../domain/state.js'
11
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
12
+
13
+ function fail(message, code = 'invalid-argument') {
14
+ throw new TowerToolError(code, message)
15
+ }
16
+
17
+ /** Record that a spawned agent failed or went idle, so roster state is truthful. */
18
+ export async function recordSpawnOutcome(tower, input) {
19
+ return withLock(tower.paths, async () => {
20
+ const state = await tower.load()
21
+ const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
22
+ if (!entry) return null
23
+ const now = Date.now()
24
+ applySpawnOutcome(entry, input, now)
25
+ state.revision += 1
26
+ state.updatedAt = now
27
+ validateState(state, tower.paths.state)
28
+ await writeJsonAtomic(tower.paths.state, state)
29
+ await appendJsonl(tower.paths.activity, {
30
+ at: now,
31
+ type: 'spawn',
32
+ missionId: input.missionId,
33
+ text: `${entry.name} ${entry.state}${entry.outcome ? `: ${entry.outcome.slice(0, 160)}` : ''}`,
34
+ })
35
+ return entry
36
+ })
37
+ }
38
+
39
+ /** Record that a live agent was interrupted by the tower. */
40
+ export async function recordInterrupt(tower, input) {
41
+ return withLock(tower.paths, async () => {
42
+ const state = await tower.load()
43
+ const entry = (state.roster ?? []).find(
44
+ (row) =>
45
+ (input.agentId !== undefined && row.agentId === input.agentId) ||
46
+ (input.name !== undefined && row.name === input.name),
47
+ )
48
+ if (!entry) fail(`unknown roster agent "${input.name ?? input.agentId}"`)
49
+ const now = Date.now()
50
+ applyInterrupt(entry, input, now)
51
+ state.revision += 1
52
+ state.updatedAt = now
53
+ validateState(state, tower.paths.state)
54
+ await writeJsonAtomic(tower.paths.state, state)
55
+ await appendJsonl(tower.paths.activity, {
56
+ at: now,
57
+ type: 'spawn',
58
+ missionId: entry.missionId,
59
+ text: `${entry.name} interrupted: ${entry.outcome}`,
60
+ })
61
+ return entry
62
+ })
63
+ }
64
+
65
+ /** Reserve a roster name before activation so a failed spawn is recoverable. */
66
+ export async function reserveRoster(tower, input) {
67
+ return withLock(tower.paths, async () => {
68
+ const state = await tower.load()
69
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
70
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
71
+ if (!Array.isArray(state.roster)) state.roster = []
72
+ const now = Date.now()
73
+ const entry = reserveRosterEntry({ roster: state.roster, input, now })
74
+ state.roster.push(entry)
75
+ state.revision += 1
76
+ state.updatedAt = now
77
+ validateState(state, tower.paths.state)
78
+ await writeJsonAtomic(tower.paths.state, state)
79
+ await appendJsonl(tower.paths.activity, {
80
+ at: now,
81
+ type: 'spawn',
82
+ missionId: input.missionId,
83
+ text: `reserved ${input.role} ${input.name}`,
84
+ })
85
+ return entry
86
+ })
87
+ }
88
+
89
+ export async function recordSpawn(tower, input) {
90
+ // Capture the spawn tip before taking the state lock (#51).
91
+ const spawnTip = input.cwd ? ((await tower.tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
92
+ return withLock(tower.paths, async () => {
93
+ const state = await tower.load()
94
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
95
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
96
+ if (!Array.isArray(state.roster)) state.roster = []
97
+ const now = Date.now()
98
+ const entry = recordSpawnEntry({
99
+ roster: state.roster,
100
+ mission,
101
+ input,
102
+ spawnTip,
103
+ maxLiveWorkers: tower.config.spawn.maxLiveWorkers,
104
+ now,
105
+ })
106
+ mission.updatedAt = now
107
+ state.revision += 1
108
+ state.updatedAt = now
109
+
110
+ validateState(state, tower.paths.state)
111
+ await writeJsonAtomic(tower.paths.state, state)
112
+ await appendJsonl(tower.paths.activity, {
113
+ at: now,
114
+ type: 'spawn',
115
+ missionId: input.missionId,
116
+ text: `${input.role} ${input.name} (${input.agentId})`,
117
+ })
118
+ return entry
119
+ })
120
+ }
121
+
122
+ export async function rosterView(tower) {
123
+ const state = await tower.load()
124
+ return rosterProjection(state)
125
+ }
@@ -0,0 +1,205 @@
1
+ // Worktree teardown use case.
2
+ //
3
+ // #51 two-phase: eligibility diagnostics (ownership, registration, dirty
4
+ // state) run outside the state lock; each removal runs its git commands under
5
+ // the git lock and is committed in its own short state lock. Every removal
6
+ // therefore bumps the state revision exactly once, and the revision is
7
+ // revalidated immediately before the irreversible git mutation so a stale
8
+ // decision never removes a worktree that changed hands.
9
+ import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
10
+ import { validateState } from '../domain/state.js'
11
+ import { TowerToolError } from '../domain/errors.js'
12
+ import { hasLiveAgent } from '../domain/integration/liveness.js'
13
+ import {
14
+ ownershipUnverifiedDecision,
15
+ replaceDecision as setDecision,
16
+ revalidateTeardownCandidate,
17
+ STALE_TEARDOWN as STALE,
18
+ teardownActivityText,
19
+ teardownDirtyDecision,
20
+ teardownPrecheck,
21
+ teardownRemoveDecision,
22
+ teardownRemovedDecision,
23
+ } from '../domain/integration/teardown.js'
24
+ import { sweepArchive } from './archive.js'
25
+
26
+ /**
27
+ * Phase 1 (#51): decide every keep/remove outcome without holding the state
28
+ * lock. Removable worktrees become candidates; the returned decision objects
29
+ * stay mutable so the removal outcome can replace them in mission order.
30
+ */
31
+ async function prepareTeardown(tower, input) {
32
+ const state = await tower.load()
33
+ const activity = input.activity ?? {}
34
+ const exclude = new Set(input.exclude ?? [])
35
+ const dryRun = input.dryRun === true
36
+ const decisions = []
37
+ const candidates = []
38
+
39
+ for (const mission of state.missions) {
40
+ const precheck = teardownPrecheck(mission, {
41
+ excluded: exclude.has(mission.id),
42
+ hasLiveAgent: hasLiveAgent(state, mission.id, activity),
43
+ })
44
+ if ('skip' in precheck) continue
45
+ if ('decision' in precheck) {
46
+ decisions.push(precheck.decision)
47
+ continue
48
+ }
49
+ try {
50
+ await tower.assertWorktree(mission.worktree)
51
+ } catch (error) {
52
+ decisions.push(ownershipUnverifiedDecision(mission.id, error))
53
+ continue
54
+ }
55
+ let dirty = true
56
+ try {
57
+ dirty = !(await tower.isClean(mission.worktree.path))
58
+ } catch {
59
+ dirty = true
60
+ }
61
+ const dirtyKeep = teardownDirtyDecision(dirty, input.force)
62
+ if (dirtyKeep) {
63
+ decisions.push({ missionId: mission.id, ...dirtyKeep })
64
+ continue
65
+ }
66
+ if (dryRun) {
67
+ decisions.push({ missionId: mission.id, ...teardownRemoveDecision(dirty) })
68
+ continue
69
+ }
70
+ const decision = { missionId: mission.id, ...teardownRemoveDecision(dirty) }
71
+ decisions.push(decision)
72
+ candidates.push({ missionId: mission.id, worktree: mission.worktree, decision })
73
+ }
74
+
75
+ return { stateRevision: state.revision, decisions, candidates, dryRun }
76
+ }
77
+
78
+ /** Short state lock: reject a stale decision before any git mutation. */
79
+ async function claimRemoval(tower, candidate, expectedRevision, input) {
80
+ const activity = input.activity ?? {}
81
+ return withLock(tower.paths, async () => {
82
+ const state = await tower.load()
83
+ if (state.revision !== expectedRevision) {
84
+ throw new TowerToolError('stale-base', `${STALE}. No worktree was removed.`)
85
+ }
86
+ return revalidateTeardownCandidate(state, candidate, activity)
87
+ })
88
+ }
89
+
90
+ export async function teardown(tower, input) {
91
+ const prepared = await prepareTeardown(tower, input)
92
+ if (prepared.dryRun) return { ok: true, dryRun: true, removed: [], decisions: prepared.decisions }
93
+
94
+ const removed = []
95
+ let expectedRevision = prepared.stateRevision
96
+
97
+ for (const candidate of prepared.candidates) {
98
+ const claim = await claimRemoval(tower, candidate, expectedRevision, input)
99
+ if (!claim.ok) {
100
+ setDecision(candidate.decision, { action: 'keep', reason: claim.reason })
101
+ continue
102
+ }
103
+
104
+ // Phase 2 (#51): the irreversible git work runs under the git lock and
105
+ // never under the state lock. Re-read state without the lock right before
106
+ // mutating: the git lock may have waited behind another operation, so a
107
+ // stale candidate is refused while the worktree still exists.
108
+ const outcome = await withGitLock(tower.paths, async () => {
109
+ let fresh
110
+ try {
111
+ fresh = await tower.load()
112
+ } catch (error) {
113
+ return { phase: 'stale', detail: String(error?.message ?? error) }
114
+ }
115
+ if (fresh.revision !== expectedRevision) {
116
+ return { phase: 'stale', detail: `state revision changed from ${expectedRevision} to ${fresh.revision} before the removal` }
117
+ }
118
+ const current = revalidateTeardownCandidate(fresh, candidate, input.activity ?? {})
119
+ if (!current.ok) return { phase: 'skip', reason: current.reason }
120
+ const mission = current.mission
121
+ const worktree = mission.worktree
122
+ if (!worktree) return { phase: 'skip', reason: 'worktree is already gone' }
123
+ let dirty = true
124
+ try {
125
+ dirty = !(await tower.isClean(worktree.path))
126
+ } catch {
127
+ dirty = true
128
+ }
129
+ const dirtyKeep = teardownDirtyDecision(dirty, input.force)
130
+ if (dirtyKeep) return { phase: 'skip', reason: dirtyKeep.reason }
131
+ try {
132
+ await tower.git(tower.root, ['worktree', 'remove', ...(dirty ? ['--force'] : []), worktree.path])
133
+ } catch (error) {
134
+ return { phase: 'remove-failed', reason: `remove failed: ${String(error?.message ?? error)}` }
135
+ }
136
+ let branchDeleteError = null
137
+ try {
138
+ await tower.git(tower.root, ['branch', '-d', worktree.branch])
139
+ } catch (error) {
140
+ branchDeleteError = String(error?.message ?? error).slice(0, 240)
141
+ }
142
+ return { phase: 'removed', branch: worktree.branch, branchDeleteError }
143
+ })
144
+
145
+ if (outcome.phase === 'stale') {
146
+ throw new TowerToolError('stale-base', `${STALE}: ${outcome.detail}. No worktree was removed.`)
147
+ }
148
+ if (outcome.phase !== 'removed') {
149
+ setDecision(candidate.decision, { action: 'keep', reason: outcome.reason })
150
+ continue
151
+ }
152
+
153
+ // Phase 3 (#51): commit this removal alone in a short state lock. The
154
+ // removal already happened, so if a writer landed while git ran we apply
155
+ // the removal on the freshly loaded state instead of rejecting it (which
156
+ // would leave state claiming a worktree that no longer exists). Only this
157
+ // mission's fields change, so no concurrent update is lost.
158
+ const commit = await withLock(tower.paths, async () => {
159
+ const state = await tower.load()
160
+ const mission = state.missions.find((entry) => entry.id === candidate.missionId)
161
+ if (!mission) return { revision: state.revision }
162
+ const now = Date.now()
163
+ mission.worktreeRemovedAt = now
164
+ mission.updatedAt = now
165
+ if (outcome.branchDeleteError) mission.branchDeleteError = outcome.branchDeleteError
166
+ else delete mission.branchDeleteError
167
+ state.revision += 1
168
+ state.updatedAt = now
169
+ validateState(state, tower.paths.state)
170
+ await writeJsonAtomic(tower.paths.state, state)
171
+ await appendJsonl(tower.paths.activity, {
172
+ at: now,
173
+ type: 'teardown',
174
+ missionId: mission.id,
175
+ text: teardownActivityText(outcome.branch, outcome.branchDeleteError),
176
+ })
177
+ return { revision: state.revision }
178
+ })
179
+ expectedRevision = commit.revision
180
+ removed.push(candidate.missionId)
181
+ setDecision(candidate.decision, teardownRemovedDecision(outcome))
182
+ }
183
+
184
+ // Archival runs under one final short lock. It is policy-driven (age or the
185
+ // hot-terminal cap), so a mission torn down a moment ago stays hot until it
186
+ // is old enough; `archive.afterDays: 0` archives it on this call.
187
+ const archive = await withLock(tower.paths, async () => {
188
+ const state = await tower.load()
189
+ const swept = await sweepArchive(tower, state, { now: Date.now(), activity: input.activity ?? {} })
190
+ if (swept.written) {
191
+ validateState(state, tower.paths.state)
192
+ await writeJsonAtomic(tower.paths.state, state)
193
+ }
194
+ return swept
195
+ })
196
+
197
+ return {
198
+ ok: true,
199
+ dryRun: false,
200
+ removed,
201
+ decisions: prepared.decisions,
202
+ archived: archive.archived,
203
+ ...(archive.error ? { archiveError: archive.error } : {}),
204
+ }
205
+ }