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/host/doctor.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// Read-only environment contract and health checks for a Tower repository.
|
|
2
|
+
import { execFile } from '../adapters/vcs-git.js'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
7
|
+
import { git } from '../adapters/git.js'
|
|
8
|
+
import { readStateLock, readGitLock, gitLockView } from '../adapters/store.js'
|
|
9
|
+
import { lockTiming } from '../adapters/persistence.js'
|
|
10
|
+
import { inspectArchive } from '../adapters/archive.js'
|
|
11
|
+
import { inspectStateVersion } from '../domain/state.js'
|
|
12
|
+
import { SCHEMA_VERSION } from '../domain/mission.js'
|
|
13
|
+
import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
|
|
14
|
+
import { comparisonMode } from '../domain/scope.js'
|
|
15
|
+
import { configSurface } from '../domain/config/schema.js'
|
|
16
|
+
import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
|
|
17
|
+
import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
|
|
18
|
+
import { profileConfig } from './config.js'
|
|
19
|
+
import { inspectWorkspaceLock } from '../adapters/workspaceLock.js'
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile)
|
|
22
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
23
|
+
|
|
24
|
+
/** Read the committed concept-budget baseline read-only; null when absent. */
|
|
25
|
+
async function readSurfaceBaseline() {
|
|
26
|
+
try {
|
|
27
|
+
return parseBaseline(await fsp.readFile(path.join(PACKAGE_ROOT, SURFACE_BASELINE_FILE), 'utf8'))
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function exists(target) {
|
|
34
|
+
try {
|
|
35
|
+
await fsp.stat(target)
|
|
36
|
+
return true
|
|
37
|
+
} catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function gitVersion() {
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await execFileAsync('git', ['--version'], { windowsHide: true })
|
|
45
|
+
return String(stdout).trim()
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function check(id, status, detail, action) {
|
|
52
|
+
return { id, status, detail, ...(action ? { action } : {}) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render a lock age the same way the state-lock checker does. */
|
|
56
|
+
function durationText(ms) {
|
|
57
|
+
if (!Number.isFinite(ms)) return 'an unknown duration'
|
|
58
|
+
return `${Math.max(0, Math.round(ms / 1000))} s`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read the on-disk state version without loading, migrating, or locking. */
|
|
62
|
+
async function inspectStateSchema(paths) {
|
|
63
|
+
try {
|
|
64
|
+
const text = await fsp.readFile(paths.state, 'utf8')
|
|
65
|
+
return inspectStateVersion(JSON.parse(text), paths.state)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return {
|
|
68
|
+
status: 'invalid',
|
|
69
|
+
currentVersion: null,
|
|
70
|
+
supportedVersion: SCHEMA_VERSION,
|
|
71
|
+
migrations: [],
|
|
72
|
+
message: `tower state could not be inspected: ${String(error?.message ?? error)}`,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Report the on-disk schema version and the migration that a write would apply.
|
|
79
|
+
* Doctor never migrates: a read-only call must leave the file byte-identical.
|
|
80
|
+
*/
|
|
81
|
+
function stateSchemaReport(info) {
|
|
82
|
+
const stateSchema = {
|
|
83
|
+
currentVersion: info.currentVersion,
|
|
84
|
+
supportedVersion: info.supportedVersion,
|
|
85
|
+
migrations: info.migrations ?? [],
|
|
86
|
+
status: info.status,
|
|
87
|
+
}
|
|
88
|
+
if (info.status === 'current') {
|
|
89
|
+
return {
|
|
90
|
+
stateSchema,
|
|
91
|
+
check: check('state-schema', 'pass', `schemaVersion ${info.currentVersion} is current`),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (info.status === 'migratable') {
|
|
95
|
+
return {
|
|
96
|
+
stateSchema,
|
|
97
|
+
check: check(
|
|
98
|
+
'state-schema',
|
|
99
|
+
'warn',
|
|
100
|
+
`schemaVersion ${info.currentVersion} is older than ${info.supportedVersion}; the next locked write migrates it and keeps state.json.v${info.currentVersion}.bak`,
|
|
101
|
+
'a write tool applies the migration; a read-only tool only migrates in memory',
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (info.status === 'newer') {
|
|
106
|
+
return {
|
|
107
|
+
stateSchema,
|
|
108
|
+
check: check(
|
|
109
|
+
'state-schema',
|
|
110
|
+
'fail',
|
|
111
|
+
`schemaVersion ${info.currentVersion} is newer than this build supports (${info.supportedVersion}); state was written by a newer dsh-tower`,
|
|
112
|
+
'upgrade dsh-tower; this build refuses to downgrade state.json',
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (info.status === 'unmigratable') {
|
|
117
|
+
return {
|
|
118
|
+
stateSchema,
|
|
119
|
+
check: check(
|
|
120
|
+
'state-schema',
|
|
121
|
+
'fail',
|
|
122
|
+
`schemaVersion ${info.currentVersion} has no migration to ${info.supportedVersion}`,
|
|
123
|
+
'restore a state.json written by a supported dsh-tower; this build will not guess at an unknown schema',
|
|
124
|
+
),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
stateSchema,
|
|
129
|
+
check: check(
|
|
130
|
+
'state-schema',
|
|
131
|
+
'fail',
|
|
132
|
+
info.message ?? 'state schemaVersion is unreadable',
|
|
133
|
+
'restore a state.json this build can read, or run tower_init for a new repository',
|
|
134
|
+
),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Run every read-only diagnostic; never writes state or takes the state lock. */
|
|
139
|
+
export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows, surface }) {
|
|
140
|
+
const checks = []
|
|
141
|
+
let reportsReport = null
|
|
142
|
+
let stateLockReport = null
|
|
143
|
+
const version = await gitVersion()
|
|
144
|
+
checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
|
|
145
|
+
|
|
146
|
+
const detectedHost = hostVersion ?? detectHostVersion(ctx)
|
|
147
|
+
const compatibilityRows = readCompatibility(compatibilityFile)
|
|
148
|
+
const hostAssessment = assessHostVersion(detectedHost, compatibilityRows)
|
|
149
|
+
checks.push(
|
|
150
|
+
check(
|
|
151
|
+
'host-version',
|
|
152
|
+
hostAssessment.verdict === 'verified' ? 'pass' : 'warn',
|
|
153
|
+
detectedHost
|
|
154
|
+
? `${detectedHost}: ${hostAssessment.detail}`
|
|
155
|
+
: `unknown host version; ${hostAssessment.detail}. Include host version + COMPATIBILITY.md row in bug reports.`,
|
|
156
|
+
'see COMPATIBILITY.md for verified host builds',
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const rows = Array.isArray(loaderRows)
|
|
161
|
+
? loaderRows
|
|
162
|
+
: (() => {
|
|
163
|
+
try {
|
|
164
|
+
const loader = ctx?.get?.('loader') ?? ctx?.loader
|
|
165
|
+
const source = loader?.entries ?? loader?.rows ?? loader?.store?.entries
|
|
166
|
+
if (Array.isArray(source)) return source
|
|
167
|
+
if (source && typeof source === 'object') return Object.values(source)
|
|
168
|
+
} catch {
|
|
169
|
+
// loader inventory is optional
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
})()
|
|
173
|
+
if (rows === null) {
|
|
174
|
+
checks.push(check('loader-conflicts', 'warn', 'loader inventory unavailable; cannot detect co-installed Tower packages'))
|
|
175
|
+
} else {
|
|
176
|
+
const towerRows = rows.filter((row) => row && row.name === 'dsh-tower')
|
|
177
|
+
const foreign = towerRows.filter((row) => row.id !== 'tower')
|
|
178
|
+
checks.push(
|
|
179
|
+
towerRows.length > 1 || foreign.length > 0
|
|
180
|
+
? check(
|
|
181
|
+
'loader-conflicts',
|
|
182
|
+
'fail',
|
|
183
|
+
`another loader row resolves to "dsh-tower" (${towerRows.map((row) => row.id ?? '?').join(', ')}); uninstall the conflicting dsh-plugins-tower row`,
|
|
184
|
+
'see README "Known conflicts"',
|
|
185
|
+
)
|
|
186
|
+
: check('loader-conflicts', 'pass', 'no conflicting dsh-tower loader rows'),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
|
|
191
|
+
// The repo-scoped git mutex can legally be held for a whole merge (15 min by
|
|
192
|
+
// default). Report the holder so a stalled tower has a visible cause (#93).
|
|
193
|
+
const gitLock = gitLockView(await readGitLock(tower.paths))
|
|
194
|
+
checks.push(
|
|
195
|
+
gitLock.held
|
|
196
|
+
? check(
|
|
197
|
+
'git-lock',
|
|
198
|
+
'warn',
|
|
199
|
+
`held by pid ${gitLock.pid ?? 'unknown'}${gitLock.phase ? ` (${gitLock.phase})` : ''} for ${durationText(gitLock.heldMs)}; the repo-scoped git mutex is busy`,
|
|
200
|
+
'wait for the git mutation to finish; tower_status reports the same holder',
|
|
201
|
+
)
|
|
202
|
+
: check('git-lock', 'pass', 'free'),
|
|
203
|
+
)
|
|
204
|
+
const schemaInfo = await inspectStateSchema(tower.paths)
|
|
205
|
+
let state = null
|
|
206
|
+
try {
|
|
207
|
+
state = await tower.load()
|
|
208
|
+
checks.push(check('state', 'pass', `${tower.paths.state} revision ${state.revision}`))
|
|
209
|
+
checks.push(
|
|
210
|
+
state.towerAgentId
|
|
211
|
+
? check('tower-owner', 'pass', `bound to ${String(state.towerAgentId)}`)
|
|
212
|
+
: check('tower-owner', 'warn', 'state has no towerAgentId; write tools will fail closed', 'run tower_init with the restored session to re-claim ownership'),
|
|
213
|
+
)
|
|
214
|
+
} catch (error) {
|
|
215
|
+
checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
|
|
216
|
+
}
|
|
217
|
+
const schemaReport = stateSchemaReport(schemaInfo)
|
|
218
|
+
checks.push(schemaReport.check)
|
|
219
|
+
|
|
220
|
+
// Scope matching is only as good as the detected filesystem mode (#90).
|
|
221
|
+
// Doctor never probes: a read-only call must not create files. A legacy state
|
|
222
|
+
// that has not been re-initialized therefore reports the safe exact default.
|
|
223
|
+
const caseInsensitive =
|
|
224
|
+
state && (state.caseInsensitive === true || state.caseInsensitive === false) ? state.caseInsensitive : null
|
|
225
|
+
const comparison = comparisonMode(caseInsensitive)
|
|
226
|
+
checks.push(
|
|
227
|
+
caseInsensitive === null
|
|
228
|
+
? check(
|
|
229
|
+
'case-mode',
|
|
230
|
+
'warn',
|
|
231
|
+
`caseInsensitive: not detected; scope matching uses ${comparison} comparison`,
|
|
232
|
+
'run tower_init on a writable checkout to probe and record the filesystem mode',
|
|
233
|
+
)
|
|
234
|
+
: check(
|
|
235
|
+
'case-mode',
|
|
236
|
+
'pass',
|
|
237
|
+
`caseInsensitive: ${caseInsensitive}; scope matching uses ${comparison} comparison`,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const config = tower.config ?? {}
|
|
242
|
+
const profile = profileConfig()
|
|
243
|
+
const staleDefaults = []
|
|
244
|
+
if (profile.review && profile.review.requireReview === undefined) {
|
|
245
|
+
staleDefaults.push('review.requireReview now defaults to false (2.0.3)')
|
|
246
|
+
}
|
|
247
|
+
if (profile.mode && profile.mode.allowAgentControl === undefined) {
|
|
248
|
+
staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
|
|
249
|
+
}
|
|
250
|
+
checks.push(
|
|
251
|
+
staleDefaults.length > 0
|
|
252
|
+
? check('default-reversals', 'warn', staleDefaults.join('; '), 'pin the key explicitly in the profile if the old posture is required')
|
|
253
|
+
: check('default-reversals', 'pass', 'profile does not rely on a reversed default'),
|
|
254
|
+
)
|
|
255
|
+
checks.push(
|
|
256
|
+
check(
|
|
257
|
+
'config',
|
|
258
|
+
config.configPath && config.repoConfigPresent ? 'pass' : 'warn',
|
|
259
|
+
config.repoConfigPresent ? `repo config ${config.configPath}` : `repo config not present (${config.configPath ?? 'unknown'})`,
|
|
260
|
+
'optional: add <git-common-dir>/dsh-tower/config.json',
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
// #84: report the concept-budget surface instead of implying it. The counts
|
|
264
|
+
// are the live schema/tool surface; the date is the committed baseline, so a
|
|
265
|
+
// reader sees both the number and the decision it was frozen at.
|
|
266
|
+
const configCounts = configSurface()
|
|
267
|
+
const baseline = await readSurfaceBaseline()
|
|
268
|
+
const toolCount = Number.isInteger(surface?.tools) ? surface.tools : null
|
|
269
|
+
const surfaceCounts = {
|
|
270
|
+
configKeys: configCounts.count,
|
|
271
|
+
sections: configCounts.sections.length,
|
|
272
|
+
tools: toolCount,
|
|
273
|
+
}
|
|
274
|
+
const surfaceReport = {
|
|
275
|
+
...surfaceCounts,
|
|
276
|
+
baselineDate: baseline?.date ?? null,
|
|
277
|
+
line: surfaceLine(surfaceCounts),
|
|
278
|
+
}
|
|
279
|
+
checks.push(
|
|
280
|
+
toolCount === null
|
|
281
|
+
? check('surface', 'warn', `${surfaceReport.line} (tool count unavailable; pass the registered surface)`)
|
|
282
|
+
: check(
|
|
283
|
+
'surface',
|
|
284
|
+
'pass',
|
|
285
|
+
`${surfaceReport.line}${baseline?.date ? ` (baseline ${baseline.date})` : ''}`,
|
|
286
|
+
baseline?.date ? undefined : `commit ${SURFACE_BASELINE_FILE} so growth is compared against a decision`,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
const presets = ctx.get?.('agentPresets')
|
|
290
|
+
if (presets && typeof presets.list === 'function') {
|
|
291
|
+
try {
|
|
292
|
+
const available = (await presets.list()).map((entry) => String(entry.id))
|
|
293
|
+
const missing = [config.spawn?.workerPreset ?? 'tower-worker', config.spawn?.reviewerPreset ?? 'tower-reviewer']
|
|
294
|
+
.filter((id) => !available.includes(id))
|
|
295
|
+
checks.push(
|
|
296
|
+
missing.length === 0
|
|
297
|
+
? check('presets', 'pass', `worker=${config.spawn?.workerPreset ?? 'tower-worker'} reviewer=${config.spawn?.reviewerPreset ?? 'tower-reviewer'}`)
|
|
298
|
+
: check('presets', 'fail', `missing preset(s): ${missing.join(', ')}`, 'enable the Tower preset bundle and retry'),
|
|
299
|
+
)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
checks.push(check('presets', 'warn', `preset registry unavailable: ${String(error?.message ?? error)}`))
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
checks.push(check('presets', 'warn', 'agentPresets service is unavailable'))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const subagents = ctx.get?.('subagents')
|
|
308
|
+
checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
|
|
309
|
+
const hmr = ctx.get?.('hmr')
|
|
310
|
+
checks.push(hmr ? check('hmr', 'pass', 'service mounted') : check('hmr', 'warn', 'HMR is not mounted; /tower reload is unavailable'))
|
|
311
|
+
|
|
312
|
+
if (state) {
|
|
313
|
+
const lock = await readStateLock(tower.paths)
|
|
314
|
+
const timing = lockTiming(lock.record)
|
|
315
|
+
stateLockReport = {
|
|
316
|
+
held: Boolean(lock.record),
|
|
317
|
+
pid: lock.record?.pid ?? null,
|
|
318
|
+
startedAt: lock.record?.startedAt ?? null,
|
|
319
|
+
at: lock.record?.at ?? null,
|
|
320
|
+
alive: lock.alive,
|
|
321
|
+
ageMs: timing.ageMs,
|
|
322
|
+
heartbeatAgeMs: timing.heartbeatAgeMs,
|
|
323
|
+
staleAfterMs: timing.staleAfterMs,
|
|
324
|
+
stale: timing.stale,
|
|
325
|
+
}
|
|
326
|
+
// The PID alone cannot distinguish a live writer from a recycled PID
|
|
327
|
+
// (#92); report the heartbeat age so a stuck tower is diagnosable without
|
|
328
|
+
// reading the file.
|
|
329
|
+
const heartbeatText =
|
|
330
|
+
timing.heartbeatAgeMs === null
|
|
331
|
+
? 'no heartbeat timestamp'
|
|
332
|
+
: `last heartbeat ${timing.heartbeatAgeMs}ms ago (stale after ${timing.staleAfterMs}ms)`
|
|
333
|
+
checks.push(
|
|
334
|
+
!lock.record
|
|
335
|
+
? check('state-lock', 'pass', 'no state lock file')
|
|
336
|
+
: lock.alive === false
|
|
337
|
+
? check(
|
|
338
|
+
'state-lock',
|
|
339
|
+
'warn',
|
|
340
|
+
`stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone; ${heartbeatText}); the next mutation reclaims it atomically`,
|
|
341
|
+
)
|
|
342
|
+
: timing.stale
|
|
343
|
+
? check(
|
|
344
|
+
'state-lock',
|
|
345
|
+
'warn',
|
|
346
|
+
`state lock pid ${lock.record.pid ?? 'unknown'} is alive but silent (${heartbeatText}); the next mutation reclaims it after byte verification, or tower_recover clear-stale-lock force:true moves it to a backup`,
|
|
347
|
+
)
|
|
348
|
+
: check(
|
|
349
|
+
'state-lock',
|
|
350
|
+
'warn',
|
|
351
|
+
`state lock held by pid ${lock.record.pid ?? 'unknown'}; ${heartbeatText}; retry after it finishes`,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
|
|
355
|
+
try {
|
|
356
|
+
const lock = await inspectWorkspaceLock(tower.commonDir, {
|
|
357
|
+
staleMs: Number(config.mode?.workspaceStaleMs) || 300000,
|
|
358
|
+
})
|
|
359
|
+
checks.push(lock.held ? check('workspace-lock', lock.stale ? 'warn' : 'pass', `held by ${lock.holder?.sessionId ?? 'unknown'}`) : check('workspace-lock', 'pass', 'free'))
|
|
360
|
+
} catch {
|
|
361
|
+
checks.push(check('workspace-lock', 'warn', `lock file ${lockDir} could not be inspected`))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const live = (state.roster ?? []).filter((entry) => !['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive'].includes(entry.state))
|
|
365
|
+
checks.push(
|
|
366
|
+
live.length > 0
|
|
367
|
+
? check('roster', 'pass', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`)
|
|
368
|
+
: check('roster', 'pass', 'no live workers'),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
let missingWorktrees = 0
|
|
372
|
+
let dirtyWorktrees = 0
|
|
373
|
+
let removedWorktrees = 0
|
|
374
|
+
for (const mission of state.missions ?? []) {
|
|
375
|
+
if (!mission.worktree) continue
|
|
376
|
+
if (mission.worktreeRemovedAt) {
|
|
377
|
+
removedWorktrees += 1
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
await tower.assertWorktree(mission.worktree)
|
|
382
|
+
if (await exists(mission.worktree.path)) {
|
|
383
|
+
const dirty = (await git(mission.worktree.path, ['status', '--porcelain'])).trim()
|
|
384
|
+
if (dirty) dirtyWorktrees += 1
|
|
385
|
+
} else {
|
|
386
|
+
missingWorktrees += 1
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
missingWorktrees += 1
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const removedNote = removedWorktrees > 0 ? ` (${removedWorktrees} removed by teardown)` : ''
|
|
393
|
+
checks.push(
|
|
394
|
+
missingWorktrees > 0
|
|
395
|
+
? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, 'inspect tower_status, restore the worktree/branch, then re-run tower_doctor')
|
|
396
|
+
: dirtyWorktrees > 0
|
|
397
|
+
? check('worktrees', 'warn', `${dirtyWorktrees} worktree(s) have uncommitted changes${removedNote}`)
|
|
398
|
+
: check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
|
|
402
|
+
checks.push(
|
|
403
|
+
check(
|
|
404
|
+
'tests',
|
|
405
|
+
config.tests?.enabled === false ? 'warn' : 'pass',
|
|
406
|
+
config.tests?.enabled === false ? 'test discovery disabled' : `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
|
|
410
|
+
checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
|
|
411
|
+
|
|
412
|
+
// Completion reports are the largest model-supplied strings in state.json;
|
|
413
|
+
// report the biggest one so growth is visible before it becomes a problem.
|
|
414
|
+
const reportSizes = (state.missions ?? [])
|
|
415
|
+
.map((mission) => ({ missionId: mission.id, bytes: completionReportBytes(mission.completionReport) }))
|
|
416
|
+
.filter((entry) => entry.bytes !== null)
|
|
417
|
+
const largestReport = reportSizes.reduce(
|
|
418
|
+
(best, entry) => (best === null || entry.bytes > best.bytes ? entry : best),
|
|
419
|
+
null,
|
|
420
|
+
)
|
|
421
|
+
const reportCap = Number(tower.config?.gates?.maxReportBytes) || DEFAULT_MAX_REPORT_BYTES
|
|
422
|
+
reportsReport = { count: reportSizes.length, largest: largestReport, capBytes: reportCap }
|
|
423
|
+
checks.push(
|
|
424
|
+
largestReport
|
|
425
|
+
? check(
|
|
426
|
+
'reports',
|
|
427
|
+
largestReport.bytes > reportCap ? 'warn' : 'pass',
|
|
428
|
+
`${reportSizes.length} completion report(s); largest ${largestReport.bytes} B on ${largestReport.missionId} (cap ${reportCap} B)`,
|
|
429
|
+
largestReport.bytes > reportCap
|
|
430
|
+
? 'existing reports predate the cap; reads are unchanged and no rewrite happens'
|
|
431
|
+
: undefined,
|
|
432
|
+
)
|
|
433
|
+
: check('reports', 'pass', `no completion reports (cap ${reportCap} B)`),
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const { inspectLeases } = await import('../adapters/leases.js')
|
|
439
|
+
const leases = await inspectLeases(tower.commonDir)
|
|
440
|
+
checks.push(check('leases', leases.leases?.length ? 'warn' : 'pass', `${leases.leases?.length ?? 0} active lease(s)`))
|
|
441
|
+
} catch (error) {
|
|
442
|
+
const corrupt = error?.code === 'state-corrupt'
|
|
443
|
+
checks.push(
|
|
444
|
+
check(
|
|
445
|
+
'leases',
|
|
446
|
+
corrupt ? 'fail' : 'warn',
|
|
447
|
+
corrupt ? `lease ledger is corrupt: ${String(error?.message ?? error)}` : 'lease module unavailable',
|
|
448
|
+
corrupt ? 'inspect <git-common-dir>/dsh-tower/leases.json and repair or remove it' : undefined,
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
const text = await fsp.readFile(path.join(tower.paths.dir, 'lock-holds.jsonl'), 'utf8')
|
|
455
|
+
// These percentiles are about the state lock. Git-mutex holds are logged
|
|
456
|
+
// too (kind: 'git' / the pre-#93 phase: 'git' form) and can legitimately
|
|
457
|
+
// span a merge, so they must not be counted as slow state mutations.
|
|
458
|
+
const samples = text
|
|
459
|
+
.split('\n')
|
|
460
|
+
.filter(Boolean)
|
|
461
|
+
.slice(-200)
|
|
462
|
+
.map((line) => JSON.parse(line))
|
|
463
|
+
.filter((hold) => hold.kind !== 'git' && hold.phase !== 'git')
|
|
464
|
+
.map((hold) => hold.ms)
|
|
465
|
+
.filter((ms) => Number.isFinite(ms))
|
|
466
|
+
.sort((a, b) => a - b)
|
|
467
|
+
if (samples.length === 0) {
|
|
468
|
+
checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
|
|
469
|
+
} else {
|
|
470
|
+
const p = (q) => samples[Math.min(samples.length - 1, Math.floor(q * samples.length))]
|
|
471
|
+
const p50 = p(0.5)
|
|
472
|
+
const p95 = p(0.95)
|
|
473
|
+
checks.push(
|
|
474
|
+
check(
|
|
475
|
+
'lock-holds',
|
|
476
|
+
p95 > 1000 ? 'warn' : 'pass',
|
|
477
|
+
`${samples.length} sample(s): p50 ${p50}ms, p95 ${p95}ms`,
|
|
478
|
+
p95 > 1000 ? 'a mutation is holding the state lock across slow work; inspect the activity/lock-holds log' : undefined,
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Archive inventory (#73): size, count and the configured trigger policy.
|
|
487
|
+
// A missing directory is a healthy empty archive; a corrupt file is a warn.
|
|
488
|
+
const archiveInventory = await inspectArchive(tower.paths)
|
|
489
|
+
const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
|
|
490
|
+
const archivePolicy = config.archive ?? {}
|
|
491
|
+
const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${archivePolicy.enabled === false ? ' disabled' : ''}`
|
|
492
|
+
if (!archiveInventory.exists) {
|
|
493
|
+
checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
|
|
494
|
+
} else if (archiveInventory.unreadable.length > 0) {
|
|
495
|
+
checks.push(
|
|
496
|
+
check(
|
|
497
|
+
'archive',
|
|
498
|
+
'warn',
|
|
499
|
+
`${archiveInventory.missions} archived mission(s), ${archiveInventory.bytes} bytes; unreadable: ${archiveInventory.unreadable[0]}`,
|
|
500
|
+
'inspect <git-common-dir>/dsh-tower/archive/ and repair or move the corrupt file; archiving is paused until it is readable',
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
} else {
|
|
504
|
+
checks.push(
|
|
505
|
+
check(
|
|
506
|
+
'archive',
|
|
507
|
+
'pass',
|
|
508
|
+
`${archiveInventory.missions} archived mission(s) in ${archiveInventory.years.length} year file(s), ${archiveInventory.bytes} bytes; ${policyText}`,
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const failed = checks.filter((item) => item.status === 'fail').length
|
|
514
|
+
return {
|
|
515
|
+
ok: failed === 0,
|
|
516
|
+
checks,
|
|
517
|
+
stateLock: stateLockReport,
|
|
518
|
+
gitLock,
|
|
519
|
+
stateSchema: schemaReport.stateSchema,
|
|
520
|
+
// reports (from #89) and archive (from #73) are both kept
|
|
521
|
+
reports: reportsReport,
|
|
522
|
+
surface: surfaceReport,
|
|
523
|
+
archive: {
|
|
524
|
+
missions: state ? state.missions.length : null,
|
|
525
|
+
archivedMissions: archiveInventory.missions,
|
|
526
|
+
stateBytes,
|
|
527
|
+
archiveBytes: archiveInventory.bytes,
|
|
528
|
+
dir: archiveInventory.dir,
|
|
529
|
+
years: archiveInventory.years,
|
|
530
|
+
files: archiveInventory.files.length,
|
|
531
|
+
unreadable: archiveInventory.unreadable,
|
|
532
|
+
policy: {
|
|
533
|
+
enabled: archivePolicy.enabled !== false,
|
|
534
|
+
afterDays: archivePolicy.afterDays ?? 30,
|
|
535
|
+
maxMissions: archivePolicy.maxMissions ?? 100,
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
environmentContract: {
|
|
539
|
+
platform: process.platform,
|
|
540
|
+
gitVersion: version,
|
|
541
|
+
repoRoot: tower.root,
|
|
542
|
+
commonDir: tower.commonDir,
|
|
543
|
+
stateDir: tower.paths.dir,
|
|
544
|
+
caseInsensitive,
|
|
545
|
+
comparisonMode: comparison,
|
|
546
|
+
gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
|
|
547
|
+
mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
|
|
548
|
+
testsAdmission: tower.config.tests?.admission ?? 'off',
|
|
549
|
+
hostVersion: detectedHost,
|
|
550
|
+
hostCompatibility: hostAssessment.verdict,
|
|
551
|
+
hostCompatibilityDetail: hostAssessment.detail,
|
|
552
|
+
},
|
|
553
|
+
mode: agent ? 'ready' : 'unknown',
|
|
554
|
+
}
|
|
555
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { fs } from '../adapters/store-fs.js'
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { acquireLockSync, readJsonSync, writeJsonAtomicSync } from '
|
|
9
|
+
import { acquireLockSync, readJsonSync, writeJsonAtomicSync } from '../adapters/persistence.js'
|
|
10
10
|
|
|
11
11
|
const DEFAULT_MAX_ENTRIES = 200
|
|
12
12
|
|