dsh-tower 2.5.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 (142) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +18 -16
  7. package/README.md +18 -11
  8. package/adapters/leases.js +20 -6
  9. package/adapters/legacy.js +149 -37
  10. package/adapters/stateFile.js +11 -1
  11. package/app/archive.js +30 -5
  12. package/app/briefing.js +95 -12
  13. package/app/checkCompletion.js +107 -18
  14. package/app/enrichedMissions.js +1 -1
  15. package/app/freshness.js +77 -0
  16. package/app/gate.js +26 -12
  17. package/app/merge.js +7 -4
  18. package/app/mission.js +24 -1
  19. package/app/missionContext.js +2 -1
  20. package/app/plan.js +64 -1
  21. package/app/provision.js +133 -22
  22. package/app/read.js +38 -5
  23. package/app/rebase.js +10 -8
  24. package/app/review.js +104 -61
  25. package/app/reviewCheckout.js +133 -0
  26. package/app/sandbox.js +121 -0
  27. package/app/sharedCheckout.js +90 -0
  28. package/app/spawn-prechecks.js +31 -20
  29. package/app/spawn-support.js +59 -8
  30. package/app/spawn.js +3 -0
  31. package/app/teardown.js +119 -2
  32. package/app/tower.js +34 -6
  33. package/client.js +321 -0
  34. package/cordis.patch.yml +55 -3
  35. package/docs/adr/0005-component-surface.md +58 -13
  36. package/docs/adr/0006-mission-shapes.md +306 -0
  37. package/docs/architecture/overview.md +18 -10
  38. package/docs/examples/first-mission.md +3 -2
  39. package/docs/faq.md +9 -7
  40. package/docs/guide/install-upgrade-migration.md +58 -7
  41. package/docs/guide/quickstart.md +20 -10
  42. package/docs/guide/scenarios-and-philosophy.md +35 -28
  43. package/docs/operations/runbook.md +1 -1
  44. package/docs/operations/security.md +11 -10
  45. package/docs/reference/blockers.md +20 -11
  46. package/docs/reference/component-surface.md +20 -8
  47. package/docs/reference/config.generated.md +8 -5
  48. package/docs/reference/config.md +30 -5
  49. package/docs/reference/doctor.md +7 -7
  50. package/docs/reference/errors.generated.md +1 -0
  51. package/docs/reference/errors.md +2 -1
  52. package/docs/reference/operations.md +1 -1
  53. package/docs/reference/shapes.md +123 -0
  54. package/docs/reference/surface-budget.md +53 -4
  55. package/docs/reference/testing.md +5 -3
  56. package/docs/reference/tools.generated.md +7 -7
  57. package/docs/reference/tools.md +5 -5
  58. package/docs/zh/faq.md +7 -5
  59. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  60. package/docs/zh/guide/quickstart.md +17 -10
  61. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  62. package/docs/zh/operations/security.md +9 -8
  63. package/docs/zh/reference/blockers.md +13 -9
  64. package/docs/zh/reference/config.md +22 -5
  65. package/docs/zh/reference/errors.md +2 -1
  66. package/docs/zh/reference/testing.md +5 -4
  67. package/docs/zh/reference/tools.md +3 -3
  68. package/domain/batch.js +75 -105
  69. package/domain/collab/batch.js +30 -10
  70. package/domain/collab/spawn.js +53 -16
  71. package/domain/components.js +12 -4
  72. package/domain/config/schema.js +87 -22
  73. package/domain/contracts/outputs.js +2 -1
  74. package/domain/contracts/tool-inputs/missions.js +10 -2
  75. package/domain/errors.js +6 -0
  76. package/domain/evidence.js +283 -0
  77. package/domain/gate.js +17 -34
  78. package/domain/integration/gate.js +17 -43
  79. package/domain/integration/init.js +3 -2
  80. package/domain/integration/provision.js +51 -0
  81. package/domain/integration/rebase.js +16 -1
  82. package/domain/integration/review.js +203 -9
  83. package/domain/mission.js +16 -33
  84. package/domain/missions/completion.js +46 -8
  85. package/domain/missions/findings.js +2 -1
  86. package/domain/missions/lifecycle.js +131 -35
  87. package/domain/missions/plan.js +30 -6
  88. package/domain/missions/review.js +2 -18
  89. package/domain/planning.js +5 -5
  90. package/domain/read/missionContext.js +17 -3
  91. package/domain/read/views.js +5 -1
  92. package/domain/roles.js +222 -0
  93. package/domain/roster.js +9 -3
  94. package/domain/scope.js +51 -0
  95. package/domain/shapes/build.js +254 -0
  96. package/domain/shapes/index.js +171 -0
  97. package/domain/shapes/survey.js +445 -0
  98. package/domain/state/archive.js +28 -4
  99. package/domain/state/migrations.js +111 -4
  100. package/domain/state/schema.js +69 -7
  101. package/domain/state/semantics.js +93 -13
  102. package/domain/state.js +5 -1
  103. package/domain/summary.js +5 -11
  104. package/domain/vocabulary.js +15 -33
  105. package/host/capability-registry.js +25 -9
  106. package/host/config.js +40 -3
  107. package/host/doctor.js +20 -8
  108. package/host/preset-survey-sandbox.js +19 -0
  109. package/host/preset-survey.js +18 -0
  110. package/host/presets.js +17 -11
  111. package/host/tool-registry.js +23 -13
  112. package/locale/en.json +16 -1
  113. package/locale/preset-survey/en.json +6 -0
  114. package/locale/preset-survey/zh.json +6 -0
  115. package/locale/preset-survey-sandbox/en.json +6 -0
  116. package/locale/preset-survey-sandbox/zh.json +6 -0
  117. package/locale/zh.json +16 -1
  118. package/package.json +19 -2
  119. package/ports/state.js +8 -1
  120. package/scripts/architecture-waivers.json +3 -3
  121. package/scripts/check-components.mjs +120 -6
  122. package/scripts/check-docs.mjs +1 -1
  123. package/scripts/check-pack.mjs +5 -0
  124. package/scripts/component-surface.json +83 -29
  125. package/scripts/generate-capability-locale.mjs +5 -0
  126. package/scripts/generate-shapes-reference.mjs +171 -0
  127. package/scripts/mount-smoke/surface.mjs +3 -0
  128. package/scripts/pack-manifest.txt +22 -0
  129. package/scripts/shape-audit.mjs +361 -0
  130. package/scripts/surface-baseline.json +75 -4
  131. package/scripts/types-coverage.mjs +7 -0
  132. package/survey-sandbox.js +48 -0
  133. package/survey.js +40 -0
  134. package/tools/complete.js +5 -5
  135. package/tools/diff.js +5 -3
  136. package/tools/plan.js +1 -1
  137. package/tools/preflight.js +3 -2
  138. package/tools/review.js +1 -1
  139. package/tools/spawn.js +48 -50
  140. package/tools/status.js +8 -1
  141. package/tools/test.js +2 -1
  142. package/types/index.d.ts +76 -5
@@ -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
+ }
@@ -2,6 +2,9 @@
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 { reviewUnavailable } from '../domain/integration/review.js'
6
+ import { shapeFor } from '../domain/shapes/index.js'
7
+ import { roleCountsTowardBudget, roleMissionBlockers, roleTracksMission } from '../domain/roles.js'
5
8
 
6
9
  /**
7
10
  * Pick a unique roster name before any activation.
@@ -27,23 +30,21 @@ export function resolveRosterName({ role, missionId, requested, roster }) {
27
30
 
28
31
  /**
29
32
  * Role/mission preconditions checked before any provisioning.
30
- * @param {{ role: string, context: any }} input
33
+ * `config` is the repository-resolved Tower config (#M39): with
34
+ * `review.mode:off` there is no review to dispatch, so the reviewer role is
35
+ * refused before a worktree, preset or activation is touched.
36
+ * @param {{ role: string, context: any, config?: any }} input
31
37
  */
32
- export function assertSpawnableMission({ role, context }) {
33
- if (role === 'reviewer') {
34
- if (!context.mission.worktree) {
35
- throw new TowerToolError('reviewer-prerequisite', 'reviewer needs an existing mission worktree; spawn a worker first')
36
- }
37
- if (context.mission.status !== 'completed') {
38
- throw new TowerToolError('reviewer-prerequisite', `reviewer requires a completed mission; ${context.mission.id} is ${context.mission.status}`)
39
- }
40
- }
41
- if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
42
- throw new TowerToolError(
43
- 'invalid-transition',
44
- `mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
45
- )
46
- }
38
+ export function assertSpawnableMission({ role, context, config }) {
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)
47
48
  }
48
49
 
49
50
  /**
@@ -73,10 +74,16 @@ export async function precheckWorkerOwnership({
73
74
  observeActivity,
74
75
  }) {
75
76
  let effectiveTakeover = takeover === true
76
- 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)))
77
83
  const liveWorkers = view.roster.filter(
78
- (entry) => entry.role === 'worker' && !ROSTER_INACTIVE_STATES.includes(entry.state),
84
+ (entry) => roleTracksMission(entry.role) && !ROSTER_INACTIVE_STATES.includes(entry.state),
79
85
  )
86
+ const budgetWorkers = liveWorkers.filter(budgeted)
80
87
  const sameMission = liveWorkers.filter((entry) => entry.missionId === missionId)
81
88
  if (sameMission.length > 1) {
82
89
  throw new TowerToolError(
@@ -118,9 +125,13 @@ export async function precheckWorkerOwnership({
118
125
  }
119
126
  }
120
127
  const replacing = effectiveTakeover ? sameMission : []
121
- const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
128
+ const remaining = budgetWorkers.filter((entry) => !replacing.includes(entry))
122
129
  const max = Math.min(Number(maxLiveWorkers) || 4, budget)
123
- 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) {
124
135
  throw new TowerToolError(
125
136
  'worker-limit',
126
137
  'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
@@ -1,7 +1,55 @@
1
1
  // tower_spawn support (#56 P3c): briefing assembly, agent options, and the
2
2
  // wait/result settling path. Split out of tools/spawn.js; no I/O of its own.
3
+ import { shapeFor } from '../domain/shapes/index.js'
4
+ import { roleBriefingKind, rolePlacement } from '../domain/roles.js'
3
5
  import { workerBriefing, reviewerBriefing } from './briefing.js'
4
6
 
7
+ /**
8
+ * Resolve where a spawn runs (#149 W3). A worker gets the shape's provisioning
9
+ * (branch worktree, shared checkout, or detached survey sandbox); a reviewer
10
+ * gets a disposable detached checkout at the recorded branch tip, so the
11
+ * verdict is stamped with a commit instead of a live worktree HEAD.
12
+ * @param {any} tower
13
+ * @param {{ role: string, context: any, missionId: string, name: string, worktreeRoot?: string, includeDirtyBase?: boolean, instructions?: string }} input
14
+ */
15
+ export async function provisionSpawnPlacement(tower, { role, context, missionId, name, worktreeRoot, includeDirtyBase, instructions }) {
16
+ // #149 W4: the role contract decides the placement; the shape supplies the
17
+ // mission provisioning facts it does not own.
18
+ const reviewCheckout = rolePlacement(role) === 'review-checkout'
19
+ const shape = shapeFor(context.mission.kind)
20
+ const provisioned = reviewCheckout
21
+ ? null
22
+ : await tower.provision({ missionId, worktreeRoot, includeDirtyBase: includeDirtyBase !== false })
23
+ const review = reviewCheckout
24
+ ? await tower.provisionReviewCheckout({ missionId, name, worktreeRoot })
25
+ : null
26
+ const mission = review ? review.mission : provisioned.mission
27
+ const worktree = review ? mission.worktree : provisioned.worktree
28
+ const cwd = review ? review.path : (provisioned.cwd ?? worktree.path)
29
+ const branch = worktree?.branch ?? null
30
+ // The briefing must describe the checkout that actually exists: a detached
31
+ // sandbox is recorded only after provision, so use the provisioned mission.
32
+ const briefing = buildSpawnBriefing({
33
+ role,
34
+ towerRoot: tower.root,
35
+ context: /** @type {any} */ ({ ...context, mission }),
36
+ worktree,
37
+ review,
38
+ instructions,
39
+ })
40
+ return {
41
+ mission,
42
+ worktree,
43
+ cwd,
44
+ branch,
45
+ review,
46
+ briefing,
47
+ provisioning: review ? 'review-checkout' : (provisioned.provisioning ?? shape.provisioning),
48
+ created: review ? true : provisioned.created,
49
+ sandbox: provisioned?.sandbox ?? null,
50
+ }
51
+ }
52
+
5
53
  /**
6
54
  * @param {any} model
7
55
  */
@@ -14,25 +62,28 @@ export function buildAgentOptions(model) {
14
62
  }
15
63
 
16
64
  /**
17
- * @param {{ role: string, towerRoot: string, context: any, worktree: any, instructions: string | undefined }} input
65
+ * @param {{ role: string, towerRoot: string, context: any, worktree: any, review?: any, instructions: string | undefined }} input
18
66
  */
19
- export function buildSpawnBriefing({ role, towerRoot, context, worktree, instructions }) {
20
- const mission = {
21
- ...context.mission,
22
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
23
- }
67
+ export function buildSpawnBriefing({ role, towerRoot, context, worktree, review = null, instructions }) {
68
+ // Only a shape that owns a worktree gets the git checkout projection; a survey
69
+ // reads the shared checkout and must not see a worktree record it does not own.
70
+ const shape = shapeFor(context.mission.kind)
71
+ const mission = shape.fields.worktree
72
+ ? { ...context.mission, worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit } }
73
+ : { ...context.mission }
24
74
  const briefing = {
25
75
  repoRoot: towerRoot,
26
76
  base: context.base,
27
77
  mission,
28
78
  deps: context.deps,
29
- worktree,
79
+ worktree: worktree ?? null,
80
+ review,
30
81
  instructions,
31
82
  reviews: context.reviews,
32
83
  findings: context.findings,
33
84
  completionReport: context.completionReport,
34
85
  }
35
- return role === 'worker' ? workerBriefing(briefing) : reviewerBriefing(briefing)
86
+ return roleBriefingKind(role) === 'reviewer' ? reviewerBriefing(briefing) : workerBriefing(briefing)
36
87
  }
37
88
 
38
89
  /**
package/app/spawn.js CHANGED
@@ -96,6 +96,9 @@ export async function recordSpawn(tower, input) {
96
96
  const now = Date.now()
97
97
  const entry = recordSpawnEntry({
98
98
  roster: state.roster,
99
+ // The live-worker count resolves each roster row's kind, so the whole
100
+ // mission list is required: a survey row is not a build worker (#148).
101
+ missions: state.missions,
99
102
  mission,
100
103
  input,
101
104
  spawnTip,
package/app/teardown.js CHANGED
@@ -21,6 +21,106 @@ import {
21
21
  teardownRemovedDecision,
22
22
  } from '../domain/integration/teardown.js'
23
23
  import { sweepArchive } from './archive.js'
24
+ import { cleanupSandbox } from './sandbox.js'
25
+ import { cleanupReviewCheckout } from './reviewCheckout.js'
26
+
27
+ /**
28
+ * Disposable review checkouts (#149 W3). A reviewer works in a detached checkout
29
+ * at its recorded review commit and the checkout is removed when the verdict is
30
+ * recorded; a failed spawn or an interrupted reviewer leaves it behind, so
31
+ * teardown retries the bounded removal and clears the recorded path. A checkout
32
+ * whose reviewer is still active is kept. This runs before the worktree phase so
33
+ * its state revisions never invalidate the worktree claims.
34
+ * @param {any} tower
35
+ * @param {any} input
36
+ * @returns {Promise<{ decisions: any[], removed: string[] }>}
37
+ */
38
+ async function teardownReviewCheckouts(tower, input) {
39
+ const state = await tower.load()
40
+ const exclude = new Set(input.exclude ?? [])
41
+ const decisions = []
42
+ const removed = []
43
+ for (const entry of state.roster ?? []) {
44
+ const checkout = typeof entry.reviewCheckout === 'string' ? entry.reviewCheckout : null
45
+ if (!checkout) continue
46
+ const row = { name: entry.name, missionId: entry.missionId ?? null, checkout }
47
+ if (exclude.has(entry.missionId)) {
48
+ decisions.push({ ...row, action: 'keep', reason: 'excluded' })
49
+ continue
50
+ }
51
+ if (entry.state === 'active') {
52
+ decisions.push({ ...row, action: 'keep', reason: 'reviewer is active' })
53
+ continue
54
+ }
55
+ if (input.dryRun === true) {
56
+ decisions.push({ ...row, action: 'remove', reason: 'eligible' })
57
+ continue
58
+ }
59
+ const outcome = await cleanupReviewCheckout(tower, { name: entry.name, missionId: entry.missionId })
60
+ if (!outcome || outcome.removal.ok) {
61
+ decisions.push({ ...row, action: 'removed', reason: outcome ? 'checkout was already gone' : 'checkout and record were already gone' })
62
+ removed.push(entry.name)
63
+ continue
64
+ }
65
+ decisions.push({
66
+ ...row,
67
+ action: 'keep',
68
+ reason: `checkout cleanup failed after ${outcome.removal.attempts} attempt(s): ${outcome.removal.error}`,
69
+ })
70
+ }
71
+ return { decisions, removed }
72
+ }
73
+
74
+ /**
75
+ * Disposable detached survey sandboxes (#148 W2). A terminal survey may still
76
+ * carry a sandbox after a failed cleanup; teardown retries with the same bounded
77
+ * removal as completion. Run before the worktree phase so its state revisions
78
+ * never invalidate the worktree claims.
79
+ * @param {any} tower
80
+ * @param {any} input
81
+ * @returns {Promise<{ decisions: any[], removed: string[] }>}
82
+ */
83
+ async function teardownSandboxes(tower, input) {
84
+ const state = await tower.load()
85
+ const activity = input.activity ?? {}
86
+ const exclude = new Set(input.exclude ?? [])
87
+ const decisions = []
88
+ const removed = []
89
+ for (const mission of state.missions) {
90
+ const sandbox = typeof /** @type {any} */ (mission).sandbox === 'string' ? /** @type {any} */ (mission).sandbox : null
91
+ if (!sandbox) continue
92
+ if (exclude.has(mission.id)) {
93
+ decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: 'excluded' })
94
+ continue
95
+ }
96
+ if (hasLiveAgent(state, mission.id, activity)) {
97
+ decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: 'live agent' })
98
+ continue
99
+ }
100
+ if (!['completed', 'abandoned'].includes(mission.status)) {
101
+ decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: `mission is ${mission.status}` })
102
+ continue
103
+ }
104
+ if (input.dryRun === true) {
105
+ decisions.push({ missionId: mission.id, action: 'remove', sandbox, reason: 'eligible' })
106
+ continue
107
+ }
108
+ const outcome = await cleanupSandbox(tower, mission.id)
109
+ if (!outcome) {
110
+ decisions.push({ missionId: mission.id, action: 'removed', sandbox, reason: 'sandbox was already gone' })
111
+ removed.push(mission.id)
112
+ continue
113
+ }
114
+ if (outcome.ok) removed.push(mission.id)
115
+ decisions.push({
116
+ missionId: mission.id,
117
+ action: outcome.ok ? 'removed' : 'keep',
118
+ sandbox,
119
+ ...(outcome.ok ? {} : { reason: `sandbox cleanup failed after ${outcome.attempts} attempt(s): ${outcome.error}` }),
120
+ })
121
+ }
122
+ return { decisions, removed }
123
+ }
24
124
 
25
125
  /**
26
126
  * Phase 1 (#51): decide every keep/remove outcome without holding the state
@@ -87,8 +187,23 @@ async function claimRemoval(tower, candidate, expectedRevision, input) {
87
187
  }
88
188
 
89
189
  export async function teardown(tower, input) {
190
+ // Sandbox cleanup runs first: it owns its own short locks and revisions, so
191
+ // the worktree prepare/act/commit phases below see a stable revision.
192
+ // Review checkout cleanup first: it owns its own short locks and revisions, so
193
+ // the worktree prepare/act/commit phases below see a stable revision.
194
+ const reviews = await teardownReviewCheckouts(tower, input)
195
+ const sandboxes = await teardownSandboxes(tower, input)
90
196
  const prepared = await prepareTeardown(tower, input)
91
- if (prepared.dryRun) return { ok: true, dryRun: true, removed: [], decisions: prepared.decisions }
197
+ if (prepared.dryRun) {
198
+ return {
199
+ ok: true,
200
+ dryRun: true,
201
+ removed: [],
202
+ decisions: [...reviews.decisions, ...sandboxes.decisions, ...prepared.decisions],
203
+ reviewCheckoutsRemoved: [],
204
+ sandboxesRemoved: [],
205
+ }
206
+ }
92
207
 
93
208
  const removed = []
94
209
  let expectedRevision = prepared.stateRevision
@@ -197,7 +312,9 @@ export async function teardown(tower, input) {
197
312
  ok: true,
198
313
  dryRun: false,
199
314
  removed,
200
- decisions: prepared.decisions,
315
+ decisions: [...reviews.decisions, ...sandboxes.decisions, ...prepared.decisions],
316
+ reviewCheckoutsRemoved: reviews.removed,
317
+ sandboxesRemoved: sandboxes.removed,
201
318
  archived: archive.archived,
202
319
  ...(archive.error ? { archiveError: archive.error } : {}),
203
320
  }