dsh-tower 2.1.0-beta.1 → 2.3.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 (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
package/app/rebase.js CHANGED
@@ -6,8 +6,6 @@
6
6
  // The durable `state.rebaseIntent` lets a crash between act and commit be
7
7
  // reconciled by the next tower_rebase, and `mission.rebase` keeps the
8
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
9
  import { validateState } from '../domain/state.js'
12
10
  import { TowerToolError } from '../domain/errors.js'
13
11
  import { liveAgentFor } from '../domain/integration/liveness.js'
@@ -150,7 +148,7 @@ async function reconcileRebaseIntent(tower) {
150
148
  if (inspection.action === 'ambiguous') {
151
149
  throw stale(ambiguousRebaseIntentMessage(intent, inspection.tip))
152
150
  }
153
- const applied = await withLock(tower.paths, async () => {
151
+ const applied = await tower.deps.store.withLock(tower.paths, async () => {
154
152
  const state = await tower.load()
155
153
  if (state.revision !== snapshot.revision) return false
156
154
  const current = state.rebaseIntent
@@ -168,8 +166,8 @@ async function reconcileRebaseIntent(tower) {
168
166
  mission.updatedAt = now
169
167
  }
170
168
  validateState(state, tower.paths.state)
171
- await writeJsonAtomic(tower.paths.state, state)
172
- await appendJsonl(tower.paths.activity, {
169
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
170
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
173
171
  at: now,
174
172
  type: 'rebase',
175
173
  missionId: intent.missionId,
@@ -197,9 +195,9 @@ export async function rebase(tower, input) {
197
195
 
198
196
  // Record the intent before touching Git: a crash between the rebase and the
199
197
  // commit leaves a durable marker instead of an unexplained moved branch.
200
- const token = crypto.randomUUID()
198
+ const token = tower.deps.ids.uuid()
201
199
  const startedAt = Date.now()
202
- const intentRevision = await withLock(tower.paths, async () => {
200
+ const intentRevision = await tower.deps.store.withLock(tower.paths, async () => {
203
201
  const state = await tower.load()
204
202
  if (state.revision !== prepared.stateRevision) throw stale(STALE_PREPARE)
205
203
  const mission = findMission(state, prepared.missionId)
@@ -216,13 +214,13 @@ export async function rebase(tower, input) {
216
214
  })
217
215
  state.revision += 1
218
216
  state.updatedAt = startedAt
219
- await writeJsonAtomic(tower.paths.state, state)
217
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
220
218
  return state.revision
221
219
  })
222
220
 
223
221
  // Phase 2 (#51): the long git work runs under the git lock and never under
224
222
  // the state lock. Re-verify the snapshot right before rewriting the branch.
225
- const outcome = await withGitLock(tower.paths, async () => {
223
+ const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
226
224
  const currentTip = await tower.revParse(worktree.path, 'HEAD')
227
225
  const currentBase = await tower.revParse(tower.root, prepared.base)
228
226
  if (rebaseTipsMoved({ oldTip: prepared.oldTip, baseTip: prepared.baseTip, currentTip, currentBase })) {
@@ -244,11 +242,11 @@ export async function rebase(tower, input) {
244
242
  }
245
243
  const newTip = await tower.revParse(worktree.path, 'HEAD')
246
244
  return { phase: 'done', newTip }
247
- })
245
+ }, tower.gitMutexOptions({ phase: 'rebase' }))
248
246
 
249
247
  // Phase 3 (#51): re-read state and revalidate the revision computed by the
250
248
  // intent write; the lock only holds the state mutation, never a subprocess.
251
- return withLock(tower.paths, async () => {
249
+ return tower.deps.store.withLock(tower.paths, async () => {
252
250
  const state = await tower.load()
253
251
  if (state.revision !== intentRevision) throw stale(STALE_ACT)
254
252
  const mission = findMission(state, prepared.missionId)
@@ -262,7 +260,7 @@ export async function rebase(tower, input) {
262
260
  state.revision += 1
263
261
  state.updatedAt = now
264
262
  validateState(state, tower.paths.state)
265
- await writeJsonAtomic(tower.paths.state, state)
263
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
266
264
  throw stale(outcome.detail)
267
265
  }
268
266
 
@@ -274,8 +272,8 @@ export async function rebase(tower, input) {
274
272
  state.revision += 1
275
273
  state.updatedAt = now
276
274
  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' })
275
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
276
+ await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
279
277
  return rebaseConflictResult(outcome.detail)
280
278
  }
281
279
 
@@ -290,8 +288,8 @@ export async function rebase(tower, input) {
290
288
  state.revision += 1
291
289
  state.updatedAt = now
292
290
  validateState(state, tower.paths.state)
293
- await writeJsonAtomic(tower.paths.state, state)
294
- await appendJsonl(tower.paths.activity, {
291
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
292
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
295
293
  at: now,
296
294
  type: 'rebase',
297
295
  missionId: mission.id,
package/app/recording.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // State recording use cases for test receipts and imported legacy snapshots.
2
2
  import { TowerToolError } from '../domain/errors.js'
3
3
  import { validateState } from '../domain/state.js'
4
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
5
4
 
6
5
  function fail(message, code = 'invalid-argument') {
7
6
  throw new TowerToolError(code, message)
8
7
  }
9
8
 
10
9
  export async function recordTestRun(tower, input = {}) {
11
- return withLock(tower.paths, async () => {
10
+ return tower.deps.store.withLock(tower.paths, async () => {
12
11
  const state = await tower.load()
13
12
  const mission = state.missions.find((entry) => entry.id === input.missionId)
14
13
  if (!mission) fail(`unknown mission "${input.missionId}"`)
@@ -28,8 +27,8 @@ export async function recordTestRun(tower, input = {}) {
28
27
  state.revision += 1
29
28
  state.updatedAt = Date.now()
30
29
  validateState(state, tower.paths.state)
31
- await writeJsonAtomic(tower.paths.state, state)
32
- await appendJsonl(tower.paths.activity, {
30
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
31
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
33
32
  at: Date.now(),
34
33
  type: 'test',
35
34
  missionId: mission.id,
@@ -40,7 +39,7 @@ export async function recordTestRun(tower, input = {}) {
40
39
  }
41
40
 
42
41
  export async function recordLegacySnapshot(tower, snapshot) {
43
- return withLock(tower.paths, async () => {
42
+ return tower.deps.store.withLock(tower.paths, async () => {
44
43
  const state = await tower.load()
45
44
  if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
46
45
  const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
@@ -49,13 +48,13 @@ export async function recordLegacySnapshot(tower, snapshot) {
49
48
  state.revision += 1
50
49
  state.updatedAt = Date.now()
51
50
  validateState(state, tower.paths.state)
52
- await writeJsonAtomic(tower.paths.state, state)
51
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
53
52
  return { snapshot, duplicate: false }
54
53
  })
55
54
  }
56
55
 
57
56
  export async function recordLegacyMap(tower, map) {
58
- return withLock(tower.paths, async () => {
57
+ return tower.deps.store.withLock(tower.paths, async () => {
59
58
  const state = await tower.load()
60
59
  if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
61
60
  const existing = state.legacyMaps.find(
@@ -66,7 +65,7 @@ export async function recordLegacyMap(tower, map) {
66
65
  state.revision += 1
67
66
  state.updatedAt = Date.now()
68
67
  validateState(state, tower.paths.state)
69
- await writeJsonAtomic(tower.paths.state, state)
68
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
70
69
  return { map, duplicate: false }
71
70
  })
72
71
  }
package/app/review.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Independent review submission use case.
2
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
2
+ import { sweepArchive } from './archive.js'
3
3
  import { validateState } from '../domain/state.js'
4
4
  import { TowerToolError } from '../domain/errors.js'
5
5
  import {
@@ -7,9 +7,11 @@ import {
7
7
  resolveReviewActor,
8
8
  normalizeReviewVerdict,
9
9
  assertReviewerSpawnTip,
10
+ reviewAdmission,
10
11
  reviewRound,
11
12
  reviewOutcome,
12
13
  reviewRecord,
14
+ applyReviewOutcome,
13
15
  } from '../domain/missions/review.js'
14
16
 
15
17
  function fail(message, code = 'invalid-argument') {
@@ -36,7 +38,7 @@ async function prepareReview(tower, input) {
36
38
  breakGlassReason: input.breakGlassReason,
37
39
  })
38
40
 
39
- if (mission.status !== 'completed') {
41
+ if (!['completed', 'active'].includes(mission.status)) {
40
42
  fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
41
43
  }
42
44
  const verdict = normalizeReviewVerdict(input)
@@ -45,11 +47,15 @@ async function prepareReview(tower, input) {
45
47
  const tip = await tower.revParse(mission.worktree.path, 'HEAD')
46
48
  const branch = mission.worktree.branch
47
49
  assertReviewerSpawnTip(caller, tip)
50
+ // #108: a later route may append to the round that returned the mission to
51
+ // active, but only for the tip that round reviewed.
52
+ const admission = reviewAdmission({ mission, reviews: state.reviews, branch, tip })
48
53
  const { reviewerRounds, round } = reviewRound({
49
54
  reviews: state.reviews,
50
55
  branch,
51
56
  reviewerName,
52
57
  maxRoundsPerReviewer: tower.config.review.maxRoundsPerReviewer,
58
+ round: admission.round,
53
59
  })
54
60
 
55
61
  return {
@@ -66,6 +72,7 @@ async function prepareReview(tower, input) {
66
72
  reviewerRole,
67
73
  reviewerRounds,
68
74
  round,
75
+ appended: admission.appended,
69
76
  breakGlassReason: reviewerRole === 'tower' ? input.breakGlassReason.trim() : undefined,
70
77
  }
71
78
  }
@@ -76,7 +83,7 @@ export async function submitReview(tower, input) {
76
83
  for (let attempt = 0; attempt < 2; attempt += 1) {
77
84
  const prepared = await prepareReview(tower, input)
78
85
  try {
79
- return await withLock(tower.paths, async () => {
86
+ return await tower.deps.store.withLock(tower.paths, async () => {
80
87
  const state = await tower.load()
81
88
  if (state.revision !== prepared.stateRevision) {
82
89
  throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
@@ -113,26 +120,28 @@ export async function submitReview(tower, input) {
113
120
  merge: prepared.merge,
114
121
  round: prepared.round,
115
122
  reviewerName: prepared.reviewerName,
123
+ appended: prepared.appended,
116
124
  })
117
- if (outcome.returnToActive) mission.status = 'active'
118
- mission.notes.push(outcome.note)
119
- mission.updatedAt = now
125
+ const applied = applyReviewOutcome({ mission, outcome, now })
126
+ await sweepArchive(tower, state, { now })
120
127
  state.revision += 1
121
128
  state.updatedAt = now
122
129
 
123
130
  validateState(state, tower.paths.state)
124
- await writeJsonAtomic(tower.paths.state, state)
125
- await appendJsonl(tower.paths.activity, {
131
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
132
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
126
133
  at: now,
127
134
  type: 'review',
128
135
  missionId: mission.id,
129
136
  text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
130
137
  })
138
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
131
139
  return {
132
140
  review,
133
141
  branchTip: prepared.tip,
134
142
  missionStatus: mission.status,
135
143
  round: prepared.round,
144
+ appended: prepared.appended,
136
145
  reviewerRounds: prepared.reviewerRounds + 1,
137
146
  }
138
147
  })
package/app/roster.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Roster observation and retirement use cases.
2
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
2
  import { validateState } from '../domain/state.js'
4
3
  import {
5
4
  recordRosterObservations as recordObservations,
@@ -11,7 +10,7 @@ import {
11
10
  import { unreadMessages } from '../domain/collab/messages.js'
12
11
 
13
12
  export async function recordRosterObservations(tower, { present = [], absent = [], confirmations = 2 }) {
14
- return withLock(tower.paths, async () => {
13
+ return tower.deps.store.withLock(tower.paths, async () => {
15
14
  const state = await tower.load()
16
15
  const now = Date.now()
17
16
  const { candidates, changed } = recordObservations(
@@ -23,7 +22,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
23
22
  state.revision += 1
24
23
  state.updatedAt = now
25
24
  validateState(state, tower.paths.state)
26
- await writeJsonAtomic(tower.paths.state, state)
25
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
27
26
  }
28
27
  return { candidates }
29
28
  })
@@ -31,7 +30,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
31
30
 
32
31
  /** Release live roster slots whose catalog activity is explicitly inactive. */
33
32
  export async function reconcileObservedInactive(tower, activity = {}) {
34
- return withLock(tower.paths, async () => {
33
+ return tower.deps.store.withLock(tower.paths, async () => {
35
34
  const state = await tower.load()
36
35
  const now = Date.now()
37
36
  const { released } = reconcileInactive(state.roster ?? [], activity, now)
@@ -39,8 +38,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
39
38
  state.revision += 1
40
39
  state.updatedAt = now
41
40
  validateState(state, tower.paths.state)
42
- await writeJsonAtomic(tower.paths.state, state)
43
- await appendJsonl(tower.paths.activity, {
41
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
42
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
44
43
  at: now,
45
44
  type: 'roster',
46
45
  text: `released inactive slots: ${released.join(', ')}`,
@@ -52,7 +51,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
52
51
 
53
52
  export async function markRetired(tower, agentIds) {
54
53
  if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
55
- return withLock(tower.paths, async () => {
54
+ return tower.deps.store.withLock(tower.paths, async () => {
56
55
  const state = await tower.load()
57
56
  const now = Date.now()
58
57
  const { retired } = retireEntries(state.roster ?? [], agentIds, now)
@@ -60,8 +59,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
60
59
  state.revision += 1
61
60
  state.updatedAt = now
62
61
  validateState(state, tower.paths.state)
63
- await writeJsonAtomic(tower.paths.state, state)
64
- await appendJsonl(tower.paths.activity, {
62
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
63
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
65
64
  at: now,
66
65
  type: 'adopt',
67
66
  text: `retired ${retired.join(', ')}`,
@@ -73,7 +72,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
73
72
 
74
73
  export async function markMissing(tower, agentIds) {
75
74
  if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
76
- return withLock(tower.paths, async () => {
75
+ return tower.deps.store.withLock(tower.paths, async () => {
77
76
  const state = await tower.load()
78
77
  const now = Date.now()
79
78
  const { missing } = markMissingEntries(state.roster ?? [], agentIds, now)
@@ -81,8 +80,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
81
80
  state.revision += 1
82
81
  state.updatedAt = now
83
82
  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(', ')}` })
83
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
84
+ await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
86
85
  }
87
86
  return { missing }
88
87
  })
@@ -95,7 +94,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
95
94
  const staleMs = Number(input.staleMs) || 0
96
95
  if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
97
96
  const activity = input.activity ?? {}
98
- return withLock(tower.paths, async () => {
97
+ return tower.deps.store.withLock(tower.paths, async () => {
99
98
  const state = await tower.load()
100
99
  const now = Date.now()
101
100
  const { marked, idled } = sweepRosterState(
@@ -107,8 +106,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
107
106
  state.revision += 1
108
107
  state.updatedAt = now
109
108
  validateState(state, tower.paths.state)
110
- await writeJsonAtomic(tower.paths.state, state)
111
- await appendJsonl(tower.paths.activity, {
109
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
110
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
112
111
  at: now,
113
112
  type: 'watchdog',
114
113
  text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
@@ -2,7 +2,6 @@
2
2
  // service/provider/lease validation. Split out of tools/spawn.js; no I/O.
3
3
  import { TowerToolError } from '../domain/errors.js'
4
4
  import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
5
- import { inspectLeases } from './leases.js'
6
5
 
7
6
  /**
8
7
  * Pick a unique roster name before any activation.
@@ -132,9 +131,11 @@ export async function precheckWorkerOwnership({
132
131
 
133
132
  /**
134
133
  * Service/provider/preset/lease checks, run before provisioning.
135
- * @param {{ tower: any, ctx: any, config: any, preset: string, actor: any, exec: any }} input
134
+ * `inspectLeases` is injected by the composition root so the app layer never
135
+ * imports the lease adapter (#69).
136
+ * @param {{ tower: any, ctx: any, config: any, preset: string, actor: any, exec: any, inspectLeases: any }} input
136
137
  */
137
- export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec }) {
138
+ export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec, inspectLeases }) {
138
139
  const subagents = ctx.get('subagents')
139
140
  if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
140
141
  const providers = subagents.list()
package/app/spawn.js CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  rosterProjection,
9
9
  } from '../domain/collab/spawn.js'
10
10
  import { validateState } from '../domain/state.js'
11
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
12
11
 
13
12
  function fail(message, code = 'invalid-argument') {
14
13
  throw new TowerToolError(code, message)
@@ -16,7 +15,7 @@ function fail(message, code = 'invalid-argument') {
16
15
 
17
16
  /** Record that a spawned agent failed or went idle, so roster state is truthful. */
18
17
  export async function recordSpawnOutcome(tower, input) {
19
- return withLock(tower.paths, async () => {
18
+ return tower.deps.store.withLock(tower.paths, async () => {
20
19
  const state = await tower.load()
21
20
  const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
22
21
  if (!entry) return null
@@ -25,8 +24,8 @@ export async function recordSpawnOutcome(tower, input) {
25
24
  state.revision += 1
26
25
  state.updatedAt = now
27
26
  validateState(state, tower.paths.state)
28
- await writeJsonAtomic(tower.paths.state, state)
29
- await appendJsonl(tower.paths.activity, {
27
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
28
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
30
29
  at: now,
31
30
  type: 'spawn',
32
31
  missionId: input.missionId,
@@ -38,7 +37,7 @@ export async function recordSpawnOutcome(tower, input) {
38
37
 
39
38
  /** Record that a live agent was interrupted by the tower. */
40
39
  export async function recordInterrupt(tower, input) {
41
- return withLock(tower.paths, async () => {
40
+ return tower.deps.store.withLock(tower.paths, async () => {
42
41
  const state = await tower.load()
43
42
  const entry = (state.roster ?? []).find(
44
43
  (row) =>
@@ -51,8 +50,8 @@ export async function recordInterrupt(tower, input) {
51
50
  state.revision += 1
52
51
  state.updatedAt = now
53
52
  validateState(state, tower.paths.state)
54
- await writeJsonAtomic(tower.paths.state, state)
55
- await appendJsonl(tower.paths.activity, {
53
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
54
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
56
55
  at: now,
57
56
  type: 'spawn',
58
57
  missionId: entry.missionId,
@@ -64,7 +63,7 @@ export async function recordInterrupt(tower, input) {
64
63
 
65
64
  /** Reserve a roster name before activation so a failed spawn is recoverable. */
66
65
  export async function reserveRoster(tower, input) {
67
- return withLock(tower.paths, async () => {
66
+ return tower.deps.store.withLock(tower.paths, async () => {
68
67
  const state = await tower.load()
69
68
  const mission = state.missions.find((entry) => entry.id === input.missionId)
70
69
  if (!mission) fail(`unknown mission "${input.missionId}"`)
@@ -75,8 +74,8 @@ export async function reserveRoster(tower, input) {
75
74
  state.revision += 1
76
75
  state.updatedAt = now
77
76
  validateState(state, tower.paths.state)
78
- await writeJsonAtomic(tower.paths.state, state)
79
- await appendJsonl(tower.paths.activity, {
77
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
78
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
80
79
  at: now,
81
80
  type: 'spawn',
82
81
  missionId: input.missionId,
@@ -89,7 +88,7 @@ export async function reserveRoster(tower, input) {
89
88
  export async function recordSpawn(tower, input) {
90
89
  // Capture the spawn tip before taking the state lock (#51).
91
90
  const spawnTip = input.cwd ? ((await tower.tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
92
- return withLock(tower.paths, async () => {
91
+ return tower.deps.store.withLock(tower.paths, async () => {
93
92
  const state = await tower.load()
94
93
  const mission = state.missions.find((entry) => entry.id === input.missionId)
95
94
  if (!mission) fail(`unknown mission "${input.missionId}"`)
@@ -108,8 +107,8 @@ export async function recordSpawn(tower, input) {
108
107
  state.updatedAt = now
109
108
 
110
109
  validateState(state, tower.paths.state)
111
- await writeJsonAtomic(tower.paths.state, state)
112
- await appendJsonl(tower.paths.activity, {
110
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
111
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
113
112
  at: now,
114
113
  type: 'spawn',
115
114
  missionId: input.missionId,
package/app/teardown.js CHANGED
@@ -6,7 +6,6 @@
6
6
  // therefore bumps the state revision exactly once, and the revision is
7
7
  // revalidated immediately before the irreversible git mutation so a stale
8
8
  // decision never removes a worktree that changed hands.
9
- import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
10
9
  import { validateState } from '../domain/state.js'
11
10
  import { TowerToolError } from '../domain/errors.js'
12
11
  import { hasLiveAgent } from '../domain/integration/liveness.js'
@@ -21,6 +20,7 @@ import {
21
20
  teardownRemoveDecision,
22
21
  teardownRemovedDecision,
23
22
  } from '../domain/integration/teardown.js'
23
+ import { sweepArchive } from './archive.js'
24
24
 
25
25
  /**
26
26
  * Phase 1 (#51): decide every keep/remove outcome without holding the state
@@ -77,7 +77,7 @@ async function prepareTeardown(tower, input) {
77
77
  /** Short state lock: reject a stale decision before any git mutation. */
78
78
  async function claimRemoval(tower, candidate, expectedRevision, input) {
79
79
  const activity = input.activity ?? {}
80
- return withLock(tower.paths, async () => {
80
+ return tower.deps.store.withLock(tower.paths, async () => {
81
81
  const state = await tower.load()
82
82
  if (state.revision !== expectedRevision) {
83
83
  throw new TowerToolError('stale-base', `${STALE}. No worktree was removed.`)
@@ -104,7 +104,7 @@ export async function teardown(tower, input) {
104
104
  // never under the state lock. Re-read state without the lock right before
105
105
  // mutating: the git lock may have waited behind another operation, so a
106
106
  // stale candidate is refused while the worktree still exists.
107
- const outcome = await withGitLock(tower.paths, async () => {
107
+ const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
108
108
  let fresh
109
109
  try {
110
110
  fresh = await tower.load()
@@ -139,7 +139,7 @@ export async function teardown(tower, input) {
139
139
  branchDeleteError = String(error?.message ?? error).slice(0, 240)
140
140
  }
141
141
  return { phase: 'removed', branch: worktree.branch, branchDeleteError }
142
- })
142
+ }, tower.gitMutexOptions({ phase: 'teardown' }))
143
143
 
144
144
  if (outcome.phase === 'stale') {
145
145
  throw new TowerToolError('stale-base', `${STALE}: ${outcome.detail}. No worktree was removed.`)
@@ -154,7 +154,7 @@ export async function teardown(tower, input) {
154
154
  // the removal on the freshly loaded state instead of rejecting it (which
155
155
  // would leave state claiming a worktree that no longer exists). Only this
156
156
  // mission's fields change, so no concurrent update is lost.
157
- const commit = await withLock(tower.paths, async () => {
157
+ const commit = await tower.deps.store.withLock(tower.paths, async () => {
158
158
  const state = await tower.load()
159
159
  const mission = state.missions.find((entry) => entry.id === candidate.missionId)
160
160
  if (!mission) return { revision: state.revision }
@@ -166,8 +166,8 @@ export async function teardown(tower, input) {
166
166
  state.revision += 1
167
167
  state.updatedAt = now
168
168
  validateState(state, tower.paths.state)
169
- await writeJsonAtomic(tower.paths.state, state)
170
- await appendJsonl(tower.paths.activity, {
169
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
170
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
171
171
  at: now,
172
172
  type: 'teardown',
173
173
  missionId: mission.id,
@@ -180,5 +180,25 @@ export async function teardown(tower, input) {
180
180
  setDecision(candidate.decision, teardownRemovedDecision(outcome))
181
181
  }
182
182
 
183
- return { ok: true, dryRun: false, removed, decisions: prepared.decisions }
183
+ // Archival runs under one final short lock. It is policy-driven (age or the
184
+ // hot-terminal cap), so a mission torn down a moment ago stays hot until it
185
+ // is old enough; `archive.afterDays: 0` archives it on this call.
186
+ const archive = await tower.deps.store.withLock(tower.paths, async () => {
187
+ const state = await tower.load()
188
+ const swept = await sweepArchive(tower, state, { now: Date.now(), activity: input.activity ?? {} })
189
+ if (swept.written) {
190
+ validateState(state, tower.paths.state)
191
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
192
+ }
193
+ return swept
194
+ })
195
+
196
+ return {
197
+ ok: true,
198
+ dryRun: false,
199
+ removed,
200
+ decisions: prepared.decisions,
201
+ archived: archive.archived,
202
+ ...(archive.error ? { archiveError: archive.error } : {}),
203
+ }
184
204
  }