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.
Files changed (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +9 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,168 @@
1
+ // tower_spawn prechecks (#56 P3c): roster naming, mission preconditions, and
2
+ // service/provider/lease validation. Split out of tools/spawn.js; no I/O.
3
+ import { TowerToolError } from '../domain/errors.js'
4
+ import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
5
+ import { inspectLeases } from './leases.js'
6
+
7
+ /**
8
+ * Pick a unique roster name before any activation.
9
+ * @param {{ role: string, missionId: string | undefined, requested: string | undefined, roster: any[] }} input
10
+ */
11
+ export function resolveRosterName({ role, missionId, requested, roster }) {
12
+ if (typeof requested === 'string' && requested.trim()) {
13
+ const name = requested.trim()
14
+ if (roster.some((entry) => entry.name === name)) {
15
+ throw new TowerToolError('invalid-argument', 'roster name already used: ' + name)
16
+ }
17
+ return name
18
+ }
19
+ const base = role + '-' + String(missionId).toLowerCase()
20
+ let candidate = base
21
+ let suffix = 2
22
+ while (roster.some((entry) => entry.name === candidate)) {
23
+ candidate = base + '-' + suffix
24
+ suffix += 1
25
+ }
26
+ return candidate
27
+ }
28
+
29
+ /**
30
+ * Role/mission preconditions checked before any provisioning.
31
+ * @param {{ role: string, context: any }} input
32
+ */
33
+ export function assertSpawnableMission({ role, context }) {
34
+ if (role === 'reviewer') {
35
+ if (!context.mission.worktree) {
36
+ throw new TowerToolError('reviewer-prerequisite', 'reviewer needs an existing mission worktree; spawn a worker first')
37
+ }
38
+ if (context.mission.status !== 'completed') {
39
+ throw new TowerToolError('reviewer-prerequisite', `reviewer requires a completed mission; ${context.mission.id} is ${context.mission.status}`)
40
+ }
41
+ }
42
+ if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
43
+ throw new TowerToolError(
44
+ 'invalid-transition',
45
+ `mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
46
+ )
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Single-owner and live-worker rules, checked before provisioning or starting
52
+ * a child so a rejection cannot leak an activation or worktree.
53
+ * @param {{
54
+ * role: string,
55
+ * tower: any,
56
+ * ctx: any,
57
+ * view: any,
58
+ * missionId: string | undefined,
59
+ * takeover: boolean | undefined,
60
+ * maxLiveWorkers: number | undefined,
61
+ * budget: number,
62
+ * observeActivity: any,
63
+ * }} input
64
+ */
65
+ export async function precheckWorkerOwnership({
66
+ role,
67
+ tower,
68
+ ctx,
69
+ view,
70
+ missionId,
71
+ takeover,
72
+ maxLiveWorkers,
73
+ budget,
74
+ observeActivity,
75
+ }) {
76
+ let effectiveTakeover = takeover === true
77
+ if (role !== 'worker') return effectiveTakeover
78
+ const liveWorkers = view.roster.filter(
79
+ (entry) => entry.role === 'worker' && !ROSTER_INACTIVE_STATES.includes(entry.state),
80
+ )
81
+ const sameMission = liveWorkers.filter((entry) => entry.missionId === missionId)
82
+ if (sameMission.length > 1) {
83
+ throw new TowerToolError(
84
+ 'invalid-transition',
85
+ 'mission ' + missionId + ' has ' + sameMission.length + ' live worker entries; inspect tower_roster before spawning',
86
+ )
87
+ }
88
+ if (sameMission.length === 1 && !effectiveTakeover) {
89
+ throw new TowerToolError(
90
+ 'invalid-transition',
91
+ 'mission ' + missionId + ' already has a live worker "' + sameMission[0].name + '"; stop it with tower_interrupt, or pass takeover:true after it is no longer running',
92
+ )
93
+ }
94
+ if (effectiveTakeover) {
95
+ if (sameMission.length === 1) {
96
+ const worker = sameMission[0]
97
+ const observed = await observeActivity(ctx, tower)
98
+ if (observed.parentStatus[worker.parentSessionId] !== 'ok') {
99
+ throw new TowerToolError(
100
+ 'takeover-refused',
101
+ 'cannot verify that worker ' + worker.name + ' stopped: its parent catalog is unavailable',
102
+ )
103
+ }
104
+ const activity = observed.activity[worker.agentId]
105
+ if (activity === 'running') {
106
+ throw new TowerToolError(
107
+ 'takeover-refused',
108
+ 'worker ' + worker.name + ' is still running; call tower_interrupt (or wait) before takeover',
109
+ )
110
+ }
111
+ if (activity === undefined) {
112
+ throw new TowerToolError(
113
+ 'takeover-refused',
114
+ 'worker ' + worker.name + ' is not in the live catalog; run tower_adopt before takeover',
115
+ )
116
+ }
117
+ } else {
118
+ effectiveTakeover = false
119
+ }
120
+ }
121
+ const replacing = effectiveTakeover ? sameMission : []
122
+ const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
123
+ const max = Math.min(Number(maxLiveWorkers) || 4, budget)
124
+ if (remaining.length >= max) {
125
+ throw new TowerToolError(
126
+ 'worker-limit',
127
+ 'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
128
+ )
129
+ }
130
+ return effectiveTakeover
131
+ }
132
+
133
+ /**
134
+ * Service/provider/preset/lease checks, run before provisioning.
135
+ * @param {{ tower: any, ctx: any, config: any, preset: string, actor: any, exec: any }} input
136
+ */
137
+ export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec }) {
138
+ const subagents = ctx.get('subagents')
139
+ if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
140
+ const providers = subagents.list()
141
+ const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
142
+ if (!provider) throw new TowerToolError('provider-missing', 'no subagent provider is registered')
143
+ const presets = ctx.get('agentPresets')
144
+ if (presets && typeof presets.list === 'function') {
145
+ const available = await presets.list()
146
+ if (!available.some((entry) => String(entry.id) === preset)) {
147
+ throw new TowerToolError('preset-missing', 'agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
148
+ }
149
+ }
150
+ if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
151
+ const requiredLease = config.spawn.requireLease.trim()
152
+ const info = await inspectLeases(tower.commonDir)
153
+ const lease = info.leases.find((entry) => entry.name === requiredLease && entry.expired !== true)
154
+ if (!lease) {
155
+ throw new TowerToolError(
156
+ 'lease-busy',
157
+ `spawn.requireLease="${requiredLease}" is not held; acquire it with tower_resource before spawning`,
158
+ )
159
+ }
160
+ if (lease.holder !== actor.name && lease.holder !== String(exec.agent.id)) {
161
+ throw new TowerToolError(
162
+ 'lease-busy',
163
+ `spawn.requireLease="${requiredLease}" is held by "${lease.holder}", not by this tower`,
164
+ )
165
+ }
166
+ }
167
+ return { subagents, provider }
168
+ }
@@ -0,0 +1,79 @@
1
+ // tower_spawn support (#56 P3c): briefing assembly, agent options, and the
2
+ // wait/result settling path. Split out of tools/spawn.js; no I/O of its own.
3
+ import { workerBriefing, reviewerBriefing } from './briefing.js'
4
+
5
+ /**
6
+ * @param {any} model
7
+ */
8
+ export function buildAgentOptions(model) {
9
+ const agentOptions = {}
10
+ for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
11
+ if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
12
+ }
13
+ return agentOptions
14
+ }
15
+
16
+ /**
17
+ * @param {{ role: string, towerRoot: string, context: any, worktree: any, instructions: string | undefined }} input
18
+ */
19
+ export function buildSpawnBriefing({ role, towerRoot, context, worktree, instructions }) {
20
+ const mission = {
21
+ ...context.mission,
22
+ worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
23
+ }
24
+ const briefing = {
25
+ repoRoot: towerRoot,
26
+ base: context.base,
27
+ mission,
28
+ deps: context.deps,
29
+ worktree,
30
+ instructions,
31
+ reviews: context.reviews,
32
+ findings: context.findings,
33
+ completionReport: context.completionReport,
34
+ }
35
+ return role === 'worker' ? workerBriefing(briefing) : reviewerBriefing(briefing)
36
+ }
37
+
38
+ /**
39
+ * Wait for the activation result when wait=true, or forward the eventual
40
+ * outcome when wait=false.
41
+ * @param {{
42
+ * activation: any,
43
+ * report: any,
44
+ * recordOutcome: (state: string, detail: any) => Promise<any>,
45
+ * maxWaitMs: number | undefined,
46
+ * requestedWaitMs: number | undefined,
47
+ * textOf: any,
48
+ * }} input
49
+ */
50
+ export async function waitForSpawnResult({ activation, report, recordOutcome, maxWaitMs, requestedWaitMs, textOf }) {
51
+ const waitMs = Math.max(1000, Math.min(maxWaitMs ?? 90000, requestedWaitMs ?? 180000))
52
+ let timer
53
+ const timeout = new Promise((resolve) => {
54
+ timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
55
+ })
56
+ let result
57
+ try {
58
+ result = await Promise.race([activation.result, timeout])
59
+ } catch (error) {
60
+ report.stopReason = 'failed'
61
+ report.diagnostic = String(error?.message ?? error)
62
+ await recordOutcome('failed', report.diagnostic)
63
+ throw error
64
+ } finally {
65
+ clearTimeout(timer)
66
+ }
67
+ if (result && result.__timeout) {
68
+ report.timedOut = true
69
+ recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
70
+ } else {
71
+ report.stopReason = result?.stopReason ?? null
72
+ report.output = textOf(result?.output)
73
+ report.diagnostic = result?.diagnostic ?? null
74
+ const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
75
+ if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
76
+ await recordOutcome(state, report.diagnostic ?? null)
77
+ }
78
+ return report
79
+ }
@@ -0,0 +1,99 @@
1
+ // State-file load path: schema-version inspection, migration, and the one
2
+ // persisted migration result (#72).
3
+ //
4
+ // The domain stays pure: `prepareState` classifies, transforms and validates the
5
+ // document. This module owns the disk effects, and it persists them only while
6
+ // the caller holds the state lock, so a reader can never rewrite a document a
7
+ // concurrent writer is about to change. A read-only load still migrates in
8
+ // memory; the next locked write persists that migration once.
9
+ //
10
+ // Ordering mirrors the protocol contract:
11
+ // 1. classify the version (a newer document is refused before any write);
12
+ // 2. write the pre-migration backup;
13
+ // 3. migrate in memory and validate the target schema;
14
+ // 4. atomically replace state.json, bump `revision` once, append
15
+ // `state.migrated {from,to}` to the activity log.
16
+ // A migration that throws therefore leaves state.json byte-identical.
17
+ import { inspectStateVersion, prepareState } from '../domain/state.js'
18
+ import { isStateLockHeld } from './store.js'
19
+
20
+ /**
21
+ * @typedef {object} StateLoadResult
22
+ * @property {import('../types/index.js').TowerState} state
23
+ * @property {ReturnType<typeof inspectStateVersion>} version
24
+ * @property {null | {
25
+ * from: number, to: number, steps: Array<{ from: number, to: number }>,
26
+ * persisted: boolean, backupPath: string|null,
27
+ * }} migration
28
+ */
29
+
30
+ /**
31
+ * Read `paths.state`, migrate it when it is older than this build, and (only
32
+ * while the state lock is held) persist that migration.
33
+ *
34
+ * @param {{
35
+ * store: any,
36
+ * paths: any,
37
+ * now?: number,
38
+ * persist?: boolean,
39
+ * migrations?: readonly import('../domain/state/migrations.js').StateMigration[],
40
+ * }} input
41
+ * @returns {Promise<StateLoadResult>}
42
+ */
43
+ export async function loadStateFile(input) {
44
+ const { store, paths, migrations } = input
45
+ const now = input.now ?? Date.now()
46
+ const raw = await store.readJson(paths.state)
47
+ const version = inspectStateVersion(raw, paths.state, migrations)
48
+ const persist = input.persist ?? isStateLockHeld(paths)
49
+ const backupPath = `${paths.state}.v${String(version.currentVersion ?? 'unknown')}.bak`
50
+
51
+ // Backup before the original can change. `prepareState` is pure, so a
52
+ // throwing migration cannot touch state.json whether or not this ran.
53
+ if (persist && version.status === 'migratable') {
54
+ await store.writeJsonAtomic(backupPath, raw)
55
+ }
56
+
57
+ const prepared = prepareState(raw, paths.state, { migrations })
58
+ if (!prepared.changed) {
59
+ return { state: prepared.state, version, migration: null }
60
+ }
61
+
62
+ if (!persist) {
63
+ return {
64
+ state: prepared.state,
65
+ version,
66
+ migration: {
67
+ from: prepared.from,
68
+ to: prepared.to,
69
+ steps: prepared.migrated,
70
+ persisted: false,
71
+ backupPath: null,
72
+ },
73
+ }
74
+ }
75
+
76
+ const baseRevision =
77
+ Number.isInteger(prepared.state.revision) && prepared.state.revision >= 0 ? prepared.state.revision : 0
78
+ const migrated = { ...prepared.state, revision: baseRevision + 1, updatedAt: now }
79
+ await store.writeJsonAtomic(paths.state, migrated)
80
+ await store.appendJsonl(paths.activity, {
81
+ at: now,
82
+ type: 'state.migrated',
83
+ from: prepared.from,
84
+ to: prepared.to,
85
+ revision: migrated.revision,
86
+ text: `schema v${prepared.from} -> v${prepared.to}`,
87
+ })
88
+ return {
89
+ state: migrated,
90
+ version,
91
+ migration: {
92
+ from: prepared.from,
93
+ to: prepared.to,
94
+ steps: prepared.migrated,
95
+ persisted: true,
96
+ backupPath,
97
+ },
98
+ }
99
+ }
package/lib/store.js CHANGED
@@ -1,14 +1,31 @@
1
+ import { TowerToolError } from '../domain/errors.js'
1
2
  // Durable repo-scoped store: exclusive lock + atomic JSON + append-only activity.
2
- import fsp from 'node:fs/promises'
3
+ // The atomic/lock/JSON primitives are shared with the mode store (#56 P2b).
4
+ import { fsp } from '../adapters/store-fs.js'
5
+ import {
6
+ PROCESS_STARTED_AT,
7
+ acquireLock,
8
+ lockRecord,
9
+ readLock,
10
+ } from './persistence.js'
3
11
  import path from 'node:path'
4
12
  import crypto from 'node:crypto'
5
13
 
14
+ export {
15
+ readJson,
16
+ writeJsonAtomic,
17
+ writeJsonAtomicSync,
18
+ renameWithRetry,
19
+ appendJsonl,
20
+ } from './persistence.js'
21
+
6
22
  export function pathsFor(commonDir) {
7
23
  const dir = path.join(commonDir, 'dsh-tower')
8
24
  return {
9
25
  dir,
10
26
  state: path.join(dir, 'state.json'),
11
27
  lock: path.join(dir, 'state.lock'),
28
+ gitLock: path.join(dir, 'git.lock'),
12
29
  activity: path.join(dir, 'activity.jsonl'),
13
30
  }
14
31
  }
@@ -26,55 +43,6 @@ export async function ensureDir(dir) {
26
43
  await fsp.mkdir(dir, { recursive: true })
27
44
  }
28
45
 
29
- export async function readJson(file) {
30
- const text = await fsp.readFile(file, 'utf8')
31
- try {
32
- return JSON.parse(text)
33
- } catch (error) {
34
- throw new Error(`corrupt tower state at ${file}: ${error.message}`)
35
- }
36
- }
37
-
38
- export async function writeJsonAtomic(file, value) {
39
- const dir = path.dirname(file)
40
- const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
41
- const handle = await fsp.open(tmp, 'w')
42
- try {
43
- await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
44
- await handle.sync()
45
- } finally {
46
- await handle.close()
47
- }
48
- await renameWithRetry(tmp, file)
49
- }
50
-
51
- /**
52
- * Windows can reject a rename while an indexer, antivirus, or a concurrent
53
- * reader still holds the destination; retry briefly instead of failing a
54
- * successful mutation. The temp file is removed when the rename never lands.
55
- */
56
- async function renameWithRetry(tmp, file) {
57
- for (let attempt = 0; ; attempt += 1) {
58
- try {
59
- await fsp.rename(tmp, file)
60
- return
61
- } catch (error) {
62
- const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
63
- if (!transient || attempt >= 12) {
64
- await fsp.unlink(tmp).catch(() => {})
65
- throw error
66
- }
67
- await sleep(10 + attempt * 15)
68
- }
69
- }
70
- }
71
-
72
- export { renameWithRetry }
73
-
74
- export async function appendJsonl(file, value) {
75
- await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
76
- }
77
-
78
46
  export function shortHash(text) {
79
47
  return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
80
48
  }
@@ -94,66 +62,21 @@ export async function readActivityTail(file, limit = 12) {
94
62
  return out
95
63
  }
96
64
 
97
- function sleep(ms) {
98
- return new Promise((resolve) => setTimeout(resolve, ms))
99
- }
100
-
101
- const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
102
-
103
- function processAlive(pid) {
104
- if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null
105
- try {
106
- process.kill(Number(pid), 0)
107
- return true
108
- } catch (error) {
109
- if (error?.code === 'ESRCH') return false
110
- // EPERM means the process exists but belongs to another user.
111
- return true
112
- }
113
- }
114
-
115
- /** Read the current lock record without acquiring the lock. */
116
65
  export async function readStateLock(paths) {
117
- try {
118
- const text = await fsp.readFile(paths.lock, 'utf8')
119
- const record = JSON.parse(text)
120
- const alive = processAlive(record?.pid)
121
- return { path: paths.lock, record, alive, raw: text }
122
- } catch (error) {
123
- if (error?.code === 'ENOENT') return { path: paths.lock, record: null, alive: null, raw: null }
124
- return { path: paths.lock, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
125
- }
66
+ return readLock(paths.lock)
126
67
  }
127
68
 
128
- async function reclaimDeadLock(paths) {
129
- let original
130
- try {
131
- original = await fsp.readFile(paths.lock, 'utf8')
132
- } catch {
133
- return false
134
- }
135
- let parsed
136
- try {
137
- parsed = JSON.parse(original)
138
- } catch {
139
- return false
140
- }
141
- if (processAlive(parsed?.pid) !== false) return false
142
- const quarantine = `${paths.lock}.stale-${process.pid}-${crypto.randomUUID()}`
143
- try {
144
- await fsp.rename(paths.lock, quarantine)
145
- } catch {
146
- return false
147
- }
148
- const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
149
- if (moved === null || moved !== original) {
150
- // A concurrent writer replaced the lock between read and rename. Put the
151
- // newer record back rather than deleting it.
152
- if (moved !== null) await fsp.rename(quarantine, paths.lock).catch(() => {})
153
- return false
154
- }
155
- await fsp.unlink(quarantine).catch(() => {})
156
- return true
69
+ /**
70
+ * Paths objects whose state lock this process currently holds. A state
71
+ * migration may be persisted only inside `withLock`: the loader reads this to
72
+ * tell a locked mutation (which may write) from a read-only load (which must
73
+ * migrate in memory and leave the file alone).
74
+ */
75
+ const HELD_STATE_LOCKS = new WeakSet()
76
+
77
+ /** @param {object} [paths] @returns {boolean} */
78
+ export function isStateLockHeld(paths) {
79
+ return Boolean(paths) && HELD_STATE_LOCKS.has(paths)
157
80
  }
158
81
 
159
82
  /**
@@ -164,38 +87,47 @@ export async function withLock(paths, fn, options = {}) {
164
87
  const timeoutMs = options.timeoutMs ?? 5000
165
88
  const pollMs = options.pollMs ?? 25
166
89
  const token = crypto.randomUUID()
167
- const record = JSON.stringify({ token, pid: process.pid, startedAt: PROCESS_STARTED_AT, at: Date.now() })
168
- const deadline = Date.now() + timeoutMs
169
-
170
- for (;;) {
171
- try {
172
- await fsp.writeFile(paths.lock, record, { flag: 'wx' })
173
- break
174
- } catch (error) {
175
- if (error.code !== 'EEXIST') throw error
176
- if (await reclaimDeadLock(paths)) continue
177
- if (Date.now() > deadline) {
178
- const owner = await readStateLock(paths)
179
- const payload = owner.raw ?? (owner.record ? JSON.stringify(owner.record) : null)
180
- throw new Error(
181
- `tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
182
- )
183
- }
184
- await sleep(pollMs)
185
- }
90
+ const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT })
91
+ const held = await acquireLock(paths.lock, { token, record, timeoutMs, pollMs })
92
+ if (!held) {
93
+ const owner = await readStateLock(paths)
94
+ const ownerRecord = owner.record
95
+ const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
96
+ throw new TowerToolError(
97
+ 'state-locked',
98
+ `tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
99
+ )
100
+ }
101
+ const startedAt = Date.now()
102
+ HELD_STATE_LOCKS.add(paths)
103
+ try {
104
+ return await fn(paths)
105
+ } finally {
106
+ HELD_STATE_LOCKS.delete(paths)
107
+ await held.release()
108
+ await fsp
109
+ .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
110
+ .catch(() => {})
186
111
  }
112
+ }
187
113
 
114
+ /** Serialize git mutations without holding the state lock. */
115
+ export async function withGitLock(paths, fn, options = {}) {
116
+ const timeoutMs = options.timeoutMs ?? 60000
117
+ const pollMs = options.pollMs ?? 25
118
+ const lock = paths.gitLock
119
+ const token = crypto.randomUUID()
120
+ const startedAt = Date.now()
121
+ const held = await acquireLock(lock, { token, record: lockRecord(token, { kind: 'git' }), timeoutMs, pollMs })
122
+ if (!held) {
123
+ throw new TowerToolError('state-locked', 'another git mutation is in progress for this repository; retry after it finishes')
124
+ }
188
125
  try {
189
126
  return await fn(paths)
190
127
  } finally {
191
- try {
192
- const current = await fsp.readFile(paths.lock, 'utf8').catch(() => null)
193
- if (current) {
194
- const parsed = JSON.parse(current)
195
- if (parsed && parsed.token === token) await fsp.unlink(paths.lock)
196
- }
197
- } catch {
198
- // a lock left behind by a crash is reported by the next writer, not fixed here
199
- }
128
+ await held.release()
129
+ await fsp
130
+ .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, phase: 'git' })}\n`, 'utf8')
131
+ .catch(() => {})
200
132
  }
201
133
  }
package/lib/tests.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { TowerToolError } from '../domain/errors.js'
1
2
  // Tower test discovery (2.1): fail-closed admission evidence from the
2
3
  // repository's own `package.json` `tower.testDiscovery` block.
3
4
  //
@@ -18,10 +19,10 @@
18
19
  // minCount/required shortfall) returns `ok: false` in the receipt;
19
20
  // - an invalid discovery configuration (or invalid call arguments) throws a
20
21
  // plain `Error`, which the tool layer wraps as `tower:invalid-argument`.
21
- import { spawn } from 'node:child_process'
22
- import { createWriteStream } from 'node:fs'
22
+ import { spawn } from '../adapters/vcs-git.js'
23
+ import { createWriteStream } from '../adapters/store-fs.js'
23
24
  import crypto from 'node:crypto'
24
- import fsp from 'node:fs/promises'
25
+ import { fsp } from '../adapters/store-fs.js'
25
26
  import path from 'node:path'
26
27
 
27
28
  import { tryGit } from './git.js'
@@ -45,7 +46,7 @@ function nonEmptyString(value) {
45
46
  }
46
47
 
47
48
  function normalizePositiveInteger(value, name) {
48
- if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be an integer > 0`)
49
+ if (!Number.isInteger(value) || value <= 0) throw new TowerToolError('invalid-argument', `${name} must be an integer > 0`)
49
50
  return value
50
51
  }
51
52
 
@@ -287,8 +288,12 @@ export function testProcessEnv(source = process.env) {
287
288
  return env
288
289
  }
289
290
 
290
- /** Platform shell for discovery commands (PowerShell 7 on Windows, bash on POSIX). */
291
- function shellSpec(platform = process.platform) {
291
+ /**
292
+ * Platform shell for discovery commands (PowerShell 7 on Windows with a
293
+ * `powershell` fallback, bash on POSIX). Exported so both branches are asserted
294
+ * on every OS instead of only on the platform that happens to run CI (#86).
295
+ */
296
+ export function shellSpec(platform = process.platform) {
292
297
  if (platform === 'win32') {
293
298
  const args = (command) => ['-NoProfile', '-NonInteractive', '-Command', command]
294
299
  return {
@@ -480,7 +485,7 @@ async function resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId }
480
485
 
481
486
  function normalizeDiscoveryInput(config) {
482
487
  if (isPlainObject(config) && Object.prototype.hasOwnProperty.call(config, 'configured')) {
483
- if (config.error) throw new Error(`test discovery configuration is invalid: ${config.error}`)
488
+ if (config.error) throw new TowerToolError('invalid-argument', `test discovery configuration is invalid: ${config.error}`)
484
489
  if (config.configured === false) return { configured: false, config: null, error: null }
485
490
  return { configured: true, config: config.config, error: null }
486
491
  }
@@ -527,16 +532,16 @@ export async function runDiscovery(options = {}) {
527
532
  } = options
528
533
  const startedAt = new Date().toISOString()
529
534
 
530
- if (!nonEmptyString(worktreePath)) throw new Error('worktreePath is required')
531
- if (!path.isAbsolute(worktreePath)) throw new Error(`worktreePath must be an absolute path: ${worktreePath}`)
535
+ if (!nonEmptyString(worktreePath)) throw new TowerToolError('invalid-argument', 'worktreePath is required')
536
+ if (!path.isAbsolute(worktreePath)) throw new TowerToolError('invalid-argument', `worktreePath must be an absolute path: ${worktreePath}`)
532
537
  const effectiveTimeout = normalizePositiveInteger(timeoutMs, 'timeoutMs')
533
538
  const effectiveMaxBytes = normalizePositiveInteger(maxBytes, 'maxBytes')
534
539
  if (runner !== undefined && runner !== null && typeof runner !== 'function') {
535
- throw new Error('runner must be a function')
540
+ throw new TowerToolError('invalid-argument', 'runner must be a function')
536
541
  }
537
542
  if (diagnosticsDir !== undefined && diagnosticsDir !== null) {
538
543
  if (!nonEmptyString(diagnosticsDir) || !path.isAbsolute(diagnosticsDir)) {
539
- throw new Error(`diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
544
+ throw new TowerToolError('invalid-argument', `diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
540
545
  }
541
546
  }
542
547
 
@@ -558,7 +563,7 @@ export async function runDiscovery(options = {}) {
558
563
 
559
564
  if (!discovery.configured) return finishUnconfigured(discovery.error)
560
565
  const normalized = normalizeDiscovery(discovery.config)
561
- if (!normalized.ok) throw new Error(`invalid tower.testDiscovery config: ${normalized.error}`)
566
+ if (!normalized.ok) throw new TowerToolError('invalid-argument', `invalid tower.testDiscovery config: ${normalized.error}`)
562
567
 
563
568
  const dir = await resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId })
564
569
  const commands = []