dsh-tower 2.0.4 → 2.1.0-beta.1

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 (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /package/{lib → domain}/batch.js +0 -0
package/app/review.js ADDED
@@ -0,0 +1,145 @@
1
+ // Independent review submission use case.
2
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
+ import { validateState } from '../domain/state.js'
4
+ import { TowerToolError } from '../domain/errors.js'
5
+ import {
6
+ assertReviewableMission,
7
+ resolveReviewActor,
8
+ normalizeReviewVerdict,
9
+ assertReviewerSpawnTip,
10
+ reviewRound,
11
+ reviewOutcome,
12
+ reviewRecord,
13
+ } from '../domain/missions/review.js'
14
+
15
+ function fail(message, code = 'invalid-argument') {
16
+ throw new TowerToolError(code, message)
17
+ }
18
+
19
+ /**
20
+ * Phase 1 (#51): load state and capture everything the review needs — actor
21
+ * checks, worktree ownership, branch tip, and round counters — without holding
22
+ * the state lock. No git call may run inside the lock.
23
+ */
24
+ async function prepareReview(tower, input) {
25
+ const state = await tower.load()
26
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
27
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
28
+ assertReviewableMission(mission)
29
+
30
+ const caller = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
31
+ const { reviewerName, reviewerRole } = resolveReviewActor({
32
+ mission,
33
+ caller,
34
+ actorRole: input.actor?.role,
35
+ agentId: input.agentId,
36
+ breakGlassReason: input.breakGlassReason,
37
+ })
38
+
39
+ if (mission.status !== 'completed') {
40
+ fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
41
+ }
42
+ const verdict = normalizeReviewVerdict(input)
43
+
44
+ await tower.assertWorktree(mission.worktree)
45
+ const tip = await tower.revParse(mission.worktree.path, 'HEAD')
46
+ const branch = mission.worktree.branch
47
+ assertReviewerSpawnTip(caller, tip)
48
+ const { reviewerRounds, round } = reviewRound({
49
+ reviews: state.reviews,
50
+ branch,
51
+ reviewerName,
52
+ maxRoundsPerReviewer: tower.config.review.maxRoundsPerReviewer,
53
+ })
54
+
55
+ return {
56
+ stateRevision: state.revision,
57
+ missionId: mission.id,
58
+ branch,
59
+ tip,
60
+ status: verdict.status,
61
+ merge: verdict.merge,
62
+ findings: verdict.findings,
63
+ checks: verdict.checks,
64
+ decision: verdict.decision,
65
+ reviewerName,
66
+ reviewerRole,
67
+ reviewerRounds,
68
+ round,
69
+ breakGlassReason: reviewerRole === 'tower' ? input.breakGlassReason.trim() : undefined,
70
+ }
71
+ }
72
+
73
+ export async function submitReview(tower, input) {
74
+ // Two-phase (#51): the lock is held only for the state reload, revision
75
+ // revalidation, and write; every git call happens in prepareReview.
76
+ for (let attempt = 0; attempt < 2; attempt += 1) {
77
+ const prepared = await prepareReview(tower, input)
78
+ try {
79
+ return await withLock(tower.paths, async () => {
80
+ const state = await tower.load()
81
+ if (state.revision !== prepared.stateRevision) {
82
+ throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
83
+ }
84
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
85
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
86
+
87
+ const now = Date.now()
88
+ const seq = (state.reviewSeq ?? 0) + 1
89
+ state.reviewSeq = seq
90
+ const review = reviewRecord({
91
+ seq,
92
+ missionId: mission.id,
93
+ branch: prepared.branch,
94
+ tip: prepared.tip,
95
+ verdict: {
96
+ status: prepared.status,
97
+ merge: prepared.merge,
98
+ findings: prepared.findings,
99
+ checks: prepared.checks,
100
+ decision: prepared.decision,
101
+ },
102
+ reviewerName: prepared.reviewerName,
103
+ reviewerRole: prepared.reviewerRole,
104
+ round: prepared.round,
105
+ now,
106
+ breakGlassReason: prepared.breakGlassReason,
107
+ })
108
+ if (!Array.isArray(state.reviews)) state.reviews = []
109
+ state.reviews.push(review)
110
+
111
+ const outcome = reviewOutcome({
112
+ status: prepared.status,
113
+ merge: prepared.merge,
114
+ round: prepared.round,
115
+ reviewerName: prepared.reviewerName,
116
+ })
117
+ if (outcome.returnToActive) mission.status = 'active'
118
+ mission.notes.push(outcome.note)
119
+ mission.updatedAt = now
120
+ state.revision += 1
121
+ state.updatedAt = now
122
+
123
+ validateState(state, tower.paths.state)
124
+ await writeJsonAtomic(tower.paths.state, state)
125
+ await appendJsonl(tower.paths.activity, {
126
+ at: now,
127
+ type: 'review',
128
+ missionId: mission.id,
129
+ text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
130
+ })
131
+ return {
132
+ review,
133
+ branchTip: prepared.tip,
134
+ missionStatus: mission.status,
135
+ round: prepared.round,
136
+ reviewerRounds: prepared.reviewerRounds + 1,
137
+ }
138
+ })
139
+ } catch (error) {
140
+ if (error.code === 'stale-base' && attempt === 0) continue
141
+ throw error
142
+ }
143
+ }
144
+ throw new TowerToolError('stale-base', 'tower state kept changing while the review was being prepared; retry')
145
+ }
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,184 @@
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
+
25
+ /**
26
+ * Phase 1 (#51): decide every keep/remove outcome without holding the state
27
+ * lock. Removable worktrees become candidates; the returned decision objects
28
+ * stay mutable so the removal outcome can replace them in mission order.
29
+ */
30
+ async function prepareTeardown(tower, input) {
31
+ const state = await tower.load()
32
+ const activity = input.activity ?? {}
33
+ const exclude = new Set(input.exclude ?? [])
34
+ const dryRun = input.dryRun === true
35
+ const decisions = []
36
+ const candidates = []
37
+
38
+ for (const mission of state.missions) {
39
+ const precheck = teardownPrecheck(mission, {
40
+ excluded: exclude.has(mission.id),
41
+ hasLiveAgent: hasLiveAgent(state, mission.id, activity),
42
+ })
43
+ if ('skip' in precheck) continue
44
+ if ('decision' in precheck) {
45
+ decisions.push(precheck.decision)
46
+ continue
47
+ }
48
+ try {
49
+ await tower.assertWorktree(mission.worktree)
50
+ } catch (error) {
51
+ decisions.push(ownershipUnverifiedDecision(mission.id, error))
52
+ continue
53
+ }
54
+ let dirty = true
55
+ try {
56
+ dirty = !(await tower.isClean(mission.worktree.path))
57
+ } catch {
58
+ dirty = true
59
+ }
60
+ const dirtyKeep = teardownDirtyDecision(dirty, input.force)
61
+ if (dirtyKeep) {
62
+ decisions.push({ missionId: mission.id, ...dirtyKeep })
63
+ continue
64
+ }
65
+ if (dryRun) {
66
+ decisions.push({ missionId: mission.id, ...teardownRemoveDecision(dirty) })
67
+ continue
68
+ }
69
+ const decision = { missionId: mission.id, ...teardownRemoveDecision(dirty) }
70
+ decisions.push(decision)
71
+ candidates.push({ missionId: mission.id, worktree: mission.worktree, decision })
72
+ }
73
+
74
+ return { stateRevision: state.revision, decisions, candidates, dryRun }
75
+ }
76
+
77
+ /** Short state lock: reject a stale decision before any git mutation. */
78
+ async function claimRemoval(tower, candidate, expectedRevision, input) {
79
+ const activity = input.activity ?? {}
80
+ return withLock(tower.paths, async () => {
81
+ const state = await tower.load()
82
+ if (state.revision !== expectedRevision) {
83
+ throw new TowerToolError('stale-base', `${STALE}. No worktree was removed.`)
84
+ }
85
+ return revalidateTeardownCandidate(state, candidate, activity)
86
+ })
87
+ }
88
+
89
+ export async function teardown(tower, input) {
90
+ const prepared = await prepareTeardown(tower, input)
91
+ if (prepared.dryRun) return { ok: true, dryRun: true, removed: [], decisions: prepared.decisions }
92
+
93
+ const removed = []
94
+ let expectedRevision = prepared.stateRevision
95
+
96
+ for (const candidate of prepared.candidates) {
97
+ const claim = await claimRemoval(tower, candidate, expectedRevision, input)
98
+ if (!claim.ok) {
99
+ setDecision(candidate.decision, { action: 'keep', reason: claim.reason })
100
+ continue
101
+ }
102
+
103
+ // Phase 2 (#51): the irreversible git work runs under the git lock and
104
+ // never under the state lock. Re-read state without the lock right before
105
+ // mutating: the git lock may have waited behind another operation, so a
106
+ // stale candidate is refused while the worktree still exists.
107
+ const outcome = await withGitLock(tower.paths, async () => {
108
+ let fresh
109
+ try {
110
+ fresh = await tower.load()
111
+ } catch (error) {
112
+ return { phase: 'stale', detail: String(error?.message ?? error) }
113
+ }
114
+ if (fresh.revision !== expectedRevision) {
115
+ return { phase: 'stale', detail: `state revision changed from ${expectedRevision} to ${fresh.revision} before the removal` }
116
+ }
117
+ const current = revalidateTeardownCandidate(fresh, candidate, input.activity ?? {})
118
+ if (!current.ok) return { phase: 'skip', reason: current.reason }
119
+ const mission = current.mission
120
+ const worktree = mission.worktree
121
+ if (!worktree) return { phase: 'skip', reason: 'worktree is already gone' }
122
+ let dirty = true
123
+ try {
124
+ dirty = !(await tower.isClean(worktree.path))
125
+ } catch {
126
+ dirty = true
127
+ }
128
+ const dirtyKeep = teardownDirtyDecision(dirty, input.force)
129
+ if (dirtyKeep) return { phase: 'skip', reason: dirtyKeep.reason }
130
+ try {
131
+ await tower.git(tower.root, ['worktree', 'remove', ...(dirty ? ['--force'] : []), worktree.path])
132
+ } catch (error) {
133
+ return { phase: 'remove-failed', reason: `remove failed: ${String(error?.message ?? error)}` }
134
+ }
135
+ let branchDeleteError = null
136
+ try {
137
+ await tower.git(tower.root, ['branch', '-d', worktree.branch])
138
+ } catch (error) {
139
+ branchDeleteError = String(error?.message ?? error).slice(0, 240)
140
+ }
141
+ return { phase: 'removed', branch: worktree.branch, branchDeleteError }
142
+ })
143
+
144
+ if (outcome.phase === 'stale') {
145
+ throw new TowerToolError('stale-base', `${STALE}: ${outcome.detail}. No worktree was removed.`)
146
+ }
147
+ if (outcome.phase !== 'removed') {
148
+ setDecision(candidate.decision, { action: 'keep', reason: outcome.reason })
149
+ continue
150
+ }
151
+
152
+ // Phase 3 (#51): commit this removal alone in a short state lock. The
153
+ // removal already happened, so if a writer landed while git ran we apply
154
+ // the removal on the freshly loaded state instead of rejecting it (which
155
+ // would leave state claiming a worktree that no longer exists). Only this
156
+ // mission's fields change, so no concurrent update is lost.
157
+ const commit = await withLock(tower.paths, async () => {
158
+ const state = await tower.load()
159
+ const mission = state.missions.find((entry) => entry.id === candidate.missionId)
160
+ if (!mission) return { revision: state.revision }
161
+ const now = Date.now()
162
+ mission.worktreeRemovedAt = now
163
+ mission.updatedAt = now
164
+ if (outcome.branchDeleteError) mission.branchDeleteError = outcome.branchDeleteError
165
+ else delete mission.branchDeleteError
166
+ state.revision += 1
167
+ state.updatedAt = now
168
+ validateState(state, tower.paths.state)
169
+ await writeJsonAtomic(tower.paths.state, state)
170
+ await appendJsonl(tower.paths.activity, {
171
+ at: now,
172
+ type: 'teardown',
173
+ missionId: mission.id,
174
+ text: teardownActivityText(outcome.branch, outcome.branchDeleteError),
175
+ })
176
+ return { revision: state.revision }
177
+ })
178
+ expectedRevision = commit.revision
179
+ removed.push(candidate.missionId)
180
+ setDecision(candidate.decision, teardownRemovedDecision(outcome))
181
+ }
182
+
183
+ return { ok: true, dryRun: false, removed, decisions: prepared.decisions }
184
+ }