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.
Files changed (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
@@ -0,0 +1,82 @@
1
+ // dsh-tower-ui 0.2.2 — the toolview card surface (one keyed view per tool).
2
+ //
3
+ // The chat-driven card publishes lifecycle transitions into the notice store
4
+ // the root-scoped overlay injects. Package-local chunk (#71).
5
+ window.__ModuleLoader__.load({
6
+ id: 'dsh-tower-ui',
7
+ chunk: 'client.toolview.js',
8
+ factory() {
9
+ function createToolview(env) {
10
+ const { React, h, translate, toolLabel, seatT, ST, dotStyle, helpers, cards, notice } = env
11
+ const { isTowerTool, parsePayload, isPlainObject, truncate } = helpers
12
+ const { rowElement, cardModel } = cards
13
+ const publishNotice = notice.publish
14
+
15
+ // Mounting an already-settled result (history) stays silent; a live
16
+ // preparing/start row and a start→result transition publish.
17
+ function useToolviewLifecycle(props) {
18
+ const phase = props ? props.phase : undefined
19
+ const toolName = props ? props.toolName : undefined
20
+ const callId = props ? props.callId : undefined
21
+ const failed = Boolean(props && props.block && (props.block.isError === true || isPlainObject(props.block.error)))
22
+ const previous = React.useRef ? React.useRef(null) : { current: null }
23
+ React.useEffect(
24
+ function () {
25
+ const before = previous.current
26
+ previous.current = { phase, callId }
27
+ if (!isTowerTool(toolName)) return
28
+ const sameCall = before !== null && before.callId === callId
29
+ if (phase !== 'result') {
30
+ if (!sameCall || before.phase !== phase) publishNotice({ tool: toolName, running: true })
31
+ } else if (sameCall && before.phase !== 'result') {
32
+ publishNotice({ tool: toolName, running: false, ok: failed !== true })
33
+ }
34
+ },
35
+ [phase, callId, toolName, failed],
36
+ )
37
+ }
38
+
39
+ function TowerToolCard(props) {
40
+ const t = seatT(props)
41
+ const toolName = props && typeof props.toolName === 'string' ? props.toolName : ''
42
+ const phase = props && typeof props.phase === 'string' ? props.phase : 'start'
43
+ const running = phase !== 'result'
44
+ useToolviewLifecycle(props)
45
+ let payload = null
46
+ if (!running) payload = parsePayload(props ? props.block : undefined)
47
+ const model = running
48
+ ? { tone: 'idle', headline: translate(t, 'card.running'), rows: [] }
49
+ : cardModel(toolName, payload, t)
50
+ const title =
51
+ toolLabel(t, toolName) +
52
+ ' · ' +
53
+ (running ? translate(t, 'card.running') : model.headline)
54
+ return h(
55
+ 'div',
56
+ {
57
+ 'data-tower-toolview': toolName === '' ? 'tower_unknown' : toolName,
58
+ 'data-tower-phase': phase,
59
+ style: ST.card,
60
+ title,
61
+ },
62
+ h(
63
+ 'div',
64
+ { style: ST.header },
65
+ h('span', { 'aria-hidden': true, style: dotStyle(running ? 'idle' : model.tone) }),
66
+ h('span', { style: ST.title }, translate(t, 'card.title')),
67
+ h('span', { style: ST.dim }, '·'),
68
+ h('span', { style: ST.title }, toolLabel(t, toolName)),
69
+ h('span', { style: running ? ST.dim : model.tone === 'error' ? ST.rowError : ST.dim }, truncate(model.headline, 100)),
70
+ ),
71
+ model.rows.slice(0, 6).map(function (row, index) {
72
+ return rowElement(t, index, row)
73
+ }),
74
+ )
75
+ }
76
+
77
+ return { TowerToolCard }
78
+ }
79
+
80
+ return { createToolview }
81
+ },
82
+ })
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "index.js",
15
15
  "client.js",
16
+ "client.*.js",
16
17
  "cordis.patch.yml",
17
18
  "locale/*.json",
18
19
  "icon.svg",
package/lib/doctor.js DELETED
@@ -1,270 +0,0 @@
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 { fsp } from '../adapters/store-fs.js'
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'
10
- import { inspectWorkspaceLock } from './workspaceLock.js'
11
-
12
- const execFileAsync = promisify(execFile)
13
-
14
- async function exists(target) {
15
- try {
16
- await fsp.stat(target)
17
- return true
18
- } catch {
19
- return false
20
- }
21
- }
22
-
23
- async function gitVersion() {
24
- try {
25
- const { stdout } = await execFileAsync('git', ['--version'], { windowsHide: true })
26
- return String(stdout).trim()
27
- } catch {
28
- return null
29
- }
30
- }
31
-
32
- function check(id, status, detail, action) {
33
- return { id, status, detail, ...(action ? { action } : {}) }
34
- }
35
-
36
- /** Run every read-only diagnostic; never writes state or takes the state lock. */
37
- export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
38
- const checks = []
39
- const version = await gitVersion()
40
- checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
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
-
86
- checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
87
- let state = null
88
- try {
89
- state = await tower.load()
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
- )
96
- } catch (error) {
97
- checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
98
- }
99
-
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
- )
114
- checks.push(
115
- check(
116
- 'config',
117
- config.configPath && config.repoConfigPresent ? 'pass' : 'warn',
118
- config.repoConfigPresent ? `repo config ${config.configPath}` : `repo config not present (${config.configPath ?? 'unknown'})`,
119
- 'optional: add <git-common-dir>/dsh-tower/config.json',
120
- ),
121
- )
122
- const presets = ctx.get?.('agentPresets')
123
- if (presets && typeof presets.list === 'function') {
124
- try {
125
- const available = (await presets.list()).map((entry) => String(entry.id))
126
- const missing = [config.spawn?.workerPreset ?? 'tower-worker', config.spawn?.reviewerPreset ?? 'tower-reviewer']
127
- .filter((id) => !available.includes(id))
128
- checks.push(
129
- missing.length === 0
130
- ? check('presets', 'pass', `worker=${config.spawn?.workerPreset ?? 'tower-worker'} reviewer=${config.spawn?.reviewerPreset ?? 'tower-reviewer'}`)
131
- : check('presets', 'fail', `missing preset(s): ${missing.join(', ')}`, 'enable the Tower preset bundle and retry'),
132
- )
133
- } catch (error) {
134
- checks.push(check('presets', 'warn', `preset registry unavailable: ${String(error?.message ?? error)}`))
135
- }
136
- } else {
137
- checks.push(check('presets', 'warn', 'agentPresets service is unavailable'))
138
- }
139
-
140
- const subagents = ctx.get?.('subagents')
141
- checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
142
- const hmr = ctx.get?.('hmr')
143
- checks.push(hmr ? check('hmr', 'pass', 'service mounted') : check('hmr', 'warn', 'HMR is not mounted; /tower reload is unavailable'))
144
-
145
- if (state) {
146
- const lock = await readStateLock(tower.paths)
147
- checks.push(
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`),
153
- )
154
- const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
155
- try {
156
- const lock = await inspectWorkspaceLock(tower.commonDir, {
157
- staleMs: Number(config.mode?.workspaceStaleMs) || 300000,
158
- })
159
- checks.push(lock.held ? check('workspace-lock', lock.stale ? 'warn' : 'pass', `held by ${lock.holder?.sessionId ?? 'unknown'}`) : check('workspace-lock', 'pass', 'free'))
160
- } catch {
161
- checks.push(check('workspace-lock', 'warn', `lock file ${lockDir} could not be inspected`))
162
- }
163
-
164
- const live = (state.roster ?? []).filter((entry) => !['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive'].includes(entry.state))
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
- )
170
-
171
- let missingWorktrees = 0
172
- let dirtyWorktrees = 0
173
- let removedWorktrees = 0
174
- for (const mission of state.missions ?? []) {
175
- if (!mission.worktree) continue
176
- if (mission.worktreeRemovedAt) {
177
- removedWorktrees += 1
178
- continue
179
- }
180
- try {
181
- await tower.assertWorktree(mission.worktree)
182
- if (await exists(mission.worktree.path)) {
183
- const dirty = (await git(mission.worktree.path, ['status', '--porcelain'])).trim()
184
- if (dirty) dirtyWorktrees += 1
185
- } else {
186
- missingWorktrees += 1
187
- }
188
- } catch {
189
- missingWorktrees += 1
190
- }
191
- }
192
- const removedNote = removedWorktrees > 0 ? ` (${removedWorktrees} removed by teardown)` : ''
193
- checks.push(
194
- missingWorktrees > 0
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')
196
- : dirtyWorktrees > 0
197
- ? check('worktrees', 'warn', `${dirtyWorktrees} worktree(s) have uncommitted changes${removedNote}`)
198
- : check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
199
- )
200
-
201
- const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
202
- checks.push(
203
- check(
204
- 'tests',
205
- config.tests?.enabled === false ? 'warn' : 'pass',
206
- config.tests?.enabled === false ? 'test discovery disabled' : `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
207
- ),
208
- )
209
- checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
210
- checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
211
- }
212
-
213
- try {
214
- const { inspectLeases } = await import('./leases.js')
215
- const leases = await inspectLeases(tower.commonDir)
216
- checks.push(check('leases', leases.leases?.length ? 'warn' : 'pass', `${leases.leases?.length ?? 0} active lease(s)`))
217
- } catch (error) {
218
- const corrupt = error?.code === 'state-corrupt'
219
- checks.push(
220
- check(
221
- 'leases',
222
- corrupt ? 'fail' : 'warn',
223
- corrupt ? `lease ledger is corrupt: ${String(error?.message ?? error)}` : 'lease module unavailable',
224
- corrupt ? 'inspect <git-common-dir>/dsh-tower/leases.json and repair or remove it' : undefined,
225
- ),
226
- )
227
- }
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
-
251
- const failed = checks.filter((item) => item.status === 'fail').length
252
- return {
253
- ok: failed === 0,
254
- checks,
255
- environmentContract: {
256
- platform: process.platform,
257
- gitVersion: version,
258
- repoRoot: tower.root,
259
- commonDir: tower.commonDir,
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,
267
- },
268
- mode: agent ? 'ready' : 'unknown',
269
- }
270
- }