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/plan.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Mission planning use case.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
2
3
|
import { nextMissionNumber, addAutoDeps, assertAcyclic } from '../domain/planning.js'
|
|
3
4
|
import {
|
|
4
5
|
normalizePlanMissions,
|
|
@@ -7,15 +8,58 @@ import {
|
|
|
7
8
|
newMission,
|
|
8
9
|
applyPlan,
|
|
9
10
|
} from '../domain/missions/plan.js'
|
|
11
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
10
12
|
import { validateState } from '../domain/state.js'
|
|
11
13
|
import { fullMission } from '../domain/summary.js'
|
|
12
14
|
import { sweepArchive } from './archive.js'
|
|
15
|
+
import { surveyStaleWarnings } from './freshness.js'
|
|
16
|
+
import { readFacts } from './sharedCheckout.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Planning a shape whose optional component is off must fail with an actionable
|
|
20
|
+
* message, not silently plan work nothing can execute. Preset rows are only
|
|
21
|
+
* visible through the async probe: the synchronous `enabled` switch cannot see
|
|
22
|
+
* a declarative agent-preset registration.
|
|
23
|
+
* @param {any} tower
|
|
24
|
+
* @param {any} input
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
async function assertShapeComponents(tower, input) {
|
|
28
|
+
const components = tower.deps?.components
|
|
29
|
+
if (!components) return
|
|
30
|
+
for (const mission of input?.missions ?? []) {
|
|
31
|
+
const required = shapeFor(mission?.kind).requiredComponent
|
|
32
|
+
if (!required) continue
|
|
33
|
+
if (typeof components.available === 'function') {
|
|
34
|
+
// null means "cannot tell" (no agent-presets service): let the plan pass,
|
|
35
|
+
// exactly as a host without the component port did before shapes existed.
|
|
36
|
+
if ((await components.available(required)) !== false) continue
|
|
37
|
+
} else if (components.enabled(required) === true) continue
|
|
38
|
+
throw new TowerToolError(
|
|
39
|
+
'invalid-argument',
|
|
40
|
+
`the ${required} component is off: surveys cannot be planned; build missions are unaffected`,
|
|
41
|
+
{ nextAction: `enable the ${required} component and plan the survey again` },
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {any} input
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function planNeedsRead(input) {
|
|
51
|
+
return (input?.missions ?? []).some((mission) => shapeFor(mission?.kind).fields.readCommit)
|
|
52
|
+
}
|
|
13
53
|
|
|
14
54
|
export async function plan(tower, input = {}) {
|
|
15
|
-
const prepared = normalizePlanMissions(input)
|
|
16
55
|
return tower.deps.store.withLock(tower.paths, async () => {
|
|
17
56
|
const state = await tower.load()
|
|
18
57
|
const now = Date.now()
|
|
58
|
+
// A survey records the commit it reads and the tracked-status fingerprint at
|
|
59
|
+
// plan time; a build plan reads nothing extra.
|
|
60
|
+
await assertShapeComponents(tower, input)
|
|
61
|
+
const read = planNeedsRead(input) ? await readFacts(tower, state.base) : {}
|
|
62
|
+
const prepared = normalizePlanMissions(input, read)
|
|
19
63
|
// Archived ids stay in the known set: a new mission must not reuse an id
|
|
20
64
|
// and a merged dependency may live in the archive index (#73).
|
|
21
65
|
const archive = await tower.archiveIndex()
|
|
@@ -29,6 +73,24 @@ export async function plan(tower, input = {}) {
|
|
|
29
73
|
|
|
30
74
|
const created = assigned.map((mission) => newMission(mission.id, mission, now))
|
|
31
75
|
const applied = applyPlan(state, created)
|
|
76
|
+
// #148 W2: report survey evidence freshness at plan time. New builds have no
|
|
77
|
+
// provisioned base yet, so the warning compares against the state base; it
|
|
78
|
+
// is advisory here and never blocks the plan. Archived survey dependencies
|
|
79
|
+
// keep their read commit on the archive index, so they are checked too.
|
|
80
|
+
const archivedMissions = new Map(
|
|
81
|
+
(archive.ok ? archive.missions : []).map((entry) => [entry.id, entry]),
|
|
82
|
+
)
|
|
83
|
+
const warnings = []
|
|
84
|
+
for (const mission of created) {
|
|
85
|
+
warnings.push(
|
|
86
|
+
...(await surveyStaleWarnings(tower, {
|
|
87
|
+
mission,
|
|
88
|
+
state,
|
|
89
|
+
missions: state.missions,
|
|
90
|
+
archived: archivedMissions,
|
|
91
|
+
})),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
32
94
|
const swept = await sweepArchive(tower, state, { now })
|
|
33
95
|
state.revision += 1
|
|
34
96
|
state.updatedAt = now
|
|
@@ -46,6 +108,7 @@ export async function plan(tower, input = {}) {
|
|
|
46
108
|
return {
|
|
47
109
|
revision: state.revision,
|
|
48
110
|
missions: created.map((mission) => fullMission(mission)),
|
|
111
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
49
112
|
...(swept.archived.length > 0 ? { archived: swept.archived } : {}),
|
|
50
113
|
...(swept.error ? { archiveError: swept.error } : {}),
|
|
51
114
|
}
|
package/app/provision.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
// `tower_provision` use case, split into three phases (#51):
|
|
2
2
|
//
|
|
3
3
|
// prepare — mission/base commit/worktree identity, all lock-free.
|
|
4
|
-
// act — revalidate the prepared revision, then
|
|
5
|
-
//
|
|
4
|
+
// act — revalidate the prepared revision, then the git mutation (and its
|
|
5
|
+
// supporting exclude/canonical reads) under the git mutex.
|
|
6
6
|
// commit — one short state lock revalidates the revision and that the
|
|
7
7
|
// mission still has no worktree, then records it. A rejected
|
|
8
|
-
// commit rolls the created worktree/branch
|
|
8
|
+
// commit rolls the created worktree/branch (or survey sandbox)
|
|
9
|
+
// back outside the lock.
|
|
10
|
+
//
|
|
11
|
+
// #148 W2: a survey resolves its placement through its shape. `shared` keeps the
|
|
12
|
+
// old no-provisioning path; `detached` creates a disposable detached worktree at
|
|
13
|
+
// the recorded readCommit (no branch, no baseCommit, never merged).
|
|
9
14
|
import path from 'node:path'
|
|
10
15
|
import { validateState } from '../domain/state.js'
|
|
11
16
|
import { TowerToolError } from '../domain/errors.js'
|
|
17
|
+
import { provisioningFor } from '../domain/shapes/index.js'
|
|
12
18
|
import {
|
|
13
19
|
baseCommitChoice,
|
|
14
20
|
createdWorktreeResult,
|
|
21
|
+
detachedSandboxResult,
|
|
15
22
|
existingWorktreeResult,
|
|
16
23
|
hasMissionId,
|
|
17
24
|
provisionActivityText,
|
|
25
|
+
sandboxActivityText,
|
|
26
|
+
sandboxPath,
|
|
27
|
+
sharedProvisionResult,
|
|
18
28
|
worktreeBranch,
|
|
19
29
|
worktreePath,
|
|
20
30
|
worktreeRecord,
|
|
@@ -25,11 +35,76 @@ function fail(message, code = 'invalid-argument') {
|
|
|
25
35
|
throw new TowerToolError(code, message)
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the parent directory for mission checkouts and refuse an unsafe one.
|
|
40
|
+
* @param {any} tower
|
|
41
|
+
* @param {any} input
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function resolveCheckoutRoot(tower, input) {
|
|
45
|
+
const root = worktreeRoot({
|
|
46
|
+
worktreeRoot: input.worktreeRoot,
|
|
47
|
+
configuredRoot: tower.config.worktrees.root,
|
|
48
|
+
repoRoot: tower.root,
|
|
49
|
+
commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
|
|
50
|
+
})
|
|
51
|
+
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
52
|
+
tower.assertOutside(root)
|
|
53
|
+
return root
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when `mission.sandbox` is a worktree registered with git and still at the
|
|
58
|
+
* recorded readCommit. A recorded path that git no longer knows is treated as
|
|
59
|
+
* gone so a failed cleanup does not wedge a re-provision; a registered sandbox
|
|
60
|
+
* whose HEAD moved is a refusal (its contents are the worker's work, not ours
|
|
61
|
+
* to discard silently).
|
|
62
|
+
* @param {any} tower
|
|
63
|
+
* @param {import('../types/index.js').Mission} mission
|
|
64
|
+
* @returns {Promise<boolean>}
|
|
65
|
+
*/
|
|
66
|
+
async function sandboxIsCurrent(tower, mission) {
|
|
67
|
+
const sandbox = /** @type {any} */ (mission).sandbox
|
|
68
|
+
if (typeof sandbox !== 'string' || sandbox.length === 0) return false
|
|
69
|
+
const entries = await tower.deps.vcs.listWorktrees(tower.root)
|
|
70
|
+
const registered = entries.some((entry) => path.resolve(entry.path) === path.resolve(sandbox))
|
|
71
|
+
if (!registered) return false
|
|
72
|
+
const head = await tower.revParse(sandbox, 'HEAD')
|
|
73
|
+
if (head !== mission.readCommit) {
|
|
74
|
+
fail(
|
|
75
|
+
`detached sandbox ${sandbox} is at ${String(head).slice(0, 12)}, not the recorded readCommit ${String(mission.readCommit).slice(0, 12)}; inspect it or remove it before provisioning again`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Phase 1 of `Tower.provision` (#51). An existing checkout returns here. */
|
|
29
82
|
async function prepareProvision(tower, input) {
|
|
30
83
|
const state = await tower.load()
|
|
31
84
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
32
85
|
if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
|
|
86
|
+
const provisioning = provisioningFor(mission.kind, tower.config, mission)
|
|
87
|
+
// A shared-checkout shape reads one commit in the main checkout: no branch,
|
|
88
|
+
// no directory, no recorded worktree (#148 D1/D2).
|
|
89
|
+
if (provisioning === 'shared') {
|
|
90
|
+
return { early: sharedProvisionResult(mission, tower.root) }
|
|
91
|
+
}
|
|
92
|
+
// #148 W2: a detached survey owns a disposable checkout, not a mission
|
|
93
|
+
// branch. The recorded readCommit is the only base it has.
|
|
94
|
+
if (provisioning === 'detached') {
|
|
95
|
+
const readCommit = mission.readCommit
|
|
96
|
+
if (typeof readCommit !== 'string' || readCommit.length === 0) {
|
|
97
|
+
fail('a detached survey needs the commit it reads (readCommit); re-plan the survey from a resolved base', 'invalid-argument')
|
|
98
|
+
}
|
|
99
|
+
if (await sandboxIsCurrent(tower, mission)) {
|
|
100
|
+
return { early: detachedSandboxResult(mission, /** @type {string} */ (mission.sandbox), false) }
|
|
101
|
+
}
|
|
102
|
+
const root = resolveCheckoutRoot(tower, input)
|
|
103
|
+
await tower.deps.store.ensureDir(root)
|
|
104
|
+
const wtPath = sandboxPath(root, mission.id)
|
|
105
|
+
if (await tower.deps.store.exists(wtPath)) fail(`sandbox path already exists: ${wtPath}`)
|
|
106
|
+
return { stateRevision: state.revision, missionId: mission.id, mode: 'detached', readCommit, wtPath }
|
|
107
|
+
}
|
|
33
108
|
if (mission.worktree) {
|
|
34
109
|
await tower.assertWorktree(mission.worktree)
|
|
35
110
|
return { early: existingWorktreeResult(mission, mission.worktree) }
|
|
@@ -47,14 +122,7 @@ async function prepareProvision(tower, input) {
|
|
|
47
122
|
})
|
|
48
123
|
const baseCommit = 'commit' in choice ? choice.commit : await tower.revParse(tower.root, state.base)
|
|
49
124
|
const branch = worktreeBranch(mission)
|
|
50
|
-
const root =
|
|
51
|
-
worktreeRoot: input.worktreeRoot,
|
|
52
|
-
configuredRoot: tower.config.worktrees.root,
|
|
53
|
-
repoRoot: tower.root,
|
|
54
|
-
commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
|
|
55
|
-
})
|
|
56
|
-
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
57
|
-
tower.assertOutside(root)
|
|
125
|
+
const root = resolveCheckoutRoot(tower, input)
|
|
58
126
|
await tower.deps.store.ensureDir(root)
|
|
59
127
|
const wtPath = worktreePath(root, mission.id)
|
|
60
128
|
if (await tower.deps.store.exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
|
|
@@ -64,7 +132,7 @@ async function prepareProvision(tower, input) {
|
|
|
64
132
|
return { stateRevision: state.revision, missionId: mission.id, baseCommit, branch, wtPath }
|
|
65
133
|
}
|
|
66
134
|
|
|
67
|
-
/** Phase 2 of `Tower.provision` (#51): no state lock,
|
|
135
|
+
/** Phase 2 of `Tower.provision` (#51): no state lock, git work under the mutex. */
|
|
68
136
|
async function actProvision(tower, prepared) {
|
|
69
137
|
return tower.deps.store.withGitLock(tower.paths, async () => {
|
|
70
138
|
let fresh
|
|
@@ -78,6 +146,17 @@ async function actProvision(tower, prepared) {
|
|
|
78
146
|
}
|
|
79
147
|
const mission = fresh.missions.find((entry) => entry.id === prepared.missionId)
|
|
80
148
|
if (!mission) return { phase: 'missing' }
|
|
149
|
+
if (prepared.mode === 'detached') {
|
|
150
|
+
if (await sandboxIsCurrent(tower, mission)) {
|
|
151
|
+
return { phase: 'existing-sandbox', mission, sandbox: /** @type {any} */ (mission).sandbox }
|
|
152
|
+
}
|
|
153
|
+
if (await tower.deps.store.exists(prepared.wtPath)) fail(`sandbox path already exists: ${prepared.wtPath}`)
|
|
154
|
+
// `--detach` creates no branch: the sandbox is a read-only commit checkout
|
|
155
|
+
// that the agent may dirty, and it never participates in merge/stale-base.
|
|
156
|
+
await tower.git(tower.root, ['worktree', 'add', '--detach', prepared.wtPath, prepared.readCommit])
|
|
157
|
+
await tower.ensureExcluded(prepared.wtPath)
|
|
158
|
+
return { phase: 'created-sandbox', sandbox: prepared.wtPath }
|
|
159
|
+
}
|
|
81
160
|
if (mission.worktree) {
|
|
82
161
|
await tower.assertWorktree(mission.worktree)
|
|
83
162
|
return { phase: 'existing', mission, worktree: mission.worktree }
|
|
@@ -99,7 +178,7 @@ async function actProvision(tower, prepared) {
|
|
|
99
178
|
}, tower.gitMutexOptions({ phase: 'provision' }))
|
|
100
179
|
}
|
|
101
180
|
|
|
102
|
-
/** Phase 3 of `Tower.provision` (#51): record the
|
|
181
|
+
/** Phase 3 of `Tower.provision` (#51): record the checkout under a short lock. */
|
|
103
182
|
async function commitProvision(tower, prepared, acted) {
|
|
104
183
|
return tower.deps.store.withLock(tower.paths, async () => {
|
|
105
184
|
const state = await tower.load()
|
|
@@ -107,9 +186,30 @@ async function commitProvision(tower, prepared, acted) {
|
|
|
107
186
|
if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
|
|
108
187
|
// Revision revalidation: the plan was built from prepared.stateRevision.
|
|
109
188
|
if (state.revision !== prepared.stateRevision) return { phase: 'stale', revision: state.revision }
|
|
189
|
+
const now = Date.now()
|
|
190
|
+
if (prepared.mode === 'detached') {
|
|
191
|
+
// Never overwrite a sandbox another writer recorded while git ran.
|
|
192
|
+
if (mission.sandbox) return { phase: 'existing-sandbox', mission, sandbox: mission.sandbox }
|
|
193
|
+
mission.sandbox = acted.sandbox
|
|
194
|
+
mission.updatedAt = now
|
|
195
|
+
state.revision += 1
|
|
196
|
+
state.updatedAt = now
|
|
197
|
+
try {
|
|
198
|
+
validateState(state, tower.paths.state)
|
|
199
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return { phase: 'persist-error', error }
|
|
202
|
+
}
|
|
203
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
204
|
+
at: now,
|
|
205
|
+
type: 'sandbox',
|
|
206
|
+
missionId: prepared.missionId,
|
|
207
|
+
text: sandboxActivityText(acted.sandbox, prepared.readCommit),
|
|
208
|
+
}).catch(() => {})
|
|
209
|
+
return { phase: 'committed-sandbox', mission, sandbox: acted.sandbox }
|
|
210
|
+
}
|
|
110
211
|
// Never overwrite a worktree another writer recorded while git ran.
|
|
111
212
|
if (mission.worktree) return { phase: 'existing', mission, worktree: mission.worktree }
|
|
112
|
-
const now = acted.worktree.createdAt
|
|
113
213
|
mission.worktree = acted.worktree
|
|
114
214
|
mission.baseCommit = prepared.baseCommit
|
|
115
215
|
mission.updatedAt = now
|
|
@@ -131,11 +231,13 @@ async function commitProvision(tower, prepared, acted) {
|
|
|
131
231
|
})
|
|
132
232
|
}
|
|
133
233
|
|
|
134
|
-
/** Undo a created
|
|
234
|
+
/** Undo a created checkout outside the state lock (git mutex only). */
|
|
135
235
|
async function rollbackProvision(tower, prepared) {
|
|
136
236
|
await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
137
237
|
await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
|
|
138
|
-
|
|
238
|
+
if (prepared.mode !== 'detached' && prepared.branch) {
|
|
239
|
+
await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
|
|
240
|
+
}
|
|
139
241
|
}, tower.gitMutexOptions({ phase: 'provision' }))
|
|
140
242
|
}
|
|
141
243
|
|
|
@@ -150,6 +252,9 @@ export async function provision(tower, input = {}) {
|
|
|
150
252
|
|
|
151
253
|
const acted = await actProvision(tower, prepared)
|
|
152
254
|
if (acted.phase === 'existing') return existingWorktreeResult(acted.mission, acted.worktree)
|
|
255
|
+
if (acted.phase === 'existing-sandbox') {
|
|
256
|
+
return detachedSandboxResult(acted.mission, acted.sandbox, false)
|
|
257
|
+
}
|
|
153
258
|
if (acted.phase === 'missing') fail(`unknown mission "${prepared.missionId}"`, 'mission-not-found')
|
|
154
259
|
if (acted.phase === 'stale') {
|
|
155
260
|
throw new TowerToolError(
|
|
@@ -162,8 +267,8 @@ export async function provision(tower, input = {}) {
|
|
|
162
267
|
try {
|
|
163
268
|
committed = await commitProvision(tower, prepared, acted)
|
|
164
269
|
} catch (error) {
|
|
165
|
-
// The
|
|
166
|
-
// lock must not leave the mission wedged with an unrecorded
|
|
270
|
+
// The checkout already exists. A commit that cannot even take the state
|
|
271
|
+
// lock must not leave the mission wedged with an unrecorded checkout.
|
|
167
272
|
await rollbackProvision(tower, prepared)
|
|
168
273
|
throw error
|
|
169
274
|
}
|
|
@@ -174,18 +279,24 @@ export async function provision(tower, input = {}) {
|
|
|
174
279
|
`tower state changed while the worktree was being created (${prepared.stateRevision} -> ${committed.revision}); the new worktree was rolled back — retry`,
|
|
175
280
|
)
|
|
176
281
|
}
|
|
177
|
-
if (committed.phase === 'existing') {
|
|
178
|
-
// Another writer recorded a
|
|
282
|
+
if (committed.phase === 'existing' || committed.phase === 'existing-sandbox') {
|
|
283
|
+
// Another writer recorded a checkout for this mission while git ran; the
|
|
179
284
|
// one created here is orphaned, so remove it and report the recorded one.
|
|
180
285
|
await rollbackProvision(tower, prepared)
|
|
286
|
+
if (committed.phase === 'existing-sandbox') {
|
|
287
|
+
return detachedSandboxResult(committed.mission, committed.sandbox, false)
|
|
288
|
+
}
|
|
181
289
|
await tower.assertWorktree(committed.worktree)
|
|
182
290
|
return existingWorktreeResult(committed.mission, committed.worktree)
|
|
183
291
|
}
|
|
184
292
|
if (committed.phase === 'persist-error') {
|
|
185
293
|
// Preserve the single-phase rollback: a failed state write must not leave
|
|
186
|
-
// an untracked worktree/branch behind.
|
|
294
|
+
// an untracked worktree/branch (or survey sandbox) behind.
|
|
187
295
|
await rollbackProvision(tower, prepared)
|
|
188
296
|
throw committed.error
|
|
189
297
|
}
|
|
298
|
+
if (committed.phase === 'committed-sandbox') {
|
|
299
|
+
return detachedSandboxResult(committed.mission, committed.sandbox, true)
|
|
300
|
+
}
|
|
190
301
|
return createdWorktreeResult(committed.mission, committed.worktree)
|
|
191
302
|
}
|
package/app/read.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Read-only dashboard/detail use cases.
|
|
2
2
|
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import { isReviewerRole } from '../domain/roles.js'
|
|
4
|
+
import { assertActionSupported } from '../domain/shapes/index.js'
|
|
3
5
|
import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
|
|
4
6
|
import { archiveView, archivedSummary } from './archive.js'
|
|
5
7
|
|
|
@@ -7,6 +9,33 @@ function fail(message, code = 'invalid-argument') {
|
|
|
7
9
|
throw new TowerToolError(code, message)
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* #149 W3: a reviewer diffs the commit recorded on its roster row — the same
|
|
14
|
+
* commit its verdict is stamped with — never the worker's live worktree HEAD.
|
|
15
|
+
* The checkout is only trusted while its HEAD still equals that commit.
|
|
16
|
+
* @returns {Promise<{ commit: string, cwd: string } | null>}
|
|
17
|
+
*/
|
|
18
|
+
async function reviewerBinding(tower, state, mission, actor) {
|
|
19
|
+
if (!actor || !isReviewerRole(actor.role) || !actor.agentId) return null
|
|
20
|
+
const row = (state.roster ?? []).find((entry) => entry.agentId === actor.agentId)
|
|
21
|
+
if (!row || typeof row.reviewCommit !== 'string' || row.reviewCommit.length === 0) {
|
|
22
|
+
fail(
|
|
23
|
+
`reviewer ${row?.name ?? actor.name} has no recorded review commit; spawn reviewers through tower_spawn so a verdict is bound to a commit`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (typeof row.reviewCheckout === 'string' && row.reviewCheckout.length > 0) {
|
|
27
|
+
const head = await tower.revParse(row.reviewCheckout, 'HEAD').catch(() => null)
|
|
28
|
+
if (head !== row.reviewCommit) {
|
|
29
|
+
const where = head ? head.slice(0, 12) : 'a missing checkout'
|
|
30
|
+
fail(
|
|
31
|
+
`reviewer ${row.name}'s checkout is at ${where} instead of the recorded review commit ${row.reviewCommit.slice(0, 12)}; spawn a fresh reviewer`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return { commit: row.reviewCommit, cwd: row.reviewCheckout }
|
|
35
|
+
}
|
|
36
|
+
return { commit: row.reviewCommit, cwd: mission.worktree.path }
|
|
37
|
+
}
|
|
38
|
+
|
|
10
39
|
/**
|
|
11
40
|
* Full detail for one mission. A mission that was archived is reconstructed
|
|
12
41
|
* from its year file, including the reviews, findings and activity that left
|
|
@@ -91,22 +120,26 @@ export async function diff(tower, input = {}) {
|
|
|
91
120
|
const state = await tower.load()
|
|
92
121
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
93
122
|
if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
|
|
123
|
+
assertActionSupported(mission, 'diff')
|
|
94
124
|
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
|
|
95
125
|
await tower.assertWorktree(mission.worktree)
|
|
126
|
+
const binding = await reviewerBinding(tower, state, mission, input.actor)
|
|
127
|
+
const cwd = binding?.cwd ?? mission.worktree.path
|
|
96
128
|
const baseTip = await tower.revParse(tower.root, state.base)
|
|
97
|
-
const branchTip = await tower.
|
|
98
|
-
const mergeBase = (await tower.git(
|
|
99
|
-
const files = (await tower.git(
|
|
129
|
+
const branchTip = binding?.commit ?? (await tower.branchTip(mission))
|
|
130
|
+
const mergeBase = (await tower.git(cwd, ['merge-base', baseTip, branchTip])).trim()
|
|
131
|
+
const files = (await tower.git(cwd, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
|
|
100
132
|
.split('\n')
|
|
101
133
|
.filter(Boolean)
|
|
102
|
-
const commits = (await tower.git(
|
|
134
|
+
const commits = (await tower.git(cwd, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
|
|
103
135
|
.split('\n')
|
|
104
136
|
.filter(Boolean)
|
|
105
|
-
const fullPatch = await tower.git(
|
|
137
|
+
const fullPatch = await tower.git(cwd, ['diff', `${mergeBase}..${branchTip}`])
|
|
106
138
|
return diffView({
|
|
107
139
|
mission,
|
|
108
140
|
baseTip,
|
|
109
141
|
branchTip,
|
|
142
|
+
reviewedCommit: binding?.commit ?? null,
|
|
110
143
|
mergeBase,
|
|
111
144
|
files,
|
|
112
145
|
commits,
|
package/app/rebase.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// reviewed-tip waiver semantics the merge gate depends on.
|
|
9
9
|
import { validateState } from '../domain/state.js'
|
|
10
10
|
import { TowerToolError } from '../domain/errors.js'
|
|
11
|
+
import { assertActionSupported } from '../domain/shapes/index.js'
|
|
12
|
+
import { markBlocked, resolveBlockers } from '../domain/missions/lifecycle.js'
|
|
11
13
|
import { liveAgentFor } from '../domain/integration/liveness.js'
|
|
12
14
|
import {
|
|
13
15
|
alreadyMergedRebaseResult,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
classifyRebaseIntent,
|
|
18
20
|
clearedRebaseIntentActivityText,
|
|
19
21
|
dirtyWorktreeResult,
|
|
22
|
+
isRebaseConflictBlocker,
|
|
20
23
|
malformedRebaseIntent,
|
|
21
24
|
rebaseActivityText,
|
|
22
25
|
rebaseConflictNote,
|
|
@@ -55,6 +58,7 @@ async function prepareRebase(tower, input) {
|
|
|
55
58
|
const state = await tower.load()
|
|
56
59
|
const mission = findMission(state, input.missionId)
|
|
57
60
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
61
|
+
assertActionSupported(mission, 'rebase')
|
|
58
62
|
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
|
|
59
63
|
const activity = input.activity ?? {}
|
|
60
64
|
const busy = liveAgentFor(state, mission.id, activity)
|
|
@@ -65,7 +69,7 @@ async function prepareRebase(tower, input) {
|
|
|
65
69
|
if (!(await tower.isClean(mission.worktree.path))) {
|
|
66
70
|
return { early: dirtyWorktreeResult(mission.worktree.path) }
|
|
67
71
|
}
|
|
68
|
-
const oldTip = await tower.
|
|
72
|
+
const oldTip = await tower.branchTip(mission)
|
|
69
73
|
const baseTip = await tower.revParse(tower.root, state.base)
|
|
70
74
|
if (await tower.isAncestor(tower.root, oldTip, baseTip)) {
|
|
71
75
|
// The branch is already contained in the base (manual merge, crashed
|
|
@@ -161,7 +165,7 @@ async function reconcileRebaseIntent(tower) {
|
|
|
161
165
|
if (inspection.action === 'record' && mission) {
|
|
162
166
|
const tip = /** @type {string} */ (inspection.tip)
|
|
163
167
|
mission.rebase = { at: now, oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }
|
|
164
|
-
|
|
168
|
+
resolveBlockers(mission, isRebaseConflictBlocker, now)
|
|
165
169
|
mission.notes.push(rebaseReconcileNote({ oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }))
|
|
166
170
|
mission.updatedAt = now
|
|
167
171
|
}
|
|
@@ -221,7 +225,7 @@ export async function rebase(tower, input) {
|
|
|
221
225
|
// Phase 2 (#51): the long git work runs under the git lock and never under
|
|
222
226
|
// the state lock. Re-verify the snapshot right before rewriting the branch.
|
|
223
227
|
const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
224
|
-
const currentTip = await tower.revParse(
|
|
228
|
+
const currentTip = await tower.revParse(tower.root, `refs/heads/${worktree.branch}`)
|
|
225
229
|
const currentBase = await tower.revParse(tower.root, prepared.base)
|
|
226
230
|
if (rebaseTipsMoved({ oldTip: prepared.oldTip, baseTip: prepared.baseTip, currentTip, currentBase })) {
|
|
227
231
|
return {
|
|
@@ -240,7 +244,7 @@ export async function rebase(tower, input) {
|
|
|
240
244
|
await tower.git(worktree.path, ['rebase', '--abort']).catch(() => {})
|
|
241
245
|
return { phase: 'conflict', detail: String(error?.message ?? error) }
|
|
242
246
|
}
|
|
243
|
-
const newTip = await tower.revParse(
|
|
247
|
+
const newTip = await tower.revParse(tower.root, `refs/heads/${worktree.branch}`)
|
|
244
248
|
return { phase: 'done', newTip }
|
|
245
249
|
}, tower.gitMutexOptions({ phase: 'rebase' }))
|
|
246
250
|
|
|
@@ -266,9 +270,7 @@ export async function rebase(tower, input) {
|
|
|
266
270
|
|
|
267
271
|
if (outcome.phase === 'conflict') {
|
|
268
272
|
delete state.rebaseIntent
|
|
269
|
-
mission.
|
|
270
|
-
mission.blockers.push(rebaseConflictNote(prepared.base, prepared.baseTip))
|
|
271
|
-
mission.updatedAt = now
|
|
273
|
+
markBlocked(mission, rebaseConflictNote(prepared.base, prepared.baseTip), now)
|
|
272
274
|
state.revision += 1
|
|
273
275
|
state.updatedAt = now
|
|
274
276
|
validateState(state, tower.paths.state)
|
|
@@ -280,7 +282,7 @@ export async function rebase(tower, input) {
|
|
|
280
282
|
const { newTip } = outcome
|
|
281
283
|
delete state.rebaseIntent
|
|
282
284
|
mission.rebase = { at: now, oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }
|
|
283
|
-
|
|
285
|
+
resolveBlockers(mission, isRebaseConflictBlocker, now)
|
|
284
286
|
mission.notes.push(
|
|
285
287
|
rebaseSuccessNote({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }),
|
|
286
288
|
)
|