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/read.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Read-only dashboard/detail use cases.
|
|
2
2
|
import { TowerToolError } from '../domain/errors.js'
|
|
3
3
|
import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
|
|
4
|
-
import { readActivityTail } from '../lib/store.js'
|
|
5
4
|
import { archiveView, archivedSummary } from './archive.js'
|
|
6
5
|
|
|
7
6
|
function fail(message, code = 'invalid-argument') {
|
|
@@ -17,7 +16,7 @@ function fail(message, code = 'invalid-argument') {
|
|
|
17
16
|
export async function missionDetail(tower, missionId) {
|
|
18
17
|
const state = await tower.load()
|
|
19
18
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
20
|
-
const activity = await readActivityTail(tower.paths.activity, 200)
|
|
19
|
+
const activity = await tower.deps.store.readActivityTail(tower.paths.activity, 200)
|
|
21
20
|
if (mission) return missionDetailView({ state, mission, activity })
|
|
22
21
|
|
|
23
22
|
const archived = await tower.archivedMission(missionId)
|
|
@@ -57,7 +56,10 @@ export async function missionDetail(tower, missionId) {
|
|
|
57
56
|
|
|
58
57
|
export async function status(tower, input = {}) {
|
|
59
58
|
const state = await tower.load()
|
|
60
|
-
const activity = await readActivityTail(tower.paths.activity, 12)
|
|
59
|
+
const activity = await tower.deps.store.readActivityTail(tower.paths.activity, 12)
|
|
60
|
+
// The repo-scoped git mutex can be held for a whole merge; expose the
|
|
61
|
+
// holder so "the tower is stuck" has a visible cause (#93).
|
|
62
|
+
const gitLock = tower.deps.store.gitLockView(await tower.deps.store.readGitLock(tower.paths))
|
|
61
63
|
const index = await tower.archiveIndex()
|
|
62
64
|
const activeIds = new Set(state.missions.map((mission) => mission.id))
|
|
63
65
|
const wanted = Array.isArray(input.missionIds) ? new Set(input.missionIds) : null
|
|
@@ -81,6 +83,7 @@ export async function status(tower, input = {}) {
|
|
|
81
83
|
missions,
|
|
82
84
|
archivedMissions: index.ok ? index.missions.length : null,
|
|
83
85
|
archive: archiveView(index),
|
|
86
|
+
gitLock,
|
|
84
87
|
})
|
|
85
88
|
}
|
|
86
89
|
|
package/app/rebase.js
CHANGED
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
// The durable `state.rebaseIntent` lets a crash between act and commit be
|
|
7
7
|
// reconciled by the next tower_rebase, and `mission.rebase` keeps the
|
|
8
8
|
// reviewed-tip waiver semantics the merge gate depends on.
|
|
9
|
-
import crypto from 'node:crypto'
|
|
10
|
-
import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
11
9
|
import { validateState } from '../domain/state.js'
|
|
12
10
|
import { TowerToolError } from '../domain/errors.js'
|
|
13
11
|
import { liveAgentFor } from '../domain/integration/liveness.js'
|
|
@@ -150,7 +148,7 @@ async function reconcileRebaseIntent(tower) {
|
|
|
150
148
|
if (inspection.action === 'ambiguous') {
|
|
151
149
|
throw stale(ambiguousRebaseIntentMessage(intent, inspection.tip))
|
|
152
150
|
}
|
|
153
|
-
const applied = await withLock(tower.paths, async () => {
|
|
151
|
+
const applied = await tower.deps.store.withLock(tower.paths, async () => {
|
|
154
152
|
const state = await tower.load()
|
|
155
153
|
if (state.revision !== snapshot.revision) return false
|
|
156
154
|
const current = state.rebaseIntent
|
|
@@ -168,8 +166,8 @@ async function reconcileRebaseIntent(tower) {
|
|
|
168
166
|
mission.updatedAt = now
|
|
169
167
|
}
|
|
170
168
|
validateState(state, tower.paths.state)
|
|
171
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
172
|
-
await appendJsonl(tower.paths.activity, {
|
|
169
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
170
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
173
171
|
at: now,
|
|
174
172
|
type: 'rebase',
|
|
175
173
|
missionId: intent.missionId,
|
|
@@ -197,9 +195,9 @@ export async function rebase(tower, input) {
|
|
|
197
195
|
|
|
198
196
|
// Record the intent before touching Git: a crash between the rebase and the
|
|
199
197
|
// commit leaves a durable marker instead of an unexplained moved branch.
|
|
200
|
-
const token =
|
|
198
|
+
const token = tower.deps.ids.uuid()
|
|
201
199
|
const startedAt = Date.now()
|
|
202
|
-
const intentRevision = await withLock(tower.paths, async () => {
|
|
200
|
+
const intentRevision = await tower.deps.store.withLock(tower.paths, async () => {
|
|
203
201
|
const state = await tower.load()
|
|
204
202
|
if (state.revision !== prepared.stateRevision) throw stale(STALE_PREPARE)
|
|
205
203
|
const mission = findMission(state, prepared.missionId)
|
|
@@ -216,13 +214,13 @@ export async function rebase(tower, input) {
|
|
|
216
214
|
})
|
|
217
215
|
state.revision += 1
|
|
218
216
|
state.updatedAt = startedAt
|
|
219
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
217
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
220
218
|
return state.revision
|
|
221
219
|
})
|
|
222
220
|
|
|
223
221
|
// Phase 2 (#51): the long git work runs under the git lock and never under
|
|
224
222
|
// the state lock. Re-verify the snapshot right before rewriting the branch.
|
|
225
|
-
const outcome = await withGitLock(tower.paths, async () => {
|
|
223
|
+
const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
226
224
|
const currentTip = await tower.revParse(worktree.path, 'HEAD')
|
|
227
225
|
const currentBase = await tower.revParse(tower.root, prepared.base)
|
|
228
226
|
if (rebaseTipsMoved({ oldTip: prepared.oldTip, baseTip: prepared.baseTip, currentTip, currentBase })) {
|
|
@@ -244,11 +242,11 @@ export async function rebase(tower, input) {
|
|
|
244
242
|
}
|
|
245
243
|
const newTip = await tower.revParse(worktree.path, 'HEAD')
|
|
246
244
|
return { phase: 'done', newTip }
|
|
247
|
-
})
|
|
245
|
+
}, tower.gitMutexOptions({ phase: 'rebase' }))
|
|
248
246
|
|
|
249
247
|
// Phase 3 (#51): re-read state and revalidate the revision computed by the
|
|
250
248
|
// intent write; the lock only holds the state mutation, never a subprocess.
|
|
251
|
-
return withLock(tower.paths, async () => {
|
|
249
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
252
250
|
const state = await tower.load()
|
|
253
251
|
if (state.revision !== intentRevision) throw stale(STALE_ACT)
|
|
254
252
|
const mission = findMission(state, prepared.missionId)
|
|
@@ -262,7 +260,7 @@ export async function rebase(tower, input) {
|
|
|
262
260
|
state.revision += 1
|
|
263
261
|
state.updatedAt = now
|
|
264
262
|
validateState(state, tower.paths.state)
|
|
265
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
263
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
266
264
|
throw stale(outcome.detail)
|
|
267
265
|
}
|
|
268
266
|
|
|
@@ -274,8 +272,8 @@ export async function rebase(tower, input) {
|
|
|
274
272
|
state.revision += 1
|
|
275
273
|
state.updatedAt = now
|
|
276
274
|
validateState(state, tower.paths.state)
|
|
277
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
278
|
-
await appendJsonl(tower.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
|
|
275
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
276
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
|
|
279
277
|
return rebaseConflictResult(outcome.detail)
|
|
280
278
|
}
|
|
281
279
|
|
|
@@ -290,8 +288,8 @@ export async function rebase(tower, input) {
|
|
|
290
288
|
state.revision += 1
|
|
291
289
|
state.updatedAt = now
|
|
292
290
|
validateState(state, tower.paths.state)
|
|
293
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
294
|
-
await appendJsonl(tower.paths.activity, {
|
|
291
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
292
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
295
293
|
at: now,
|
|
296
294
|
type: 'rebase',
|
|
297
295
|
missionId: mission.id,
|
package/app/recording.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// State recording use cases for test receipts and imported legacy snapshots.
|
|
2
2
|
import { TowerToolError } from '../domain/errors.js'
|
|
3
3
|
import { validateState } from '../domain/state.js'
|
|
4
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
5
4
|
|
|
6
5
|
function fail(message, code = 'invalid-argument') {
|
|
7
6
|
throw new TowerToolError(code, message)
|
|
8
7
|
}
|
|
9
8
|
|
|
10
9
|
export async function recordTestRun(tower, input = {}) {
|
|
11
|
-
return withLock(tower.paths, async () => {
|
|
10
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
12
11
|
const state = await tower.load()
|
|
13
12
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
14
13
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
@@ -28,8 +27,8 @@ export async function recordTestRun(tower, input = {}) {
|
|
|
28
27
|
state.revision += 1
|
|
29
28
|
state.updatedAt = Date.now()
|
|
30
29
|
validateState(state, tower.paths.state)
|
|
31
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
32
|
-
await appendJsonl(tower.paths.activity, {
|
|
30
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
31
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
33
32
|
at: Date.now(),
|
|
34
33
|
type: 'test',
|
|
35
34
|
missionId: mission.id,
|
|
@@ -40,7 +39,7 @@ export async function recordTestRun(tower, input = {}) {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
export async function recordLegacySnapshot(tower, snapshot) {
|
|
43
|
-
return withLock(tower.paths, async () => {
|
|
42
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
44
43
|
const state = await tower.load()
|
|
45
44
|
if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
|
|
46
45
|
const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
|
|
@@ -49,13 +48,13 @@ export async function recordLegacySnapshot(tower, snapshot) {
|
|
|
49
48
|
state.revision += 1
|
|
50
49
|
state.updatedAt = Date.now()
|
|
51
50
|
validateState(state, tower.paths.state)
|
|
52
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
51
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
53
52
|
return { snapshot, duplicate: false }
|
|
54
53
|
})
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
export async function recordLegacyMap(tower, map) {
|
|
58
|
-
return withLock(tower.paths, async () => {
|
|
57
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
59
58
|
const state = await tower.load()
|
|
60
59
|
if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
|
|
61
60
|
const existing = state.legacyMaps.find(
|
|
@@ -66,7 +65,7 @@ export async function recordLegacyMap(tower, map) {
|
|
|
66
65
|
state.revision += 1
|
|
67
66
|
state.updatedAt = Date.now()
|
|
68
67
|
validateState(state, tower.paths.state)
|
|
69
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
68
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
70
69
|
return { map, duplicate: false }
|
|
71
70
|
})
|
|
72
71
|
}
|
package/app/review.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Independent review submission use case.
|
|
2
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
2
|
import { sweepArchive } from './archive.js'
|
|
4
3
|
import { validateState } from '../domain/state.js'
|
|
5
4
|
import { TowerToolError } from '../domain/errors.js'
|
|
@@ -8,9 +7,11 @@ import {
|
|
|
8
7
|
resolveReviewActor,
|
|
9
8
|
normalizeReviewVerdict,
|
|
10
9
|
assertReviewerSpawnTip,
|
|
10
|
+
reviewAdmission,
|
|
11
11
|
reviewRound,
|
|
12
12
|
reviewOutcome,
|
|
13
13
|
reviewRecord,
|
|
14
|
+
applyReviewOutcome,
|
|
14
15
|
} from '../domain/missions/review.js'
|
|
15
16
|
|
|
16
17
|
function fail(message, code = 'invalid-argument') {
|
|
@@ -37,7 +38,7 @@ async function prepareReview(tower, input) {
|
|
|
37
38
|
breakGlassReason: input.breakGlassReason,
|
|
38
39
|
})
|
|
39
40
|
|
|
40
|
-
if (mission.status
|
|
41
|
+
if (!['completed', 'active'].includes(mission.status)) {
|
|
41
42
|
fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
|
|
42
43
|
}
|
|
43
44
|
const verdict = normalizeReviewVerdict(input)
|
|
@@ -46,11 +47,15 @@ async function prepareReview(tower, input) {
|
|
|
46
47
|
const tip = await tower.revParse(mission.worktree.path, 'HEAD')
|
|
47
48
|
const branch = mission.worktree.branch
|
|
48
49
|
assertReviewerSpawnTip(caller, tip)
|
|
50
|
+
// #108: a later route may append to the round that returned the mission to
|
|
51
|
+
// active, but only for the tip that round reviewed.
|
|
52
|
+
const admission = reviewAdmission({ mission, reviews: state.reviews, branch, tip })
|
|
49
53
|
const { reviewerRounds, round } = reviewRound({
|
|
50
54
|
reviews: state.reviews,
|
|
51
55
|
branch,
|
|
52
56
|
reviewerName,
|
|
53
57
|
maxRoundsPerReviewer: tower.config.review.maxRoundsPerReviewer,
|
|
58
|
+
round: admission.round,
|
|
54
59
|
})
|
|
55
60
|
|
|
56
61
|
return {
|
|
@@ -67,6 +72,7 @@ async function prepareReview(tower, input) {
|
|
|
67
72
|
reviewerRole,
|
|
68
73
|
reviewerRounds,
|
|
69
74
|
round,
|
|
75
|
+
appended: admission.appended,
|
|
70
76
|
breakGlassReason: reviewerRole === 'tower' ? input.breakGlassReason.trim() : undefined,
|
|
71
77
|
}
|
|
72
78
|
}
|
|
@@ -77,7 +83,7 @@ export async function submitReview(tower, input) {
|
|
|
77
83
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
78
84
|
const prepared = await prepareReview(tower, input)
|
|
79
85
|
try {
|
|
80
|
-
return await withLock(tower.paths, async () => {
|
|
86
|
+
return await tower.deps.store.withLock(tower.paths, async () => {
|
|
81
87
|
const state = await tower.load()
|
|
82
88
|
if (state.revision !== prepared.stateRevision) {
|
|
83
89
|
throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
|
|
@@ -114,27 +120,28 @@ export async function submitReview(tower, input) {
|
|
|
114
120
|
merge: prepared.merge,
|
|
115
121
|
round: prepared.round,
|
|
116
122
|
reviewerName: prepared.reviewerName,
|
|
123
|
+
appended: prepared.appended,
|
|
117
124
|
})
|
|
118
|
-
|
|
119
|
-
mission.notes.push(outcome.note)
|
|
120
|
-
mission.updatedAt = now
|
|
125
|
+
const applied = applyReviewOutcome({ mission, outcome, now })
|
|
121
126
|
await sweepArchive(tower, state, { now })
|
|
122
127
|
state.revision += 1
|
|
123
128
|
state.updatedAt = now
|
|
124
129
|
|
|
125
130
|
validateState(state, tower.paths.state)
|
|
126
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
127
|
-
await appendJsonl(tower.paths.activity, {
|
|
131
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
132
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
128
133
|
at: now,
|
|
129
134
|
type: 'review',
|
|
130
135
|
missionId: mission.id,
|
|
131
136
|
text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
|
|
132
137
|
})
|
|
138
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
133
139
|
return {
|
|
134
140
|
review,
|
|
135
141
|
branchTip: prepared.tip,
|
|
136
142
|
missionStatus: mission.status,
|
|
137
143
|
round: prepared.round,
|
|
144
|
+
appended: prepared.appended,
|
|
138
145
|
reviewerRounds: prepared.reviewerRounds + 1,
|
|
139
146
|
}
|
|
140
147
|
})
|
package/app/roster.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Roster observation and retirement use cases.
|
|
2
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
2
|
import { validateState } from '../domain/state.js'
|
|
4
3
|
import {
|
|
5
4
|
recordRosterObservations as recordObservations,
|
|
@@ -11,7 +10,7 @@ import {
|
|
|
11
10
|
import { unreadMessages } from '../domain/collab/messages.js'
|
|
12
11
|
|
|
13
12
|
export async function recordRosterObservations(tower, { present = [], absent = [], confirmations = 2 }) {
|
|
14
|
-
return withLock(tower.paths, async () => {
|
|
13
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
15
14
|
const state = await tower.load()
|
|
16
15
|
const now = Date.now()
|
|
17
16
|
const { candidates, changed } = recordObservations(
|
|
@@ -23,7 +22,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
23
22
|
state.revision += 1
|
|
24
23
|
state.updatedAt = now
|
|
25
24
|
validateState(state, tower.paths.state)
|
|
26
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
25
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
27
26
|
}
|
|
28
27
|
return { candidates }
|
|
29
28
|
})
|
|
@@ -31,7 +30,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
31
30
|
|
|
32
31
|
/** Release live roster slots whose catalog activity is explicitly inactive. */
|
|
33
32
|
export async function reconcileObservedInactive(tower, activity = {}) {
|
|
34
|
-
return withLock(tower.paths, async () => {
|
|
33
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
35
34
|
const state = await tower.load()
|
|
36
35
|
const now = Date.now()
|
|
37
36
|
const { released } = reconcileInactive(state.roster ?? [], activity, now)
|
|
@@ -39,8 +38,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
39
38
|
state.revision += 1
|
|
40
39
|
state.updatedAt = now
|
|
41
40
|
validateState(state, tower.paths.state)
|
|
42
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
43
|
-
await appendJsonl(tower.paths.activity, {
|
|
41
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
42
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
44
43
|
at: now,
|
|
45
44
|
type: 'roster',
|
|
46
45
|
text: `released inactive slots: ${released.join(', ')}`,
|
|
@@ -52,7 +51,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
52
51
|
|
|
53
52
|
export async function markRetired(tower, agentIds) {
|
|
54
53
|
if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
55
|
-
return withLock(tower.paths, async () => {
|
|
54
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
56
55
|
const state = await tower.load()
|
|
57
56
|
const now = Date.now()
|
|
58
57
|
const { retired } = retireEntries(state.roster ?? [], agentIds, now)
|
|
@@ -60,8 +59,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
60
59
|
state.revision += 1
|
|
61
60
|
state.updatedAt = now
|
|
62
61
|
validateState(state, tower.paths.state)
|
|
63
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
64
|
-
await appendJsonl(tower.paths.activity, {
|
|
62
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
63
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
65
64
|
at: now,
|
|
66
65
|
type: 'adopt',
|
|
67
66
|
text: `retired ${retired.join(', ')}`,
|
|
@@ -73,7 +72,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
73
72
|
|
|
74
73
|
export async function markMissing(tower, agentIds) {
|
|
75
74
|
if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
|
|
76
|
-
return withLock(tower.paths, async () => {
|
|
75
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
77
76
|
const state = await tower.load()
|
|
78
77
|
const now = Date.now()
|
|
79
78
|
const { missing } = markMissingEntries(state.roster ?? [], agentIds, now)
|
|
@@ -81,8 +80,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
81
80
|
state.revision += 1
|
|
82
81
|
state.updatedAt = now
|
|
83
82
|
validateState(state, tower.paths.state)
|
|
84
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
85
|
-
await appendJsonl(tower.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
|
|
83
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
84
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
|
|
86
85
|
}
|
|
87
86
|
return { missing }
|
|
88
87
|
})
|
|
@@ -95,7 +94,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
95
94
|
const staleMs = Number(input.staleMs) || 0
|
|
96
95
|
if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
|
|
97
96
|
const activity = input.activity ?? {}
|
|
98
|
-
return withLock(tower.paths, async () => {
|
|
97
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
99
98
|
const state = await tower.load()
|
|
100
99
|
const now = Date.now()
|
|
101
100
|
const { marked, idled } = sweepRosterState(
|
|
@@ -107,8 +106,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
|
|
|
107
106
|
state.revision += 1
|
|
108
107
|
state.updatedAt = now
|
|
109
108
|
validateState(state, tower.paths.state)
|
|
110
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
111
|
-
await appendJsonl(tower.paths.activity, {
|
|
109
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
110
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
112
111
|
at: now,
|
|
113
112
|
type: 'watchdog',
|
|
114
113
|
text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// service/provider/lease validation. Split out of tools/spawn.js; no I/O.
|
|
3
3
|
import { TowerToolError } from '../domain/errors.js'
|
|
4
4
|
import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
|
|
5
|
-
import { inspectLeases } from './leases.js'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Pick a unique roster name before any activation.
|
|
@@ -132,9 +131,11 @@ export async function precheckWorkerOwnership({
|
|
|
132
131
|
|
|
133
132
|
/**
|
|
134
133
|
* Service/provider/preset/lease checks, run before provisioning.
|
|
135
|
-
*
|
|
134
|
+
* `inspectLeases` is injected by the composition root so the app layer never
|
|
135
|
+
* imports the lease adapter (#69).
|
|
136
|
+
* @param {{ tower: any, ctx: any, config: any, preset: string, actor: any, exec: any, inspectLeases: any }} input
|
|
136
137
|
*/
|
|
137
|
-
export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec }) {
|
|
138
|
+
export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec, inspectLeases }) {
|
|
138
139
|
const subagents = ctx.get('subagents')
|
|
139
140
|
if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
|
|
140
141
|
const providers = subagents.list()
|
package/app/spawn.js
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
rosterProjection,
|
|
9
9
|
} from '../domain/collab/spawn.js'
|
|
10
10
|
import { validateState } from '../domain/state.js'
|
|
11
|
-
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
12
11
|
|
|
13
12
|
function fail(message, code = 'invalid-argument') {
|
|
14
13
|
throw new TowerToolError(code, message)
|
|
@@ -16,7 +15,7 @@ function fail(message, code = 'invalid-argument') {
|
|
|
16
15
|
|
|
17
16
|
/** Record that a spawned agent failed or went idle, so roster state is truthful. */
|
|
18
17
|
export async function recordSpawnOutcome(tower, input) {
|
|
19
|
-
return withLock(tower.paths, async () => {
|
|
18
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
20
19
|
const state = await tower.load()
|
|
21
20
|
const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
|
|
22
21
|
if (!entry) return null
|
|
@@ -25,8 +24,8 @@ export async function recordSpawnOutcome(tower, input) {
|
|
|
25
24
|
state.revision += 1
|
|
26
25
|
state.updatedAt = now
|
|
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: 'spawn',
|
|
32
31
|
missionId: input.missionId,
|
|
@@ -38,7 +37,7 @@ export async function recordSpawnOutcome(tower, input) {
|
|
|
38
37
|
|
|
39
38
|
/** Record that a live agent was interrupted by the tower. */
|
|
40
39
|
export async function recordInterrupt(tower, input) {
|
|
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 entry = (state.roster ?? []).find(
|
|
44
43
|
(row) =>
|
|
@@ -51,8 +50,8 @@ export async function recordInterrupt(tower, input) {
|
|
|
51
50
|
state.revision += 1
|
|
52
51
|
state.updatedAt = now
|
|
53
52
|
validateState(state, tower.paths.state)
|
|
54
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
55
|
-
await appendJsonl(tower.paths.activity, {
|
|
53
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
54
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
56
55
|
at: now,
|
|
57
56
|
type: 'spawn',
|
|
58
57
|
missionId: entry.missionId,
|
|
@@ -64,7 +63,7 @@ export async function recordInterrupt(tower, input) {
|
|
|
64
63
|
|
|
65
64
|
/** Reserve a roster name before activation so a failed spawn is recoverable. */
|
|
66
65
|
export async function reserveRoster(tower, input) {
|
|
67
|
-
return withLock(tower.paths, async () => {
|
|
66
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
68
67
|
const state = await tower.load()
|
|
69
68
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
70
69
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
@@ -75,8 +74,8 @@ export async function reserveRoster(tower, input) {
|
|
|
75
74
|
state.revision += 1
|
|
76
75
|
state.updatedAt = now
|
|
77
76
|
validateState(state, tower.paths.state)
|
|
78
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
79
|
-
await appendJsonl(tower.paths.activity, {
|
|
77
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
78
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
80
79
|
at: now,
|
|
81
80
|
type: 'spawn',
|
|
82
81
|
missionId: input.missionId,
|
|
@@ -89,7 +88,7 @@ export async function reserveRoster(tower, input) {
|
|
|
89
88
|
export async function recordSpawn(tower, input) {
|
|
90
89
|
// Capture the spawn tip before taking the state lock (#51).
|
|
91
90
|
const spawnTip = input.cwd ? ((await tower.tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
|
|
92
|
-
return withLock(tower.paths, async () => {
|
|
91
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
93
92
|
const state = await tower.load()
|
|
94
93
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
95
94
|
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
@@ -108,8 +107,8 @@ export async function recordSpawn(tower, input) {
|
|
|
108
107
|
state.updatedAt = now
|
|
109
108
|
|
|
110
109
|
validateState(state, tower.paths.state)
|
|
111
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
112
|
-
await appendJsonl(tower.paths.activity, {
|
|
110
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
111
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
113
112
|
at: now,
|
|
114
113
|
type: 'spawn',
|
|
115
114
|
missionId: input.missionId,
|
package/app/teardown.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
// therefore bumps the state revision exactly once, and the revision is
|
|
7
7
|
// revalidated immediately before the irreversible git mutation so a stale
|
|
8
8
|
// decision never removes a worktree that changed hands.
|
|
9
|
-
import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
10
9
|
import { validateState } from '../domain/state.js'
|
|
11
10
|
import { TowerToolError } from '../domain/errors.js'
|
|
12
11
|
import { hasLiveAgent } from '../domain/integration/liveness.js'
|
|
@@ -78,7 +77,7 @@ async function prepareTeardown(tower, input) {
|
|
|
78
77
|
/** Short state lock: reject a stale decision before any git mutation. */
|
|
79
78
|
async function claimRemoval(tower, candidate, expectedRevision, input) {
|
|
80
79
|
const activity = input.activity ?? {}
|
|
81
|
-
return withLock(tower.paths, async () => {
|
|
80
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
82
81
|
const state = await tower.load()
|
|
83
82
|
if (state.revision !== expectedRevision) {
|
|
84
83
|
throw new TowerToolError('stale-base', `${STALE}. No worktree was removed.`)
|
|
@@ -105,7 +104,7 @@ export async function teardown(tower, input) {
|
|
|
105
104
|
// never under the state lock. Re-read state without the lock right before
|
|
106
105
|
// mutating: the git lock may have waited behind another operation, so a
|
|
107
106
|
// stale candidate is refused while the worktree still exists.
|
|
108
|
-
const outcome = await withGitLock(tower.paths, async () => {
|
|
107
|
+
const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
109
108
|
let fresh
|
|
110
109
|
try {
|
|
111
110
|
fresh = await tower.load()
|
|
@@ -140,7 +139,7 @@ export async function teardown(tower, input) {
|
|
|
140
139
|
branchDeleteError = String(error?.message ?? error).slice(0, 240)
|
|
141
140
|
}
|
|
142
141
|
return { phase: 'removed', branch: worktree.branch, branchDeleteError }
|
|
143
|
-
})
|
|
142
|
+
}, tower.gitMutexOptions({ phase: 'teardown' }))
|
|
144
143
|
|
|
145
144
|
if (outcome.phase === 'stale') {
|
|
146
145
|
throw new TowerToolError('stale-base', `${STALE}: ${outcome.detail}. No worktree was removed.`)
|
|
@@ -155,7 +154,7 @@ export async function teardown(tower, input) {
|
|
|
155
154
|
// the removal on the freshly loaded state instead of rejecting it (which
|
|
156
155
|
// would leave state claiming a worktree that no longer exists). Only this
|
|
157
156
|
// mission's fields change, so no concurrent update is lost.
|
|
158
|
-
const commit = await withLock(tower.paths, async () => {
|
|
157
|
+
const commit = await tower.deps.store.withLock(tower.paths, async () => {
|
|
159
158
|
const state = await tower.load()
|
|
160
159
|
const mission = state.missions.find((entry) => entry.id === candidate.missionId)
|
|
161
160
|
if (!mission) return { revision: state.revision }
|
|
@@ -167,8 +166,8 @@ export async function teardown(tower, input) {
|
|
|
167
166
|
state.revision += 1
|
|
168
167
|
state.updatedAt = now
|
|
169
168
|
validateState(state, tower.paths.state)
|
|
170
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
171
|
-
await appendJsonl(tower.paths.activity, {
|
|
169
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
170
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
172
171
|
at: now,
|
|
173
172
|
type: 'teardown',
|
|
174
173
|
missionId: mission.id,
|
|
@@ -184,12 +183,12 @@ export async function teardown(tower, input) {
|
|
|
184
183
|
// Archival runs under one final short lock. It is policy-driven (age or the
|
|
185
184
|
// hot-terminal cap), so a mission torn down a moment ago stays hot until it
|
|
186
185
|
// is old enough; `archive.afterDays: 0` archives it on this call.
|
|
187
|
-
const archive = await withLock(tower.paths, async () => {
|
|
186
|
+
const archive = await tower.deps.store.withLock(tower.paths, async () => {
|
|
188
187
|
const state = await tower.load()
|
|
189
188
|
const swept = await sweepArchive(tower, state, { now: Date.now(), activity: input.activity ?? {} })
|
|
190
189
|
if (swept.written) {
|
|
191
190
|
validateState(state, tower.paths.state)
|
|
192
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
191
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
193
192
|
}
|
|
194
193
|
return swept
|
|
195
194
|
})
|