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.
- package/CHANGELOG.md +82 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +2 -1
- package/README.md +6 -1
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +97 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +25 -24
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +29 -1
- package/cordis.patch.yml +50 -0
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +6 -4
- package/docs/guide/install-upgrade-migration.md +26 -0
- package/docs/operations/runbook.md +1 -1
- package/docs/reference/blockers.md +8 -4
- package/docs/reference/component-surface.md +7 -2
- package/docs/reference/config.generated.md +6 -4
- package/docs/reference/config.md +1 -2
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +33 -1
- package/docs/reference/testing.md +4 -2
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +2 -2
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +1 -2
- package/docs/zh/reference/testing.md +3 -2
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/config/schema.js +47 -17
- package/domain/contracts/outputs.js +0 -1
- package/domain/contracts/tool-inputs/missions.js +9 -1
- package/domain/evidence.js +283 -0
- package/domain/gate.js +11 -31
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/doctor.js +2 -1
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +8 -3
- package/host/tool-registry.js +14 -12
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +13 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +2 -0
- package/scripts/component-surface.json +68 -20
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +21 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +68 -4
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/spawn.js +47 -49
- package/tools/test.js +2 -1
- 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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
}
|
package/app/spawn-prechecks.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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 =
|
|
128
|
+
const remaining = budgetWorkers.filter((entry) => !replacing.includes(entry))
|
|
132
129
|
const max = Math.min(Number(maxLiveWorkers) || 4, budget)
|
|
133
|
-
|
|
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',
|