dsh-tower 2.6.0 → 2.7.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 (116) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.en.md +2 -1
  5. package/README.md +6 -1
  6. package/adapters/leases.js +20 -6
  7. package/adapters/legacy.js +149 -37
  8. package/adapters/stateFile.js +11 -1
  9. package/app/archive.js +30 -5
  10. package/app/briefing.js +95 -12
  11. package/app/checkCompletion.js +107 -18
  12. package/app/enrichedMissions.js +1 -1
  13. package/app/freshness.js +77 -0
  14. package/app/gate.js +26 -12
  15. package/app/merge.js +7 -4
  16. package/app/mission.js +24 -1
  17. package/app/missionContext.js +2 -1
  18. package/app/plan.js +64 -1
  19. package/app/provision.js +133 -22
  20. package/app/read.js +38 -5
  21. package/app/rebase.js +10 -8
  22. package/app/review.js +97 -61
  23. package/app/reviewCheckout.js +133 -0
  24. package/app/sandbox.js +121 -0
  25. package/app/sharedCheckout.js +90 -0
  26. package/app/spawn-prechecks.js +25 -24
  27. package/app/spawn-support.js +59 -8
  28. package/app/spawn.js +3 -0
  29. package/app/teardown.js +119 -2
  30. package/app/tower.js +29 -1
  31. package/cordis.patch.yml +50 -0
  32. package/docs/adr/0006-mission-shapes.md +306 -0
  33. package/docs/architecture/overview.md +6 -4
  34. package/docs/guide/install-upgrade-migration.md +26 -0
  35. package/docs/operations/runbook.md +1 -1
  36. package/docs/reference/blockers.md +8 -4
  37. package/docs/reference/component-surface.md +7 -2
  38. package/docs/reference/config.generated.md +6 -4
  39. package/docs/reference/config.md +1 -2
  40. package/docs/reference/doctor.md +7 -7
  41. package/docs/reference/operations.md +1 -1
  42. package/docs/reference/shapes.md +123 -0
  43. package/docs/reference/surface-budget.md +33 -1
  44. package/docs/reference/testing.md +4 -2
  45. package/docs/reference/tools.generated.md +5 -5
  46. package/docs/reference/tools.md +2 -2
  47. package/docs/zh/reference/blockers.md +2 -2
  48. package/docs/zh/reference/config.md +1 -2
  49. package/docs/zh/reference/testing.md +3 -2
  50. package/domain/batch.js +75 -105
  51. package/domain/collab/batch.js +30 -10
  52. package/domain/collab/spawn.js +53 -16
  53. package/domain/config/schema.js +47 -17
  54. package/domain/contracts/outputs.js +0 -1
  55. package/domain/contracts/tool-inputs/missions.js +9 -1
  56. package/domain/evidence.js +283 -0
  57. package/domain/gate.js +11 -31
  58. package/domain/integration/gate.js +17 -43
  59. package/domain/integration/init.js +3 -2
  60. package/domain/integration/provision.js +51 -0
  61. package/domain/integration/rebase.js +16 -1
  62. package/domain/mission.js +16 -33
  63. package/domain/missions/completion.js +46 -8
  64. package/domain/missions/findings.js +2 -1
  65. package/domain/missions/lifecycle.js +131 -35
  66. package/domain/missions/plan.js +30 -6
  67. package/domain/missions/review.js +2 -18
  68. package/domain/planning.js +5 -5
  69. package/domain/read/missionContext.js +17 -3
  70. package/domain/read/views.js +5 -1
  71. package/domain/roles.js +222 -0
  72. package/domain/roster.js +9 -3
  73. package/domain/scope.js +51 -0
  74. package/domain/shapes/build.js +254 -0
  75. package/domain/shapes/index.js +171 -0
  76. package/domain/shapes/survey.js +445 -0
  77. package/domain/state/archive.js +28 -4
  78. package/domain/state/migrations.js +111 -4
  79. package/domain/state/schema.js +69 -7
  80. package/domain/state/semantics.js +93 -13
  81. package/domain/state.js +5 -1
  82. package/domain/summary.js +5 -11
  83. package/domain/vocabulary.js +15 -33
  84. package/host/capability-registry.js +25 -9
  85. package/host/doctor.js +2 -1
  86. package/host/preset-survey-sandbox.js +19 -0
  87. package/host/preset-survey.js +18 -0
  88. package/host/presets.js +8 -3
  89. package/host/tool-registry.js +14 -12
  90. package/locale/en.json +16 -1
  91. package/locale/preset-survey/en.json +6 -0
  92. package/locale/preset-survey/zh.json +6 -0
  93. package/locale/preset-survey-sandbox/en.json +6 -0
  94. package/locale/preset-survey-sandbox/zh.json +6 -0
  95. package/locale/zh.json +16 -1
  96. package/package.json +13 -2
  97. package/ports/state.js +8 -1
  98. package/scripts/architecture-waivers.json +3 -3
  99. package/scripts/check-docs.mjs +1 -1
  100. package/scripts/check-pack.mjs +2 -0
  101. package/scripts/component-surface.json +68 -20
  102. package/scripts/generate-capability-locale.mjs +5 -0
  103. package/scripts/generate-shapes-reference.mjs +171 -0
  104. package/scripts/mount-smoke/surface.mjs +3 -0
  105. package/scripts/pack-manifest.txt +21 -0
  106. package/scripts/shape-audit.mjs +361 -0
  107. package/scripts/surface-baseline.json +68 -4
  108. package/survey-sandbox.js +48 -0
  109. package/survey.js +40 -0
  110. package/tools/complete.js +5 -5
  111. package/tools/diff.js +5 -3
  112. package/tools/plan.js +1 -1
  113. package/tools/preflight.js +3 -2
  114. package/tools/spawn.js +47 -49
  115. package/tools/test.js +2 -1
  116. package/types/index.d.ts +66 -5
package/app/review.js CHANGED
@@ -3,11 +3,11 @@ import { sweepArchive } from './archive.js'
3
3
  import { validateState } from '../domain/state.js'
4
4
  import { TowerToolError } from '../domain/errors.js'
5
5
  import { reviewUnavailable } from '../domain/integration/review.js'
6
+ import { cleanupReviewCheckout } from './reviewCheckout.js'
6
7
  import {
7
8
  assertReviewableMission,
8
9
  resolveReviewActor,
9
10
  normalizeReviewVerdict,
10
- assertReviewerSpawnTip,
11
11
  reviewAdmission,
12
12
  reviewRound,
13
13
  reviewOutcome,
@@ -19,6 +19,37 @@ function fail(message, code = 'invalid-argument') {
19
19
  throw new TowerToolError(code, message)
20
20
  }
21
21
 
22
+ /**
23
+ * The commit a verdict is stamped with (#149 W3).
24
+ *
25
+ * A spawned reviewer is bound to the review commit recorded on its roster row
26
+ * and reads it in a disposable detached checkout; that checkout must still be
27
+ * at the recorded commit, so a verdict can never review a moving target. The
28
+ * tower's break-glass review has no checkout and stamps the current branch tip.
29
+ * @param {any} tower
30
+ * @param {{ mission: any, caller: any, reviewerRole: string }} input
31
+ * @returns {Promise<string>}
32
+ */
33
+ async function reviewCommitFor(tower, { mission, caller, reviewerRole }) {
34
+ if (reviewerRole === 'tower') return tower.branchTip(mission)
35
+ const recorded = caller?.reviewCommit
36
+ if (typeof recorded !== 'string' || recorded.length === 0) {
37
+ fail(
38
+ `reviewer ${caller?.name ?? '?'} has no recorded review commit; spawn reviewers through tower_spawn (a verdict is stamped with a commit, not a live branch tip)`,
39
+ )
40
+ }
41
+ if (typeof caller.reviewCheckout === 'string' && caller.reviewCheckout.length > 0) {
42
+ const head = await tower.revParse(caller.reviewCheckout, 'HEAD').catch(() => null)
43
+ if (head !== recorded) {
44
+ const where = head ? head.slice(0, 12) : 'a missing checkout'
45
+ fail(
46
+ `reviewer ${caller.name}'s checkout is at ${where} instead of the recorded review commit ${recorded.slice(0, 12)}; spawn a fresh reviewer`,
47
+ )
48
+ }
49
+ }
50
+ return recorded
51
+ }
52
+
22
53
  /**
23
54
  * Phase 1 (#51): load state and capture everything the review needs — actor
24
55
  * checks, worktree ownership, branch tip, and round counters — without holding
@@ -45,9 +76,8 @@ async function prepareReview(tower, input) {
45
76
  const verdict = normalizeReviewVerdict(input)
46
77
 
47
78
  await tower.assertWorktree(mission.worktree)
48
- const tip = await tower.revParse(mission.worktree.path, 'HEAD')
79
+ const tip = await reviewCommitFor(tower, { mission, caller, reviewerRole })
49
80
  const branch = mission.worktree.branch
50
- assertReviewerSpawnTip(caller, tip)
51
81
  // #108: a later route may append to the round that returned the mission to
52
82
  // active, but only for the tip that round reviewed.
53
83
  const admission = reviewAdmission({ mission, reviews: state.reviews, branch, tip })
@@ -90,68 +120,74 @@ export async function submitReview(tower, input) {
90
120
  for (let attempt = 0; attempt < 2; attempt += 1) {
91
121
  const prepared = await prepareReview(tower, input)
92
122
  try {
93
- return await tower.deps.store.withLock(tower.paths, async () => {
94
- const state = await tower.load()
95
- if (state.revision !== prepared.stateRevision) {
96
- throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
97
- }
98
- const mission = state.missions.find((entry) => entry.id === input.missionId)
99
- if (!mission) fail(`unknown mission "${input.missionId}"`)
123
+ const result = await tower.deps.store.withLock(tower.paths, async () => {
124
+ const state = await tower.load()
125
+ if (state.revision !== prepared.stateRevision) {
126
+ throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
127
+ }
128
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
129
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
100
130
 
101
- const now = Date.now()
102
- const seq = (state.reviewSeq ?? 0) + 1
103
- state.reviewSeq = seq
104
- const review = reviewRecord({
105
- seq,
106
- missionId: mission.id,
107
- branch: prepared.branch,
108
- tip: prepared.tip,
109
- verdict: {
110
- status: prepared.status,
111
- merge: prepared.merge,
112
- findings: prepared.findings,
113
- checks: prepared.checks,
114
- decision: prepared.decision,
115
- },
116
- reviewerName: prepared.reviewerName,
117
- reviewerRole: prepared.reviewerRole,
118
- round: prepared.round,
119
- now,
120
- breakGlassReason: prepared.breakGlassReason,
121
- })
122
- if (!Array.isArray(state.reviews)) state.reviews = []
123
- state.reviews.push(review)
131
+ const now = Date.now()
132
+ const seq = (state.reviewSeq ?? 0) + 1
133
+ state.reviewSeq = seq
134
+ const review = reviewRecord({
135
+ seq,
136
+ missionId: mission.id,
137
+ branch: prepared.branch,
138
+ tip: prepared.tip,
139
+ verdict: {
140
+ status: prepared.status,
141
+ merge: prepared.merge,
142
+ findings: prepared.findings,
143
+ checks: prepared.checks,
144
+ decision: prepared.decision,
145
+ },
146
+ reviewerName: prepared.reviewerName,
147
+ reviewerRole: prepared.reviewerRole,
148
+ round: prepared.round,
149
+ now,
150
+ breakGlassReason: prepared.breakGlassReason,
151
+ })
152
+ if (!Array.isArray(state.reviews)) state.reviews = []
153
+ state.reviews.push(review)
124
154
 
125
- const outcome = reviewOutcome({
126
- status: prepared.status,
127
- merge: prepared.merge,
128
- round: prepared.round,
129
- reviewerName: prepared.reviewerName,
130
- appended: prepared.appended,
131
- })
132
- const applied = applyReviewOutcome({ mission, outcome, now })
133
- await sweepArchive(tower, state, { now })
134
- state.revision += 1
135
- state.updatedAt = now
155
+ const outcome = reviewOutcome({
156
+ status: prepared.status,
157
+ merge: prepared.merge,
158
+ round: prepared.round,
159
+ reviewerName: prepared.reviewerName,
160
+ appended: prepared.appended,
161
+ })
162
+ const applied = applyReviewOutcome({ mission, outcome, now })
163
+ await sweepArchive(tower, state, { now })
164
+ state.revision += 1
165
+ state.updatedAt = now
136
166
 
137
- validateState(state, tower.paths.state)
138
- await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
139
- await tower.deps.store.appendJsonl(tower.paths.activity, {
140
- at: now,
141
- type: 'review',
142
- missionId: mission.id,
143
- text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
144
- })
145
- tower.emitEvents(applied.events, { revision: state.revision, at: now })
146
- return {
147
- review,
148
- branchTip: prepared.tip,
149
- missionStatus: mission.status,
150
- round: prepared.round,
151
- appended: prepared.appended,
152
- reviewerRounds: prepared.reviewerRounds + 1,
153
- }
167
+ validateState(state, tower.paths.state)
168
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
169
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
170
+ at: now,
171
+ type: 'review',
172
+ missionId: mission.id,
173
+ text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
174
+ })
175
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
176
+ return {
177
+ review,
178
+ branchTip: prepared.tip,
179
+ missionStatus: mission.status,
180
+ round: prepared.round,
181
+ appended: prepared.appended,
182
+ reviewerRounds: prepared.reviewerRounds + 1,
183
+ }
154
184
  })
185
+ // The verdict is recorded, so the reviewer's evidence checkout has served
186
+ // its purpose. Removal is advisory: a failed cleanup is retried by teardown.
187
+ if (prepared.reviewerRole === 'reviewer') {
188
+ await cleanupReviewCheckout(tower, { name: prepared.reviewerName, missionId: prepared.missionId }).catch(() => {})
189
+ }
190
+ return result
155
191
  } catch (error) {
156
192
  if (error.code === 'stale-base' && attempt === 0) continue
157
193
  throw error
@@ -0,0 +1,133 @@
1
+ // Review evidence checkouts (#149 W3).
2
+ //
3
+ // A review verdict is stamped with a commit, not with whatever a live worktree
4
+ // HEAD happens to be when the reviewer submits. When a reviewer is spawned the
5
+ // tower creates a disposable detached checkout at the mission branch tip and
6
+ // records that commit on the roster row. The reviewer reads (and tower_diff
7
+ // shows) that commit; the verdict uses it; the merge gate compares it to the
8
+ // current branch tip with the existing `tip-moved` blocker. The checkout is
9
+ // removed as soon as the verdict is recorded, so it can never silently drift.
10
+ //
11
+ // This module owns only the checkout lifecycle. The commit stamp and the
12
+ // admission rules live in `domain/missions/review.js` and `app/review.js`.
13
+ import path from 'node:path'
14
+ import { TowerToolError } from '../domain/errors.js'
15
+ import { validateState } from '../domain/state.js'
16
+ import { removeSandbox } from './sandbox.js'
17
+ import { resolveCheckoutRoot } from './provision.js'
18
+
19
+ /**
20
+ * @param {string} message
21
+ * @param {string} [code]
22
+ * @returns {never}
23
+ */
24
+ function fail(message, code = 'invalid-argument') {
25
+ throw new TowerToolError(code, message)
26
+ }
27
+
28
+ /**
29
+ * Deterministic per-reviewer checkout path. `-review-` cannot collide with the
30
+ * `-sandbox` survey suffix or a mission worktree directory.
31
+ * @param {string} root
32
+ * @param {string} missionId
33
+ * @param {string} reviewerName
34
+ * @returns {string}
35
+ */
36
+ export function reviewCheckoutPath(root, missionId, reviewerName) {
37
+ const safeName = String(reviewerName).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 64)
38
+ return path.join(root, `${missionId}-review-${safeName}`)
39
+ }
40
+
41
+ /**
42
+ * Provision the detached review checkout for a completed mission at its current
43
+ * branch tip. Returns the mission, the checkout path and the commit the reviewer
44
+ * is bound to.
45
+ * @param {any} tower
46
+ * @param {{ missionId?: unknown, name?: unknown, worktreeRoot?: string }} input
47
+ * @returns {Promise<{ mission: any, path: string, commit: string }>}
48
+ */
49
+ export async function provisionReviewCheckout(tower, input = {}) {
50
+ const missionId = input.missionId
51
+ if (typeof missionId !== 'string' || missionId.trim().length === 0) fail('tower_spawn requires missionId in this version')
52
+ if (typeof input.name !== 'string' || input.name.trim().length === 0) fail('a review checkout needs the reviewer name')
53
+ const state = await tower.load()
54
+ const mission = state.missions.find((entry) => entry.id === missionId)
55
+ if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
56
+ if (!mission.worktree) fail(`mission ${mission.id} has no branch to review`, 'reviewer-prerequisite')
57
+ if (mission.status !== 'completed') {
58
+ fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`, 'reviewer-prerequisite')
59
+ }
60
+ const commit = await tower.branchTip(mission)
61
+ const root = resolveCheckoutRoot(tower, input)
62
+ await tower.deps.store.ensureDir(root)
63
+ const checkout = reviewCheckoutPath(root, mission.id, input.name.trim())
64
+ await tower.deps.store.withGitLock(tower.paths, async () => {
65
+ if (await tower.deps.store.exists(checkout)) {
66
+ const registered = (await tower.deps.vcs.listWorktrees(tower.root)).some(
67
+ (entry) => path.resolve(entry.path) === path.resolve(checkout),
68
+ )
69
+ if (!registered) {
70
+ fail(`review checkout path already exists and is not a git worktree: ${checkout}; remove it and retry`)
71
+ }
72
+ // A leftover checkout from a failed spawn is disposable evidence for a
73
+ // review that never happened; recreate it at the recorded tip.
74
+ await tower.git(tower.root, ['worktree', 'remove', '--force', checkout]).catch(() => {})
75
+ }
76
+ await tower.git(tower.root, ['worktree', 'add', '--detach', checkout, commit])
77
+ await tower.ensureExcluded(checkout)
78
+ }, tower.gitMutexOptions({ phase: 'review' }))
79
+ const head = await tower.revParse(checkout, 'HEAD')
80
+ if (head !== commit) {
81
+ await removeSandbox(tower, checkout)
82
+ fail(`review checkout ${checkout} was created at ${String(head).slice(0, 12)} instead of ${commit.slice(0, 12)}; no reviewer was started`)
83
+ }
84
+ return { mission, path: checkout, commit }
85
+ }
86
+
87
+ /**
88
+ * Remove one review checkout. Advisory and bounded: a failed cleanup never
89
+ * blocks the verdict, and `tower_teardown`/`tower_recover` retry it later.
90
+ * @param {any} tower
91
+ * @param {string} checkout
92
+ * @returns {Promise<{ ok: boolean, removed: boolean, attempts: number, error?: string }>}
93
+ */
94
+ export async function removeReviewCheckout(tower, checkout) {
95
+ return removeSandbox(tower, checkout)
96
+ }
97
+
98
+ /**
99
+ * Remove a reviewer's checkout and clear the recorded path on its roster row.
100
+ * The recorded commit stays: it is the evidence the review was based on.
101
+ * @param {any} tower
102
+ * @param {{ name: string, missionId?: string | null }} input
103
+ * @returns {Promise<{ entry: any, removal: { ok: boolean, removed: boolean, attempts: number, error?: string } } | null>}
104
+ */
105
+ export async function cleanupReviewCheckout(tower, input) {
106
+ const removal = await tower.deps.store.withLock(tower.paths, async () => {
107
+ const state = await tower.load()
108
+ const entry = (state.roster ?? []).find(
109
+ (row) =>
110
+ row.name === input.name &&
111
+ (input.missionId === undefined || input.missionId === null || row.missionId === input.missionId),
112
+ )
113
+ if (!entry || typeof entry.reviewCheckout !== 'string' || entry.reviewCheckout.length === 0) return null
114
+ return { checkout: entry.reviewCheckout, entryName: entry.name, missionId: entry.missionId }
115
+ })
116
+ if (!removal) return null
117
+ const result = await removeSandbox(tower, removal.checkout)
118
+ // A failed removal keeps the path on the row so teardown can retry it; the
119
+ // verdict itself never depends on this advisory cleanup.
120
+ if (!result.ok) return { entry: null, removal: result }
121
+ const written = await tower.deps.store.withLock(tower.paths, async () => {
122
+ const state = await tower.load()
123
+ const entry = (state.roster ?? []).find((row) => row.name === removal.entryName)
124
+ if (!entry || entry.reviewCheckout !== removal.checkout) return entry ?? null
125
+ delete entry.reviewCheckout
126
+ state.revision += 1
127
+ state.updatedAt = Date.now()
128
+ validateState(state, tower.paths.state)
129
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
130
+ return entry
131
+ })
132
+ return { entry: written, removal: result }
133
+ }
package/app/sandbox.js ADDED
@@ -0,0 +1,121 @@
1
+ // Disposable detached survey sandboxes (#148 W2).
2
+ //
3
+ // A `survey.sandbox: 'detached'` survey reads in a detached git worktree with no
4
+ // branch. The checkout is disposable: completion, abandon and teardown remove it
5
+ // with a bounded retry. Cleanup is advisory — a removal failure leaves the
6
+ // recorded path and a visible diagnostic in state, and never fails or corrupts
7
+ // the transition that triggered it.
8
+ // @ts-check
9
+ import path from 'node:path'
10
+ import { validateState } from '../domain/state.js'
11
+
12
+ /** Bounded cleanup attempts: a transient git lock or busy file is retried. */
13
+ export const SANDBOX_CLEANUP_ATTEMPTS = 3
14
+ export const SANDBOX_RETRY_DELAY_MS = 50
15
+
16
+ /** @param {number} ms */
17
+ function delay(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms))
19
+ }
20
+
21
+ /**
22
+ * @param {any} tower
23
+ * @param {string} sandbox
24
+ * @returns {Promise<boolean>}
25
+ */
26
+ async function registered(tower, sandbox) {
27
+ try {
28
+ const entries = await tower.deps.vcs.listWorktrees(tower.root)
29
+ return entries.some((entry) => entry.path && path.resolve(entry.path) === path.resolve(sandbox))
30
+ } catch {
31
+ return false
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Remove one detached sandbox worktree, retrying a bounded number of times.
37
+ * Never throws: the result is the diagnostic. A path git no longer registers is
38
+ * treated as already removed, so a half-finished cleanup is not an error.
39
+ * @param {any} tower
40
+ * @param {string} sandbox
41
+ * @param {{ attempts?: number, delayMs?: number }} [options]
42
+ * @returns {Promise<{ ok: boolean, removed: boolean, attempts: number, error?: string }>}
43
+ */
44
+ export async function removeSandbox(tower, sandbox, options = {}) {
45
+ const attempts = Math.max(1, options.attempts ?? SANDBOX_CLEANUP_ATTEMPTS)
46
+ const delayMs = options.delayMs ?? SANDBOX_RETRY_DELAY_MS
47
+ /** @type {unknown} */
48
+ let lastError = null
49
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
50
+ try {
51
+ if (!(await registered(tower, sandbox))) return { ok: true, removed: false, attempts: attempt }
52
+ await tower.deps.store.withGitLock(tower.paths, async () => {
53
+ await tower.git(tower.root, ['worktree', 'remove', '--force', sandbox])
54
+ }, tower.gitMutexOptions({ phase: 'sandbox-cleanup' }))
55
+ // Removing the registered worktree also removes its directory. Verify
56
+ // both, so a git that reports success but leaves the entry is retried.
57
+ if (!(await tower.deps.store.exists(sandbox)) && !(await registered(tower, sandbox))) {
58
+ return { ok: true, removed: true, attempts: attempt }
59
+ }
60
+ lastError = new Error(`git still reports ${sandbox} after worktree remove`)
61
+ } catch (error) {
62
+ lastError = error
63
+ if (!(await registered(tower, sandbox))) return { ok: true, removed: false, attempts: attempt }
64
+ }
65
+ if (attempt < attempts && delayMs > 0) await delay(delayMs)
66
+ }
67
+ return {
68
+ ok: false,
69
+ removed: false,
70
+ attempts,
71
+ error: String(/** @type {{ message?: unknown }} */ (lastError)?.message ?? lastError ?? 'sandbox cleanup failed').slice(0, 400),
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Remove a mission's recorded sandbox and persist the outcome under a short
77
+ * state lock. Success clears `mission.sandbox`; failure keeps it and records an
78
+ * actionable `sandboxCleanupError` plus an activity entry, so the tower can see
79
+ * the retained path and retry without the state contradicting the filesystem.
80
+ *
81
+ * Returns null when the mission has no sandbox (or no longer exists).
82
+ * @param {any} tower
83
+ * @param {string} missionId
84
+ * @param {{ attempts?: number, delayMs?: number }} [options]
85
+ * @returns {Promise<{ ok: boolean, removed: boolean, attempts: number, path: string, error?: string } | null>}
86
+ */
87
+ export async function cleanupSandbox(tower, missionId, options = {}) {
88
+ const state = await tower.load()
89
+ const mission = state.missions.find((entry) => entry.id === missionId)
90
+ const sandbox = mission && typeof /** @type {any} */ (mission).sandbox === 'string' ? /** @type {any} */ (mission).sandbox : null
91
+ if (!sandbox) return null
92
+ const removal = await removeSandbox(tower, sandbox, options)
93
+ const now = Date.now()
94
+ await tower.deps.store.withLock(tower.paths, async () => {
95
+ const fresh = await tower.load()
96
+ const current = fresh.missions.find((entry) => entry.id === missionId)
97
+ if (!current || /** @type {any} */ (current).sandbox !== sandbox) return
98
+ const record = /** @type {any} */ (current)
99
+ if (removal.ok) {
100
+ delete record.sandbox
101
+ delete record.sandboxCleanupError
102
+ record.sandboxRemovedAt = now
103
+ } else {
104
+ record.sandboxCleanupError = removal.error
105
+ }
106
+ current.updatedAt = now
107
+ fresh.revision += 1
108
+ fresh.updatedAt = now
109
+ validateState(fresh, tower.paths.state)
110
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, fresh)
111
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
112
+ at: now,
113
+ type: 'sandbox',
114
+ missionId,
115
+ text: removal.ok
116
+ ? `removed detached sandbox at ${sandbox}`
117
+ : `sandbox cleanup failed after ${removal.attempts} attempt(s): ${removal.error}; path retained at ${sandbox} — remove it with "git worktree remove --force ${sandbox}" and call tower_teardown to retry the record`,
118
+ }).catch(() => {})
119
+ })
120
+ return { ...removal, path: sandbox }
121
+ }
@@ -0,0 +1,90 @@
1
+ // Shared-checkout helpers for the survey shape (#148).
2
+ //
3
+ // A survey reads one commit in the shared checkout. The fingerprint recorded at
4
+ // plan time is the set of *tracked* changed paths; untracked files (node_modules,
5
+ // tmp output) are intentionally ignored so a read-only survey is not blocked by
6
+ // build artefacts it never touched.
7
+ // @ts-check
8
+
9
+ import { TowerToolError } from '../domain/errors.js'
10
+
11
+ /**
12
+ * @param {string} porcelain `git status --porcelain` output
13
+ * @returns {string[]} sorted tracked changed paths
14
+ */
15
+ export function trackedFingerprint(porcelain) {
16
+ /** @type {string[]} */
17
+ const paths = []
18
+ for (const line of String(porcelain ?? '').split(/\r?\n/)) {
19
+ if (line.length < 4) continue
20
+ const status = line.slice(0, 2)
21
+ if (status === '??' || status === '!!') continue
22
+ let path = line.slice(3)
23
+ const rename = path.indexOf(' -> ')
24
+ if (rename >= 0) path = path.slice(rename + 4)
25
+ paths.push(path.replace(/^"|"$/g, ''))
26
+ }
27
+ return paths.sort()
28
+ }
29
+
30
+ /**
31
+ * Resolve the commit a survey reads and fingerprint the tracked status at the
32
+ * same moment.
33
+ * @param {{ revParse(cwd: string, ref: string): Promise<string>, git(cwd: string, args: string[]): Promise<string>, root: string }} tower
34
+ * @param {string} ref
35
+ * @returns {Promise<{ commit: string, dirty: string[] }>}
36
+ */
37
+ export async function readFacts(tower, ref) {
38
+ const commit = await tower.revParse(tower.root, ref)
39
+ // A survey reads the shared checkout as it stands, so the checkout must be at
40
+ // the base it records. Otherwise every later completion check would compare
41
+ // against a commit the worker never saw (or block it for the wrong reason).
42
+ const head = await tower.revParse(tower.root, 'HEAD')
43
+ if (head !== commit) {
44
+ throw new TowerToolError(
45
+ 'invalid-argument',
46
+ `the shared checkout is not at ${ref}: HEAD is ${head.slice(0, 12)} but ${ref} reads ${commit.slice(0, 12)}`,
47
+ { nextAction: `check out ${ref} in the shared checkout and plan the survey again` },
48
+ )
49
+ }
50
+ const porcelain = await tower.git(tower.root, ['status', '--porcelain'])
51
+ return { commit, dirty: trackedFingerprint(porcelain) }
52
+ }
53
+
54
+ /**
55
+ * Load-time resolver for a legacy survey that recorded no base at all (#149).
56
+ * A 2.6.0 survey planned but never provisioned is a valid document with no
57
+ * `baseCommit` and no worktree; the v3 -> v4 migration must anchor it at the
58
+ * state's base ref as that ref resolves *now*, together with the tracked-status
59
+ * fingerprint at the same moment (never a fabricated empty set).
60
+ *
61
+ * HEAD is deliberately not compared with the base here: a migration must load
62
+ * even when the shared checkout is parked on another branch. The plan-time
63
+ * guard in `readFacts` is what enforces HEAD === the recorded commit for a new
64
+ * survey.
65
+ *
66
+ * Returns null when the document records no base ref at all; the migration then
67
+ * fails with its own clear message instead of inventing a commit.
68
+ * @param {{ revParse(cwd: string, ref: string): Promise<string>, git(cwd: string, args: string[]): Promise<string>, root: string }} tower
69
+ * @param {Record<string, any>} rawState
70
+ * @returns {Promise<{ readCommit: string, readDirty: string[] } | null>}
71
+ */
72
+ export async function resolveLegacySurveyRead(tower, rawState) {
73
+ const base = rawState?.base
74
+ if (typeof base !== 'string' || base.length === 0) return null
75
+ let readCommit
76
+ try {
77
+ readCommit = await tower.revParse(tower.root, base)
78
+ } catch (error) {
79
+ throw new TowerToolError(
80
+ 'state-migration-failed',
81
+ `cannot anchor a planned-but-never-provisioned legacy survey: the state base "${base}" does not resolve (${String(error?.message ?? error)})`,
82
+ {
83
+ cause: error,
84
+ nextAction: `restore or check out the base ref "${base}" in the shared checkout, then load tower again; state.json was not modified`,
85
+ },
86
+ )
87
+ }
88
+ const porcelain = await tower.git(tower.root, ['status', '--porcelain'])
89
+ return { readCommit, readDirty: trackedFingerprint(porcelain) }
90
+ }
@@ -3,6 +3,8 @@
3
3
  import { TowerToolError } from '../domain/errors.js'
4
4
  import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
5
5
  import { reviewUnavailable } from '../domain/integration/review.js'
6
+ import { shapeFor } from '../domain/shapes/index.js'
7
+ import { roleCountsTowardBudget, roleMissionBlockers, roleTracksMission } from '../domain/roles.js'
6
8
 
7
9
  /**
8
10
  * Pick a unique roster name before any activation.
@@ -34,26 +36,15 @@ export function resolveRosterName({ role, missionId, requested, roster }) {
34
36
  * @param {{ role: string, context: any, config?: any }} input
35
37
  */
36
38
  export function assertSpawnableMission({ role, context, config }) {
37
- if (role === 'reviewer') {
38
- if (reviewUnavailable(config)) {
39
- throw new TowerToolError(
40
- 'review-disabled',
41
- 'review.mode is off, so review is unavailable and a reviewer cannot be spawned',
42
- )
43
- }
44
- if (!context.mission.worktree) {
45
- throw new TowerToolError('reviewer-prerequisite', 'reviewer needs an existing mission worktree; spawn a worker first')
46
- }
47
- if (context.mission.status !== 'completed') {
48
- throw new TowerToolError('reviewer-prerequisite', `reviewer requires a completed mission; ${context.mission.id} is ${context.mission.status}`)
49
- }
50
- }
51
- if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
52
- throw new TowerToolError(
53
- 'invalid-transition',
54
- `mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
55
- )
56
- }
39
+ const shape = shapeFor(context.mission.kind)
40
+ // #149 W4: the role contract owns the prerequisites; the shape only supplies
41
+ // facts (can it merge? does it have a worktree? which statuses accept a spawn).
42
+ const [blocker] = roleMissionBlockers(role, {
43
+ shape,
44
+ mission: context.mission,
45
+ reviewAvailable: !reviewUnavailable(config),
46
+ })
47
+ if (blocker) throw new TowerToolError(blocker.code, blocker.message)
57
48
  }
58
49
 
59
50
  /**
@@ -83,10 +74,16 @@ export async function precheckWorkerOwnership({
83
74
  observeActivity,
84
75
  }) {
85
76
  let effectiveTakeover = takeover === true
86
- if (role !== 'worker') return effectiveTakeover
77
+ if (!roleTracksMission(role)) return effectiveTakeover
78
+ // #148: a read-only survey worker must not consume a build worker slot, but
79
+ // the single-owner rule still applies to every worker entry.
80
+ const state = await tower.load()
81
+ const kinds = new Map(state.missions.map((entry) => [entry.id, entry.kind]))
82
+ const budgeted = (entry) => roleCountsTowardBudget(entry.role, shapeFor(kinds.get(entry.missionId)))
87
83
  const liveWorkers = view.roster.filter(
88
- (entry) => entry.role === 'worker' && !ROSTER_INACTIVE_STATES.includes(entry.state),
84
+ (entry) => roleTracksMission(entry.role) && !ROSTER_INACTIVE_STATES.includes(entry.state),
89
85
  )
86
+ const budgetWorkers = liveWorkers.filter(budgeted)
90
87
  const sameMission = liveWorkers.filter((entry) => entry.missionId === missionId)
91
88
  if (sameMission.length > 1) {
92
89
  throw new TowerToolError(
@@ -128,9 +125,13 @@ export async function precheckWorkerOwnership({
128
125
  }
129
126
  }
130
127
  const replacing = effectiveTakeover ? sameMission : []
131
- const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
128
+ const remaining = budgetWorkers.filter((entry) => !replacing.includes(entry))
132
129
  const max = Math.min(Number(maxLiveWorkers) || 4, budget)
133
- if (remaining.length >= max) {
130
+ // The new mission's shape decides whether this spawn consumes a build slot: a
131
+ // survey does not, so it is allowed while every build slot is busy (#148).
132
+ const mission = state.missions.find((entry) => entry.id === missionId)
133
+ const countsTowardBudget = roleCountsTowardBudget(role, shapeFor(mission?.kind))
134
+ if (countsTowardBudget && remaining.length >= max) {
134
135
  throw new TowerToolError(
135
136
  'worker-limit',
136
137
  'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',