dsh-tower 2.1.0-beta.1 → 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 +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -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 +28 -8
- package/app/tower.js +121 -39
- 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/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -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 +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- 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 +64 -19
- 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 +3 -4
- 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 +4 -7
- 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 +7 -6
- package/tools/preflight.js +3 -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 +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -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/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /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/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,5 @@
|
|
|
1
1
|
// Independent review submission use case.
|
|
2
|
-
import {
|
|
2
|
+
import { sweepArchive } from './archive.js'
|
|
3
3
|
import { validateState } from '../domain/state.js'
|
|
4
4
|
import { TowerToolError } from '../domain/errors.js'
|
|
5
5
|
import {
|
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
resolveReviewActor,
|
|
8
8
|
normalizeReviewVerdict,
|
|
9
9
|
assertReviewerSpawnTip,
|
|
10
|
+
reviewAdmission,
|
|
10
11
|
reviewRound,
|
|
11
12
|
reviewOutcome,
|
|
12
13
|
reviewRecord,
|
|
14
|
+
applyReviewOutcome,
|
|
13
15
|
} from '../domain/missions/review.js'
|
|
14
16
|
|
|
15
17
|
function fail(message, code = 'invalid-argument') {
|
|
@@ -36,7 +38,7 @@ async function prepareReview(tower, input) {
|
|
|
36
38
|
breakGlassReason: input.breakGlassReason,
|
|
37
39
|
})
|
|
38
40
|
|
|
39
|
-
if (mission.status
|
|
41
|
+
if (!['completed', 'active'].includes(mission.status)) {
|
|
40
42
|
fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
|
|
41
43
|
}
|
|
42
44
|
const verdict = normalizeReviewVerdict(input)
|
|
@@ -45,11 +47,15 @@ async function prepareReview(tower, input) {
|
|
|
45
47
|
const tip = await tower.revParse(mission.worktree.path, 'HEAD')
|
|
46
48
|
const branch = mission.worktree.branch
|
|
47
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 })
|
|
48
53
|
const { reviewerRounds, round } = reviewRound({
|
|
49
54
|
reviews: state.reviews,
|
|
50
55
|
branch,
|
|
51
56
|
reviewerName,
|
|
52
57
|
maxRoundsPerReviewer: tower.config.review.maxRoundsPerReviewer,
|
|
58
|
+
round: admission.round,
|
|
53
59
|
})
|
|
54
60
|
|
|
55
61
|
return {
|
|
@@ -66,6 +72,7 @@ async function prepareReview(tower, input) {
|
|
|
66
72
|
reviewerRole,
|
|
67
73
|
reviewerRounds,
|
|
68
74
|
round,
|
|
75
|
+
appended: admission.appended,
|
|
69
76
|
breakGlassReason: reviewerRole === 'tower' ? input.breakGlassReason.trim() : undefined,
|
|
70
77
|
}
|
|
71
78
|
}
|
|
@@ -76,7 +83,7 @@ export async function submitReview(tower, input) {
|
|
|
76
83
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
77
84
|
const prepared = await prepareReview(tower, input)
|
|
78
85
|
try {
|
|
79
|
-
return await withLock(tower.paths, async () => {
|
|
86
|
+
return await tower.deps.store.withLock(tower.paths, async () => {
|
|
80
87
|
const state = await tower.load()
|
|
81
88
|
if (state.revision !== prepared.stateRevision) {
|
|
82
89
|
throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
|
|
@@ -113,26 +120,28 @@ export async function submitReview(tower, input) {
|
|
|
113
120
|
merge: prepared.merge,
|
|
114
121
|
round: prepared.round,
|
|
115
122
|
reviewerName: prepared.reviewerName,
|
|
123
|
+
appended: prepared.appended,
|
|
116
124
|
})
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
mission.updatedAt = now
|
|
125
|
+
const applied = applyReviewOutcome({ mission, outcome, now })
|
|
126
|
+
await sweepArchive(tower, state, { now })
|
|
120
127
|
state.revision += 1
|
|
121
128
|
state.updatedAt = now
|
|
122
129
|
|
|
123
130
|
validateState(state, tower.paths.state)
|
|
124
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
125
|
-
await appendJsonl(tower.paths.activity, {
|
|
131
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
132
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
126
133
|
at: now,
|
|
127
134
|
type: 'review',
|
|
128
135
|
missionId: mission.id,
|
|
129
136
|
text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
|
|
130
137
|
})
|
|
138
|
+
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
131
139
|
return {
|
|
132
140
|
review,
|
|
133
141
|
branchTip: prepared.tip,
|
|
134
142
|
missionStatus: mission.status,
|
|
135
143
|
round: prepared.round,
|
|
144
|
+
appended: prepared.appended,
|
|
136
145
|
reviewerRounds: prepared.reviewerRounds + 1,
|
|
137
146
|
}
|
|
138
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'
|
|
@@ -21,6 +20,7 @@ import {
|
|
|
21
20
|
teardownRemoveDecision,
|
|
22
21
|
teardownRemovedDecision,
|
|
23
22
|
} from '../domain/integration/teardown.js'
|
|
23
|
+
import { sweepArchive } from './archive.js'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Phase 1 (#51): decide every keep/remove outcome without holding the state
|
|
@@ -77,7 +77,7 @@ async function prepareTeardown(tower, input) {
|
|
|
77
77
|
/** Short state lock: reject a stale decision before any git mutation. */
|
|
78
78
|
async function claimRemoval(tower, candidate, expectedRevision, input) {
|
|
79
79
|
const activity = input.activity ?? {}
|
|
80
|
-
return withLock(tower.paths, async () => {
|
|
80
|
+
return tower.deps.store.withLock(tower.paths, async () => {
|
|
81
81
|
const state = await tower.load()
|
|
82
82
|
if (state.revision !== expectedRevision) {
|
|
83
83
|
throw new TowerToolError('stale-base', `${STALE}. No worktree was removed.`)
|
|
@@ -104,7 +104,7 @@ export async function teardown(tower, input) {
|
|
|
104
104
|
// never under the state lock. Re-read state without the lock right before
|
|
105
105
|
// mutating: the git lock may have waited behind another operation, so a
|
|
106
106
|
// stale candidate is refused while the worktree still exists.
|
|
107
|
-
const outcome = await withGitLock(tower.paths, async () => {
|
|
107
|
+
const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
|
|
108
108
|
let fresh
|
|
109
109
|
try {
|
|
110
110
|
fresh = await tower.load()
|
|
@@ -139,7 +139,7 @@ export async function teardown(tower, input) {
|
|
|
139
139
|
branchDeleteError = String(error?.message ?? error).slice(0, 240)
|
|
140
140
|
}
|
|
141
141
|
return { phase: 'removed', branch: worktree.branch, branchDeleteError }
|
|
142
|
-
})
|
|
142
|
+
}, tower.gitMutexOptions({ phase: 'teardown' }))
|
|
143
143
|
|
|
144
144
|
if (outcome.phase === 'stale') {
|
|
145
145
|
throw new TowerToolError('stale-base', `${STALE}: ${outcome.detail}. No worktree was removed.`)
|
|
@@ -154,7 +154,7 @@ export async function teardown(tower, input) {
|
|
|
154
154
|
// the removal on the freshly loaded state instead of rejecting it (which
|
|
155
155
|
// would leave state claiming a worktree that no longer exists). Only this
|
|
156
156
|
// mission's fields change, so no concurrent update is lost.
|
|
157
|
-
const commit = await withLock(tower.paths, async () => {
|
|
157
|
+
const commit = await tower.deps.store.withLock(tower.paths, async () => {
|
|
158
158
|
const state = await tower.load()
|
|
159
159
|
const mission = state.missions.find((entry) => entry.id === candidate.missionId)
|
|
160
160
|
if (!mission) return { revision: state.revision }
|
|
@@ -166,8 +166,8 @@ export async function teardown(tower, input) {
|
|
|
166
166
|
state.revision += 1
|
|
167
167
|
state.updatedAt = now
|
|
168
168
|
validateState(state, tower.paths.state)
|
|
169
|
-
await writeJsonAtomic(tower.paths.state, state)
|
|
170
|
-
await appendJsonl(tower.paths.activity, {
|
|
169
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
170
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
171
171
|
at: now,
|
|
172
172
|
type: 'teardown',
|
|
173
173
|
missionId: mission.id,
|
|
@@ -180,5 +180,25 @@ export async function teardown(tower, input) {
|
|
|
180
180
|
setDecision(candidate.decision, teardownRemovedDecision(outcome))
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
// Archival runs under one final short lock. It is policy-driven (age or the
|
|
184
|
+
// hot-terminal cap), so a mission torn down a moment ago stays hot until it
|
|
185
|
+
// is old enough; `archive.afterDays: 0` archives it on this call.
|
|
186
|
+
const archive = await tower.deps.store.withLock(tower.paths, async () => {
|
|
187
|
+
const state = await tower.load()
|
|
188
|
+
const swept = await sweepArchive(tower, state, { now: Date.now(), activity: input.activity ?? {} })
|
|
189
|
+
if (swept.written) {
|
|
190
|
+
validateState(state, tower.paths.state)
|
|
191
|
+
await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
|
|
192
|
+
}
|
|
193
|
+
return swept
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
dryRun: false,
|
|
199
|
+
removed,
|
|
200
|
+
decisions: prepared.decisions,
|
|
201
|
+
archived: archive.archived,
|
|
202
|
+
...(archive.error ? { archiveError: archive.error } : {}),
|
|
203
|
+
}
|
|
184
204
|
}
|