dsh-tower 2.0.4 → 2.1.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 +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- 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/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- 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 +427 -0
- package/domain/contracts/outputs.js +305 -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 +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -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 +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -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 +9 -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 +212 -0
- package/scripts/release-check.mjs +303 -0
- 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 +45 -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 +102 -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 +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
package/lib/doctor.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
// Read-only environment contract and health checks for a Tower repository.
|
|
2
|
-
import { execFile } from '
|
|
2
|
+
import { execFile } from '../adapters/vcs-git.js'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import fsp from '
|
|
5
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
6
6
|
import { git } from './git.js'
|
|
7
7
|
import { readStateLock } from './store.js'
|
|
8
|
+
import { inspectArchive } from './archive.js'
|
|
9
|
+
import { inspectStateVersion } from '../domain/state.js'
|
|
10
|
+
import { SCHEMA_VERSION } from '../domain/mission.js'
|
|
11
|
+
import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
|
|
12
|
+
import { comparisonMode } from '../domain/scope.js'
|
|
13
|
+
import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
|
|
14
|
+
import { profileConfig } from './config.js'
|
|
8
15
|
import { inspectWorkspaceLock } from './workspaceLock.js'
|
|
9
16
|
|
|
10
17
|
const execFileAsync = promisify(execFile)
|
|
@@ -31,22 +38,186 @@ function check(id, status, detail, action) {
|
|
|
31
38
|
return { id, status, detail, ...(action ? { action } : {}) }
|
|
32
39
|
}
|
|
33
40
|
|
|
41
|
+
/** Read the on-disk state version without loading, migrating, or locking. */
|
|
42
|
+
async function inspectStateSchema(paths) {
|
|
43
|
+
try {
|
|
44
|
+
const text = await fsp.readFile(paths.state, 'utf8')
|
|
45
|
+
return inspectStateVersion(JSON.parse(text), paths.state)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return {
|
|
48
|
+
status: 'invalid',
|
|
49
|
+
currentVersion: null,
|
|
50
|
+
supportedVersion: SCHEMA_VERSION,
|
|
51
|
+
migrations: [],
|
|
52
|
+
message: `tower state could not be inspected: ${String(error?.message ?? error)}`,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Report the on-disk schema version and the migration that a write would apply.
|
|
59
|
+
* Doctor never migrates: a read-only call must leave the file byte-identical.
|
|
60
|
+
*/
|
|
61
|
+
function stateSchemaReport(info) {
|
|
62
|
+
const stateSchema = {
|
|
63
|
+
currentVersion: info.currentVersion,
|
|
64
|
+
supportedVersion: info.supportedVersion,
|
|
65
|
+
migrations: info.migrations ?? [],
|
|
66
|
+
status: info.status,
|
|
67
|
+
}
|
|
68
|
+
if (info.status === 'current') {
|
|
69
|
+
return {
|
|
70
|
+
stateSchema,
|
|
71
|
+
check: check('state-schema', 'pass', `schemaVersion ${info.currentVersion} is current`),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (info.status === 'migratable') {
|
|
75
|
+
return {
|
|
76
|
+
stateSchema,
|
|
77
|
+
check: check(
|
|
78
|
+
'state-schema',
|
|
79
|
+
'warn',
|
|
80
|
+
`schemaVersion ${info.currentVersion} is older than ${info.supportedVersion}; the next locked write migrates it and keeps state.json.v${info.currentVersion}.bak`,
|
|
81
|
+
'a write tool applies the migration; a read-only tool only migrates in memory',
|
|
82
|
+
),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (info.status === 'newer') {
|
|
86
|
+
return {
|
|
87
|
+
stateSchema,
|
|
88
|
+
check: check(
|
|
89
|
+
'state-schema',
|
|
90
|
+
'fail',
|
|
91
|
+
`schemaVersion ${info.currentVersion} is newer than this build supports (${info.supportedVersion}); state was written by a newer dsh-tower`,
|
|
92
|
+
'upgrade dsh-tower; this build refuses to downgrade state.json',
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (info.status === 'unmigratable') {
|
|
97
|
+
return {
|
|
98
|
+
stateSchema,
|
|
99
|
+
check: check(
|
|
100
|
+
'state-schema',
|
|
101
|
+
'fail',
|
|
102
|
+
`schemaVersion ${info.currentVersion} has no migration to ${info.supportedVersion}`,
|
|
103
|
+
'restore a state.json written by a supported dsh-tower; this build will not guess at an unknown schema',
|
|
104
|
+
),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
stateSchema,
|
|
109
|
+
check: check(
|
|
110
|
+
'state-schema',
|
|
111
|
+
'fail',
|
|
112
|
+
info.message ?? 'state schemaVersion is unreadable',
|
|
113
|
+
'restore a state.json this build can read, or run tower_init for a new repository',
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
34
118
|
/** Run every read-only diagnostic; never writes state or takes the state lock. */
|
|
35
|
-
export async function runDoctor({ tower, ctx, agent }) {
|
|
119
|
+
export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
|
|
36
120
|
const checks = []
|
|
121
|
+
let reportsReport = null
|
|
37
122
|
const version = await gitVersion()
|
|
38
123
|
checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
|
|
39
124
|
|
|
125
|
+
const detectedHost = hostVersion ?? detectHostVersion(ctx)
|
|
126
|
+
const compatibilityRows = readCompatibility(compatibilityFile)
|
|
127
|
+
const hostAssessment = assessHostVersion(detectedHost, compatibilityRows)
|
|
128
|
+
checks.push(
|
|
129
|
+
check(
|
|
130
|
+
'host-version',
|
|
131
|
+
hostAssessment.verdict === 'verified' ? 'pass' : 'warn',
|
|
132
|
+
detectedHost
|
|
133
|
+
? `${detectedHost}: ${hostAssessment.detail}`
|
|
134
|
+
: `unknown host version; ${hostAssessment.detail}. Include host version + COMPATIBILITY.md row in bug reports.`,
|
|
135
|
+
'see COMPATIBILITY.md for verified host builds',
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const rows = Array.isArray(loaderRows)
|
|
140
|
+
? loaderRows
|
|
141
|
+
: (() => {
|
|
142
|
+
try {
|
|
143
|
+
const loader = ctx?.get?.('loader') ?? ctx?.loader
|
|
144
|
+
const source = loader?.entries ?? loader?.rows ?? loader?.store?.entries
|
|
145
|
+
if (Array.isArray(source)) return source
|
|
146
|
+
if (source && typeof source === 'object') return Object.values(source)
|
|
147
|
+
} catch {
|
|
148
|
+
// loader inventory is optional
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
})()
|
|
152
|
+
if (rows === null) {
|
|
153
|
+
checks.push(check('loader-conflicts', 'warn', 'loader inventory unavailable; cannot detect co-installed Tower packages'))
|
|
154
|
+
} else {
|
|
155
|
+
const towerRows = rows.filter((row) => row && row.name === 'dsh-tower')
|
|
156
|
+
const foreign = towerRows.filter((row) => row.id !== 'tower')
|
|
157
|
+
checks.push(
|
|
158
|
+
towerRows.length > 1 || foreign.length > 0
|
|
159
|
+
? check(
|
|
160
|
+
'loader-conflicts',
|
|
161
|
+
'fail',
|
|
162
|
+
`another loader row resolves to "dsh-tower" (${towerRows.map((row) => row.id ?? '?').join(', ')}); uninstall the conflicting dsh-plugins-tower row`,
|
|
163
|
+
'see README "Known conflicts"',
|
|
164
|
+
)
|
|
165
|
+
: check('loader-conflicts', 'pass', 'no conflicting dsh-tower loader rows'),
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
40
169
|
checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
|
|
170
|
+
const schemaInfo = await inspectStateSchema(tower.paths)
|
|
41
171
|
let state = null
|
|
42
172
|
try {
|
|
43
173
|
state = await tower.load()
|
|
44
174
|
checks.push(check('state', 'pass', `${tower.paths.state} revision ${state.revision}`))
|
|
175
|
+
checks.push(
|
|
176
|
+
state.towerAgentId
|
|
177
|
+
? check('tower-owner', 'pass', `bound to ${String(state.towerAgentId)}`)
|
|
178
|
+
: check('tower-owner', 'warn', 'state has no towerAgentId; write tools will fail closed', 'run tower_init with the restored session to re-claim ownership'),
|
|
179
|
+
)
|
|
45
180
|
} catch (error) {
|
|
46
181
|
checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
|
|
47
182
|
}
|
|
183
|
+
const schemaReport = stateSchemaReport(schemaInfo)
|
|
184
|
+
checks.push(schemaReport.check)
|
|
185
|
+
|
|
186
|
+
// Scope matching is only as good as the detected filesystem mode (#90).
|
|
187
|
+
// Doctor never probes: a read-only call must not create files. A legacy state
|
|
188
|
+
// that has not been re-initialized therefore reports the safe exact default.
|
|
189
|
+
const caseInsensitive =
|
|
190
|
+
state && (state.caseInsensitive === true || state.caseInsensitive === false) ? state.caseInsensitive : null
|
|
191
|
+
const comparison = comparisonMode(caseInsensitive)
|
|
192
|
+
checks.push(
|
|
193
|
+
caseInsensitive === null
|
|
194
|
+
? check(
|
|
195
|
+
'case-mode',
|
|
196
|
+
'warn',
|
|
197
|
+
`caseInsensitive: not detected; scope matching uses ${comparison} comparison`,
|
|
198
|
+
'run tower_init on a writable checkout to probe and record the filesystem mode',
|
|
199
|
+
)
|
|
200
|
+
: check(
|
|
201
|
+
'case-mode',
|
|
202
|
+
'pass',
|
|
203
|
+
`caseInsensitive: ${caseInsensitive}; scope matching uses ${comparison} comparison`,
|
|
204
|
+
),
|
|
205
|
+
)
|
|
48
206
|
|
|
49
207
|
const config = tower.config ?? {}
|
|
208
|
+
const profile = profileConfig()
|
|
209
|
+
const staleDefaults = []
|
|
210
|
+
if (profile.review && profile.review.requireReview === undefined) {
|
|
211
|
+
staleDefaults.push('review.requireReview now defaults to false (2.0.3)')
|
|
212
|
+
}
|
|
213
|
+
if (profile.mode && profile.mode.allowAgentControl === undefined) {
|
|
214
|
+
staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
|
|
215
|
+
}
|
|
216
|
+
checks.push(
|
|
217
|
+
staleDefaults.length > 0
|
|
218
|
+
? check('default-reversals', 'warn', staleDefaults.join('; '), 'pin the key explicitly in the profile if the old posture is required')
|
|
219
|
+
: check('default-reversals', 'pass', 'profile does not rely on a reversed default'),
|
|
220
|
+
)
|
|
50
221
|
checks.push(
|
|
51
222
|
check(
|
|
52
223
|
'config',
|
|
@@ -98,7 +269,11 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
98
269
|
}
|
|
99
270
|
|
|
100
271
|
const live = (state.roster ?? []).filter((entry) => !['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive'].includes(entry.state))
|
|
101
|
-
checks.push(
|
|
272
|
+
checks.push(
|
|
273
|
+
live.length > 0
|
|
274
|
+
? check('roster', 'pass', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`)
|
|
275
|
+
: check('roster', 'pass', 'no live workers'),
|
|
276
|
+
)
|
|
102
277
|
|
|
103
278
|
let missingWorktrees = 0
|
|
104
279
|
let dirtyWorktrees = 0
|
|
@@ -140,6 +315,30 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
140
315
|
)
|
|
141
316
|
checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
|
|
142
317
|
checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
|
|
318
|
+
|
|
319
|
+
// Completion reports are the largest model-supplied strings in state.json;
|
|
320
|
+
// report the biggest one so growth is visible before it becomes a problem.
|
|
321
|
+
const reportSizes = (state.missions ?? [])
|
|
322
|
+
.map((mission) => ({ missionId: mission.id, bytes: completionReportBytes(mission.completionReport) }))
|
|
323
|
+
.filter((entry) => entry.bytes !== null)
|
|
324
|
+
const largestReport = reportSizes.reduce(
|
|
325
|
+
(best, entry) => (best === null || entry.bytes > best.bytes ? entry : best),
|
|
326
|
+
null,
|
|
327
|
+
)
|
|
328
|
+
const reportCap = Number(tower.config?.gates?.maxReportBytes) || DEFAULT_MAX_REPORT_BYTES
|
|
329
|
+
reportsReport = { count: reportSizes.length, largest: largestReport, capBytes: reportCap }
|
|
330
|
+
checks.push(
|
|
331
|
+
largestReport
|
|
332
|
+
? check(
|
|
333
|
+
'reports',
|
|
334
|
+
largestReport.bytes > reportCap ? 'warn' : 'pass',
|
|
335
|
+
`${reportSizes.length} completion report(s); largest ${largestReport.bytes} B on ${largestReport.missionId} (cap ${reportCap} B)`,
|
|
336
|
+
largestReport.bytes > reportCap
|
|
337
|
+
? 'existing reports predate the cap; reads are unchanged and no rewrite happens'
|
|
338
|
+
: undefined,
|
|
339
|
+
)
|
|
340
|
+
: check('reports', 'pass', `no completion reports (cap ${reportCap} B)`),
|
|
341
|
+
)
|
|
143
342
|
}
|
|
144
343
|
|
|
145
344
|
try {
|
|
@@ -158,19 +357,91 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
158
357
|
)
|
|
159
358
|
}
|
|
160
359
|
|
|
360
|
+
try {
|
|
361
|
+
const text = await fsp.readFile(path.join(tower.paths.dir, 'lock-holds.jsonl'), 'utf8')
|
|
362
|
+
const samples = text.split('\n').filter(Boolean).slice(-200).map((line) => JSON.parse(line).ms).filter((ms) => Number.isFinite(ms)).sort((a, b) => a - b)
|
|
363
|
+
if (samples.length === 0) {
|
|
364
|
+
checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
|
|
365
|
+
} else {
|
|
366
|
+
const p = (q) => samples[Math.min(samples.length - 1, Math.floor(q * samples.length))]
|
|
367
|
+
const p50 = p(0.5)
|
|
368
|
+
const p95 = p(0.95)
|
|
369
|
+
checks.push(
|
|
370
|
+
check(
|
|
371
|
+
'lock-holds',
|
|
372
|
+
p95 > 1000 ? 'warn' : 'pass',
|
|
373
|
+
`${samples.length} sample(s): p50 ${p50}ms, p95 ${p95}ms`,
|
|
374
|
+
p95 > 1000 ? 'a mutation is holding the state lock across slow work; inspect the activity/lock-holds log' : undefined,
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Archive inventory (#73): size, count and the configured trigger policy.
|
|
383
|
+
// A missing directory is a healthy empty archive; a corrupt file is a warn.
|
|
384
|
+
const archiveInventory = await inspectArchive(tower.paths)
|
|
385
|
+
const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
|
|
386
|
+
const archivePolicy = config.archive ?? {}
|
|
387
|
+
const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${archivePolicy.enabled === false ? ' disabled' : ''}`
|
|
388
|
+
if (!archiveInventory.exists) {
|
|
389
|
+
checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
|
|
390
|
+
} else if (archiveInventory.unreadable.length > 0) {
|
|
391
|
+
checks.push(
|
|
392
|
+
check(
|
|
393
|
+
'archive',
|
|
394
|
+
'warn',
|
|
395
|
+
`${archiveInventory.missions} archived mission(s), ${archiveInventory.bytes} bytes; unreadable: ${archiveInventory.unreadable[0]}`,
|
|
396
|
+
'inspect <git-common-dir>/dsh-tower/archive/ and repair or move the corrupt file; archiving is paused until it is readable',
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
} else {
|
|
400
|
+
checks.push(
|
|
401
|
+
check(
|
|
402
|
+
'archive',
|
|
403
|
+
'pass',
|
|
404
|
+
`${archiveInventory.missions} archived mission(s) in ${archiveInventory.years.length} year file(s), ${archiveInventory.bytes} bytes; ${policyText}`,
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
161
409
|
const failed = checks.filter((item) => item.status === 'fail').length
|
|
162
410
|
return {
|
|
163
411
|
ok: failed === 0,
|
|
164
412
|
checks,
|
|
413
|
+
stateSchema: schemaReport.stateSchema,
|
|
414
|
+
// reports (from #89) and archive (from #73) are both kept
|
|
415
|
+
reports: reportsReport,
|
|
416
|
+
archive: {
|
|
417
|
+
missions: state ? state.missions.length : null,
|
|
418
|
+
archivedMissions: archiveInventory.missions,
|
|
419
|
+
stateBytes,
|
|
420
|
+
archiveBytes: archiveInventory.bytes,
|
|
421
|
+
dir: archiveInventory.dir,
|
|
422
|
+
years: archiveInventory.years,
|
|
423
|
+
files: archiveInventory.files.length,
|
|
424
|
+
unreadable: archiveInventory.unreadable,
|
|
425
|
+
policy: {
|
|
426
|
+
enabled: archivePolicy.enabled !== false,
|
|
427
|
+
afterDays: archivePolicy.afterDays ?? 30,
|
|
428
|
+
maxMissions: archivePolicy.maxMissions ?? 100,
|
|
429
|
+
},
|
|
430
|
+
},
|
|
165
431
|
environmentContract: {
|
|
166
432
|
platform: process.platform,
|
|
167
433
|
gitVersion: version,
|
|
168
434
|
repoRoot: tower.root,
|
|
169
435
|
commonDir: tower.commonDir,
|
|
170
436
|
stateDir: tower.paths.dir,
|
|
437
|
+
caseInsensitive,
|
|
438
|
+
comparisonMode: comparison,
|
|
171
439
|
gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
|
|
172
440
|
mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
|
|
173
441
|
testsAdmission: tower.config.tests?.admission ?? 'off',
|
|
442
|
+
hostVersion: detectedHost,
|
|
443
|
+
hostCompatibility: hostAssessment.verdict,
|
|
444
|
+
hostCompatibilityDetail: hostAssessment.detail,
|
|
174
445
|
},
|
|
175
446
|
mode: agent ? 'ready' : 'unknown',
|
|
176
447
|
}
|
package/lib/git.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
1
2
|
// Minimal git port: array arguments only, bounded output, no shell.
|
|
2
|
-
import { execFile } from '
|
|
3
|
+
import { execFile } from '../adapters/vcs-git.js'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
|
|
5
6
|
const GIT_TIMEOUT_MS = 15000
|
|
6
7
|
const MAX_BUFFER = 16 * 1024 * 1024
|
|
7
8
|
let configuredTimeoutMs = GIT_TIMEOUT_MS
|
|
8
9
|
|
|
10
|
+
/** @typedef {{ timeoutMs?: number, maxBuffer?: number }} GitOptions */
|
|
11
|
+
/** @typedef {{ path: string, branch?: string, head?: string }} WorktreeEntry */
|
|
12
|
+
|
|
9
13
|
/** Override the default Git timeout from resolved Tower config. */
|
|
10
14
|
export function setGitTimeout(ms) {
|
|
11
15
|
const value = Number(ms)
|
|
12
16
|
if (Number.isFinite(value) && value >= 1000) configuredTimeoutMs = value
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
/** Process default retained for manual adapter use; Tower instances pass their own. */
|
|
20
|
+
export function configuredGitTimeout() {
|
|
21
|
+
return configuredTimeoutMs
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
const BLOCKED_GIT_ENV = new Set([
|
|
16
25
|
'GIT_DIR',
|
|
17
26
|
'GIT_WORK_TREE',
|
|
@@ -29,8 +38,13 @@ const BLOCKED_GIT_ENV = new Set([
|
|
|
29
38
|
'GIT_QUARANTINE_PATH',
|
|
30
39
|
])
|
|
31
40
|
|
|
32
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Git must not inherit a repository redirect from the DSH process environment.
|
|
43
|
+
* @param {NodeJS.ProcessEnv} [source]
|
|
44
|
+
* @returns {NodeJS.ProcessEnv}
|
|
45
|
+
*/
|
|
33
46
|
export function gitEnv(source = process.env) {
|
|
47
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
34
48
|
const env = {}
|
|
35
49
|
for (const [key, value] of Object.entries(source)) {
|
|
36
50
|
if (BLOCKED_GIT_ENV.has(key)) continue
|
|
@@ -39,17 +53,16 @@ export function gitEnv(source = process.env) {
|
|
|
39
53
|
return env
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
super(`git ${args.join(' ')} failed: ${detail}`)
|
|
45
|
-
this.name = 'GitError'
|
|
46
|
-
this.code = code
|
|
47
|
-
this.timedOut = meta.timedOut === true
|
|
48
|
-
this.bufferOverflow = meta.bufferOverflow === true
|
|
49
|
-
this.stdout = meta.stdout ?? ''
|
|
50
|
-
}
|
|
51
|
-
}
|
|
56
|
+
import { GitError } from '../domain/errors.js'
|
|
57
|
+
export { GitError }
|
|
52
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Run one git command with bounded output and no shell.
|
|
61
|
+
* @param {string} cwd
|
|
62
|
+
* @param {string[]} args
|
|
63
|
+
* @param {GitOptions} [options]
|
|
64
|
+
* @returns {Promise<string>}
|
|
65
|
+
*/
|
|
53
66
|
export function git(cwd, args, options = {}) {
|
|
54
67
|
return new Promise((resolve, reject) => {
|
|
55
68
|
execFile(
|
|
@@ -82,6 +95,13 @@ export function git(cwd, args, options = {}) {
|
|
|
82
95
|
})
|
|
83
96
|
}
|
|
84
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Run git when its failure is not an error (existence probes).
|
|
100
|
+
* @param {string} cwd
|
|
101
|
+
* @param {string[]} args
|
|
102
|
+
* @param {GitOptions} [options]
|
|
103
|
+
* @returns {Promise<string | null>}
|
|
104
|
+
*/
|
|
85
105
|
export async function tryGit(cwd, args, options) {
|
|
86
106
|
try {
|
|
87
107
|
return await git(cwd, args, options)
|
|
@@ -93,17 +113,29 @@ export async function tryGit(cwd, args, options) {
|
|
|
93
113
|
/**
|
|
94
114
|
* Resolve an absolute caller path to the repository root, its common git dir
|
|
95
115
|
* (shared by all worktrees), and the current branch of that checkout.
|
|
116
|
+
* @param {string} repoRoot
|
|
117
|
+
* @returns {Promise<{ root: string, commonDir: string, branch: string | null }>}
|
|
96
118
|
*/
|
|
97
119
|
export async function resolveRepo(repoRoot) {
|
|
98
120
|
if (typeof repoRoot !== 'string' || repoRoot.trim().length === 0) {
|
|
99
|
-
throw new
|
|
121
|
+
throw new TowerToolError('repo-invalid', 'repoRoot is required')
|
|
100
122
|
}
|
|
101
123
|
if (!path.isAbsolute(repoRoot)) {
|
|
102
|
-
throw new
|
|
124
|
+
throw new TowerToolError('repo-invalid', `repoRoot must be an absolute path: ${repoRoot}`)
|
|
125
|
+
}
|
|
126
|
+
let top
|
|
127
|
+
try {
|
|
128
|
+
top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new TowerToolError('repo-invalid', `not a git repository: ${repoRoot}`, { cause: error })
|
|
103
131
|
}
|
|
104
|
-
const top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
|
|
105
132
|
const root = path.resolve(top)
|
|
106
|
-
|
|
133
|
+
let commonRaw
|
|
134
|
+
try {
|
|
135
|
+
commonRaw = await git(root, ['rev-parse', '--git-common-dir'])
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new TowerToolError('repo-invalid', `not a git repository: ${root}`, { cause: error })
|
|
138
|
+
}
|
|
107
139
|
const commonDir = path.isAbsolute(commonRaw)
|
|
108
140
|
? path.resolve(commonRaw)
|
|
109
141
|
: path.resolve(root, commonRaw)
|
|
@@ -115,25 +147,30 @@ export async function resolveRepo(repoRoot) {
|
|
|
115
147
|
}
|
|
116
148
|
}
|
|
117
149
|
|
|
150
|
+
/** @param {string} root @returns {Promise<boolean>} */
|
|
118
151
|
export async function hasAnyCommit(root) {
|
|
119
152
|
const out = await tryGit(root, ['rev-list', '-n', '1', '--all'])
|
|
120
153
|
return Boolean(out && out.trim())
|
|
121
154
|
}
|
|
122
155
|
|
|
156
|
+
/** @param {string} root @param {string} branch @returns {Promise<boolean>} */
|
|
123
157
|
export async function localBranchExists(root, branch) {
|
|
124
158
|
const out = await tryGit(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
|
|
125
159
|
return out !== null
|
|
126
160
|
}
|
|
127
161
|
|
|
162
|
+
/** @param {string} cwd @param {string} ref @returns {Promise<string>} */
|
|
128
163
|
export async function revParse(cwd, ref) {
|
|
129
164
|
const out = await git(cwd, ['rev-parse', ref])
|
|
130
165
|
return out.trim()
|
|
131
166
|
}
|
|
132
167
|
|
|
168
|
+
/** @param {string} cwd @param {string} branch @returns {Promise<boolean>} */
|
|
133
169
|
export async function branchExists(cwd, branch) {
|
|
134
170
|
return (await localBranchExists(cwd, branch)) === true
|
|
135
171
|
}
|
|
136
172
|
|
|
173
|
+
/** @param {string} cwd @returns {Promise<string | null>} */
|
|
137
174
|
export async function currentBranch(cwd) {
|
|
138
175
|
const out = await tryGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
|
|
139
176
|
return out && out.trim() ? out.trim() : null
|
|
@@ -144,9 +181,12 @@ export async function addWorktree(cwd, { path: worktreePath, branch, commit }) {
|
|
|
144
181
|
return worktreePath
|
|
145
182
|
}
|
|
146
183
|
|
|
184
|
+
/** @param {string} cwd @returns {Promise<WorktreeEntry[]>} */
|
|
147
185
|
export async function listWorktrees(cwd) {
|
|
148
186
|
const out = await git(cwd, ['worktree', 'list', '--porcelain'])
|
|
187
|
+
/** @type {WorktreeEntry[]} */
|
|
149
188
|
const entries = []
|
|
189
|
+
/** @type {WorktreeEntry | null} */
|
|
150
190
|
let current = null
|
|
151
191
|
for (const line of out.split('\n')) {
|
|
152
192
|
if (line.startsWith('worktree ')) {
|
|
@@ -162,22 +202,26 @@ export async function listWorktrees(cwd) {
|
|
|
162
202
|
return entries
|
|
163
203
|
}
|
|
164
204
|
|
|
205
|
+
/** @param {string} cwd @returns {Promise<boolean>} */
|
|
165
206
|
export async function isClean(cwd) {
|
|
166
207
|
const out = await git(cwd, ['status', '--porcelain'])
|
|
167
208
|
return out.trim().length === 0
|
|
168
209
|
}
|
|
169
210
|
|
|
211
|
+
/** @param {string} cwd @returns {Promise<string | null>} */
|
|
170
212
|
export async function currentCommonDir(cwd) {
|
|
171
213
|
const raw = await tryGit(cwd, ['rev-parse', '--git-common-dir'])
|
|
172
214
|
if (raw === null) return null
|
|
173
215
|
return path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw)
|
|
174
216
|
}
|
|
175
217
|
|
|
218
|
+
/** @param {string} cwd @param {string} fromRef @returns {Promise<number>} */
|
|
176
219
|
export async function countCommits(cwd, fromRef) {
|
|
177
220
|
const out = await git(cwd, ['rev-list', '--count', `${fromRef}..HEAD`])
|
|
178
221
|
return Number(out.trim() || 0)
|
|
179
222
|
}
|
|
180
223
|
|
|
224
|
+
/** @param {string} cwd @param {string} ancestor @param {string} descendant @returns {Promise<boolean>} */
|
|
181
225
|
export async function isAncestor(cwd, ancestor, descendant) {
|
|
182
226
|
const out = await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant])
|
|
183
227
|
return out !== null
|
package/lib/hmr.js
CHANGED
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
// `restartReason` means the whole process must be restarted.
|
|
19
19
|
// - `runExclusive` rejects nested transactions; a tool call is never inside
|
|
20
20
|
// one, so a tool-triggered reload is safe.
|
|
21
|
-
import { TowerToolError } from '
|
|
21
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
22
22
|
|
|
23
23
|
/** Entry ids Tower expects to be active after a reconciliation. */
|
|
24
24
|
export const DEFAULT_REQUIRED_IDS = Object.freeze([
|
|
25
|
+
'tower-bundle',
|
|
25
26
|
'tower',
|
|
26
27
|
'tower-skills',
|
|
27
28
|
'preset-tower-worker',
|
|
@@ -94,7 +95,7 @@ function warn(logger, message) {
|
|
|
94
95
|
* @param {string[]} [options.requiredIds] Entry ids whose inactivity is reported as a failure.
|
|
95
96
|
* @param {number} [options.delayMs] Delay before a scheduled reload runs.
|
|
96
97
|
* @param {{ warn?: Function }} [options.logger] Optional logger for failed scheduled reloads.
|
|
97
|
-
* @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function }}
|
|
98
|
+
* @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function, dispose: Function }}
|
|
98
99
|
*/
|
|
99
100
|
export function createHmrBridge(ctx, options = {}) {
|
|
100
101
|
const requiredIds = Object.freeze(normalizeRequiredIds(options.requiredIds))
|
|
@@ -186,7 +187,7 @@ export function createHmrBridge(ctx, options = {}) {
|
|
|
186
187
|
get available() {
|
|
187
188
|
return available()
|
|
188
189
|
},
|
|
189
|
-
requiredIds,
|
|
190
|
+
requiredIds: [...requiredIds],
|
|
190
191
|
delayMs,
|
|
191
192
|
status,
|
|
192
193
|
schedule,
|
package/lib/leases.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Fail-closed protocol: a held, expired, or token-mismatched lease is reported,
|
|
12
12
|
// never silently taken over. Only the token holder may release a lease, and
|
|
13
13
|
// releasing an expired lease requires an explicit `force: true`.
|
|
14
|
-
import fsp from '
|
|
14
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import crypto from 'node:crypto'
|
|
17
17
|
|
|
@@ -27,7 +27,13 @@ const LOCK_STALE_MS = 10000
|
|
|
27
27
|
const LOCK_POLL_MS = 20
|
|
28
28
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
29
29
|
|
|
30
|
+
// Exported with the retry loop so a test can assert the budget and the exact
|
|
31
|
+
// transient set instead of copying them (#86).
|
|
32
|
+
export const UNLINK_ATTEMPTS = 12
|
|
33
|
+
export const TRANSIENT_UNLINK_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
34
|
+
|
|
30
35
|
function fail(code, message) {
|
|
36
|
+
/** @type {Error & {code?: string}} */
|
|
31
37
|
const error = new Error(message)
|
|
32
38
|
error.code = code
|
|
33
39
|
throw error
|
|
@@ -56,17 +62,21 @@ export function leasesLockPath(commonDir) {
|
|
|
56
62
|
/**
|
|
57
63
|
* Windows can reject unlink while an indexer, antivirus, or a concurrent
|
|
58
64
|
* reader still holds the file; retry briefly instead of failing the mutation.
|
|
65
|
+
*
|
|
66
|
+
* `options.unlink` and `options.sleep` inject the syscall and the backoff so
|
|
67
|
+
* both outcomes are testable on any OS (#86); production callers omit them.
|
|
59
68
|
*/
|
|
60
|
-
async function unlinkWithRetry(file) {
|
|
69
|
+
export async function unlinkWithRetry(file, options = {}) {
|
|
70
|
+
const unlink = options.unlink ?? fsp.unlink
|
|
71
|
+
const wait = options.sleep ?? sleep
|
|
61
72
|
for (let attempt = 0; ; attempt += 1) {
|
|
62
73
|
try {
|
|
63
|
-
await
|
|
74
|
+
await unlink(file)
|
|
64
75
|
return true
|
|
65
76
|
} catch (error) {
|
|
66
77
|
if (error?.code === 'ENOENT') return false
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
await sleep(10 + attempt * 15)
|
|
78
|
+
if (!TRANSIENT_UNLINK_ERRORS.has(error?.code) || attempt >= UNLINK_ATTEMPTS) throw error
|
|
79
|
+
await wait(10 + attempt * 15)
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
}
|
|
@@ -147,7 +157,7 @@ async function lockIsStale(lockFile) {
|
|
|
147
157
|
} catch {
|
|
148
158
|
at = null
|
|
149
159
|
}
|
|
150
|
-
const age = Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
|
|
160
|
+
const age = at !== null && Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
|
|
151
161
|
return age > LOCK_STALE_MS
|
|
152
162
|
}
|
|
153
163
|
|