dsh-tower 2.0.3 → 2.1.0-beta.1
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 +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +44 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
package/app/review.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Independent review submission use case.
|
|
2
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
|
+
import { validateState } from '../domain/state.js'
|
|
4
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
5
|
+
import {
|
|
6
|
+
assertReviewableMission,
|
|
7
|
+
resolveReviewActor,
|
|
8
|
+
normalizeReviewVerdict,
|
|
9
|
+
assertReviewerSpawnTip,
|
|
10
|
+
reviewRound,
|
|
11
|
+
reviewOutcome,
|
|
12
|
+
reviewRecord,
|
|
13
|
+
} from '../domain/missions/review.js'
|
|
14
|
+
|
|
15
|
+
function fail(message, code = 'invalid-argument') {
|
|
16
|
+
throw new TowerToolError(code, message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Phase 1 (#51): load state and capture everything the review needs — actor
|
|
21
|
+
* checks, worktree ownership, branch tip, and round counters — without holding
|
|
22
|
+
* the state lock. No git call may run inside the lock.
|
|
23
|
+
*/
|
|
24
|
+
async function prepareReview(tower, input) {
|
|
25
|
+
const state = await tower.load()
|
|
26
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
27
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
28
|
+
assertReviewableMission(mission)
|
|
29
|
+
|
|
30
|
+
const caller = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
31
|
+
const { reviewerName, reviewerRole } = resolveReviewActor({
|
|
32
|
+
mission,
|
|
33
|
+
caller,
|
|
34
|
+
actorRole: input.actor?.role,
|
|
35
|
+
agentId: input.agentId,
|
|
36
|
+
breakGlassReason: input.breakGlassReason,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (mission.status !== 'completed') {
|
|
40
|
+
fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
|
|
41
|
+
}
|
|
42
|
+
const verdict = normalizeReviewVerdict(input)
|
|
43
|
+
|
|
44
|
+
await tower.assertWorktree(mission.worktree)
|
|
45
|
+
const tip = await tower.revParse(mission.worktree.path, 'HEAD')
|
|
46
|
+
const branch = mission.worktree.branch
|
|
47
|
+
assertReviewerSpawnTip(caller, tip)
|
|
48
|
+
const { reviewerRounds, round } = reviewRound({
|
|
49
|
+
reviews: state.reviews,
|
|
50
|
+
branch,
|
|
51
|
+
reviewerName,
|
|
52
|
+
maxRoundsPerReviewer: tower.config.review.maxRoundsPerReviewer,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
stateRevision: state.revision,
|
|
57
|
+
missionId: mission.id,
|
|
58
|
+
branch,
|
|
59
|
+
tip,
|
|
60
|
+
status: verdict.status,
|
|
61
|
+
merge: verdict.merge,
|
|
62
|
+
findings: verdict.findings,
|
|
63
|
+
checks: verdict.checks,
|
|
64
|
+
decision: verdict.decision,
|
|
65
|
+
reviewerName,
|
|
66
|
+
reviewerRole,
|
|
67
|
+
reviewerRounds,
|
|
68
|
+
round,
|
|
69
|
+
breakGlassReason: reviewerRole === 'tower' ? input.breakGlassReason.trim() : undefined,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function submitReview(tower, input) {
|
|
74
|
+
// Two-phase (#51): the lock is held only for the state reload, revision
|
|
75
|
+
// revalidation, and write; every git call happens in prepareReview.
|
|
76
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
77
|
+
const prepared = await prepareReview(tower, input)
|
|
78
|
+
try {
|
|
79
|
+
return await withLock(tower.paths, async () => {
|
|
80
|
+
const state = await tower.load()
|
|
81
|
+
if (state.revision !== prepared.stateRevision) {
|
|
82
|
+
throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
|
|
83
|
+
}
|
|
84
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
85
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
86
|
+
|
|
87
|
+
const now = Date.now()
|
|
88
|
+
const seq = (state.reviewSeq ?? 0) + 1
|
|
89
|
+
state.reviewSeq = seq
|
|
90
|
+
const review = reviewRecord({
|
|
91
|
+
seq,
|
|
92
|
+
missionId: mission.id,
|
|
93
|
+
branch: prepared.branch,
|
|
94
|
+
tip: prepared.tip,
|
|
95
|
+
verdict: {
|
|
96
|
+
status: prepared.status,
|
|
97
|
+
merge: prepared.merge,
|
|
98
|
+
findings: prepared.findings,
|
|
99
|
+
checks: prepared.checks,
|
|
100
|
+
decision: prepared.decision,
|
|
101
|
+
},
|
|
102
|
+
reviewerName: prepared.reviewerName,
|
|
103
|
+
reviewerRole: prepared.reviewerRole,
|
|
104
|
+
round: prepared.round,
|
|
105
|
+
now,
|
|
106
|
+
breakGlassReason: prepared.breakGlassReason,
|
|
107
|
+
})
|
|
108
|
+
if (!Array.isArray(state.reviews)) state.reviews = []
|
|
109
|
+
state.reviews.push(review)
|
|
110
|
+
|
|
111
|
+
const outcome = reviewOutcome({
|
|
112
|
+
status: prepared.status,
|
|
113
|
+
merge: prepared.merge,
|
|
114
|
+
round: prepared.round,
|
|
115
|
+
reviewerName: prepared.reviewerName,
|
|
116
|
+
})
|
|
117
|
+
if (outcome.returnToActive) mission.status = 'active'
|
|
118
|
+
mission.notes.push(outcome.note)
|
|
119
|
+
mission.updatedAt = now
|
|
120
|
+
state.revision += 1
|
|
121
|
+
state.updatedAt = now
|
|
122
|
+
|
|
123
|
+
validateState(state, tower.paths.state)
|
|
124
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
125
|
+
await appendJsonl(tower.paths.activity, {
|
|
126
|
+
at: now,
|
|
127
|
+
type: 'review',
|
|
128
|
+
missionId: mission.id,
|
|
129
|
+
text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
|
|
130
|
+
})
|
|
131
|
+
return {
|
|
132
|
+
review,
|
|
133
|
+
branchTip: prepared.tip,
|
|
134
|
+
missionStatus: mission.status,
|
|
135
|
+
round: prepared.round,
|
|
136
|
+
reviewerRounds: prepared.reviewerRounds + 1,
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error.code === 'stale-base' && attempt === 0) continue
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw new TowerToolError('stale-base', 'tower state kept changing while the review was being prepared; retry')
|
|
145
|
+
}
|
package/app/roster.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Roster observation and retirement use cases.
|
|
2
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
|
+
import { validateState } from '../domain/state.js'
|
|
4
|
+
import {
|
|
5
|
+
recordRosterObservations as recordObservations,
|
|
6
|
+
reconcileObservedInactive as reconcileInactive,
|
|
7
|
+
retireEntries,
|
|
8
|
+
markMissingEntries,
|
|
9
|
+
sweepRosterState,
|
|
10
|
+
} from '../domain/collab/roster.js'
|
|
11
|
+
import { unreadMessages } from '../domain/collab/messages.js'
|
|
12
|
+
|
|
13
|
+
export async function recordRosterObservations(tower, { present = [], absent = [], confirmations = 2 }) {
|
|
14
|
+
return withLock(tower.paths, async () => {
|
|
15
|
+
const state = await tower.load()
|
|
16
|
+
const now = Date.now()
|
|
17
|
+
const { candidates, changed } = recordObservations(
|
|
18
|
+
state.roster ?? [],
|
|
19
|
+
{ present, absent, confirmations },
|
|
20
|
+
now,
|
|
21
|
+
)
|
|
22
|
+
if (changed) {
|
|
23
|
+
state.revision += 1
|
|
24
|
+
state.updatedAt = now
|
|
25
|
+
validateState(state, tower.paths.state)
|
|
26
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
27
|
+
}
|
|
28
|
+
return { candidates }
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Release live roster slots whose catalog activity is explicitly inactive. */
|
|
33
|
+
export async function reconcileObservedInactive(tower, activity = {}) {
|
|
34
|
+
return withLock(tower.paths, async () => {
|
|
35
|
+
const state = await tower.load()
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
const { released } = reconcileInactive(state.roster ?? [], activity, now)
|
|
38
|
+
if (released.length > 0) {
|
|
39
|
+
state.revision += 1
|
|
40
|
+
state.updatedAt = now
|
|
41
|
+
validateState(state, tower.paths.state)
|
|
42
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
43
|
+
await appendJsonl(tower.paths.activity, {
|
|
44
|
+
at: now,
|
|
45
|
+
type: 'roster',
|
|
46
|
+
text: `released inactive slots: ${released.join(', ')}`,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
return { released }
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function markRetired(tower, agentIds) {
|
|
54
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
|
|
55
|
+
return withLock(tower.paths, async () => {
|
|
56
|
+
const state = await tower.load()
|
|
57
|
+
const now = Date.now()
|
|
58
|
+
const { retired } = retireEntries(state.roster ?? [], agentIds, now)
|
|
59
|
+
if (retired.length > 0) {
|
|
60
|
+
state.revision += 1
|
|
61
|
+
state.updatedAt = now
|
|
62
|
+
validateState(state, tower.paths.state)
|
|
63
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
64
|
+
await appendJsonl(tower.paths.activity, {
|
|
65
|
+
at: now,
|
|
66
|
+
type: 'adopt',
|
|
67
|
+
text: `retired ${retired.join(', ')}`,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
return { retired }
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function markMissing(tower, agentIds) {
|
|
75
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
|
|
76
|
+
return withLock(tower.paths, async () => {
|
|
77
|
+
const state = await tower.load()
|
|
78
|
+
const now = Date.now()
|
|
79
|
+
const { missing } = markMissingEntries(state.roster ?? [], agentIds, now)
|
|
80
|
+
if (missing.length > 0) {
|
|
81
|
+
state.revision += 1
|
|
82
|
+
state.updatedAt = now
|
|
83
|
+
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(', ')}` })
|
|
86
|
+
}
|
|
87
|
+
return { missing }
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Mark live roster entries whose catalog activity disappeared after staleMs.
|
|
92
|
+
* `authoritative` must be true only when the parent catalog was readable;
|
|
93
|
+
* unknown state is never treated as stopped. */
|
|
94
|
+
export async function sweepRoster(tower, input = {}) {
|
|
95
|
+
const staleMs = Number(input.staleMs) || 0
|
|
96
|
+
if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
|
|
97
|
+
const activity = input.activity ?? {}
|
|
98
|
+
return withLock(tower.paths, async () => {
|
|
99
|
+
const state = await tower.load()
|
|
100
|
+
const now = Date.now()
|
|
101
|
+
const { marked, idled } = sweepRosterState(
|
|
102
|
+
state.roster ?? [],
|
|
103
|
+
{ activity, staleMs, authoritative: input.authoritative },
|
|
104
|
+
now,
|
|
105
|
+
)
|
|
106
|
+
if (marked.length > 0 || idled.length > 0) {
|
|
107
|
+
state.revision += 1
|
|
108
|
+
state.updatedAt = now
|
|
109
|
+
validateState(state, tower.paths.state)
|
|
110
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
111
|
+
await appendJsonl(tower.paths.activity, {
|
|
112
|
+
at: now,
|
|
113
|
+
type: 'watchdog',
|
|
114
|
+
text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
return { marked, idled }
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function unreadFor(tower, state, name) {
|
|
122
|
+
return /** @type {any} */ (unreadMessages(state, name))
|
|
123
|
+
}
|
package/app/spawn.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Spawn reservation/outcome and roster recording use cases.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import {
|
|
4
|
+
applySpawnOutcome,
|
|
5
|
+
applyInterrupt,
|
|
6
|
+
reserveRosterEntry,
|
|
7
|
+
recordSpawnEntry,
|
|
8
|
+
rosterProjection,
|
|
9
|
+
} from '../domain/collab/spawn.js'
|
|
10
|
+
import { validateState } from '../domain/state.js'
|
|
11
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
12
|
+
|
|
13
|
+
function fail(message, code = 'invalid-argument') {
|
|
14
|
+
throw new TowerToolError(code, message)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Record that a spawned agent failed or went idle, so roster state is truthful. */
|
|
18
|
+
export async function recordSpawnOutcome(tower, input) {
|
|
19
|
+
return withLock(tower.paths, async () => {
|
|
20
|
+
const state = await tower.load()
|
|
21
|
+
const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
|
|
22
|
+
if (!entry) return null
|
|
23
|
+
const now = Date.now()
|
|
24
|
+
applySpawnOutcome(entry, input, now)
|
|
25
|
+
state.revision += 1
|
|
26
|
+
state.updatedAt = now
|
|
27
|
+
validateState(state, tower.paths.state)
|
|
28
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
29
|
+
await appendJsonl(tower.paths.activity, {
|
|
30
|
+
at: now,
|
|
31
|
+
type: 'spawn',
|
|
32
|
+
missionId: input.missionId,
|
|
33
|
+
text: `${entry.name} ${entry.state}${entry.outcome ? `: ${entry.outcome.slice(0, 160)}` : ''}`,
|
|
34
|
+
})
|
|
35
|
+
return entry
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Record that a live agent was interrupted by the tower. */
|
|
40
|
+
export async function recordInterrupt(tower, input) {
|
|
41
|
+
return withLock(tower.paths, async () => {
|
|
42
|
+
const state = await tower.load()
|
|
43
|
+
const entry = (state.roster ?? []).find(
|
|
44
|
+
(row) =>
|
|
45
|
+
(input.agentId !== undefined && row.agentId === input.agentId) ||
|
|
46
|
+
(input.name !== undefined && row.name === input.name),
|
|
47
|
+
)
|
|
48
|
+
if (!entry) fail(`unknown roster agent "${input.name ?? input.agentId}"`)
|
|
49
|
+
const now = Date.now()
|
|
50
|
+
applyInterrupt(entry, input, now)
|
|
51
|
+
state.revision += 1
|
|
52
|
+
state.updatedAt = now
|
|
53
|
+
validateState(state, tower.paths.state)
|
|
54
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
55
|
+
await appendJsonl(tower.paths.activity, {
|
|
56
|
+
at: now,
|
|
57
|
+
type: 'spawn',
|
|
58
|
+
missionId: entry.missionId,
|
|
59
|
+
text: `${entry.name} interrupted: ${entry.outcome}`,
|
|
60
|
+
})
|
|
61
|
+
return entry
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Reserve a roster name before activation so a failed spawn is recoverable. */
|
|
66
|
+
export async function reserveRoster(tower, input) {
|
|
67
|
+
return withLock(tower.paths, async () => {
|
|
68
|
+
const state = await tower.load()
|
|
69
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
70
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
71
|
+
if (!Array.isArray(state.roster)) state.roster = []
|
|
72
|
+
const now = Date.now()
|
|
73
|
+
const entry = reserveRosterEntry({ roster: state.roster, input, now })
|
|
74
|
+
state.roster.push(entry)
|
|
75
|
+
state.revision += 1
|
|
76
|
+
state.updatedAt = now
|
|
77
|
+
validateState(state, tower.paths.state)
|
|
78
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
79
|
+
await appendJsonl(tower.paths.activity, {
|
|
80
|
+
at: now,
|
|
81
|
+
type: 'spawn',
|
|
82
|
+
missionId: input.missionId,
|
|
83
|
+
text: `reserved ${input.role} ${input.name}`,
|
|
84
|
+
})
|
|
85
|
+
return entry
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function recordSpawn(tower, input) {
|
|
90
|
+
// Capture the spawn tip before taking the state lock (#51).
|
|
91
|
+
const spawnTip = input.cwd ? ((await tower.tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
|
|
92
|
+
return withLock(tower.paths, async () => {
|
|
93
|
+
const state = await tower.load()
|
|
94
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
95
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
96
|
+
if (!Array.isArray(state.roster)) state.roster = []
|
|
97
|
+
const now = Date.now()
|
|
98
|
+
const entry = recordSpawnEntry({
|
|
99
|
+
roster: state.roster,
|
|
100
|
+
mission,
|
|
101
|
+
input,
|
|
102
|
+
spawnTip,
|
|
103
|
+
maxLiveWorkers: tower.config.spawn.maxLiveWorkers,
|
|
104
|
+
now,
|
|
105
|
+
})
|
|
106
|
+
mission.updatedAt = now
|
|
107
|
+
state.revision += 1
|
|
108
|
+
state.updatedAt = now
|
|
109
|
+
|
|
110
|
+
validateState(state, tower.paths.state)
|
|
111
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
112
|
+
await appendJsonl(tower.paths.activity, {
|
|
113
|
+
at: now,
|
|
114
|
+
type: 'spawn',
|
|
115
|
+
missionId: input.missionId,
|
|
116
|
+
text: `${input.role} ${input.name} (${input.agentId})`,
|
|
117
|
+
})
|
|
118
|
+
return entry
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function rosterView(tower) {
|
|
123
|
+
const state = await tower.load()
|
|
124
|
+
return rosterProjection(state)
|
|
125
|
+
}
|
package/app/teardown.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Worktree teardown use case.
|
|
2
|
+
//
|
|
3
|
+
// #51 two-phase: eligibility diagnostics (ownership, registration, dirty
|
|
4
|
+
// state) run outside the state lock; each removal runs its git commands under
|
|
5
|
+
// the git lock and is committed in its own short state lock. Every removal
|
|
6
|
+
// therefore bumps the state revision exactly once, and the revision is
|
|
7
|
+
// revalidated immediately before the irreversible git mutation so a stale
|
|
8
|
+
// decision never removes a worktree that changed hands.
|
|
9
|
+
import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
10
|
+
import { validateState } from '../domain/state.js'
|
|
11
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
12
|
+
import { hasLiveAgent } from '../domain/integration/liveness.js'
|
|
13
|
+
import {
|
|
14
|
+
ownershipUnverifiedDecision,
|
|
15
|
+
replaceDecision as setDecision,
|
|
16
|
+
revalidateTeardownCandidate,
|
|
17
|
+
STALE_TEARDOWN as STALE,
|
|
18
|
+
teardownActivityText,
|
|
19
|
+
teardownDirtyDecision,
|
|
20
|
+
teardownPrecheck,
|
|
21
|
+
teardownRemoveDecision,
|
|
22
|
+
teardownRemovedDecision,
|
|
23
|
+
} from '../domain/integration/teardown.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Phase 1 (#51): decide every keep/remove outcome without holding the state
|
|
27
|
+
* lock. Removable worktrees become candidates; the returned decision objects
|
|
28
|
+
* stay mutable so the removal outcome can replace them in mission order.
|
|
29
|
+
*/
|
|
30
|
+
async function prepareTeardown(tower, input) {
|
|
31
|
+
const state = await tower.load()
|
|
32
|
+
const activity = input.activity ?? {}
|
|
33
|
+
const exclude = new Set(input.exclude ?? [])
|
|
34
|
+
const dryRun = input.dryRun === true
|
|
35
|
+
const decisions = []
|
|
36
|
+
const candidates = []
|
|
37
|
+
|
|
38
|
+
for (const mission of state.missions) {
|
|
39
|
+
const precheck = teardownPrecheck(mission, {
|
|
40
|
+
excluded: exclude.has(mission.id),
|
|
41
|
+
hasLiveAgent: hasLiveAgent(state, mission.id, activity),
|
|
42
|
+
})
|
|
43
|
+
if ('skip' in precheck) continue
|
|
44
|
+
if ('decision' in precheck) {
|
|
45
|
+
decisions.push(precheck.decision)
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await tower.assertWorktree(mission.worktree)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
decisions.push(ownershipUnverifiedDecision(mission.id, error))
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
let dirty = true
|
|
55
|
+
try {
|
|
56
|
+
dirty = !(await tower.isClean(mission.worktree.path))
|
|
57
|
+
} catch {
|
|
58
|
+
dirty = true
|
|
59
|
+
}
|
|
60
|
+
const dirtyKeep = teardownDirtyDecision(dirty, input.force)
|
|
61
|
+
if (dirtyKeep) {
|
|
62
|
+
decisions.push({ missionId: mission.id, ...dirtyKeep })
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (dryRun) {
|
|
66
|
+
decisions.push({ missionId: mission.id, ...teardownRemoveDecision(dirty) })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const decision = { missionId: mission.id, ...teardownRemoveDecision(dirty) }
|
|
70
|
+
decisions.push(decision)
|
|
71
|
+
candidates.push({ missionId: mission.id, worktree: mission.worktree, decision })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { stateRevision: state.revision, decisions, candidates, dryRun }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Short state lock: reject a stale decision before any git mutation. */
|
|
78
|
+
async function claimRemoval(tower, candidate, expectedRevision, input) {
|
|
79
|
+
const activity = input.activity ?? {}
|
|
80
|
+
return withLock(tower.paths, async () => {
|
|
81
|
+
const state = await tower.load()
|
|
82
|
+
if (state.revision !== expectedRevision) {
|
|
83
|
+
throw new TowerToolError('stale-base', `${STALE}. No worktree was removed.`)
|
|
84
|
+
}
|
|
85
|
+
return revalidateTeardownCandidate(state, candidate, activity)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function teardown(tower, input) {
|
|
90
|
+
const prepared = await prepareTeardown(tower, input)
|
|
91
|
+
if (prepared.dryRun) return { ok: true, dryRun: true, removed: [], decisions: prepared.decisions }
|
|
92
|
+
|
|
93
|
+
const removed = []
|
|
94
|
+
let expectedRevision = prepared.stateRevision
|
|
95
|
+
|
|
96
|
+
for (const candidate of prepared.candidates) {
|
|
97
|
+
const claim = await claimRemoval(tower, candidate, expectedRevision, input)
|
|
98
|
+
if (!claim.ok) {
|
|
99
|
+
setDecision(candidate.decision, { action: 'keep', reason: claim.reason })
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Phase 2 (#51): the irreversible git work runs under the git lock and
|
|
104
|
+
// never under the state lock. Re-read state without the lock right before
|
|
105
|
+
// mutating: the git lock may have waited behind another operation, so a
|
|
106
|
+
// stale candidate is refused while the worktree still exists.
|
|
107
|
+
const outcome = await withGitLock(tower.paths, async () => {
|
|
108
|
+
let fresh
|
|
109
|
+
try {
|
|
110
|
+
fresh = await tower.load()
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return { phase: 'stale', detail: String(error?.message ?? error) }
|
|
113
|
+
}
|
|
114
|
+
if (fresh.revision !== expectedRevision) {
|
|
115
|
+
return { phase: 'stale', detail: `state revision changed from ${expectedRevision} to ${fresh.revision} before the removal` }
|
|
116
|
+
}
|
|
117
|
+
const current = revalidateTeardownCandidate(fresh, candidate, input.activity ?? {})
|
|
118
|
+
if (!current.ok) return { phase: 'skip', reason: current.reason }
|
|
119
|
+
const mission = current.mission
|
|
120
|
+
const worktree = mission.worktree
|
|
121
|
+
if (!worktree) return { phase: 'skip', reason: 'worktree is already gone' }
|
|
122
|
+
let dirty = true
|
|
123
|
+
try {
|
|
124
|
+
dirty = !(await tower.isClean(worktree.path))
|
|
125
|
+
} catch {
|
|
126
|
+
dirty = true
|
|
127
|
+
}
|
|
128
|
+
const dirtyKeep = teardownDirtyDecision(dirty, input.force)
|
|
129
|
+
if (dirtyKeep) return { phase: 'skip', reason: dirtyKeep.reason }
|
|
130
|
+
try {
|
|
131
|
+
await tower.git(tower.root, ['worktree', 'remove', ...(dirty ? ['--force'] : []), worktree.path])
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return { phase: 'remove-failed', reason: `remove failed: ${String(error?.message ?? error)}` }
|
|
134
|
+
}
|
|
135
|
+
let branchDeleteError = null
|
|
136
|
+
try {
|
|
137
|
+
await tower.git(tower.root, ['branch', '-d', worktree.branch])
|
|
138
|
+
} catch (error) {
|
|
139
|
+
branchDeleteError = String(error?.message ?? error).slice(0, 240)
|
|
140
|
+
}
|
|
141
|
+
return { phase: 'removed', branch: worktree.branch, branchDeleteError }
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
if (outcome.phase === 'stale') {
|
|
145
|
+
throw new TowerToolError('stale-base', `${STALE}: ${outcome.detail}. No worktree was removed.`)
|
|
146
|
+
}
|
|
147
|
+
if (outcome.phase !== 'removed') {
|
|
148
|
+
setDecision(candidate.decision, { action: 'keep', reason: outcome.reason })
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Phase 3 (#51): commit this removal alone in a short state lock. The
|
|
153
|
+
// removal already happened, so if a writer landed while git ran we apply
|
|
154
|
+
// the removal on the freshly loaded state instead of rejecting it (which
|
|
155
|
+
// would leave state claiming a worktree that no longer exists). Only this
|
|
156
|
+
// mission's fields change, so no concurrent update is lost.
|
|
157
|
+
const commit = await withLock(tower.paths, async () => {
|
|
158
|
+
const state = await tower.load()
|
|
159
|
+
const mission = state.missions.find((entry) => entry.id === candidate.missionId)
|
|
160
|
+
if (!mission) return { revision: state.revision }
|
|
161
|
+
const now = Date.now()
|
|
162
|
+
mission.worktreeRemovedAt = now
|
|
163
|
+
mission.updatedAt = now
|
|
164
|
+
if (outcome.branchDeleteError) mission.branchDeleteError = outcome.branchDeleteError
|
|
165
|
+
else delete mission.branchDeleteError
|
|
166
|
+
state.revision += 1
|
|
167
|
+
state.updatedAt = now
|
|
168
|
+
validateState(state, tower.paths.state)
|
|
169
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
170
|
+
await appendJsonl(tower.paths.activity, {
|
|
171
|
+
at: now,
|
|
172
|
+
type: 'teardown',
|
|
173
|
+
missionId: mission.id,
|
|
174
|
+
text: teardownActivityText(outcome.branch, outcome.branchDeleteError),
|
|
175
|
+
})
|
|
176
|
+
return { revision: state.revision }
|
|
177
|
+
})
|
|
178
|
+
expectedRevision = commit.revision
|
|
179
|
+
removed.push(candidate.missionId)
|
|
180
|
+
setDecision(candidate.decision, teardownRemovedDecision(outcome))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { ok: true, dryRun: false, removed, decisions: prepared.decisions }
|
|
184
|
+
}
|