dsh-tower 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -0
- package/COMPATIBILITY.md +5 -1
- package/CONTRIBUTING.md +64 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +8 -9
- package/app/tower.js +89 -35
- package/docs/README.md +6 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +167 -36
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +2 -2
- package/docs/reference/config.generated.md +1 -0
- package/docs/reference/doctor.md +48 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +5 -4
- package/docs/reference/tools.md +4 -4
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +4 -4
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +25 -1
- package/domain/contracts/outputs.js +6 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -42
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +248 -0
- package/{lib → host}/doctor.js +116 -9
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +58 -22
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +2 -3
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +7 -7
- package/tools/init.js +3 -6
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +6 -5
- package/tools/preflight.js +2 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +3 -3
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +1 -1
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/persistence.js +0 -356
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /package/{lib → host}/presets.js +0 -0
package/app/init.js
CHANGED
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
// the repo-scoped git mutex, never under the state lock.
|
|
7
7
|
// commit — one short state lock writes/claims/reanchors, revalidating the
|
|
8
8
|
// state revision captured by prepare.
|
|
9
|
-
import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
10
|
-
import { detectCaseSensitivity } from '../lib/caseSensitivity.js'
|
|
11
9
|
import { validateState } from '../domain/state.js'
|
|
12
10
|
import { TowerToolError } from '../domain/errors.js'
|
|
13
11
|
import {
|
|
@@ -36,16 +34,13 @@ function fail(message, code = 'invalid-argument') {
|
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
/**
|
|
39
|
-
* Probe the checkout's own filesystem through the injected
|
|
40
|
-
*
|
|
41
|
-
* working through the fallback.
|
|
37
|
+
* Probe the checkout's own filesystem through the injected case-sensitivity
|
|
38
|
+
* port. tower_init probes once and records the answer in state (#90).
|
|
42
39
|
* @param {any} tower
|
|
43
40
|
* @returns {Promise<boolean | null>}
|
|
44
41
|
*/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (typeof detect === 'function') return detect(tower.root)
|
|
48
|
-
return detectCaseSensitivity(tower.root)
|
|
42
|
+
function detectCaseMode(tower) {
|
|
43
|
+
return tower.deps.caseSensitivity.detect(tower.root)
|
|
49
44
|
}
|
|
50
45
|
|
|
51
46
|
/** Phase 1 of `Tower.init` (#51). A read-only init returns its result here. */
|
|
@@ -68,7 +63,7 @@ async function prepareInit(tower, input) {
|
|
|
68
63
|
return /** @type {{ base: string }} */ (fallback).base
|
|
69
64
|
}
|
|
70
65
|
|
|
71
|
-
if (await exists(tower.paths.state)) {
|
|
66
|
+
if (await tower.deps.store.exists(tower.paths.state)) {
|
|
72
67
|
const state = await tower.load()
|
|
73
68
|
const owner = state.towerAgentId ? String(state.towerAgentId) : null
|
|
74
69
|
const caller = input.towerAgentId ? String(input.towerAgentId) : null
|
|
@@ -108,8 +103,8 @@ async function prepareInit(tower, input) {
|
|
|
108
103
|
* revalidated first so a concurrent init cannot leave an unreferenced snapshot.
|
|
109
104
|
*/
|
|
110
105
|
async function actInitCreate(tower, prepared) {
|
|
111
|
-
return withGitLock(tower.paths, async () => {
|
|
112
|
-
if (await exists(tower.paths.state)) return { stale: true }
|
|
106
|
+
return tower.deps.store.withGitLock(tower.paths, async () => {
|
|
107
|
+
if (await tower.deps.store.exists(tower.paths.state)) return { stale: true }
|
|
113
108
|
if (!prepared.snapshot) return { snapshotCommit: null, snapshotRef: null }
|
|
114
109
|
const snapshotCommit = ((await tower.tryGit(tower.root, ['stash', 'create'])) ?? '').trim() || null
|
|
115
110
|
if (!snapshotCommit) {
|
|
@@ -120,12 +115,12 @@ async function actInitCreate(tower, prepared) {
|
|
|
120
115
|
const snapshotRef = snapshotRefFor(snapshotCommit)
|
|
121
116
|
await tower.git(tower.root, ['update-ref', snapshotRef, snapshotCommit])
|
|
122
117
|
return { snapshotCommit, snapshotRef }
|
|
123
|
-
})
|
|
118
|
+
}, tower.gitMutexOptions({ phase: 'init' }))
|
|
124
119
|
}
|
|
125
120
|
|
|
126
121
|
/** Phase 2b of `Tower.init` (#51): snapshot-ref cleanup for a base reanchor. */
|
|
127
122
|
async function actInitReanchor(tower, prepared) {
|
|
128
|
-
return withGitLock(tower.paths, async () => {
|
|
123
|
+
return tower.deps.store.withGitLock(tower.paths, async () => {
|
|
129
124
|
const state = await tower.load()
|
|
130
125
|
if (state.revision !== prepared.stateRevision) return { staleRevision: state.revision }
|
|
131
126
|
if (state.base === prepared.base) return { deleted: [] }
|
|
@@ -143,26 +138,26 @@ async function actInitReanchor(tower, prepared) {
|
|
|
143
138
|
}
|
|
144
139
|
}
|
|
145
140
|
return { deleted }
|
|
146
|
-
})
|
|
141
|
+
}, tower.gitMutexOptions({ phase: 'init' }))
|
|
147
142
|
}
|
|
148
143
|
|
|
149
144
|
/** Restore snapshot refs when the reanchor commit is rejected after phase 2. */
|
|
150
145
|
async function restoreInitRefs(tower, deleted) {
|
|
151
146
|
const entries = deleted.filter((entry) => typeof entry.sha === 'string' && entry.sha.length > 0)
|
|
152
147
|
if (entries.length === 0) return
|
|
153
|
-
await withGitLock(tower.paths, async () => {
|
|
148
|
+
await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
154
149
|
for (const entry of entries) {
|
|
155
150
|
await tower.git(tower.root, ['update-ref', entry.ref, entry.sha]).catch(() => {})
|
|
156
151
|
}
|
|
157
|
-
})
|
|
152
|
+
}, tower.gitMutexOptions({ phase: 'init' }))
|
|
158
153
|
}
|
|
159
154
|
|
|
160
155
|
/** Phase 3a of `Tower.init` (#51): write the initial state under a short lock. */
|
|
161
156
|
async function commitInitCreate(tower, prepared, acted, input) {
|
|
162
|
-
return withLock(tower.paths, async () => {
|
|
157
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
163
158
|
// Revision revalidation for the create path: prepare saw "absent", so a
|
|
164
159
|
// state file that appeared meanwhile must never be overwritten.
|
|
165
|
-
if (await exists(tower.paths.state)) {
|
|
160
|
+
if (await tower.deps.store.exists(tower.paths.state)) {
|
|
166
161
|
throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
|
|
167
162
|
}
|
|
168
163
|
const now = Date.now()
|
|
@@ -178,8 +173,8 @@ async function commitInitCreate(tower, prepared, acted, input) {
|
|
|
178
173
|
now,
|
|
179
174
|
})
|
|
180
175
|
validateState(state, tower.paths.state)
|
|
181
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
182
|
-
await appendJsonl(tower.paths.activity, {
|
|
176
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
177
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
183
178
|
at: now,
|
|
184
179
|
type: 'init',
|
|
185
180
|
text: initActivityText({
|
|
@@ -204,7 +199,7 @@ async function commitInitCreate(tower, prepared, acted, input) {
|
|
|
204
199
|
|
|
205
200
|
/** Phase 3b of `Tower.init` (#51): owner claim and reanchor writes under a short lock. */
|
|
206
201
|
async function commitInitExisting(tower, prepared, input) {
|
|
207
|
-
return withLock(tower.paths, async () => {
|
|
202
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
208
203
|
const state = await tower.load()
|
|
209
204
|
if (state.revision !== prepared.stateRevision) {
|
|
210
205
|
throw new TowerToolError(
|
|
@@ -224,8 +219,8 @@ async function commitInitExisting(tower, prepared, input) {
|
|
|
224
219
|
state.revision += 1
|
|
225
220
|
state.updatedAt = now
|
|
226
221
|
validateState(state, tower.paths.state)
|
|
227
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
228
|
-
await appendJsonl(tower.paths.activity, {
|
|
222
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
223
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
229
224
|
at: now,
|
|
230
225
|
type: 'init',
|
|
231
226
|
text: ownerClaimActivityText(claimant),
|
|
@@ -240,8 +235,8 @@ async function commitInitExisting(tower, prepared, input) {
|
|
|
240
235
|
state.revision += 1
|
|
241
236
|
state.updatedAt = now
|
|
242
237
|
validateState(state, tower.paths.state)
|
|
243
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
244
|
-
await appendJsonl(tower.paths.activity, {
|
|
238
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
239
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
245
240
|
at: now,
|
|
246
241
|
type: 'init',
|
|
247
242
|
text: caseProbeActivityText(prepared.caseInsensitive),
|
|
@@ -256,8 +251,8 @@ async function commitInitExisting(tower, prepared, input) {
|
|
|
256
251
|
state.revision += 1
|
|
257
252
|
state.updatedAt = now
|
|
258
253
|
validateState(state, tower.paths.state)
|
|
259
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
260
|
-
await appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
|
|
254
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
255
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
|
|
261
256
|
}
|
|
262
257
|
return existingInitWriteResult({ state, statePath: tower.paths.state, dirty: prepared.dirty, reanchored })
|
|
263
258
|
})
|
|
@@ -265,7 +260,7 @@ async function commitInitExisting(tower, prepared, input) {
|
|
|
265
260
|
|
|
266
261
|
/** #51 two-phase init: prepare/act/commit with stale-state retries. */
|
|
267
262
|
export async function init(tower, input = {}) {
|
|
268
|
-
await ensureDir(tower.paths.dir)
|
|
263
|
+
await tower.deps.store.ensureDir(tower.paths.dir)
|
|
269
264
|
const prepared = await prepareInit(tower, input)
|
|
270
265
|
if (prepared.early) return prepared.early
|
|
271
266
|
if (prepared.create) {
|
package/app/merge.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
// from. A writer that slipped in between observation and write makes the
|
|
18
18
|
// prepare stale, so the whole prepare is retried, never partially applied.
|
|
19
19
|
import path from 'node:path'
|
|
20
|
-
import { withLock, withGitLock, writeJsonAtomic, appendJsonl, exists } from '../lib/store.js'
|
|
21
20
|
import { validateState } from '../domain/state.js'
|
|
22
21
|
import {
|
|
23
22
|
alreadyMergedResult,
|
|
23
|
+
applyLandedMerge,
|
|
24
|
+
applyMergeSuccess,
|
|
24
25
|
buildMergeIntent,
|
|
25
26
|
classifyCommitOutcome,
|
|
26
27
|
gateTipDrift,
|
|
@@ -37,7 +38,6 @@ import {
|
|
|
37
38
|
mergeIntentEvidence,
|
|
38
39
|
mergeIntentReconcileNote,
|
|
39
40
|
mergeShapeDetail,
|
|
40
|
-
mergeSuccessNote,
|
|
41
41
|
missionMissingResult,
|
|
42
42
|
pendingMergeIntentResult,
|
|
43
43
|
staleBaseResult,
|
|
@@ -83,7 +83,7 @@ async function observeMergeIntent(tower, state, mission) {
|
|
|
83
83
|
} catch {
|
|
84
84
|
return null
|
|
85
85
|
}
|
|
86
|
-
const mergeInProgress = await exists(path.join(tower.commonDir, 'MERGE_HEAD'))
|
|
86
|
+
const mergeInProgress = await tower.deps.store.exists(path.join(tower.commonDir, 'MERGE_HEAD'))
|
|
87
87
|
if (mergeInProgress) return null
|
|
88
88
|
if (head === intent.baseTip) return mergeIntentEvidence({ head, baseTip: intent.baseTip, mergeInProgress, landed: null })
|
|
89
89
|
const tips = new Set([intent.branchTip, intent.baseTip].filter(Boolean))
|
|
@@ -98,7 +98,7 @@ async function observeMergeIntent(tower, state, mission) {
|
|
|
98
98
|
* the new state instead of overwriting it.
|
|
99
99
|
*/
|
|
100
100
|
async function applyMergeIntentReconcile(tower, stateRevision, missionId, evidence) {
|
|
101
|
-
return withLock(tower.paths, async () => {
|
|
101
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
102
102
|
const state = await tower.load()
|
|
103
103
|
if (state.revision !== stateRevision) return { retry: true }
|
|
104
104
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
@@ -108,8 +108,8 @@ async function applyMergeIntentReconcile(tower, stateRevision, missionId, eviden
|
|
|
108
108
|
delete state.mergeIntent
|
|
109
109
|
state.revision += 1
|
|
110
110
|
state.updatedAt = now
|
|
111
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
112
|
-
await appendJsonl(tower.paths.activity, {
|
|
111
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
112
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
113
113
|
at: now,
|
|
114
114
|
type: 'merge',
|
|
115
115
|
missionId: mission.id,
|
|
@@ -173,31 +173,30 @@ async function observeLandedMerge(tower, state, mission) {
|
|
|
173
173
|
|
|
174
174
|
/** Locked write half of `reconcileLandedMerge`, guarded by the revision CAS. */
|
|
175
175
|
async function applyLandedMergeReconcile(tower, stateRevision, missionId, observed) {
|
|
176
|
-
return withLock(tower.paths, async () => {
|
|
176
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
177
177
|
const state = await tower.load()
|
|
178
178
|
if (state.revision !== stateRevision) return { retry: true }
|
|
179
179
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
180
180
|
if (!mission) return { result: missionMissingResult(missionId) }
|
|
181
181
|
const now = Date.now()
|
|
182
182
|
if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
mission.updatedAt = now
|
|
183
|
+
const applied = applyLandedMerge({
|
|
184
|
+
mission,
|
|
185
|
+
mergedCommit: observed.mergedCommit,
|
|
186
|
+
note: landedMergeNote({ found: observed.found, mergedCommit: observed.mergedCommit, base: state.base }),
|
|
187
|
+
now,
|
|
188
|
+
})
|
|
191
189
|
state.revision += 1
|
|
192
190
|
state.updatedAt = now
|
|
193
191
|
validateState(state, tower.paths.state)
|
|
194
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
195
|
-
await appendJsonl(tower.paths.activity, {
|
|
192
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
193
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
196
194
|
at: now,
|
|
197
195
|
type: 'merge',
|
|
198
196
|
missionId: mission.id,
|
|
199
197
|
text: `reconciled already-landed ${observed.mergedCommit.slice(0, 7)} on ${state.base}`,
|
|
200
198
|
})
|
|
199
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
201
200
|
return { result: landedMergeResult(observed) }
|
|
202
201
|
})
|
|
203
202
|
}
|
|
@@ -302,7 +301,7 @@ async function prepareFromState(tower, input, deps) {
|
|
|
302
301
|
* next tower_merge/preflight instead of leaving an unexplained base tip.
|
|
303
302
|
*/
|
|
304
303
|
async function recordMergeIntent(tower, plan) {
|
|
305
|
-
return withLock(tower.paths, async () => {
|
|
304
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
306
305
|
const state = await tower.load()
|
|
307
306
|
// State revision policy: the plan was built from `plan.stateRevision`; a
|
|
308
307
|
// writer that slipped in before any Git mutation makes the plan stale and
|
|
@@ -336,7 +335,7 @@ async function recordMergeIntent(tower, plan) {
|
|
|
336
335
|
})
|
|
337
336
|
state.revision += 1
|
|
338
337
|
state.updatedAt = now
|
|
339
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
338
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
340
339
|
return { intent: state.mergeIntent, intentRevision: state.revision }
|
|
341
340
|
})
|
|
342
341
|
}
|
|
@@ -361,7 +360,7 @@ async function runMergeGit(tower, plan, intent, deps) {
|
|
|
361
360
|
}
|
|
362
361
|
|
|
363
362
|
const newTip = await tower.revParse(tower.root, 'HEAD')
|
|
364
|
-
const parents = await commitParents(tower.root, newTip)
|
|
363
|
+
const parents = await commitParents(tower, tower.root, newTip)
|
|
365
364
|
const expectedShape = isExpectedMergeShape(parents, intent.baseTip, intent.branchTip)
|
|
366
365
|
const warnings = []
|
|
367
366
|
|
|
@@ -384,12 +383,12 @@ async function runMergeGit(tower, plan, intent, deps) {
|
|
|
384
383
|
const conflict = unmerged.trim().length > 0
|
|
385
384
|
let cleanup = 'none'
|
|
386
385
|
const mergeHeadPath = path.join(tower.commonDir, 'MERGE_HEAD')
|
|
387
|
-
if (await exists(mergeHeadPath)) {
|
|
386
|
+
if (await tower.deps.store.exists(mergeHeadPath)) {
|
|
388
387
|
try {
|
|
389
388
|
await tower.git(tower.root, ['merge', '--abort'])
|
|
390
389
|
cleanup = 'aborted'
|
|
391
390
|
} catch {
|
|
392
|
-
await removeStaleGitLocks(tower.commonDir)
|
|
391
|
+
await removeStaleGitLocks(tower, tower.commonDir)
|
|
393
392
|
try {
|
|
394
393
|
await tower.git(tower.root, ['merge', '--abort'])
|
|
395
394
|
cleanup = 'aborted-after-lock-cleanup'
|
|
@@ -411,7 +410,7 @@ async function runMergeGit(tower, plan, intent, deps) {
|
|
|
411
410
|
if (dirtyAfter.length > 0) {
|
|
412
411
|
const changed = new Set(plan.changedFiles)
|
|
413
412
|
if (newTip === intent.baseTip && dirtyAfter.every((file) => changed.has(file))) {
|
|
414
|
-
await removeStaleGitLocks(tower.commonDir)
|
|
413
|
+
await removeStaleGitLocks(tower, tower.commonDir)
|
|
415
414
|
await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
|
|
416
415
|
cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
|
|
417
416
|
} else {
|
|
@@ -435,7 +434,7 @@ async function runMergeGit(tower, plan, intent, deps) {
|
|
|
435
434
|
|
|
436
435
|
/** Phase 3: apply the observed outcome under a short state lock. */
|
|
437
436
|
async function commitMerge(tower, plan, recorded, outcome) {
|
|
438
|
-
return withLock(tower.paths, async () => {
|
|
437
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
439
438
|
const state = await tower.load()
|
|
440
439
|
const mission = state.missions.find((entry) => entry.id === plan.missionId)
|
|
441
440
|
if (!mission) return missionMissingResult(plan.missionId)
|
|
@@ -463,21 +462,24 @@ async function commitMerge(tower, plan, recorded, outcome) {
|
|
|
463
462
|
if (kind === 'merged') {
|
|
464
463
|
const now = Date.now()
|
|
465
464
|
if (intentActive) delete state.mergeIntent
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
465
|
+
const applied = applyMergeSuccess({
|
|
466
|
+
mission,
|
|
467
|
+
branchTip: recorded.intent.branchTip,
|
|
468
|
+
base: state.base,
|
|
469
|
+
newTip: outcome.newTip,
|
|
470
|
+
now,
|
|
471
|
+
})
|
|
471
472
|
state.revision += 1
|
|
472
473
|
state.updatedAt = now
|
|
473
474
|
validateState(state, tower.paths.state)
|
|
474
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
475
|
-
await appendJsonl(tower.paths.activity, {
|
|
475
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
476
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
476
477
|
at: now,
|
|
477
478
|
type: 'merge',
|
|
478
479
|
missionId: mission.id,
|
|
479
480
|
text: `${recorded.intent.branchTip.slice(0, 7)} -> ${outcome.newTip.slice(0, 7)} on ${state.base}`,
|
|
480
481
|
})
|
|
482
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
481
483
|
return {
|
|
482
484
|
ok: true,
|
|
483
485
|
mergedCommit: outcome.newTip,
|
|
@@ -497,8 +499,8 @@ async function commitMerge(tower, plan, recorded, outcome) {
|
|
|
497
499
|
state.mergeIntent.problem = { code: 'merge-shape', newTip: outcome.newTip, parents: outcome.parents, at: Date.now() }
|
|
498
500
|
state.revision += 1
|
|
499
501
|
state.updatedAt = Date.now()
|
|
500
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
501
|
-
await appendJsonl(tower.paths.activity, {
|
|
502
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
503
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
502
504
|
at: state.updatedAt,
|
|
503
505
|
type: 'merge',
|
|
504
506
|
missionId: mission.id,
|
|
@@ -528,8 +530,8 @@ async function commitMerge(tower, plan, recorded, outcome) {
|
|
|
528
530
|
mission.updatedAt = now
|
|
529
531
|
state.revision += 1
|
|
530
532
|
state.updatedAt = now
|
|
531
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
532
|
-
await appendJsonl(tower.paths.activity, {
|
|
533
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
534
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
533
535
|
at: now,
|
|
534
536
|
type: 'merge',
|
|
535
537
|
missionId: mission.id,
|
|
@@ -550,23 +552,28 @@ export async function merge(tower, input, deps) {
|
|
|
550
552
|
const plan = await prepareMerge(tower, input, deps)
|
|
551
553
|
if (plan.result) return plan.result
|
|
552
554
|
|
|
553
|
-
const acted = await
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
555
|
+
const acted = await tower.deps.store.withGitLock(
|
|
556
|
+
tower.paths,
|
|
557
|
+
async () => {
|
|
558
|
+
// No Git mutation has happened yet: a base that moved after prepare is a
|
|
559
|
+
// clean retryable stale-base rejection, with no intent left behind.
|
|
560
|
+
const head = await tower.revParse(tower.root, 'HEAD')
|
|
561
|
+
if (head !== plan.baseTip) {
|
|
562
|
+
return {
|
|
563
|
+
result: staleBaseResult(
|
|
564
|
+
plan.missionId,
|
|
565
|
+
`base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
|
|
566
|
+
),
|
|
567
|
+
}
|
|
563
568
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
569
|
+
const recorded = await recordMergeIntent(tower, plan)
|
|
570
|
+
if (recorded.result) return recorded
|
|
571
|
+
const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
|
|
572
|
+
return { recorded, outcome }
|
|
573
|
+
},
|
|
574
|
+
// The merge itself may hold the mutex for plan.timeoutMs (#93).
|
|
575
|
+
tower.gitMutexOptions({ phase: 'merge', mergeTimeoutMs: plan.timeoutMs }),
|
|
576
|
+
)
|
|
570
577
|
if (acted.result) return acted.result
|
|
571
578
|
|
|
572
579
|
return commitMerge(tower, plan, acted.recorded, acted.outcome)
|
package/app/messages.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Inbox/message use cases.
|
|
2
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
2
|
import { validateState } from '../domain/state.js'
|
|
4
3
|
import { TowerToolError } from '../domain/errors.js'
|
|
5
4
|
import {
|
|
@@ -14,7 +13,7 @@ function fail(message) {
|
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
export async function recordMessage(tower, input) {
|
|
17
|
-
return withLock(tower.paths, async () => {
|
|
16
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
18
17
|
const state = await tower.load()
|
|
19
18
|
if (!Array.isArray(state.messages)) state.messages = []
|
|
20
19
|
assertMessageRecipient({ roster: state.roster ?? [], to: input.to, from: input.from })
|
|
@@ -27,8 +26,8 @@ export async function recordMessage(tower, input) {
|
|
|
27
26
|
state.updatedAt = now
|
|
28
27
|
|
|
29
28
|
validateState(state, tower.paths.state)
|
|
30
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
31
|
-
await appendJsonl(tower.paths.activity, {
|
|
29
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
30
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
32
31
|
at: now,
|
|
33
32
|
type: 'message',
|
|
34
33
|
text: `${input.from} -> ${input.to}: ${input.subject}`,
|
|
@@ -38,20 +37,20 @@ export async function recordMessage(tower, input) {
|
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
export async function recordDelivery(tower, messageId, results) {
|
|
41
|
-
return withLock(tower.paths, async () => {
|
|
40
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
42
41
|
const state = await tower.load()
|
|
43
42
|
const message = (state.messages ?? []).find((entry) => entry.id === messageId)
|
|
44
43
|
if (!message) fail(`unknown message "${messageId}"`)
|
|
45
44
|
addDelivery(message, results)
|
|
46
45
|
state.revision += 1
|
|
47
46
|
state.updatedAt = Date.now()
|
|
48
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
47
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
49
48
|
return message.delivery
|
|
50
49
|
})
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
export async function inbox(tower, input) {
|
|
54
|
-
return withLock(tower.paths, async () => {
|
|
53
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
55
54
|
const state = await tower.load()
|
|
56
55
|
const now = Date.now()
|
|
57
56
|
const { payload, changed } = inboxView(state.messages ?? [], input, now)
|
|
@@ -59,7 +58,7 @@ export async function recordMessage(tower, input) {
|
|
|
59
58
|
state.revision += 1
|
|
60
59
|
state.updatedAt = now
|
|
61
60
|
validateState(state, tower.paths.state)
|
|
62
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
61
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
63
62
|
}
|
|
64
63
|
return payload
|
|
65
64
|
})
|
package/app/mission.js
CHANGED
|
@@ -3,7 +3,6 @@ import { TowerToolError } from '../domain/errors.js'
|
|
|
3
3
|
import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
|
|
4
4
|
import { validateState } from '../domain/state.js'
|
|
5
5
|
import { fullMission } from '../domain/summary.js'
|
|
6
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
7
6
|
import { sweepArchive } from './archive.js'
|
|
8
7
|
|
|
9
8
|
function fail(message, code = 'invalid-argument') {
|
|
@@ -13,11 +12,11 @@ function fail(message, code = 'invalid-argument') {
|
|
|
13
12
|
export async function updateMission(tower, input = {}) {
|
|
14
13
|
const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
|
|
15
14
|
if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
|
|
16
|
-
return withLock(tower.paths, async () => {
|
|
15
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
17
16
|
const state = await tower.load()
|
|
18
17
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
19
18
|
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
20
|
-
const { changes } = applyMissionUpdate({ mission, state, input, config: tower.config })
|
|
19
|
+
const { changes, events } = applyMissionUpdate({ mission, state, input, config: tower.config })
|
|
21
20
|
const now = Date.now()
|
|
22
21
|
mission.updatedAt = now
|
|
23
22
|
await sweepArchive(tower, state, { now })
|
|
@@ -25,13 +24,14 @@ export async function updateMission(tower, input = {}) {
|
|
|
25
24
|
state.updatedAt = now
|
|
26
25
|
|
|
27
26
|
validateState(state, tower.paths.state)
|
|
28
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
29
|
-
await appendJsonl(tower.paths.activity, {
|
|
27
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
28
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
30
29
|
at: now,
|
|
31
30
|
type: 'mission',
|
|
32
31
|
missionId: mission.id,
|
|
33
32
|
text: changes.join('; '),
|
|
34
33
|
})
|
|
34
|
+
tower.emitEvents(events, { revision: state.revision, at: now })
|
|
35
35
|
return { revision: state.revision, mission: fullMission(mission) }
|
|
36
36
|
})
|
|
37
37
|
}
|
package/app/plan.js
CHANGED
|
@@ -5,15 +5,15 @@ import {
|
|
|
5
5
|
assignMissionIds,
|
|
6
6
|
assertKnownDependencies,
|
|
7
7
|
newMission,
|
|
8
|
+
applyPlan,
|
|
8
9
|
} from '../domain/missions/plan.js'
|
|
9
10
|
import { validateState } from '../domain/state.js'
|
|
10
11
|
import { fullMission } from '../domain/summary.js'
|
|
11
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
12
12
|
import { sweepArchive } from './archive.js'
|
|
13
13
|
|
|
14
14
|
export async function plan(tower, input = {}) {
|
|
15
15
|
const prepared = normalizePlanMissions(input)
|
|
16
|
-
return withLock(tower.paths, async () => {
|
|
16
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
17
17
|
const state = await tower.load()
|
|
18
18
|
const now = Date.now()
|
|
19
19
|
// Archived ids stay in the known set: a new mission must not reuse an id
|
|
@@ -28,18 +28,21 @@ export async function plan(tower, input = {}) {
|
|
|
28
28
|
assertAcyclic([...known, ...assigned])
|
|
29
29
|
|
|
30
30
|
const created = assigned.map((mission) => newMission(mission.id, mission, now))
|
|
31
|
-
state
|
|
31
|
+
const applied = applyPlan(state, created)
|
|
32
32
|
const swept = await sweepArchive(tower, state, { now })
|
|
33
33
|
state.revision += 1
|
|
34
34
|
state.updatedAt = now
|
|
35
35
|
|
|
36
36
|
validateState(state, tower.paths.state)
|
|
37
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
38
|
-
await appendJsonl(tower.paths.activity, {
|
|
37
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
38
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
39
39
|
at: now,
|
|
40
40
|
type: 'plan',
|
|
41
41
|
text: `created ${created.map((mission) => mission.id).join(', ')}`,
|
|
42
42
|
})
|
|
43
|
+
// Persist first, then describe: the event carries the revision the caller
|
|
44
|
+
// can read back from state.json (#75).
|
|
45
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
43
46
|
return {
|
|
44
47
|
revision: state.revision,
|
|
45
48
|
missions: created.map((mission) => fullMission(mission)),
|
package/app/provision.js
CHANGED
|
@@ -7,9 +7,7 @@
|
|
|
7
7
|
// mission still has no worktree, then records it. A rejected
|
|
8
8
|
// commit rolls the created worktree/branch back outside the lock.
|
|
9
9
|
import path from 'node:path'
|
|
10
|
-
import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl, shortHash } from '../lib/store.js'
|
|
11
10
|
import { validateState } from '../domain/state.js'
|
|
12
|
-
import { canonicalPath } from '../lib/worktreeOwnership.js'
|
|
13
11
|
import { TowerToolError } from '../domain/errors.js'
|
|
14
12
|
import {
|
|
15
13
|
baseCommitChoice,
|
|
@@ -53,13 +51,13 @@ async function prepareProvision(tower, input) {
|
|
|
53
51
|
worktreeRoot: input.worktreeRoot,
|
|
54
52
|
configuredRoot: tower.config.worktrees.root,
|
|
55
53
|
repoRoot: tower.root,
|
|
56
|
-
commonDirHash: shortHash(tower.commonDir),
|
|
54
|
+
commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
|
|
57
55
|
})
|
|
58
56
|
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
59
57
|
tower.assertOutside(root)
|
|
60
|
-
await ensureDir(root)
|
|
58
|
+
await tower.deps.store.ensureDir(root)
|
|
61
59
|
const wtPath = worktreePath(root, mission.id)
|
|
62
|
-
if (await exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
|
|
60
|
+
if (await tower.deps.store.exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
|
|
63
61
|
if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== null) {
|
|
64
62
|
fail(`branch already exists: ${branch}`)
|
|
65
63
|
}
|
|
@@ -68,7 +66,7 @@ async function prepareProvision(tower, input) {
|
|
|
68
66
|
|
|
69
67
|
/** Phase 2 of `Tower.provision` (#51): no state lock, worktree add under the git mutex. */
|
|
70
68
|
async function actProvision(tower, prepared) {
|
|
71
|
-
return withGitLock(tower.paths, async () => {
|
|
69
|
+
return tower.deps.store.withGitLock(tower.paths, async () => {
|
|
72
70
|
let fresh
|
|
73
71
|
try {
|
|
74
72
|
fresh = await tower.load()
|
|
@@ -84,7 +82,7 @@ async function actProvision(tower, prepared) {
|
|
|
84
82
|
await tower.assertWorktree(mission.worktree)
|
|
85
83
|
return { phase: 'existing', mission, worktree: mission.worktree }
|
|
86
84
|
}
|
|
87
|
-
if (await exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
|
|
85
|
+
if (await tower.deps.store.exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
|
|
88
86
|
if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${prepared.branch}`])) !== null) {
|
|
89
87
|
fail(`branch already exists: ${prepared.branch}`)
|
|
90
88
|
}
|
|
@@ -95,15 +93,15 @@ async function actProvision(tower, prepared) {
|
|
|
95
93
|
branch: prepared.branch,
|
|
96
94
|
baseCommit: prepared.baseCommit,
|
|
97
95
|
createdAt: Date.now(),
|
|
98
|
-
realPath: await canonicalPath(prepared.wtPath),
|
|
96
|
+
realPath: await tower.deps.ownership.canonicalPath(prepared.wtPath),
|
|
99
97
|
})
|
|
100
98
|
return { phase: 'created', worktree }
|
|
101
|
-
})
|
|
99
|
+
}, tower.gitMutexOptions({ phase: 'provision' }))
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
/** Phase 3 of `Tower.provision` (#51): record the worktree under a short lock. */
|
|
105
103
|
async function commitProvision(tower, prepared, acted) {
|
|
106
|
-
return withLock(tower.paths, async () => {
|
|
104
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
107
105
|
const state = await tower.load()
|
|
108
106
|
const mission = state.missions.find((entry) => entry.id === prepared.missionId)
|
|
109
107
|
if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
|
|
@@ -119,11 +117,11 @@ async function commitProvision(tower, prepared, acted) {
|
|
|
119
117
|
state.updatedAt = now
|
|
120
118
|
try {
|
|
121
119
|
validateState(state, tower.paths.state)
|
|
122
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
120
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
123
121
|
} catch (error) {
|
|
124
122
|
return { phase: 'persist-error', error }
|
|
125
123
|
}
|
|
126
|
-
await appendJsonl(tower.paths.activity, {
|
|
124
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
127
125
|
at: now,
|
|
128
126
|
type: 'worktree',
|
|
129
127
|
missionId: prepared.missionId,
|
|
@@ -135,10 +133,10 @@ async function commitProvision(tower, prepared, acted) {
|
|
|
135
133
|
|
|
136
134
|
/** Undo a created worktree/branch outside the state lock (git mutex only). */
|
|
137
135
|
async function rollbackProvision(tower, prepared) {
|
|
138
|
-
await withGitLock(tower.paths, async () => {
|
|
136
|
+
await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
139
137
|
await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
|
|
140
138
|
await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
|
|
141
|
-
})
|
|
139
|
+
}, tower.gitMutexOptions({ phase: 'provision' }))
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
/** #51 two-phase provision: prepare/act/commit with rollback on rejection. */
|