dsh-tower 2.0.3 → 2.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +44 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Release compatibility provenance. COMPATIBILITY.md is shipped in the package;
|
|
2
|
+
// no network lookup is involved.
|
|
3
|
+
import { fs } from '../adapters/store-fs.js'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
8
|
+
|
|
9
|
+
export function parseVersion(value) {
|
|
10
|
+
const text = String(value ?? '').trim()
|
|
11
|
+
const match = text.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
|
|
12
|
+
if (!match) return null
|
|
13
|
+
return {
|
|
14
|
+
numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
15
|
+
prerelease: match[4] ? match[4].split('.') : [],
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function comparePrerelease(a, b) {
|
|
20
|
+
if (a.length === 0 && b.length === 0) return 0
|
|
21
|
+
if (a.length === 0) return 1
|
|
22
|
+
if (b.length === 0) return -1
|
|
23
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
24
|
+
if (a[index] === undefined) return -1
|
|
25
|
+
if (b[index] === undefined) return 1
|
|
26
|
+
const aNumber = /^\d+$/.test(a[index])
|
|
27
|
+
const bNumber = /^\d+$/.test(b[index])
|
|
28
|
+
if (aNumber && bNumber) {
|
|
29
|
+
const delta = Number(a[index]) - Number(b[index])
|
|
30
|
+
if (delta !== 0) return delta
|
|
31
|
+
} else if (aNumber !== bNumber) {
|
|
32
|
+
return aNumber ? -1 : 1
|
|
33
|
+
} else if (a[index] !== b[index]) {
|
|
34
|
+
return a[index] < b[index] ? -1 : 1
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function compareVersions(a, b) {
|
|
41
|
+
const left = typeof a === 'string' ? parseVersion(a) : a
|
|
42
|
+
const right = typeof b === 'string' ? parseVersion(b) : b
|
|
43
|
+
if (!left || !right) return null
|
|
44
|
+
for (let index = 0; index < 3; index += 1) {
|
|
45
|
+
if (left.numbers[index] !== right.numbers[index]) return left.numbers[index] < right.numbers[index] ? -1 : 1
|
|
46
|
+
}
|
|
47
|
+
return comparePrerelease(left.prerelease, right.prerelease)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseCompatibility(text) {
|
|
51
|
+
const rows = []
|
|
52
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
53
|
+
if (!/^\|/.test(line)) continue
|
|
54
|
+
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim())
|
|
55
|
+
if (cells.length < 4) continue
|
|
56
|
+
if (!parseVersion(cells[0])) continue
|
|
57
|
+
rows.push({ hostVersion: cells[0], commit: cells[1], verifiedOn: cells[2], ran: cells[3] })
|
|
58
|
+
}
|
|
59
|
+
return rows
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readCompatibility(file = path.join(PACKAGE_ROOT, 'COMPATIBILITY.md')) {
|
|
63
|
+
try {
|
|
64
|
+
return parseCompatibility(fs.readFileSync(file, 'utf8'))
|
|
65
|
+
} catch {
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function assessHostVersion(hostVersion, rows) {
|
|
71
|
+
const parsed = parseVersion(hostVersion)
|
|
72
|
+
if (!parsed) return { verdict: 'unknown', row: null, detail: 'cannot parse the running host version' }
|
|
73
|
+
const exact = rows.find((row) => compareVersions(row.hostVersion, parsed) === 0)
|
|
74
|
+
if (exact) return { verdict: 'verified', row: exact, detail: `${exact.hostVersion} verified on ${exact.verifiedOn}` }
|
|
75
|
+
const known = rows
|
|
76
|
+
.map((row) => ({ row, cmp: compareVersions(parsed, row.hostVersion) }))
|
|
77
|
+
.filter((entry) => entry.cmp !== null)
|
|
78
|
+
if (known.length === 0) return { verdict: 'unknown', row: null, detail: 'COMPATIBILITY.md has no version rows' }
|
|
79
|
+
const newest = known.reduce((best, entry) => (entry.cmp > best.cmp ? entry : best), known[0])
|
|
80
|
+
if (newest.cmp > 0) {
|
|
81
|
+
return {
|
|
82
|
+
verdict: 'untested-newer',
|
|
83
|
+
row: newest.row,
|
|
84
|
+
detail: `${hostVersion} is newer than every verified build (latest row ${newest.row.hostVersion})`,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
verdict: 'untested-older',
|
|
89
|
+
row: newest.row,
|
|
90
|
+
detail: `${hostVersion} is older than the verified builds (latest row ${newest.row.hostVersion})`,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function detectHostVersion(ctx, env = process.env) {
|
|
95
|
+
for (const value of [env.DSH_VERSION, env.DSH_HOST_VERSION, env.npm_config_dsh_version]) {
|
|
96
|
+
if (typeof value === 'string' && parseVersion(value)) return value
|
|
97
|
+
}
|
|
98
|
+
for (const serviceName of ['app', 'runtime', 'host']) {
|
|
99
|
+
try {
|
|
100
|
+
const service = ctx?.get?.(serviceName)
|
|
101
|
+
const value = service && (service.version ?? service.build?.version)
|
|
102
|
+
if (typeof value === 'string' && parseVersion(value)) return value
|
|
103
|
+
} catch {
|
|
104
|
+
// defensive: a missing service is not a doctor failure
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -1,317 +1,50 @@
|
|
|
1
|
-
// Tower configuration:
|
|
2
|
-
// process-local effective snapshot consumed by the domain modules.
|
|
1
|
+
// Tower configuration: process-local profile snapshot and repository layer.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 3. repository config: <git-common-dir>/dsh-tower/config.json
|
|
8
|
-
// 4. per-tool arguments (e.g. worktreeRoot, timeoutMs)
|
|
3
|
+
// The pure Schemastery schema, default resolution, and raw-config validation
|
|
4
|
+
// live in domain/config/schema.js (#56 P2a). This module keeps only the layers
|
|
5
|
+
// that need process or filesystem state:
|
|
9
6
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
7
|
+
// profile -> the raw config `apply()` received, resolved with code defaults
|
|
8
|
+
// on every read (runtimeConfig)
|
|
9
|
+
// repo -> <git-common-dir>/dsh-tower/config.json merged over the profile
|
|
10
|
+
//
|
|
11
|
+
// `Config` and `resolveTowerConfig` are re-exported for compatibility; new
|
|
12
|
+
// code should import them from domain/config/schema.js.
|
|
13
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
13
14
|
import path from 'node:path'
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'subagent',
|
|
29
|
-
'subagent_fork',
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
const LEGACY_KEYS = ['section', 'allowAgentControl']
|
|
33
|
-
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'leases', 'dev']
|
|
15
|
+
import {
|
|
16
|
+
Config,
|
|
17
|
+
DEFAULT_DENY_TOOLS,
|
|
18
|
+
assertKnownKeys,
|
|
19
|
+
assertSafeKeys,
|
|
20
|
+
clone,
|
|
21
|
+
deepMerge,
|
|
22
|
+
isPlainObject,
|
|
23
|
+
normalizeLegacy,
|
|
24
|
+
resolveTowerConfig,
|
|
25
|
+
} from '../domain/config/schema.js'
|
|
26
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
27
|
+
|
|
28
|
+
export { Config, DEFAULT_DENY_TOOLS, resolveTowerConfig }
|
|
34
29
|
|
|
35
|
-
const
|
|
36
|
-
Schema.object({
|
|
37
|
-
provider: Schema.string().description('Provider route override for this role.'),
|
|
38
|
-
model: Schema.string().description('Model id override for this role.'),
|
|
39
|
-
reasoningEffort: Schema.string().description('Optional reasoning effort override.'),
|
|
40
|
-
maxTokens: Schema.natural().description('Optional output token cap.'),
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
export const Config = Schema.object({
|
|
44
|
-
section: Schema.string().description(
|
|
45
|
-
'Legacy alias for mode.promptSection: replaces the whole Tower-mode system prompt text.',
|
|
46
|
-
),
|
|
47
|
-
allowAgentControl: Schema.boolean().description(
|
|
48
|
-
'Legacy alias for mode.allowAgentControl: register tower_control so the model may toggle the mode.',
|
|
49
|
-
),
|
|
50
|
-
init: Schema.object({
|
|
51
|
-
autoGitInit: Schema.boolean()
|
|
52
|
-
.default(false)
|
|
53
|
-
.description('tower_init may run git init and create an empty initial commit when the directory is not a repository.'),
|
|
54
|
-
snapshotDirty: Schema.boolean()
|
|
55
|
-
.default(false)
|
|
56
|
-
.description('tower_init may snapshot uncommitted tracked changes into a dangling commit used as the mission baseline.'),
|
|
57
|
-
})
|
|
58
|
-
.default({})
|
|
59
|
-
.description('Repository initialization policy.'),
|
|
60
|
-
mode: Schema.object({
|
|
61
|
-
promptSection: Schema.string().description('Replacement Tower-mode system prompt text. Empty keeps the built-in reminder.'),
|
|
62
|
-
sectionAppend: Schema.string().description('Extra text appended to the Tower-mode system prompt section (team conventions).'),
|
|
63
|
-
allowAgentControl: Schema.boolean().description('Register tower_control so the model may enter/leave the mode with a reason.'),
|
|
64
|
-
autoPauseGoal: Schema.boolean().default(true).description('Pause an active goal when entering Tower mode.'),
|
|
65
|
-
requireResumeAuthorization: Schema.boolean()
|
|
66
|
-
.default(false)
|
|
67
|
-
.description('Require an authorization reference when resuming a mission paused with pauseCause=user.'),
|
|
68
|
-
workspaceLock: Schema.boolean()
|
|
69
|
-
.default(false)
|
|
70
|
-
.description('Allow only one Tower session per repository (advisory <git-common-dir>/dsh-tower/tower.lock).'),
|
|
71
|
-
workspaceLockScope: Schema.union(['all', 'mutations'])
|
|
72
|
-
.default('all')
|
|
73
|
-
.description('all = every repo-scoped tool acquires the workspace lock; mutations = only tools that write state/worktrees do.'),
|
|
74
|
-
planModePolicy: Schema.union(['clear', 'refuse'])
|
|
75
|
-
.default('clear')
|
|
76
|
-
.description('On Tower entry: clear an active plan mode, or refuse to enter until the plan is exited.'),
|
|
77
|
-
workspaceStaleMs: Schema.natural()
|
|
78
|
-
.min(1000)
|
|
79
|
-
.default(300000)
|
|
80
|
-
.description('A workspace lock older than this is stale and can be taken over.'),
|
|
81
|
-
heartbeatMs: Schema.natural().min(1000).default(10000).description('Workspace-lock heartbeat interval while Tower mode is on.'),
|
|
82
|
-
denyTools: Schema.array(Schema.string())
|
|
83
|
-
.default(DEFAULT_DENY_TOOLS)
|
|
84
|
-
.description('Tools refused while Tower mode is active.'),
|
|
85
|
-
storePath: Schema.string().description('Directory for modes.json; defaults to $DSH_HOME/tower-mode.'),
|
|
86
|
-
maxEntries: Schema.natural().min(1).max(2000).default(200).description('Maximum persisted mode entries.'),
|
|
87
|
-
})
|
|
88
|
-
.default({})
|
|
89
|
-
.description('Tower-mode behavior and persistence.'),
|
|
90
|
-
review: Schema.object({
|
|
91
|
-
requireReview: Schema.boolean()
|
|
92
|
-
.default(false)
|
|
93
|
-
.description('Require an independent clean review before merge. Default false: review is optional; set true to enforce the review gate globally.'),
|
|
94
|
-
maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
|
|
95
|
-
})
|
|
96
|
-
.default({})
|
|
97
|
-
.description('Review policy.'),
|
|
98
|
-
merge: Schema.object({
|
|
99
|
-
timeoutMs: Schema.natural().min(1000).default(900000).description('Default tower_merge timeout, including pre-merge hooks.'),
|
|
100
|
-
maxBufferBytes: Schema.natural().min(65536).default(67108864).description('Captured merge output buffer.'),
|
|
101
|
-
})
|
|
102
|
-
.default({})
|
|
103
|
-
.description('Merge execution policy.'),
|
|
104
|
-
worktrees: Schema.object({
|
|
105
|
-
root: Schema.string().description('Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>.'),
|
|
106
|
-
})
|
|
107
|
-
.default({})
|
|
108
|
-
.description('Worktree placement.'),
|
|
109
|
-
spawn: Schema.object({
|
|
110
|
-
workerPreset: Schema.string().default('tower-worker').description('Agent preset for worker missions.'),
|
|
111
|
-
reviewerPreset: Schema.string().default('tower-reviewer').description('Agent preset for reviewers.'),
|
|
112
|
-
maxDepth: Schema.natural().min(1).default(1).description('Subagent depth requested when spawning tower agents.'),
|
|
113
|
-
provider: Schema.string().description('Subagent provider id; empty picks spawn, then the first registered provider.'),
|
|
114
|
-
workerModel: ModelSchema().description('Model route for workers; omitted inherits the parent session.'),
|
|
115
|
-
reviewerModel: ModelSchema().description('Model route for reviewers; omitted inherits the parent session (recommended).'),
|
|
116
|
-
maxLiveWorkers: Schema.natural()
|
|
117
|
-
.min(1)
|
|
118
|
-
.max(64)
|
|
119
|
-
.default(4)
|
|
120
|
-
.description('Maximum live worker entries allowed per repository at once.'),
|
|
121
|
-
staleMs: Schema.natural()
|
|
122
|
-
.min(0)
|
|
123
|
-
.default(15000)
|
|
124
|
-
.description('Roster entries with no catalog activity after this age are marked stale and block merge (0 disables).'),
|
|
125
|
-
maxWaitMs: Schema.natural()
|
|
126
|
-
.min(1000)
|
|
127
|
-
.max(600000)
|
|
128
|
-
.default(90000)
|
|
129
|
-
.description('Upper bound for tower_spawn waitMs (default 90s, Kimi-compatible).'),
|
|
130
|
-
requireLease: Schema.string().description('Optional lease name tower_spawn must hold before starting a worker.'),
|
|
131
|
-
})
|
|
132
|
-
.default({})
|
|
133
|
-
.description('Worker and reviewer presets plus model routing.'),
|
|
134
|
-
send: Schema.object({
|
|
135
|
-
maxPerMinute: Schema.natural()
|
|
136
|
-
.min(0)
|
|
137
|
-
.default(120)
|
|
138
|
-
.description('Per-repository tower_send rate limit per minute (0 disables).'),
|
|
139
|
-
interruptDefault: Schema.boolean()
|
|
140
|
-
.default(false)
|
|
141
|
-
.description('Default value of tower_send.interrupt when the caller omits it.'),
|
|
142
|
-
})
|
|
143
|
-
.default({})
|
|
144
|
-
.description('Message delivery policy.'),
|
|
145
|
-
tests: Schema.object({
|
|
146
|
-
enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
|
|
147
|
-
admission: Schema.union(['spawn', 'complete', 'off'])
|
|
148
|
-
.default('complete')
|
|
149
|
-
.description('Run discovery before spawning a worker, when completing, or only on explicit tower_test.'),
|
|
150
|
-
timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
|
|
151
|
-
maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
|
|
152
|
-
autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
|
|
153
|
-
allowPackageDiscovery: Schema.boolean()
|
|
154
|
-
.default(true)
|
|
155
|
-
.description('Allow package.json tower.testDiscovery commands from the mission worktree (executes repository code; see security docs).'),
|
|
156
|
-
})
|
|
157
|
-
.default({})
|
|
158
|
-
.description('Declared test discovery and admission.'),
|
|
159
|
-
gates: Schema.object({
|
|
160
|
-
requireChecks: Schema.boolean()
|
|
161
|
-
.default(false)
|
|
162
|
-
.description('Require at least one completion check (tower_complete.checks) before a mission can merge.'),
|
|
163
|
-
requireTests: Schema.boolean()
|
|
164
|
-
.default(false)
|
|
165
|
-
.description('Require a passing test-discovery receipt for the reviewed worktree tip before merge.'),
|
|
166
|
-
allowReviewExemption: Schema.boolean()
|
|
167
|
-
.default(false)
|
|
168
|
-
.description('Allow reviewPolicy:none missions with a non-empty reviewExemptionReason to skip review blockers.'),
|
|
169
|
-
})
|
|
170
|
-
.default({})
|
|
171
|
-
.description('Merge-gate strictness.'),
|
|
172
|
-
docs: Schema.object({
|
|
173
|
-
board: Schema.boolean()
|
|
174
|
-
.default(true)
|
|
175
|
-
.description('Write a human-readable MISSIONS.md after mission changes.'),
|
|
176
|
-
boardLocation: Schema.union(['common', 'repo'])
|
|
177
|
-
.default('common')
|
|
178
|
-
.description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md.'),
|
|
179
|
-
})
|
|
180
|
-
.default({})
|
|
181
|
-
.description('Human-readable artifacts.'),
|
|
182
|
-
leases: Schema.object({
|
|
183
|
-
ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
|
|
184
|
-
})
|
|
185
|
-
.default({})
|
|
186
|
-
.description('Advisory resource leases.'),
|
|
187
|
-
dev: Schema.object({
|
|
188
|
-
hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
|
|
189
|
-
reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
|
|
190
|
-
reloadRequiredIds: Schema.array(Schema.string())
|
|
191
|
-
.default(['tower', 'tower-skills', 'preset-tower-worker', 'preset-tower-reviewer'])
|
|
192
|
-
.description('Loader entry ids passed to reconcileProfile as required.'),
|
|
193
|
-
})
|
|
194
|
-
.default({})
|
|
195
|
-
.description('Development and hot-reload behavior.'),
|
|
196
|
-
}).description('DSH Tower configuration')
|
|
30
|
+
const REPO_CONFIG_MAX_BYTES = 1024 * 1024
|
|
197
31
|
|
|
198
|
-
let effective = null
|
|
199
32
|
// The last raw profile config `apply()` received; repository config is merged
|
|
200
33
|
// over it so a repo can tune spawn/merge/review without editing the profile.
|
|
201
34
|
let profileRaw = {}
|
|
202
35
|
|
|
203
|
-
function clone(value) {
|
|
204
|
-
return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function isPlainObject(value) {
|
|
208
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function deepMerge(base, override) {
|
|
212
|
-
if (override === undefined) return clone(base) ?? {}
|
|
213
|
-
if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
|
|
214
|
-
const out = clone(base)
|
|
215
|
-
for (const [key, value] of Object.entries(override)) {
|
|
216
|
-
out[key] = value === undefined ? out[key] : deepMerge(base[key], value)
|
|
217
|
-
}
|
|
218
|
-
return out
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Move the legacy top-level aliases into their canonical group keys. */
|
|
222
|
-
function normalizeLegacy(raw = {}) {
|
|
223
|
-
const out = clone(raw) ?? {}
|
|
224
|
-
if (out.section !== undefined) {
|
|
225
|
-
const mode = { ...(out.mode ?? {}) }
|
|
226
|
-
if (mode.promptSection === undefined) mode.promptSection = out.section
|
|
227
|
-
out.mode = mode
|
|
228
|
-
delete out.section
|
|
229
|
-
}
|
|
230
|
-
if (out.allowAgentControl !== undefined) {
|
|
231
|
-
const mode = { ...(out.mode ?? {}) }
|
|
232
|
-
if (mode.allowAgentControl === undefined) mode.allowAgentControl = out.allowAgentControl
|
|
233
|
-
out.mode = mode
|
|
234
|
-
delete out.allowAgentControl
|
|
235
|
-
}
|
|
236
|
-
return out
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function assertKnownKeys(raw, label) {
|
|
240
|
-
const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
|
|
241
|
-
if (unknown.length > 0) {
|
|
242
|
-
throw new TowerToolError(
|
|
243
|
-
'config-unknown-key',
|
|
244
|
-
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, worktrees?, spawn?, send?, tests?, gates?, docs?, leases?, dev? }`,
|
|
245
|
-
)
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
250
|
-
const REPO_CONFIG_MAX_BYTES = 1024 * 1024
|
|
251
|
-
|
|
252
|
-
/** Reject prototype-pollution style keys anywhere in a repository config. */
|
|
253
|
-
function assertSafeKeys(value, label) {
|
|
254
|
-
if (Array.isArray(value)) {
|
|
255
|
-
for (const item of value) assertSafeKeys(item, label)
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
if (!isPlainObject(value)) return
|
|
259
|
-
for (const [key, child] of Object.entries(value)) {
|
|
260
|
-
if (FORBIDDEN_KEYS.has(key)) {
|
|
261
|
-
throw new TowerToolError('config-invalid', `${label} contains the forbidden key "${key}"`)
|
|
262
|
-
}
|
|
263
|
-
assertSafeKeys(child, label)
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export function resolveTowerConfig(rawConfig) {
|
|
268
|
-
const raw = rawConfig ?? {}
|
|
269
|
-
if (raw !== null && typeof raw !== 'object') throw new Error('Tower config must be an object')
|
|
270
|
-
assertKnownKeys(raw, 'Tower config')
|
|
271
|
-
let parsed
|
|
272
|
-
try {
|
|
273
|
-
parsed = Config(raw)
|
|
274
|
-
} catch (error) {
|
|
275
|
-
throw new Error(`Tower config is invalid: ${String(error?.message ?? error)}`)
|
|
276
|
-
}
|
|
277
|
-
const mode = clone(parsed.mode ?? {})
|
|
278
|
-
// Canonical keys win; the legacy top-level aliases only fill gaps.
|
|
279
|
-
const rawMode = raw.mode ?? {}
|
|
280
|
-
if (rawMode.promptSection === undefined && raw.section !== undefined) mode.promptSection = raw.section
|
|
281
|
-
if (rawMode.allowAgentControl === undefined && raw.allowAgentControl !== undefined) {
|
|
282
|
-
mode.allowAgentControl = raw.allowAgentControl
|
|
283
|
-
}
|
|
284
|
-
// Apply the canonical default only after alias resolution: a validated
|
|
285
|
-
// Cordis config may omit this key, and an explicit legacy top-level
|
|
286
|
-
// `allowAgentControl:true` must not be shadowed by a schema default.
|
|
287
|
-
if (mode.allowAgentControl === undefined) mode.allowAgentControl = false
|
|
288
|
-
if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
|
|
289
|
-
return {
|
|
290
|
-
section: mode.promptSection ?? '',
|
|
291
|
-
init: clone(parsed.init ?? {}),
|
|
292
|
-
mode,
|
|
293
|
-
review: clone(parsed.review ?? {}),
|
|
294
|
-
merge: clone(parsed.merge ?? {}),
|
|
295
|
-
worktrees: clone(parsed.worktrees ?? {}),
|
|
296
|
-
spawn: clone(parsed.spawn ?? {}),
|
|
297
|
-
send: clone(parsed.send ?? {}),
|
|
298
|
-
tests: clone(parsed.tests ?? {}),
|
|
299
|
-
gates: clone(parsed.gates ?? {}),
|
|
300
|
-
docs: clone(parsed.docs ?? {}),
|
|
301
|
-
leases: clone(parsed.leases ?? {}),
|
|
302
|
-
dev: clone(parsed.dev ?? {}),
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
36
|
export function configureRuntime(config) {
|
|
307
37
|
profileRaw = clone(config ?? {}) ?? {}
|
|
308
|
-
|
|
309
|
-
return effective
|
|
38
|
+
return resolveTowerConfig(config)
|
|
310
39
|
}
|
|
311
40
|
|
|
312
41
|
export function runtimeConfig() {
|
|
313
|
-
|
|
314
|
-
|
|
42
|
+
return resolveTowerConfig(profileRaw)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Raw profile config before defaults are materialized (for doctor warnings). */
|
|
46
|
+
export function profileConfig() {
|
|
47
|
+
return clone(profileRaw) ?? {}
|
|
315
48
|
}
|
|
316
49
|
|
|
317
50
|
export function repoConfigPath(commonDir) {
|
|
@@ -373,7 +106,27 @@ export async function readRepoConfig(commonDir) {
|
|
|
373
106
|
export async function resolveRepoConfig(repo) {
|
|
374
107
|
const base = runtimeConfig()
|
|
375
108
|
const layer = await readRepoConfig(repo.commonDir)
|
|
376
|
-
|
|
109
|
+
// The mode guard is a session/profile concern. A repository checkout may
|
|
110
|
+
// only tighten it, never weaken denyTools, workspaceLock, allowAgentControl,
|
|
111
|
+
// plan policy, or resume authorization.
|
|
112
|
+
const repoLayer = { ...layer.config }
|
|
113
|
+
delete repoLayer.mode
|
|
114
|
+
delete repoLayer.section
|
|
115
|
+
delete repoLayer.allowAgentControl
|
|
116
|
+
const merged = deepMerge(base, repoLayer)
|
|
117
|
+
const repoMode = layer.config.mode
|
|
118
|
+
if (repoMode && typeof repoMode === 'object' && !Array.isArray(repoMode)) {
|
|
119
|
+
merged.mode = { ...(merged.mode ?? {}) }
|
|
120
|
+
if (repoMode.requireResumeAuthorization === true) merged.mode.requireResumeAuthorization = true
|
|
121
|
+
if (repoMode.workspaceLock === true) merged.mode.workspaceLock = true
|
|
122
|
+
if (repoMode.autoPauseGoal === true) merged.mode.autoPauseGoal = true
|
|
123
|
+
if (repoMode.workspaceLockScope === 'all') merged.mode.workspaceLockScope = 'all'
|
|
124
|
+
if (repoMode.planModePolicy === 'refuse') merged.mode.planModePolicy = 'refuse'
|
|
125
|
+
if (repoMode.allowAgentControl === false) merged.mode.allowAgentControl = false
|
|
126
|
+
if (Array.isArray(repoMode.denyTools)) {
|
|
127
|
+
merged.mode.denyTools = [...new Set([...(base.mode?.denyTools ?? DEFAULT_DENY_TOOLS), ...repoMode.denyTools])]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
377
130
|
if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
|
|
378
131
|
merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
|
|
379
132
|
}
|
|
@@ -385,4 +138,3 @@ export async function resolveRepoConfig(repo) {
|
|
|
385
138
|
profileConfigured: Object.keys(profileRaw).length > 0,
|
|
386
139
|
}
|
|
387
140
|
}
|
|
388
|
-
|
package/lib/doctor.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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
|
+
import { readStateLock } from './store.js'
|
|
8
|
+
import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
|
|
9
|
+
import { profileConfig } from './config.js'
|
|
7
10
|
import { inspectWorkspaceLock } from './workspaceLock.js'
|
|
8
11
|
|
|
9
12
|
const execFileAsync = promisify(execFile)
|
|
@@ -31,21 +34,83 @@ function check(id, status, detail, action) {
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/** Run every read-only diagnostic; never writes state or takes the state lock. */
|
|
34
|
-
export async function runDoctor({ tower, ctx, agent }) {
|
|
37
|
+
export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
|
|
35
38
|
const checks = []
|
|
36
39
|
const version = await gitVersion()
|
|
37
40
|
checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
|
|
38
41
|
|
|
42
|
+
const detectedHost = hostVersion ?? detectHostVersion(ctx)
|
|
43
|
+
const compatibilityRows = readCompatibility(compatibilityFile)
|
|
44
|
+
const hostAssessment = assessHostVersion(detectedHost, compatibilityRows)
|
|
45
|
+
checks.push(
|
|
46
|
+
check(
|
|
47
|
+
'host-version',
|
|
48
|
+
hostAssessment.verdict === 'verified' ? 'pass' : 'warn',
|
|
49
|
+
detectedHost
|
|
50
|
+
? `${detectedHost}: ${hostAssessment.detail}`
|
|
51
|
+
: `unknown host version; ${hostAssessment.detail}. Include host version + COMPATIBILITY.md row in bug reports.`,
|
|
52
|
+
'see COMPATIBILITY.md for verified host builds',
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const rows = Array.isArray(loaderRows)
|
|
57
|
+
? loaderRows
|
|
58
|
+
: (() => {
|
|
59
|
+
try {
|
|
60
|
+
const loader = ctx?.get?.('loader') ?? ctx?.loader
|
|
61
|
+
const source = loader?.entries ?? loader?.rows ?? loader?.store?.entries
|
|
62
|
+
if (Array.isArray(source)) return source
|
|
63
|
+
if (source && typeof source === 'object') return Object.values(source)
|
|
64
|
+
} catch {
|
|
65
|
+
// loader inventory is optional
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
})()
|
|
69
|
+
if (rows === null) {
|
|
70
|
+
checks.push(check('loader-conflicts', 'warn', 'loader inventory unavailable; cannot detect co-installed Tower packages'))
|
|
71
|
+
} else {
|
|
72
|
+
const towerRows = rows.filter((row) => row && row.name === 'dsh-tower')
|
|
73
|
+
const foreign = towerRows.filter((row) => row.id !== 'tower')
|
|
74
|
+
checks.push(
|
|
75
|
+
towerRows.length > 1 || foreign.length > 0
|
|
76
|
+
? check(
|
|
77
|
+
'loader-conflicts',
|
|
78
|
+
'fail',
|
|
79
|
+
`another loader row resolves to "dsh-tower" (${towerRows.map((row) => row.id ?? '?').join(', ')}); uninstall the conflicting dsh-plugins-tower row`,
|
|
80
|
+
'see README "Known conflicts"',
|
|
81
|
+
)
|
|
82
|
+
: check('loader-conflicts', 'pass', 'no conflicting dsh-tower loader rows'),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
39
86
|
checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
|
|
40
87
|
let state = null
|
|
41
88
|
try {
|
|
42
89
|
state = await tower.load()
|
|
43
90
|
checks.push(check('state', 'pass', `${tower.paths.state} revision ${state.revision}`))
|
|
91
|
+
checks.push(
|
|
92
|
+
state.towerAgentId
|
|
93
|
+
? check('tower-owner', 'pass', `bound to ${String(state.towerAgentId)}`)
|
|
94
|
+
: check('tower-owner', 'warn', 'state has no towerAgentId; write tools will fail closed', 'run tower_init with the restored session to re-claim ownership'),
|
|
95
|
+
)
|
|
44
96
|
} catch (error) {
|
|
45
97
|
checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
|
|
46
98
|
}
|
|
47
99
|
|
|
48
100
|
const config = tower.config ?? {}
|
|
101
|
+
const profile = profileConfig()
|
|
102
|
+
const staleDefaults = []
|
|
103
|
+
if (profile.review && profile.review.requireReview === undefined) {
|
|
104
|
+
staleDefaults.push('review.requireReview now defaults to false (2.0.3)')
|
|
105
|
+
}
|
|
106
|
+
if (profile.mode && profile.mode.allowAgentControl === undefined) {
|
|
107
|
+
staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
|
|
108
|
+
}
|
|
109
|
+
checks.push(
|
|
110
|
+
staleDefaults.length > 0
|
|
111
|
+
? check('default-reversals', 'warn', staleDefaults.join('; '), 'pin the key explicitly in the profile if the old posture is required')
|
|
112
|
+
: check('default-reversals', 'pass', 'profile does not rely on a reversed default'),
|
|
113
|
+
)
|
|
49
114
|
checks.push(
|
|
50
115
|
check(
|
|
51
116
|
'config',
|
|
@@ -78,11 +143,13 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
78
143
|
checks.push(hmr ? check('hmr', 'pass', 'service mounted') : check('hmr', 'warn', 'HMR is not mounted; /tower reload is unavailable'))
|
|
79
144
|
|
|
80
145
|
if (state) {
|
|
81
|
-
const
|
|
146
|
+
const lock = await readStateLock(tower.paths)
|
|
82
147
|
checks.push(
|
|
83
|
-
|
|
84
|
-
? check('state-lock', '
|
|
85
|
-
:
|
|
148
|
+
!lock.record
|
|
149
|
+
? check('state-lock', 'pass', 'no state lock file')
|
|
150
|
+
: lock.alive === false
|
|
151
|
+
? check('state-lock', 'warn', `stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone); the next mutation reclaims it atomically`)
|
|
152
|
+
: check('state-lock', 'warn', `state lock held by pid ${lock.record.pid ?? 'unknown'}; retry after it finishes`),
|
|
86
153
|
)
|
|
87
154
|
const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
|
|
88
155
|
try {
|
|
@@ -95,7 +162,11 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
95
162
|
}
|
|
96
163
|
|
|
97
164
|
const live = (state.roster ?? []).filter((entry) => !['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive'].includes(entry.state))
|
|
98
|
-
checks.push(
|
|
165
|
+
checks.push(
|
|
166
|
+
live.length > 0
|
|
167
|
+
? check('roster', 'pass', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`)
|
|
168
|
+
: check('roster', 'pass', 'no live workers'),
|
|
169
|
+
)
|
|
99
170
|
|
|
100
171
|
let missingWorktrees = 0
|
|
101
172
|
let dirtyWorktrees = 0
|
|
@@ -121,7 +192,7 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
121
192
|
const removedNote = removedWorktrees > 0 ? ` (${removedWorktrees} removed by teardown)` : ''
|
|
122
193
|
checks.push(
|
|
123
194
|
missingWorktrees > 0
|
|
124
|
-
? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, '
|
|
195
|
+
? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, 'inspect tower_status, restore the worktree/branch, then re-run tower_doctor')
|
|
125
196
|
: dirtyWorktrees > 0
|
|
126
197
|
? check('worktrees', 'warn', `${dirtyWorktrees} worktree(s) have uncommitted changes${removedNote}`)
|
|
127
198
|
: check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
|
|
@@ -155,6 +226,28 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
155
226
|
)
|
|
156
227
|
}
|
|
157
228
|
|
|
229
|
+
try {
|
|
230
|
+
const text = await fsp.readFile(path.join(tower.paths.dir, 'lock-holds.jsonl'), 'utf8')
|
|
231
|
+
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)
|
|
232
|
+
if (samples.length === 0) {
|
|
233
|
+
checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
|
|
234
|
+
} else {
|
|
235
|
+
const p = (q) => samples[Math.min(samples.length - 1, Math.floor(q * samples.length))]
|
|
236
|
+
const p50 = p(0.5)
|
|
237
|
+
const p95 = p(0.95)
|
|
238
|
+
checks.push(
|
|
239
|
+
check(
|
|
240
|
+
'lock-holds',
|
|
241
|
+
p95 > 1000 ? 'warn' : 'pass',
|
|
242
|
+
`${samples.length} sample(s): p50 ${p50}ms, p95 ${p95}ms`,
|
|
243
|
+
p95 > 1000 ? 'a mutation is holding the state lock across slow work; inspect the activity/lock-holds log' : undefined,
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
|
|
249
|
+
}
|
|
250
|
+
|
|
158
251
|
const failed = checks.filter((item) => item.status === 'fail').length
|
|
159
252
|
return {
|
|
160
253
|
ok: failed === 0,
|
|
@@ -165,6 +258,12 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
165
258
|
repoRoot: tower.root,
|
|
166
259
|
commonDir: tower.commonDir,
|
|
167
260
|
stateDir: tower.paths.dir,
|
|
261
|
+
gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
|
|
262
|
+
mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
|
|
263
|
+
testsAdmission: tower.config.tests?.admission ?? 'off',
|
|
264
|
+
hostVersion: detectedHost,
|
|
265
|
+
hostCompatibility: hostAssessment.verdict,
|
|
266
|
+
hostCompatibilityDetail: hostAssessment.detail,
|
|
168
267
|
},
|
|
169
268
|
mode: agent ? 'ready' : 'unknown',
|
|
170
269
|
}
|