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/plan.js ADDED
@@ -0,0 +1,42 @@
1
+ // Mission planning use case.
2
+ import { nextMissionNumber, addAutoDeps, assertAcyclic } from '../domain/planning.js'
3
+ import {
4
+ normalizePlanMissions,
5
+ assignMissionIds,
6
+ assertKnownDependencies,
7
+ newMission,
8
+ } from '../domain/missions/plan.js'
9
+ import { validateState } from '../domain/state.js'
10
+ import { fullMission } from '../domain/summary.js'
11
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
12
+
13
+ export async function plan(tower, input = {}) {
14
+ const prepared = normalizePlanMissions(input)
15
+ return withLock(tower.paths, async () => {
16
+ const state = await tower.load()
17
+ const now = Date.now()
18
+ const assigned = assignMissionIds(prepared, nextMissionNumber(state.missions))
19
+
20
+ assertKnownDependencies(assigned, state.missions)
21
+
22
+ addAutoDeps(assigned, state.missions)
23
+ assertAcyclic([...state.missions, ...assigned])
24
+
25
+ const created = assigned.map((mission) => newMission(mission.id, mission, now))
26
+ state.missions.push(...created)
27
+ state.revision += 1
28
+ state.updatedAt = now
29
+
30
+ validateState(state, tower.paths.state)
31
+ await writeJsonAtomic(tower.paths.state, state)
32
+ await appendJsonl(tower.paths.activity, {
33
+ at: now,
34
+ type: 'plan',
35
+ text: `created ${created.map((mission) => mission.id).join(', ')}`,
36
+ })
37
+ return {
38
+ revision: state.revision,
39
+ missions: created.map((mission) => fullMission(mission)),
40
+ }
41
+ })
42
+ }
@@ -0,0 +1,193 @@
1
+ // `tower_provision` use case, split into three phases (#51):
2
+ //
3
+ // prepare — mission/base commit/worktree identity, all lock-free.
4
+ // act — revalidate the prepared revision, then `git worktree add` (and
5
+ // its supporting exclude/canonical reads) under the git mutex.
6
+ // commit — one short state lock revalidates the revision and that the
7
+ // mission still has no worktree, then records it. A rejected
8
+ // commit rolls the created worktree/branch back outside the lock.
9
+ import path from 'node:path'
10
+ import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl, shortHash } from '../lib/store.js'
11
+ import { validateState } from '../domain/state.js'
12
+ import { canonicalPath } from '../lib/worktreeOwnership.js'
13
+ import { TowerToolError } from '../domain/errors.js'
14
+ import {
15
+ baseCommitChoice,
16
+ createdWorktreeResult,
17
+ existingWorktreeResult,
18
+ hasMissionId,
19
+ provisionActivityText,
20
+ worktreeBranch,
21
+ worktreePath,
22
+ worktreeRecord,
23
+ worktreeRoot,
24
+ } from '../domain/integration/provision.js'
25
+
26
+ function fail(message, code = 'invalid-argument') {
27
+ throw new TowerToolError(code, message)
28
+ }
29
+
30
+ /** Phase 1 of `Tower.provision` (#51). An existing worktree returns here. */
31
+ async function prepareProvision(tower, input) {
32
+ const state = await tower.load()
33
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
34
+ if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
35
+ if (mission.worktree) {
36
+ await tower.assertWorktree(mission.worktree)
37
+ return { early: existingWorktreeResult(mission, mission.worktree) }
38
+ }
39
+ const needsSnapshot = !input.baseCommit && input.includeDirtyBase !== false
40
+ const snapshotCommitValid =
41
+ needsSnapshot && state.snapshotCommit
42
+ ? (await tower.tryGit(tower.root, ['cat-file', '-e', `${state.snapshotCommit}^{commit}`])) !== null
43
+ : false
44
+ const choice = baseCommitChoice({
45
+ baseCommit: input.baseCommit,
46
+ includeDirtyBase: input.includeDirtyBase,
47
+ snapshotCommit: state.snapshotCommit,
48
+ snapshotCommitValid,
49
+ })
50
+ const baseCommit = 'commit' in choice ? choice.commit : await tower.revParse(tower.root, state.base)
51
+ const branch = worktreeBranch(mission)
52
+ const root = worktreeRoot({
53
+ worktreeRoot: input.worktreeRoot,
54
+ configuredRoot: tower.config.worktrees.root,
55
+ repoRoot: tower.root,
56
+ commonDirHash: shortHash(tower.commonDir),
57
+ })
58
+ if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
59
+ tower.assertOutside(root)
60
+ await ensureDir(root)
61
+ const wtPath = worktreePath(root, mission.id)
62
+ if (await exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
63
+ if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== null) {
64
+ fail(`branch already exists: ${branch}`)
65
+ }
66
+ return { stateRevision: state.revision, missionId: mission.id, baseCommit, branch, wtPath }
67
+ }
68
+
69
+ /** Phase 2 of `Tower.provision` (#51): no state lock, worktree add under the git mutex. */
70
+ async function actProvision(tower, prepared) {
71
+ return withGitLock(tower.paths, async () => {
72
+ let fresh
73
+ try {
74
+ fresh = await tower.load()
75
+ } catch (error) {
76
+ return { phase: 'stale', detail: String(error?.message ?? error) }
77
+ }
78
+ if (fresh.revision !== prepared.stateRevision) {
79
+ return { phase: 'stale', detail: `state revision changed from ${prepared.stateRevision} to ${fresh.revision}` }
80
+ }
81
+ const mission = fresh.missions.find((entry) => entry.id === prepared.missionId)
82
+ if (!mission) return { phase: 'missing' }
83
+ if (mission.worktree) {
84
+ await tower.assertWorktree(mission.worktree)
85
+ return { phase: 'existing', mission, worktree: mission.worktree }
86
+ }
87
+ if (await exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
88
+ if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${prepared.branch}`])) !== null) {
89
+ fail(`branch already exists: ${prepared.branch}`)
90
+ }
91
+ await tower.git(tower.root, ['worktree', 'add', '-b', prepared.branch, prepared.wtPath, prepared.baseCommit])
92
+ await tower.ensureExcluded(prepared.wtPath)
93
+ const worktree = worktreeRecord({
94
+ path: prepared.wtPath,
95
+ branch: prepared.branch,
96
+ baseCommit: prepared.baseCommit,
97
+ createdAt: Date.now(),
98
+ realPath: await canonicalPath(prepared.wtPath),
99
+ })
100
+ return { phase: 'created', worktree }
101
+ })
102
+ }
103
+
104
+ /** Phase 3 of `Tower.provision` (#51): record the worktree under a short lock. */
105
+ async function commitProvision(tower, prepared, acted) {
106
+ return withLock(tower.paths, async () => {
107
+ const state = await tower.load()
108
+ const mission = state.missions.find((entry) => entry.id === prepared.missionId)
109
+ if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
110
+ // Revision revalidation: the plan was built from prepared.stateRevision.
111
+ if (state.revision !== prepared.stateRevision) return { phase: 'stale', revision: state.revision }
112
+ // Never overwrite a worktree another writer recorded while git ran.
113
+ if (mission.worktree) return { phase: 'existing', mission, worktree: mission.worktree }
114
+ const now = acted.worktree.createdAt
115
+ mission.worktree = acted.worktree
116
+ mission.baseCommit = prepared.baseCommit
117
+ mission.updatedAt = now
118
+ state.revision += 1
119
+ state.updatedAt = now
120
+ try {
121
+ validateState(state, tower.paths.state)
122
+ await writeJsonAtomic(tower.paths.state, state)
123
+ } catch (error) {
124
+ return { phase: 'persist-error', error }
125
+ }
126
+ await appendJsonl(tower.paths.activity, {
127
+ at: now,
128
+ type: 'worktree',
129
+ missionId: prepared.missionId,
130
+ text: provisionActivityText(prepared.branch, prepared.wtPath),
131
+ }).catch(() => {})
132
+ return { phase: 'committed', mission, worktree: acted.worktree }
133
+ })
134
+ }
135
+
136
+ /** Undo a created worktree/branch outside the state lock (git mutex only). */
137
+ async function rollbackProvision(tower, prepared) {
138
+ await withGitLock(tower.paths, async () => {
139
+ await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
140
+ await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
141
+ })
142
+ }
143
+
144
+ /** #51 two-phase provision: prepare/act/commit with rollback on rejection. */
145
+ export async function provision(tower, input = {}) {
146
+ const missionId = input.missionId
147
+ if (!hasMissionId(missionId)) {
148
+ fail('tower_spawn requires missionId in this version')
149
+ }
150
+ const prepared = await prepareProvision(tower, input)
151
+ if (prepared.early) return prepared.early
152
+
153
+ const acted = await actProvision(tower, prepared)
154
+ if (acted.phase === 'existing') return existingWorktreeResult(acted.mission, acted.worktree)
155
+ if (acted.phase === 'missing') fail(`unknown mission "${prepared.missionId}"`, 'mission-not-found')
156
+ if (acted.phase === 'stale') {
157
+ throw new TowerToolError(
158
+ 'stale-base',
159
+ `tower state changed while the worktree was being prepared (${acted.detail}); no worktree was created — retry`,
160
+ )
161
+ }
162
+
163
+ let committed
164
+ try {
165
+ committed = await commitProvision(tower, prepared, acted)
166
+ } catch (error) {
167
+ // The worktree already exists. A commit that cannot even take the state
168
+ // lock must not leave the mission wedged with an unrecorded worktree.
169
+ await rollbackProvision(tower, prepared)
170
+ throw error
171
+ }
172
+ if (committed.phase === 'stale') {
173
+ await rollbackProvision(tower, prepared)
174
+ throw new TowerToolError(
175
+ 'stale-base',
176
+ `tower state changed while the worktree was being created (${prepared.stateRevision} -> ${committed.revision}); the new worktree was rolled back — retry`,
177
+ )
178
+ }
179
+ if (committed.phase === 'existing') {
180
+ // Another writer recorded a worktree for this mission while git ran; the
181
+ // one created here is orphaned, so remove it and report the recorded one.
182
+ await rollbackProvision(tower, prepared)
183
+ await tower.assertWorktree(committed.worktree)
184
+ return existingWorktreeResult(committed.mission, committed.worktree)
185
+ }
186
+ if (committed.phase === 'persist-error') {
187
+ // Preserve the single-phase rollback: a failed state write must not leave
188
+ // an untracked worktree/branch behind.
189
+ await rollbackProvision(tower, prepared)
190
+ throw committed.error
191
+ }
192
+ return createdWorktreeResult(committed.mission, committed.worktree)
193
+ }
package/app/read.js ADDED
@@ -0,0 +1,57 @@
1
+ // Read-only dashboard/detail use cases.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+ import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
4
+ import { readActivityTail } from '../lib/store.js'
5
+
6
+ function fail(message, code = 'invalid-argument') {
7
+ throw new TowerToolError(code, message)
8
+ }
9
+
10
+ export async function missionDetail(tower, missionId) {
11
+ const state = await tower.load()
12
+ const mission = state.missions.find((entry) => entry.id === missionId)
13
+ if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
14
+ const activity = await readActivityTail(tower.paths.activity, 200)
15
+ return missionDetailView({ state, mission, activity })
16
+ }
17
+
18
+ export async function status(tower, input = {}) {
19
+ const state = await tower.load()
20
+ const activity = await readActivityTail(tower.paths.activity, 12)
21
+ return statusView({
22
+ state,
23
+ statePath: tower.paths.state,
24
+ activity,
25
+ unreadTower: tower.unreadFor(state, 'tower').length,
26
+ config: tower.config,
27
+ missions: await tower.enrichedMissions(state, input.missionIds),
28
+ })
29
+ }
30
+
31
+ export async function diff(tower, input = {}) {
32
+ const state = await tower.load()
33
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
34
+ if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
35
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
36
+ await tower.assertWorktree(mission.worktree)
37
+ const baseTip = await tower.revParse(tower.root, state.base)
38
+ const branchTip = await tower.revParse(mission.worktree.path, 'HEAD')
39
+ const mergeBase = (await tower.git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
40
+ const files = (await tower.git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
41
+ .split('\n')
42
+ .filter(Boolean)
43
+ const commits = (await tower.git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
44
+ .split('\n')
45
+ .filter(Boolean)
46
+ const fullPatch = await tower.git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
47
+ return diffView({
48
+ mission,
49
+ baseTip,
50
+ branchTip,
51
+ mergeBase,
52
+ files,
53
+ commits,
54
+ fullPatch,
55
+ maxBytes: input.maxBytes,
56
+ })
57
+ }
package/app/rebase.js ADDED
@@ -0,0 +1,302 @@
1
+ // Rebase use case.
2
+ //
3
+ // #51 two-phase: long git work runs outside the state lock.
4
+ // prepare (lock-free reads) -> intent (short state lock) -> act (git lock)
5
+ // -> commit (short state lock with revision revalidation).
6
+ // The durable `state.rebaseIntent` lets a crash between act and commit be
7
+ // reconciled by the next tower_rebase, and `mission.rebase` keeps the
8
+ // reviewed-tip waiver semantics the merge gate depends on.
9
+ import crypto from 'node:crypto'
10
+ import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
11
+ import { validateState } from '../domain/state.js'
12
+ import { TowerToolError } from '../domain/errors.js'
13
+ import { liveAgentFor } from '../domain/integration/liveness.js'
14
+ import {
15
+ alreadyMergedRebaseResult,
16
+ alreadyUpToDateResult,
17
+ ambiguousRebaseIntentMessage,
18
+ buildRebaseIntent,
19
+ classifyRebaseIntent,
20
+ clearedRebaseIntentActivityText,
21
+ dirtyWorktreeResult,
22
+ malformedRebaseIntent,
23
+ rebaseActivityText,
24
+ rebaseConflictNote,
25
+ rebaseConflictResult,
26
+ rebaseReconcileActivityText,
27
+ rebaseReconcileNote,
28
+ rebaseSuccessNote,
29
+ rebaseSuccessResult,
30
+ rebaseTipsMoved,
31
+ rebaseTipsMovedDetail,
32
+ STALE_ACT,
33
+ STALE_PREPARE,
34
+ workerBusyResult,
35
+ } from '../domain/integration/rebase.js'
36
+
37
+ const RECONCILE_ATTEMPTS = 5
38
+
39
+ function fail(message, code = 'invalid-transition') {
40
+ throw new TowerToolError(code, message)
41
+ }
42
+
43
+ function stale(message) {
44
+ return new TowerToolError('stale-base', message)
45
+ }
46
+
47
+ function findMission(state, missionId) {
48
+ return state.missions.find((entry) => entry.id === missionId)
49
+ }
50
+
51
+ /**
52
+ * Phase 1 (#51): every read needed to decide the rebase — roster liveness,
53
+ * worktree ownership, cleanliness, and both tips — happens without any lock.
54
+ * Returns the snapshot the intent and the commit revalidate against.
55
+ */
56
+ async function prepareRebase(tower, input) {
57
+ const state = await tower.load()
58
+ const mission = findMission(state, input.missionId)
59
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
60
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
61
+ const activity = input.activity ?? {}
62
+ const busy = liveAgentFor(state, mission.id, activity)
63
+ if (busy) {
64
+ return { early: workerBusyResult(busy) }
65
+ }
66
+ await tower.assertWorktree(mission.worktree)
67
+ if (!(await tower.isClean(mission.worktree.path))) {
68
+ return { early: dirtyWorktreeResult(mission.worktree.path) }
69
+ }
70
+ const oldTip = await tower.revParse(mission.worktree.path, 'HEAD')
71
+ const baseTip = await tower.revParse(tower.root, state.base)
72
+ if (await tower.isAncestor(tower.root, oldTip, baseTip)) {
73
+ // The branch is already contained in the base (manual merge, crashed
74
+ // merge, earlier fast-forward). Rewriting the branch tip to the base
75
+ // would destroy the reviewed tip and the ability to record the merge.
76
+ return { early: alreadyMergedRebaseResult({ oldTip, baseTip, base: state.base }) }
77
+ }
78
+ if (await tower.isAncestor(tower.root, baseTip, oldTip)) {
79
+ return { early: alreadyUpToDateResult({ oldTip, baseTip }) }
80
+ }
81
+ // Count the commits a successful rebase will replay. The count is recorded
82
+ // with the intent so reconciliation can prove a crashed rebase finished
83
+ // instead of guessing from a moved tip.
84
+ let commits = null
85
+ try {
86
+ const count = Number.parseInt(String(await tower.git(mission.worktree.path, ['rev-list', '--count', `${baseTip}..${oldTip}`])).trim(), 10)
87
+ commits = Number.isInteger(count) ? count : null
88
+ } catch {
89
+ commits = null
90
+ }
91
+ return {
92
+ stateRevision: state.revision,
93
+ missionId: mission.id,
94
+ worktree: { path: mission.worktree.path, branch: mission.worktree.branch },
95
+ base: state.base,
96
+ oldTip,
97
+ baseTip,
98
+ commits,
99
+ early: null,
100
+ }
101
+ }
102
+
103
+ /** Inspect a durable intent with lock-free git reads. Never mutates. */
104
+ async function inspectRebaseIntent(tower, state, intent) {
105
+ const mission = findMission(state, intent.missionId)
106
+ const worktreePath =
107
+ mission && mission.worktree && typeof mission.worktree.path === 'string' ? mission.worktree.path : null
108
+ if (!worktreePath) {
109
+ return classifyRebaseIntent({ intent, mission, tipReadable: false, baseIsAncestor: false })
110
+ }
111
+ let tip
112
+ try {
113
+ tip = await tower.revParse(worktreePath, 'HEAD')
114
+ } catch {
115
+ return classifyRebaseIntent({ intent, mission, tipReadable: false, baseIsAncestor: false })
116
+ }
117
+ if (tip === intent.oldTip) {
118
+ return classifyRebaseIntent({ intent, mission, tip, tipReadable: true, baseIsAncestor: true })
119
+ }
120
+ if (!(await tower.isAncestor(tower.root, intent.baseTip, tip))) {
121
+ return classifyRebaseIntent({ intent, mission, tip, tipReadable: true, baseIsAncestor: false })
122
+ }
123
+ let actualCommits = null
124
+ if (intent.commits !== null && intent.commits !== undefined) {
125
+ try {
126
+ const raw = await tower.git(worktreePath, ['rev-list', '--count', `${intent.baseTip}..${tip}`])
127
+ actualCommits = Number.parseInt(String(raw).trim(), 10)
128
+ } catch {
129
+ actualCommits = null
130
+ }
131
+ }
132
+ return classifyRebaseIntent({ intent, mission, tip, tipReadable: true, baseIsAncestor: true, actualCommits })
133
+ }
134
+
135
+ /**
136
+ * Reconcile a rebase intent left behind by a crash between act and commit.
137
+ * The two unambiguous outcomes (the branch never moved, or the rebase landed
138
+ * with the expected commit count) are committed; anything else keeps the
139
+ * marker and refuses rather than guessing.
140
+ */
141
+ async function reconcileRebaseIntent(tower) {
142
+ for (let attempt = 0; attempt < RECONCILE_ATTEMPTS; attempt += 1) {
143
+ const snapshot = await tower.load()
144
+ const intent = snapshot.rebaseIntent
145
+ if (!intent) return
146
+ if (malformedRebaseIntent(intent)) {
147
+ throw stale('a rebase intent in tower state is malformed; inspect it and retry')
148
+ }
149
+ const inspection = await inspectRebaseIntent(tower, snapshot, intent)
150
+ if (inspection.action === 'ambiguous') {
151
+ throw stale(ambiguousRebaseIntentMessage(intent, inspection.tip))
152
+ }
153
+ const applied = await withLock(tower.paths, async () => {
154
+ const state = await tower.load()
155
+ if (state.revision !== snapshot.revision) return false
156
+ const current = state.rebaseIntent
157
+ if (!current || current.token !== intent.token) return true
158
+ const now = Date.now()
159
+ const mission = findMission(state, intent.missionId)
160
+ delete state.rebaseIntent
161
+ state.revision += 1
162
+ state.updatedAt = now
163
+ if (inspection.action === 'record' && mission) {
164
+ const tip = /** @type {string} */ (inspection.tip)
165
+ mission.rebase = { at: now, oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }
166
+ if (mission.status === 'blocked') mission.status = 'active'
167
+ mission.notes.push(rebaseReconcileNote({ oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }))
168
+ mission.updatedAt = now
169
+ }
170
+ validateState(state, tower.paths.state)
171
+ await writeJsonAtomic(tower.paths.state, state)
172
+ await appendJsonl(tower.paths.activity, {
173
+ at: now,
174
+ type: 'rebase',
175
+ missionId: intent.missionId,
176
+ text:
177
+ inspection.action === 'record'
178
+ ? rebaseReconcileActivityText({
179
+ oldTip: intent.oldTip,
180
+ newTip: /** @type {string} */ (inspection.tip),
181
+ baseTip: intent.baseTip,
182
+ })
183
+ : clearedRebaseIntentActivityText(inspection.reason),
184
+ })
185
+ return true
186
+ })
187
+ if (applied) return
188
+ }
189
+ throw stale('tower state kept changing while reconciling a pending rebase intent; retry')
190
+ }
191
+
192
+ export async function rebase(tower, input) {
193
+ await reconcileRebaseIntent(tower)
194
+ const prepared = await prepareRebase(tower, input)
195
+ if (prepared.early) return prepared.early
196
+ const worktree = /** @type {{ path: string, branch: string }} */ (prepared.worktree)
197
+
198
+ // Record the intent before touching Git: a crash between the rebase and the
199
+ // commit leaves a durable marker instead of an unexplained moved branch.
200
+ const token = crypto.randomUUID()
201
+ const startedAt = Date.now()
202
+ const intentRevision = await withLock(tower.paths, async () => {
203
+ const state = await tower.load()
204
+ if (state.revision !== prepared.stateRevision) throw stale(STALE_PREPARE)
205
+ const mission = findMission(state, prepared.missionId)
206
+ if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
207
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
208
+ state.rebaseIntent = buildRebaseIntent({
209
+ token,
210
+ missionId: mission.id,
211
+ base: prepared.base,
212
+ oldTip: prepared.oldTip,
213
+ baseTip: prepared.baseTip,
214
+ commits: prepared.commits ?? null,
215
+ startedAt,
216
+ })
217
+ state.revision += 1
218
+ state.updatedAt = startedAt
219
+ await writeJsonAtomic(tower.paths.state, state)
220
+ return state.revision
221
+ })
222
+
223
+ // Phase 2 (#51): the long git work runs under the git lock and never under
224
+ // the state lock. Re-verify the snapshot right before rewriting the branch.
225
+ const outcome = await withGitLock(tower.paths, async () => {
226
+ const currentTip = await tower.revParse(worktree.path, 'HEAD')
227
+ const currentBase = await tower.revParse(tower.root, prepared.base)
228
+ if (rebaseTipsMoved({ oldTip: prepared.oldTip, baseTip: prepared.baseTip, currentTip, currentBase })) {
229
+ return {
230
+ phase: 'stale',
231
+ detail: rebaseTipsMovedDetail({
232
+ oldTip: prepared.oldTip,
233
+ baseTip: prepared.baseTip,
234
+ currentTip,
235
+ currentBase,
236
+ }),
237
+ }
238
+ }
239
+ try {
240
+ await tower.git(worktree.path, ['rebase', prepared.baseTip])
241
+ } catch (error) {
242
+ await tower.git(worktree.path, ['rebase', '--abort']).catch(() => {})
243
+ return { phase: 'conflict', detail: String(error?.message ?? error) }
244
+ }
245
+ const newTip = await tower.revParse(worktree.path, 'HEAD')
246
+ return { phase: 'done', newTip }
247
+ })
248
+
249
+ // Phase 3 (#51): re-read state and revalidate the revision computed by the
250
+ // intent write; the lock only holds the state mutation, never a subprocess.
251
+ return withLock(tower.paths, async () => {
252
+ const state = await tower.load()
253
+ if (state.revision !== intentRevision) throw stale(STALE_ACT)
254
+ const mission = findMission(state, prepared.missionId)
255
+ if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
256
+ const intent = state.rebaseIntent
257
+ if (!intent || intent.token !== token) throw stale(STALE_ACT)
258
+ const now = Date.now()
259
+
260
+ if (outcome.phase === 'stale') {
261
+ delete state.rebaseIntent
262
+ state.revision += 1
263
+ state.updatedAt = now
264
+ validateState(state, tower.paths.state)
265
+ await writeJsonAtomic(tower.paths.state, state)
266
+ throw stale(outcome.detail)
267
+ }
268
+
269
+ if (outcome.phase === 'conflict') {
270
+ delete state.rebaseIntent
271
+ mission.status = 'blocked'
272
+ mission.blockers.push(rebaseConflictNote(prepared.base, prepared.baseTip))
273
+ mission.updatedAt = now
274
+ state.revision += 1
275
+ state.updatedAt = now
276
+ validateState(state, tower.paths.state)
277
+ await writeJsonAtomic(tower.paths.state, state)
278
+ await appendJsonl(tower.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
279
+ return rebaseConflictResult(outcome.detail)
280
+ }
281
+
282
+ const { newTip } = outcome
283
+ delete state.rebaseIntent
284
+ mission.rebase = { at: now, oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }
285
+ if (mission.status === 'blocked') mission.status = 'active'
286
+ mission.notes.push(
287
+ rebaseSuccessNote({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }),
288
+ )
289
+ mission.updatedAt = now
290
+ state.revision += 1
291
+ state.updatedAt = now
292
+ validateState(state, tower.paths.state)
293
+ await writeJsonAtomic(tower.paths.state, state)
294
+ await appendJsonl(tower.paths.activity, {
295
+ at: now,
296
+ type: 'rebase',
297
+ missionId: mission.id,
298
+ text: rebaseActivityText({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }),
299
+ })
300
+ return rebaseSuccessResult({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip })
301
+ })
302
+ }
@@ -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
+ }