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
|
@@ -79,6 +79,7 @@ const SCHEMAS = {
|
|
|
79
79
|
recentFindings: S.arr(S.obj()),
|
|
80
80
|
missions: S.arr(S.obj()),
|
|
81
81
|
activity: S.arr(S.obj()),
|
|
82
|
+
gitLock: S.obj({ held: S.bool, pid: S.any, phase: S.any, heldMs: S.any }),
|
|
82
83
|
mode: S.any,
|
|
83
84
|
spawn: S.any,
|
|
84
85
|
towerAgentId: S.any,
|
|
@@ -169,7 +170,7 @@ const SCHEMAS = {
|
|
|
169
170
|
['counts', 'total'],
|
|
170
171
|
),
|
|
171
172
|
review: S.obj(
|
|
172
|
-
{ review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, reviewerRounds: S.int },
|
|
173
|
+
{ review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, appended: S.bool, reviewerRounds: S.int },
|
|
173
174
|
['review', 'branchTip', 'missionStatus', 'round', 'reviewerRounds'],
|
|
174
175
|
),
|
|
175
176
|
preflight: S.obj(
|
|
@@ -243,6 +244,8 @@ const SCHEMAS = {
|
|
|
243
244
|
{
|
|
244
245
|
ok: S.bool,
|
|
245
246
|
checks: S.arr(S.obj()),
|
|
247
|
+
stateLock: S.any,
|
|
248
|
+
gitLock: S.any,
|
|
246
249
|
stateSchema: S.obj({
|
|
247
250
|
currentVersion: S.any,
|
|
248
251
|
supportedVersion: S.int,
|
|
@@ -250,6 +253,7 @@ const SCHEMAS = {
|
|
|
250
253
|
status: S.str,
|
|
251
254
|
}),
|
|
252
255
|
reports: S.any,
|
|
256
|
+
surface: S.any,
|
|
253
257
|
archive: S.obj({
|
|
254
258
|
missions: S.any,
|
|
255
259
|
archivedMissions: S.int,
|
|
@@ -266,7 +270,7 @@ const SCHEMAS = {
|
|
|
266
270
|
['ok', 'checks'],
|
|
267
271
|
),
|
|
268
272
|
recover: S.obj(
|
|
269
|
-
{ ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, error: S.str },
|
|
273
|
+
{ ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, forced: S.bool, error: S.str },
|
|
270
274
|
['ok', 'action'],
|
|
271
275
|
),
|
|
272
276
|
test: S.obj(
|
|
@@ -25,6 +25,7 @@ export const RecoverInput = Type.Object({
|
|
|
25
25
|
repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
|
|
26
26
|
action: enumOf(RECOVERY_ACTIONS, { description: 'Recovery action.' }),
|
|
27
27
|
confirm: Type.Optional(Type.String({ description: 'Required literal CLEAR_STALE_LOCK for clear-stale-lock.' })),
|
|
28
|
+
force: Type.Optional(Type.Boolean({ description: 'clear-stale-lock: clear the lock even when its recorded PID is alive (a recycled PID); requires confirm and still moves the record to a .recovered-<ts> backup.' })),
|
|
28
29
|
}, { additionalProperties: false })
|
|
29
30
|
|
|
30
31
|
export const TestInput = Type.Object({
|
package/domain/errors.js
CHANGED
|
@@ -3,8 +3,24 @@
|
|
|
3
3
|
// Everything a Tower tool throws reaches the model as
|
|
4
4
|
// [tower:<code>] <original message>
|
|
5
5
|
// so callers (and tests) can branch on the code instead of matching prose.
|
|
6
|
+
// The catalog also carries the recovery metadata the model can act on:
|
|
7
|
+
//
|
|
8
|
+
// - `retryable` — the same input may succeed later without a change;
|
|
9
|
+
// - `nextAction` — the concrete recovery step, rendered once as a trailing
|
|
10
|
+
// ` — next: …` segment (plus ` (retryable)` when retryable);
|
|
11
|
+
// - `trigger` — the typical condition, documented in errors.md.
|
|
12
|
+
//
|
|
13
|
+
// Why the message suffix rather than a structured error payload: the DSH tool
|
|
14
|
+
// contract declares output schemas for successful values only, and a thrown
|
|
15
|
+
// error reaches the model as its rendered message. There is no separate
|
|
16
|
+
// structured error channel to return `{ code, retryable, nextAction }` through,
|
|
17
|
+
// so the suffix is the model-visible channel; the error object still carries
|
|
18
|
+
// `code`, `retryable`, `nextAction` and `details` for programmatic callers.
|
|
19
|
+
// Codes without a recovery step render byte-identically to the pre-#74 form.
|
|
20
|
+
//
|
|
6
21
|
// The catalog is documented in docs/reference/errors.md; scripts/check-docs.mjs
|
|
7
|
-
//
|
|
22
|
+
// and scripts/generate-error-reference.mjs keep the two in sync.
|
|
23
|
+
// @ts-check
|
|
8
24
|
|
|
9
25
|
/** Neutral error type shared by the git adapter and the domain classifier. */
|
|
10
26
|
export class GitError extends Error {
|
|
@@ -18,58 +34,295 @@ export class GitError extends Error {
|
|
|
18
34
|
}
|
|
19
35
|
}
|
|
20
36
|
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} ErrorCodeEntry
|
|
39
|
+
* @property {string} description
|
|
40
|
+
* @property {string} trigger
|
|
41
|
+
* @property {boolean} retryable
|
|
42
|
+
* @property {string | null} nextAction
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One stable entry per failure family. `description` and `trigger` are the
|
|
47
|
+
* documented meaning; `retryable`/`nextAction` are the recovery contract the
|
|
48
|
+
* tool boundary renders.
|
|
49
|
+
* @type {Record<string, ErrorCodeEntry>}
|
|
50
|
+
*/
|
|
21
51
|
export const ERROR_CODES = {
|
|
22
|
-
'invalid-argument':
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
'
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
'invalid-argument': {
|
|
53
|
+
description: 'A tool argument is missing, malformed, or not allowed.',
|
|
54
|
+
trigger: '`missionId`/`id` omitted; unknown status; empty report; bad scope glob; `timeoutMs < 1000`; `status: "all"` on an update.',
|
|
55
|
+
retryable: false,
|
|
56
|
+
nextAction: null,
|
|
57
|
+
},
|
|
58
|
+
'repo-invalid': {
|
|
59
|
+
description: 'repoRoot is missing, relative, or not inside a Git repository.',
|
|
60
|
+
trigger: 'passing a path in a directory that is not a repo',
|
|
61
|
+
retryable: false,
|
|
62
|
+
nextAction: 'pass an absolute path to a directory inside a Git repository',
|
|
63
|
+
},
|
|
64
|
+
'not-initialized': {
|
|
65
|
+
description: 'Tower is not initialized for this repository; run tower_init.',
|
|
66
|
+
trigger: 'a read tool before `tower_init`',
|
|
67
|
+
retryable: false,
|
|
68
|
+
nextAction: 'run tower_init for this repository',
|
|
69
|
+
},
|
|
70
|
+
'mission-not-found': {
|
|
71
|
+
description: 'No mission with that id exists.',
|
|
72
|
+
trigger: 'a stale or mistyped `M9`',
|
|
73
|
+
retryable: false,
|
|
74
|
+
nextAction: 'list missions with tower_status',
|
|
75
|
+
},
|
|
76
|
+
'finding-not-found': {
|
|
77
|
+
description: 'No finding with that id exists.',
|
|
78
|
+
trigger: '`tower_findings({id:"F99"})`',
|
|
79
|
+
retryable: false,
|
|
80
|
+
nextAction: 'list findings with tower_findings',
|
|
81
|
+
},
|
|
82
|
+
'invalid-transition': {
|
|
83
|
+
description: 'The requested state transition is not allowed.',
|
|
84
|
+
trigger: 'a blocked→merged attempt; a finding transition not in the graph; a reviewer mutating findings; completing a mission with open tasks, blockers, no commits, or unread inbox messages.',
|
|
85
|
+
retryable: false,
|
|
86
|
+
nextAction: null,
|
|
87
|
+
},
|
|
88
|
+
'stale-base': {
|
|
89
|
+
description: 'The repository state changed while the operation was being prepared; retry.',
|
|
90
|
+
trigger: 'two-phase mutation revalidation',
|
|
91
|
+
retryable: true,
|
|
92
|
+
nextAction: 'retry the tool; if it keeps failing, run tower_preflight and inspect tower_status',
|
|
93
|
+
},
|
|
94
|
+
'state-locked': {
|
|
95
|
+
description: 'Another Tower writer holds the state lock.',
|
|
96
|
+
trigger: 'concurrent mutation after the lock timeout',
|
|
97
|
+
retryable: true,
|
|
98
|
+
nextAction: 'wait for the other Tower writer to finish, then retry',
|
|
99
|
+
},
|
|
100
|
+
'state-corrupt': {
|
|
101
|
+
description: 'The Tower state file is unreadable or invalid.',
|
|
102
|
+
trigger: 'hand-edited `state.json`; invalid JSON; no usable `schemaVersion`',
|
|
103
|
+
retryable: false,
|
|
104
|
+
nextAction: 'restore state.json from a backup, or re-initialize after filing the corruption',
|
|
105
|
+
},
|
|
106
|
+
'state-version-unsupported': {
|
|
107
|
+
description: 'The Tower state file uses a schema version this build cannot read or downgrade.',
|
|
108
|
+
trigger: 'opening a repository written by a newer plugin; an unknown older version; this build refuses to downgrade',
|
|
109
|
+
retryable: false,
|
|
110
|
+
nextAction: 'upgrade dsh-tower to a release that supports this state schemaVersion',
|
|
111
|
+
},
|
|
112
|
+
'state-migration-failed': {
|
|
113
|
+
description: 'A Tower state migration failed; the original state file was not modified.',
|
|
114
|
+
trigger: 'a bug in a migration step; see the pre-migration `state.json.v<N>.bak`',
|
|
115
|
+
retryable: false,
|
|
116
|
+
nextAction: 'restore the pre-migration backup state.json.v<N>.bak and report the migration bug',
|
|
117
|
+
},
|
|
118
|
+
'config-invalid': {
|
|
119
|
+
description: 'Tower configuration is invalid.',
|
|
120
|
+
trigger: 'malformed repo `config.json`, a value that violates the schema',
|
|
121
|
+
retryable: false,
|
|
122
|
+
nextAction: 'fix the repository config file, then retry',
|
|
123
|
+
},
|
|
124
|
+
'config-unknown-key': {
|
|
125
|
+
description: 'Tower configuration has an unknown top-level key.',
|
|
126
|
+
trigger: 'a typo such as `allowAgentToggle`',
|
|
127
|
+
retryable: false,
|
|
128
|
+
nextAction: 'remove the unknown key from the repository config file, then retry',
|
|
129
|
+
},
|
|
130
|
+
'git-failed': {
|
|
131
|
+
description: 'A git command failed.',
|
|
132
|
+
trigger: 'a rejected merge command, invalid ref',
|
|
133
|
+
retryable: false,
|
|
134
|
+
nextAction: null,
|
|
135
|
+
},
|
|
136
|
+
'git-timeout': {
|
|
137
|
+
description: 'A git command timed out or exceeded its output buffer.',
|
|
138
|
+
trigger: 'slow pre-merge hook, huge diff',
|
|
139
|
+
retryable: true,
|
|
140
|
+
nextAction: 'retry; if it persists, raise git.timeoutMs or merge.timeoutMs',
|
|
141
|
+
},
|
|
142
|
+
'worktree-dirty': {
|
|
143
|
+
description: 'The mission worktree has uncommitted changes and cannot complete.',
|
|
144
|
+
trigger: 'staged, modified, or untracked files left behind before `tower_complete`',
|
|
145
|
+
retryable: false,
|
|
146
|
+
nextAction: 'commit or remove the worktree changes, then retry tower_complete',
|
|
147
|
+
},
|
|
148
|
+
'spawn-unavailable': {
|
|
149
|
+
description: 'The subagents service is unavailable.',
|
|
150
|
+
trigger: 'a Host without `subagents`',
|
|
151
|
+
retryable: false,
|
|
152
|
+
nextAction: 'enable the DSH subagents service, then retry',
|
|
153
|
+
},
|
|
154
|
+
'provider-missing': {
|
|
155
|
+
description: 'No subagent provider is registered.',
|
|
156
|
+
trigger: 'an empty provider list',
|
|
157
|
+
retryable: false,
|
|
158
|
+
nextAction: 'register a subagent provider in the DSH profile, then retry',
|
|
159
|
+
},
|
|
160
|
+
'provider-cooldown': {
|
|
161
|
+
description: 'The provider is throttled; spawn is in a short cooldown.',
|
|
162
|
+
trigger: 'repeated retryable provider failures; wait for `blockedUntil`',
|
|
163
|
+
retryable: true,
|
|
164
|
+
nextAction: 'wait for the cooldown to expire, then retry',
|
|
165
|
+
},
|
|
166
|
+
'preset-missing': {
|
|
167
|
+
description: 'The configured agent preset is not registered.',
|
|
168
|
+
trigger: 'missing `tower-worker`/`tower-reviewer` preset rows, DSH not restarted',
|
|
169
|
+
retryable: false,
|
|
170
|
+
nextAction: 'install the tower-worker and tower-reviewer agent presets and restart DSH',
|
|
171
|
+
},
|
|
172
|
+
'reviewer-prerequisite': {
|
|
173
|
+
description: 'The reviewer spawn prerequisite is not met.',
|
|
174
|
+
trigger: 'reviewer spawned before the mission is `completed`, or before a worktree exists',
|
|
175
|
+
retryable: false,
|
|
176
|
+
nextAction: 'complete the mission first, then spawn the reviewer',
|
|
177
|
+
},
|
|
178
|
+
'not-authorized': {
|
|
179
|
+
description: 'The calling agent role may not perform this action.',
|
|
180
|
+
trigger: 'a worker calling a tower-only tool or changing mission status/scope; a reviewer mutating findings',
|
|
181
|
+
retryable: false,
|
|
182
|
+
nextAction: 'call the tool as the tower, or as the worker/reviewer it is assigned to',
|
|
183
|
+
},
|
|
184
|
+
'workspace-busy': {
|
|
185
|
+
description: 'Another Tower session owns this repository workspace lock.',
|
|
186
|
+
trigger: '`mode.workspaceLock:true` and a second session calls a repo-scoped tool',
|
|
187
|
+
retryable: true,
|
|
188
|
+
nextAction: 'run /tower off in the other session or wait for its lock to become stale',
|
|
189
|
+
},
|
|
190
|
+
'worker-limit': {
|
|
191
|
+
description: 'The repository reached spawn.maxLiveWorkers live workers.',
|
|
192
|
+
trigger: 'spawning a fifth worker with the default limit',
|
|
193
|
+
retryable: false,
|
|
194
|
+
nextAction: 'wait for a live worker to finish or raise spawn.maxLiveWorkers',
|
|
195
|
+
},
|
|
196
|
+
'rate-limited': {
|
|
197
|
+
description: 'The tower_send per-minute rate limit was reached.',
|
|
198
|
+
trigger: '`send.maxPerMinute` exceeded',
|
|
199
|
+
retryable: true,
|
|
200
|
+
nextAction: 'wait for the next per-minute window or raise send.maxPerMinute',
|
|
201
|
+
},
|
|
202
|
+
'takeover-refused': {
|
|
203
|
+
description: 'The mission already has a live worker that was not stopped first.',
|
|
204
|
+
trigger: '`tower_spawn({takeover:true})` before `tower_interrupt`',
|
|
205
|
+
retryable: false,
|
|
206
|
+
nextAction: 'run tower_interrupt for the live worker, then retry the takeover',
|
|
207
|
+
},
|
|
208
|
+
'reload-unavailable': {
|
|
209
|
+
description: 'The DSH HMR service is not available in this profile.',
|
|
210
|
+
trigger: '`tower_reload` / `/tower reload` on a profile without HMR',
|
|
211
|
+
retryable: false,
|
|
212
|
+
nextAction: 'restart DSH instead of reloading',
|
|
213
|
+
},
|
|
214
|
+
'reload-failed': {
|
|
215
|
+
description: 'The HMR reload failed; check the DSH log and restart if needed.',
|
|
216
|
+
trigger: '`reconcileProfile` threw',
|
|
217
|
+
retryable: false,
|
|
218
|
+
nextAction: 'check the DSH log and restart DSH',
|
|
219
|
+
},
|
|
220
|
+
'tests-failed': {
|
|
221
|
+
description: 'Declared test discovery failed or did not meet its admission requirements.',
|
|
222
|
+
trigger: 'nonzero command, malformed output, minCount/required shortfall',
|
|
223
|
+
retryable: false,
|
|
224
|
+
nextAction: 'fix the failing tests, then retry tower_complete',
|
|
225
|
+
},
|
|
226
|
+
'tests-timeout': {
|
|
227
|
+
description: 'Test discovery exceeded its time or output budget.',
|
|
228
|
+
trigger: '`tests.timeoutMs`, or stdout over `tests.maxBytes`',
|
|
229
|
+
retryable: false,
|
|
230
|
+
nextAction: 'raise tests.timeoutMs or tests.maxBytes (or speed up the suite), then retry',
|
|
231
|
+
},
|
|
232
|
+
'lease-busy': {
|
|
233
|
+
description: 'Another holder owns the requested advisory lease.',
|
|
234
|
+
trigger: '`tower_resource acquire` while `heavy-ci` is held',
|
|
235
|
+
retryable: false,
|
|
236
|
+
nextAction: 'release the lease or wait for it to expire, then retry',
|
|
237
|
+
},
|
|
238
|
+
'lease-token': {
|
|
239
|
+
description: 'The lease release token does not match the current holder.',
|
|
240
|
+
trigger: 'releasing with another session\'s token',
|
|
241
|
+
retryable: false,
|
|
242
|
+
nextAction: 'use the token returned when the lease was acquired',
|
|
243
|
+
},
|
|
244
|
+
'batch-invalid': {
|
|
245
|
+
description: 'The batch request or its close evidence is invalid.',
|
|
246
|
+
trigger: 'unknown member, duplicate id, unsafe receipt path, missing receipt hash',
|
|
247
|
+
retryable: false,
|
|
248
|
+
nextAction: 'fix the batch request or the receipt hashes, then retry',
|
|
249
|
+
},
|
|
250
|
+
'legacy-invalid': {
|
|
251
|
+
description: 'The legacy snapshot or mapping is invalid.',
|
|
252
|
+
trigger: 'unknown schema, source inside the Tower state dir, mapping rewrite',
|
|
253
|
+
retryable: false,
|
|
254
|
+
nextAction: 'check the legacy snapshot path, schema and mapping, then retry',
|
|
255
|
+
},
|
|
256
|
+
'tool-internal': {
|
|
257
|
+
description: 'An unexpected failure; read the message and the activity log.',
|
|
258
|
+
trigger: 'a bug; read the message and the activity log',
|
|
259
|
+
retryable: false,
|
|
260
|
+
nextAction: null,
|
|
261
|
+
},
|
|
57
262
|
}
|
|
58
263
|
|
|
59
264
|
export const ERROR_CODE_VALUES = Object.freeze(Object.keys(ERROR_CODES))
|
|
265
|
+
|
|
266
|
+
/** @param {unknown} value */
|
|
60
267
|
export function isErrorCode(value) {
|
|
61
268
|
return typeof value === 'string' && Object.hasOwn(ERROR_CODES, value)
|
|
62
269
|
}
|
|
63
270
|
|
|
64
|
-
|
|
271
|
+
/** @param {string} code */
|
|
272
|
+
export function errorEntry(code) {
|
|
273
|
+
return isErrorCode(code) ? ERROR_CODES[code] : null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {string} code */
|
|
277
|
+
export function errorDescription(code) {
|
|
278
|
+
return errorEntry(code)?.description ?? ''
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @param {string} code */
|
|
282
|
+
export function errorRetryable(code) {
|
|
283
|
+
return errorEntry(code)?.retryable === true
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @param {string} code */
|
|
287
|
+
export function errorNextAction(code) {
|
|
288
|
+
return errorEntry(code)?.nextAction ?? null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The model-visible message. Exactly one trailing ` — next: …` segment is
|
|
293
|
+
* appended when there is a recovery step; a retryable code marks the same
|
|
294
|
+
* segment with ` (retryable)`. A message that already carries the action is
|
|
295
|
+
* never suffixed twice.
|
|
296
|
+
* @param {string} code
|
|
297
|
+
* @param {string} message
|
|
298
|
+
* @param {{ nextAction?: string | null, retryable?: boolean }} [options]
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
export function renderErrorMessage(code, message, options = {}) {
|
|
302
|
+
const text = String(message)
|
|
303
|
+
const base = `[tower:${code}] ${text}`
|
|
304
|
+
const nextAction = options.nextAction ?? errorNextAction(code)
|
|
305
|
+
if (nextAction === null || nextAction === undefined) return base
|
|
306
|
+
if (text.includes(nextAction) || text.includes('— next:')) return base
|
|
307
|
+
const retryable = options.retryable ?? errorRetryable(code)
|
|
308
|
+
return `${base} — next: ${nextAction}${retryable ? ' (retryable)' : ''}`
|
|
309
|
+
}
|
|
65
310
|
|
|
66
311
|
export class TowerToolError extends Error {
|
|
312
|
+
/**
|
|
313
|
+
* @param {string} code
|
|
314
|
+
* @param {string} message
|
|
315
|
+
* @param {{ cause?: unknown, retryable?: boolean, nextAction?: string | null, details?: unknown }} [options]
|
|
316
|
+
*/
|
|
67
317
|
constructor(code, message, options = {}) {
|
|
68
|
-
|
|
318
|
+
const entry = errorEntry(code)
|
|
319
|
+
const retryable = options.retryable ?? entry?.retryable === true
|
|
320
|
+
const nextAction = options.nextAction ?? entry?.nextAction ?? null
|
|
321
|
+
super(renderErrorMessage(code, message, { nextAction, retryable }), options.cause ? { cause: options.cause } : undefined)
|
|
69
322
|
this.name = 'TowerToolError'
|
|
70
323
|
this.code = code
|
|
71
|
-
this.retryable =
|
|
72
|
-
this.nextAction =
|
|
324
|
+
this.retryable = retryable
|
|
325
|
+
this.nextAction = nextAction
|
|
73
326
|
this.details = options.details ?? null
|
|
74
327
|
}
|
|
75
328
|
}
|
|
@@ -84,8 +337,10 @@ export function classifyError(error) {
|
|
|
84
337
|
if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
|
|
85
338
|
return new TowerToolError(error.code, originalMessage(error), {
|
|
86
339
|
cause: error,
|
|
87
|
-
|
|
88
|
-
|
|
340
|
+
// Let the catalog supply a default when the thrown object does not
|
|
341
|
+
// explicitly override the recovery metadata.
|
|
342
|
+
retryable: typeof error.retryable === 'boolean' ? error.retryable : undefined,
|
|
343
|
+
nextAction: error.nextAction ?? undefined,
|
|
89
344
|
details: error.details ?? null,
|
|
90
345
|
})
|
|
91
346
|
}
|
package/domain/events.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Stable domain events (#75).
|
|
2
|
+
//
|
|
3
|
+
// The domain layer *describes* what changed; `app/` persists the state and only
|
|
4
|
+
// then emits the event through the injected event port. `state.json` remains the
|
|
5
|
+
// authoritative record — events are a process-local observation channel, not an
|
|
6
|
+
// event-sourced log, and are never replayed.
|
|
7
|
+
//
|
|
8
|
+
// The table below is the single source of truth for the generated reference
|
|
9
|
+
// (`docs/reference/events.md`), so a rename cannot drift from the runtime.
|
|
10
|
+
// Every domain event carries `{ missionId, revision, at }`; `merged` also
|
|
11
|
+
// carries the merge `commit`. Names are dotted (`tower.mission.*`); the older
|
|
12
|
+
// tool-shaped `tower/<tool>` aliases stay for one minor release and are marked
|
|
13
|
+
// deprecated in the generated reference.
|
|
14
|
+
// @ts-check
|
|
15
|
+
|
|
16
|
+
/** @typedef {'planned'|'completed'|'reviewed'|'merged'|'abandoned'} MissionEventType */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} MissionEventEntry
|
|
20
|
+
* @property {MissionEventType} type
|
|
21
|
+
* @property {string} name
|
|
22
|
+
* @property {string[]} payload
|
|
23
|
+
* @property {string} description
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Stable domain-fact events (`tower.mission.*`). */
|
|
27
|
+
export const MISSION_EVENTS = Object.freeze({
|
|
28
|
+
planned: {
|
|
29
|
+
type: 'planned',
|
|
30
|
+
name: 'tower.mission.planned',
|
|
31
|
+
payload: ['missionId', 'revision', 'at'],
|
|
32
|
+
description: 'tower_plan persisted one or more new missions',
|
|
33
|
+
},
|
|
34
|
+
completed: {
|
|
35
|
+
type: 'completed',
|
|
36
|
+
name: 'tower.mission.completed',
|
|
37
|
+
payload: ['missionId', 'revision', 'at'],
|
|
38
|
+
description: 'tower_complete recorded a completion report (or tower_mission set status "completed")',
|
|
39
|
+
},
|
|
40
|
+
reviewed: {
|
|
41
|
+
type: 'reviewed',
|
|
42
|
+
name: 'tower.mission.reviewed',
|
|
43
|
+
payload: ['missionId', 'revision', 'at'],
|
|
44
|
+
description: 'tower_review recorded a verdict for the mission branch',
|
|
45
|
+
},
|
|
46
|
+
merged: {
|
|
47
|
+
type: 'merged',
|
|
48
|
+
name: 'tower.mission.merged',
|
|
49
|
+
payload: ['missionId', 'revision', 'at', 'commit'],
|
|
50
|
+
description: 'tower_merge recorded the merge commit that landed the mission branch',
|
|
51
|
+
},
|
|
52
|
+
abandoned: {
|
|
53
|
+
type: 'abandoned',
|
|
54
|
+
name: 'tower.mission.abandoned',
|
|
55
|
+
payload: ['missionId', 'revision', 'at'],
|
|
56
|
+
description: 'a mission transitioned to "abandoned"',
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** @type {readonly MissionEventType[]} */
|
|
61
|
+
export const MISSION_EVENT_TYPES = Object.freeze(['planned', 'completed', 'reviewed', 'merged', 'abandoned'])
|
|
62
|
+
|
|
63
|
+
/** @type {readonly MissionEventEntry[]} */
|
|
64
|
+
export const MISSION_EVENT_TABLE = Object.freeze(
|
|
65
|
+
MISSION_EVENT_TYPES.map((type) => /** @type {MissionEventEntry} */ (MISSION_EVENTS[type])),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/** Tool-shaped alias events, kept for one minor release (#75). */
|
|
69
|
+
export const LEGACY_TOOL_EVENT_TABLE = Object.freeze([
|
|
70
|
+
{ name: 'tower/init', description: '`tower_init` created or re-anchored state' },
|
|
71
|
+
{ name: 'tower/plan', description: '`tower_plan` created missions' },
|
|
72
|
+
{ name: 'tower/mission', description: '`tower_mission` changed one mission' },
|
|
73
|
+
{ name: 'tower/spawn', description: '`tower_spawn` recorded a worker/reviewer' },
|
|
74
|
+
{ name: 'tower/complete', description: '`tower_complete` recorded a completion report' },
|
|
75
|
+
{ name: 'tower/review', description: '`tower_review` recorded a verdict' },
|
|
76
|
+
{ name: 'tower/merge', description: '`tower_merge` returned (including gate failures; inspect `value.ok`)' },
|
|
77
|
+
{ name: 'tower/rebase', description: '`tower_rebase` returned' },
|
|
78
|
+
{ name: 'tower/teardown', description: '`tower_teardown` removed worktrees' },
|
|
79
|
+
{ name: 'tower/finding', description: '`tower_finding` created one finding' },
|
|
80
|
+
{ name: 'tower/findings', description: '`tower_findings` read/listed/updated findings' },
|
|
81
|
+
{ name: 'tower/send', description: '`tower_send` persisted a message' },
|
|
82
|
+
{ name: 'tower/inbox', description: '`tower_inbox` acknowledged messages' },
|
|
83
|
+
{ name: 'tower/interrupt', description: '`tower_interrupt` stopped a live agent' },
|
|
84
|
+
{ name: 'tower/diff', description: '`tower_diff` read a mission diff' },
|
|
85
|
+
{ name: 'tower/adopt', description: '`tower_adopt` reconciled the roster' },
|
|
86
|
+
{ name: 'tower/roster', description: '`tower_roster` read the roster' },
|
|
87
|
+
{ name: 'tower/status', description: '`tower_status` read the dashboard' },
|
|
88
|
+
{ name: 'tower/preflight', description: '`tower_preflight` evaluated the gate' },
|
|
89
|
+
{ name: 'tower/doctor', description: '`tower_doctor` completed its read-only checks' },
|
|
90
|
+
{ name: 'tower/recover', description: '`tower_recover` inspected or cleared a stale state lock' },
|
|
91
|
+
{ name: 'tower/test', description: '`tower_test` ran discovery' },
|
|
92
|
+
{ name: 'tower/batch', description: '`tower_batch` created, inspected or closed a batch' },
|
|
93
|
+
{ name: 'tower/resource', description: '`tower_resource` acquired/released/inspected leases' },
|
|
94
|
+
{ name: 'tower/import_legacy', description: '`tower_import_legacy` stored a legacy snapshot' },
|
|
95
|
+
{ name: 'tower/legacy_map', description: '`tower_legacy_map` mapped or listed legacy ids' },
|
|
96
|
+
{ name: 'tower/mode', description: '`tower_mode` read the session stance (`value.mode`, `allowAgentControl`, `deniedWhileActive`)' },
|
|
97
|
+
{ name: 'tower/control', description: '`tower_control` entered/left the mode (`value.mode`, actor/reason in `value`)' },
|
|
98
|
+
{ name: 'tower/reload', description: '`tower_reload` returned a reload schedule/result' },
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
/** @type {readonly string[]} */
|
|
102
|
+
export const LEGACY_TOOL_EVENT_NAMES = Object.freeze(LEGACY_TOOL_EVENT_TABLE.map((entry) => entry.name))
|
|
103
|
+
|
|
104
|
+
/** Total number of process-local events described by the generated reference. */
|
|
105
|
+
export const EVENT_COUNT = MISSION_EVENT_TABLE.length + LEGACY_TOOL_EVENT_TABLE.length
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The runtime name of a domain event type. Throws on an unknown type so a typo
|
|
109
|
+
* fails in tests instead of emitting an unobservable event.
|
|
110
|
+
* @param {string} type
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function missionEventName(type) {
|
|
114
|
+
const entry = /** @type {Record<string, MissionEventEntry>} */ (MISSION_EVENTS)[type]
|
|
115
|
+
if (!entry) throw new TypeError(`unknown mission event type "${type}"`)
|
|
116
|
+
return entry.name
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A domain transition's event *descriptor*: what changed, before the caller
|
|
121
|
+
* knows the persisted revision and timestamp. App code finalises it with
|
|
122
|
+
* `buildMissionEvent` after the state write.
|
|
123
|
+
* @param {MissionEventType} type
|
|
124
|
+
* @param {string} missionId
|
|
125
|
+
* @param {{ commit?: string }} [extra]
|
|
126
|
+
* @returns {{ type: MissionEventType, missionId: string, commit?: string }}
|
|
127
|
+
*/
|
|
128
|
+
export function missionEventDescriptor(type, missionId, extra = {}) {
|
|
129
|
+
if (!MISSION_EVENT_TYPES.includes(type)) throw new TypeError(`unknown mission event type "${type}"`)
|
|
130
|
+
if (typeof missionId !== 'string' || missionId.length === 0) {
|
|
131
|
+
throw new TypeError('mission events require a non-empty missionId')
|
|
132
|
+
}
|
|
133
|
+
const descriptor = /** @type {{ type: MissionEventType, missionId: string, commit?: string }} */ ({
|
|
134
|
+
type,
|
|
135
|
+
missionId,
|
|
136
|
+
})
|
|
137
|
+
if (type === 'merged') {
|
|
138
|
+
if (typeof extra.commit !== 'string' || extra.commit.length === 0) {
|
|
139
|
+
throw new TypeError('tower.mission.merged requires the merge commit')
|
|
140
|
+
}
|
|
141
|
+
descriptor.commit = extra.commit
|
|
142
|
+
}
|
|
143
|
+
return descriptor
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Finalise one descriptor into `{ name, payload }` for the event port.
|
|
148
|
+
* Payload shapes are fixed here so `docs/reference/events.md` (generated from
|
|
149
|
+
* the same table) cannot describe a different shape than the runtime emits.
|
|
150
|
+
* @param {{ type: MissionEventType, missionId: string, commit?: string }} descriptor
|
|
151
|
+
* @param {{ revision: number, at: number }} context
|
|
152
|
+
* @returns {{ name: string, payload: Record<string, any> }}
|
|
153
|
+
*/
|
|
154
|
+
export function buildMissionEvent(descriptor, { revision, at }) {
|
|
155
|
+
const entry = /** @type {Record<string, MissionEventEntry>} */ (MISSION_EVENTS)[descriptor.type]
|
|
156
|
+
if (!entry) throw new TypeError(`unknown mission event type "${descriptor.type}"`)
|
|
157
|
+
if (!Number.isInteger(revision)) throw new TypeError('mission events require the persisted integer revision')
|
|
158
|
+
if (!Number.isFinite(at)) throw new TypeError('mission events require the transition timestamp')
|
|
159
|
+
/** @type {Record<string, any>} */
|
|
160
|
+
const payload = { missionId: descriptor.missionId, revision, at }
|
|
161
|
+
if (entry.payload.includes('commit')) payload.commit = descriptor.commit
|
|
162
|
+
return { name: entry.name, payload }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Descriptors for a set of freshly persisted missions.
|
|
167
|
+
* @param {Array<{ id: string }>} missions
|
|
168
|
+
* @returns {Array<{ type: MissionEventType, missionId: string }>}
|
|
169
|
+
*/
|
|
170
|
+
export function plannedEvents(missions) {
|
|
171
|
+
return missions.map((mission) => missionEventDescriptor('planned', mission.id))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {{ id: string }} mission
|
|
176
|
+
* @returns {{ type: MissionEventType, missionId: string }}
|
|
177
|
+
*/
|
|
178
|
+
export function completedEvent(mission) {
|
|
179
|
+
return missionEventDescriptor('completed', mission.id)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {{ id: string }} mission
|
|
184
|
+
* @returns {{ type: MissionEventType, missionId: string }}
|
|
185
|
+
*/
|
|
186
|
+
export function reviewedEvent(mission) {
|
|
187
|
+
return missionEventDescriptor('reviewed', mission.id)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {{ id: string }} mission
|
|
192
|
+
* @param {string} commit
|
|
193
|
+
* @returns {{ type: MissionEventType, missionId: string, commit: string }}
|
|
194
|
+
*/
|
|
195
|
+
export function mergedEvent(mission, commit) {
|
|
196
|
+
return /** @type {{ type: MissionEventType, missionId: string, commit: string }} */ (
|
|
197
|
+
missionEventDescriptor('merged', mission.id, { commit })
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {{ id: string }} mission
|
|
203
|
+
* @returns {{ type: MissionEventType, missionId: string }}
|
|
204
|
+
*/
|
|
205
|
+
export function abandonedEvent(mission) {
|
|
206
|
+
return missionEventDescriptor('abandoned', mission.id)
|
|
207
|
+
}
|