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.
Files changed (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -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 +44 -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 +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,112 @@
1
+ // Pure batch lifecycle decisions and status projection.
2
+ //
3
+ // `domain/batch.js` owns the admission/close evidence validation; this module
4
+ // owns the id assignment, close/abandon record transitions, and status payload
5
+ // that app/batch.js used to keep inline. Receipt file reads stay in the tool
6
+ // layer.
7
+ // @ts-check
8
+ import { TowerToolError } from '../errors.js'
9
+ import { summarize } from '../batch.js'
10
+
11
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
12
+
13
+ /**
14
+ * @param {string} message
15
+ * @param {string} [code]
16
+ * @returns {never}
17
+ */
18
+ function fail(message, code = 'batch-invalid') {
19
+ throw new TowerToolError(code, message)
20
+ }
21
+
22
+ /**
23
+ * Next free `B<n>` id.
24
+ * @param {any[]} batches
25
+ * @returns {string}
26
+ */
27
+ export function nextBatchId(batches) {
28
+ const max = batches.reduce((value, batch) => {
29
+ const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
30
+ return match ? Math.max(value, Number(match[1])) : value
31
+ }, 0)
32
+ return `B${max + 1}`
33
+ }
34
+
35
+ /**
36
+ * Project the persisted batch record from normalized create input.
37
+ * @param {string} id
38
+ * @param {any} normalized
39
+ * @param {string} actor
40
+ * @param {number} now
41
+ * @returns {any}
42
+ */
43
+ export function createBatchRecord(id, normalized, actor, now) {
44
+ return {
45
+ id,
46
+ status: 'open',
47
+ ...normalized,
48
+ createdBy: actor,
49
+ createdAt: now,
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Mark a validated open batch closed with its evidence snapshot.
55
+ * @param {any} batch
56
+ * @param {{ checks?: unknown, treeTip?: unknown, receiptHashes?: unknown }} options
57
+ * @param {string} actor
58
+ * @param {number} now
59
+ * @returns {any}
60
+ */
61
+ export function closeBatchRecord(batch, options, actor, now) {
62
+ batch.status = 'closed'
63
+ batch.closedAt = now
64
+ batch.closedBy = actor
65
+ batch.close = {
66
+ checks: options.checks ?? [],
67
+ treeTip: options.treeTip ?? null,
68
+ receiptHashes: options.receiptHashes ?? {},
69
+ at: now,
70
+ }
71
+ return batch
72
+ }
73
+
74
+ /**
75
+ * A closed batch cannot be abandoned; an already-abandoned one is a no-op.
76
+ * @param {any} batch
77
+ * @param {string} batchId
78
+ * @returns {{ alreadyAbandoned: boolean }}
79
+ */
80
+ export function abandonBatchCheck(batch, batchId) {
81
+ if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
82
+ return { alreadyAbandoned: batch.status === 'abandoned' }
83
+ }
84
+
85
+ /**
86
+ * Apply an abandonment to the draft batch.
87
+ * @param {{ batch: any, actor: string, reason?: unknown, now: number }} params
88
+ * @returns {any}
89
+ */
90
+ export function applyBatchAbandon({ batch, actor, reason, now }) {
91
+ batch.status = 'abandoned'
92
+ batch.abandonedAt = now
93
+ batch.abandonedBy = actor
94
+ batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
95
+ return batch
96
+ }
97
+
98
+ /**
99
+ * Read-only `tower_batch status` payload.
100
+ * @param {TowerState} state
101
+ * @param {string | undefined} batchId
102
+ * @returns {any}
103
+ */
104
+ export function batchStatusPayload(state, batchId) {
105
+ const batches = state.batches ?? []
106
+ const batch = batchId ? batches.find((entry) => entry.id === batchId) : null
107
+ if (batchId && !batch) fail(`unknown batch "${batchId}"`)
108
+ return {
109
+ batches: batches.map((entry) => summarize(state, entry)),
110
+ ...(batch ? { batch: summarize(state, batch) } : {}),
111
+ }
112
+ }
@@ -0,0 +1,127 @@
1
+ // Pure message routing, read tracking, and inbox projections.
2
+ //
3
+ // app/messages.js owns the state lock, load, persistence, and activity log;
4
+ // app/roster.js's unreadFor delegates to `unreadMessages` here.
5
+ // @ts-check
6
+ import { TowerToolError } from '../errors.js'
7
+
8
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
9
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
10
+
11
+ /**
12
+ * @param {string} message
13
+ * @returns {never}
14
+ */
15
+ function fail(message) {
16
+ throw new TowerToolError('invalid-argument', message)
17
+ }
18
+
19
+ /**
20
+ * Validate the recipient against the roster plus the synthetic `tower` name.
21
+ * @param {{ roster?: RosterEntry[], to: string, from: string }} params
22
+ * @returns {void}
23
+ */
24
+ export function assertMessageRecipient({ roster = [], to, from }) {
25
+ const names = new Set(roster.map((entry) => entry.name))
26
+ names.add('tower')
27
+ if (to !== 'all' && !names.has(to)) {
28
+ fail(`unknown message recipient "${to}" (known: ${[...names].join(', ')}, all)`)
29
+ }
30
+ if (to === from) fail('refusing to send a message to yourself')
31
+ }
32
+
33
+ /**
34
+ * Project the persisted message record.
35
+ * @param {any} input
36
+ * @param {number} seq
37
+ * @param {number} now
38
+ * @returns {any}
39
+ */
40
+ export function createMessage(input, seq, now) {
41
+ return {
42
+ id: `msg-${seq}`,
43
+ seq,
44
+ from: input.from,
45
+ to: input.to,
46
+ subject: input.subject,
47
+ body: input.body,
48
+ scope: input.scope ?? null,
49
+ consentRef: input.consentRef ?? null,
50
+ at: now,
51
+ readBy: [],
52
+ delivery: [],
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Append delivery results to a message, preserving earlier entries.
58
+ * @param {{ delivery?: unknown[] }} message
59
+ * @param {unknown[]} results
60
+ * @returns {unknown[]}
61
+ */
62
+ export function addDelivery(message, results) {
63
+ message.delivery = [...(message.delivery ?? []), ...results]
64
+ return message.delivery
65
+ }
66
+
67
+ /**
68
+ * Inbox page plus read-marking mutation. Returns `changed` so the use case can
69
+ * decide whether the state file needs a write.
70
+ * @param {any[]} messages
71
+ * @param {{ reader: string, limit?: number, includeRead?: boolean }} input
72
+ * @param {number} now
73
+ * @returns {{ payload: any, changed: boolean }}
74
+ */
75
+ export function inboxView(messages, input, now) {
76
+ const reader = input.reader
77
+ const limit = Math.max(1, Math.min(100, input.limit ?? 20))
78
+ const includeRead = input.includeRead === true
79
+ const visible = [...messages]
80
+ .reverse()
81
+ .filter((message) => (message.to === reader || message.to === 'all') && message.from !== reader)
82
+ const unread = visible.filter((message) => !(message.readBy ?? []).includes(reader))
83
+ const page = (includeRead ? visible : unread).slice(0, limit)
84
+
85
+ let changed = false
86
+ for (const message of page) {
87
+ if (!(message.readBy ?? []).includes(reader)) {
88
+ message.readBy = [...(message.readBy ?? []), reader]
89
+ message.readAt = { ...(message.readAt ?? {}), [reader]: now }
90
+ changed = true
91
+ }
92
+ }
93
+ const unreadRemaining = visible.filter((message) => !(message.readBy ?? []).includes(reader)).length
94
+ return {
95
+ payload: {
96
+ reader,
97
+ messages: page.map((message) => ({
98
+ id: message.id,
99
+ from: message.from,
100
+ to: message.to,
101
+ subject: message.subject,
102
+ body: message.body,
103
+ scope: message.scope ?? null,
104
+ at: message.at,
105
+ delivery: message.delivery ?? [],
106
+ ackAt: message.readAt?.[reader] ?? null,
107
+ })),
108
+ pageSize: page.length,
109
+ unreadRemaining,
110
+ },
111
+ changed,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Unread inbox rows for one recipient (messages from self never count).
117
+ * @param {TowerState} state
118
+ * @param {string} name
119
+ * @returns {any[]}
120
+ */
121
+ export function unreadMessages(state, name) {
122
+ return (state.messages ?? []).filter((message) => {
123
+ if (message.from === name) return false
124
+ if (message.to !== name && message.to !== 'all') return false
125
+ return !(/** @type {any[]} */ (message.readBy ?? [])).includes(name)
126
+ })
127
+ }
@@ -0,0 +1,158 @@
1
+ // Pure roster observation and stale/inactive policy.
2
+ //
3
+ // app/roster.js owns the state lock, load, persistence, and activity log; the
4
+ // transitions below mutate only the in-memory roster rows they are given.
5
+ // @ts-check
6
+ import { ROSTER_INACTIVE } from '../roster.js'
7
+
8
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
9
+
10
+ /**
11
+ * @param {RosterEntry} entry
12
+ * @returns {void}
13
+ */
14
+ export function clearStale(entry) {
15
+ delete entry.stale
16
+ delete entry.staleAt
17
+ }
18
+
19
+ /**
20
+ * Count repeated absence observations and list rows ready to adopt.
21
+ * @param {RosterEntry[]} roster
22
+ * @param {{ present?: unknown[], absent?: unknown[], confirmations?: unknown }} observations
23
+ * @param {number} now
24
+ * @returns {{ candidates: string[], changed: boolean }}
25
+ */
26
+ export function recordRosterObservations(roster, { present = [], absent = [], confirmations = 2 }, now) {
27
+ const presentIds = new Set(present)
28
+ const absentIds = new Set(absent)
29
+ /** @type {string[]} */
30
+ const candidates = []
31
+ let changed = false
32
+ for (const entry of roster) {
33
+ if (presentIds.has(entry.agentId)) {
34
+ if (entry.absentObservations !== undefined) {
35
+ delete entry.absentObservations
36
+ delete entry.absentSince
37
+ changed = true
38
+ }
39
+ continue
40
+ }
41
+ if (!absentIds.has(entry.agentId)) continue
42
+ entry.absentObservations = (/** @type {number} */ (entry.absentObservations ?? 0)) + 1
43
+ entry.absentSince ??= now
44
+ changed = true
45
+ const observations = /** @type {number} */ (entry.absentObservations)
46
+ if (observations >= Math.max(2, Number(confirmations) || 2)) {
47
+ candidates.push(/** @type {string} */ (entry.agentId))
48
+ }
49
+ }
50
+ return { candidates, changed }
51
+ }
52
+
53
+ /**
54
+ * Release live slots whose catalog activity is explicitly inactive.
55
+ * @param {RosterEntry[]} roster
56
+ * @param {Record<string, string>} activity
57
+ * @param {number} now
58
+ * @returns {{ released: string[] }}
59
+ */
60
+ export function reconcileObservedInactive(roster, activity = {}, now) {
61
+ /** @type {string[]} */
62
+ const released = []
63
+ for (const entry of roster) {
64
+ if (entry.state === 'reserved' || ROSTER_INACTIVE.has(entry.state)) continue
65
+ const agentId = /** @type {string} */ (entry.agentId)
66
+ if (activity[agentId] !== 'inactive') continue
67
+ entry.state = 'inactive'
68
+ entry.outcome = 'catalog reported inactive'
69
+ entry.outcomeAt = now
70
+ clearStale(entry)
71
+ released.push(entry.name)
72
+ }
73
+ return { released }
74
+ }
75
+
76
+ /**
77
+ * Retire entries by agent id, skipping rows already retired.
78
+ * @param {RosterEntry[]} roster
79
+ * @param {unknown[]} agentIds
80
+ * @param {number} now
81
+ * @returns {{ retired: string[] }}
82
+ */
83
+ export function retireEntries(roster, agentIds, now) {
84
+ if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
85
+ /** @type {string[]} */
86
+ const retired = []
87
+ for (const entry of roster) {
88
+ if (entry.state !== 'retired' && agentIds.includes(entry.agentId)) {
89
+ entry.state = 'retired'
90
+ entry.retiredAt = now
91
+ clearStale(entry)
92
+ retired.push(/** @type {string} */ (entry.agentId))
93
+ }
94
+ }
95
+ return { retired }
96
+ }
97
+
98
+ /**
99
+ * Mark entries missing unless they are already inactive.
100
+ * @param {RosterEntry[]} roster
101
+ * @param {unknown[]} agentIds
102
+ * @param {number} now
103
+ * @returns {{ missing: string[] }}
104
+ */
105
+ export function markMissingEntries(roster, agentIds, now) {
106
+ if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
107
+ /** @type {string[]} */
108
+ const missing = []
109
+ for (const entry of roster) {
110
+ if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
111
+ entry.state = 'missing'
112
+ entry.missingSince = now
113
+ clearStale(entry)
114
+ missing.push(/** @type {string} */ (entry.agentId))
115
+ }
116
+ }
117
+ return { missing }
118
+ }
119
+
120
+ /**
121
+ * Watchdog sweep: catalog-inactive rows become inactive; rows absent from an
122
+ * authoritative catalog and older than `staleMs` become stale. Unknown catalog
123
+ * state is never treated as stopped.
124
+ * @param {RosterEntry[]} roster
125
+ * @param {{ activity?: Record<string, string>, staleMs?: unknown, authoritative?: unknown }} input
126
+ * @param {number} now
127
+ * @returns {{ marked: string[], idled: string[] }}
128
+ */
129
+ export function sweepRosterState(roster, input = {}, now) {
130
+ const staleMs = Number(input.staleMs) || 0
131
+ if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
132
+ const activity = input.activity ?? {}
133
+ /** @type {string[]} */
134
+ const marked = []
135
+ /** @type {string[]} */
136
+ const idled = []
137
+ for (const entry of roster) {
138
+ if (ROSTER_INACTIVE.has(entry.state)) continue
139
+ const observed = activity[/** @type {string} */ (entry.agentId)]
140
+ if (observed === 'inactive') {
141
+ entry.state = 'inactive'
142
+ entry.outcome = entry.outcome ?? 'idle (catalog)'
143
+ entry.outcomeAt = now
144
+ idled.push(entry.name)
145
+ continue
146
+ }
147
+ if (observed === 'running') continue
148
+ if (observed !== undefined) continue
149
+ const age = now - Number(entry.spawnedAt ?? now)
150
+ if (age > staleMs) {
151
+ entry.state = 'stale'
152
+ entry.stale = true
153
+ entry.staleAt = now
154
+ marked.push(entry.name)
155
+ }
156
+ }
157
+ return { marked, idled }
158
+ }
@@ -0,0 +1,188 @@
1
+ // Pure spawn reservation/outcome policy: roster entry projections, role and
2
+ // live-worker limit decisions.
3
+ //
4
+ // app/spawn.js owns the state lock, load, Git spawn-tip capture, persistence,
5
+ // and activity log. Preset/provider selection happens in tools/spawn.js and is
6
+ // intentionally not duplicated here.
7
+ // @ts-check
8
+ import { TowerToolError } from '../errors.js'
9
+ import { ROSTER_INACTIVE, countLiveWorkers, workerEntries } from '../roster.js'
10
+ import { clearStale } from './roster.js'
11
+
12
+ /** @typedef {import('../../types/index.js').Mission} Mission */
13
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
14
+
15
+ /**
16
+ * @param {string} message
17
+ * @returns {never}
18
+ */
19
+ function fail(message) {
20
+ throw new TowerToolError('invalid-argument', message)
21
+ }
22
+
23
+ /**
24
+ * @param {unknown} role
25
+ * @returns {boolean}
26
+ */
27
+ export function isWorkerRole(role) {
28
+ return role === 'worker'
29
+ }
30
+
31
+ /**
32
+ * The configured live-worker ceiling, defaulting to four.
33
+ * @param {unknown} maxLiveWorkers
34
+ * @returns {number}
35
+ */
36
+ export function liveWorkerLimit(maxLiveWorkers) {
37
+ return Number(maxLiveWorkers) || 4
38
+ }
39
+
40
+ /**
41
+ * Record that a spawned agent failed or went idle.
42
+ * @param {RosterEntry} entry
43
+ * @param {{ state?: string, detail?: unknown }} input
44
+ * @param {number} now
45
+ * @returns {RosterEntry}
46
+ */
47
+ export function applySpawnOutcome(entry, input, now) {
48
+ entry.state = input.state ?? 'failed'
49
+ entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
50
+ entry.outcomeAt = now
51
+ clearStale(entry)
52
+ return entry
53
+ }
54
+
55
+ /**
56
+ * Record that a live agent was interrupted by the tower.
57
+ * @param {RosterEntry} entry
58
+ * @param {{ reason?: unknown }} input
59
+ * @param {number} now
60
+ * @returns {RosterEntry}
61
+ */
62
+ export function applyInterrupt(entry, input, now) {
63
+ entry.state = 'interrupted'
64
+ entry.outcome = String(input.reason ?? 'interrupted by tower').slice(0, 1000)
65
+ entry.outcomeAt = now
66
+ clearStale(entry)
67
+ return entry
68
+ }
69
+
70
+ /**
71
+ * A reserved roster row: the name is claimed before activation so a failed
72
+ * spawn remains recoverable.
73
+ * @param {any} input
74
+ * @param {number} now
75
+ * @returns {RosterEntry}
76
+ */
77
+ export function reservedRosterEntry(input, now) {
78
+ return {
79
+ name: input.name,
80
+ role: input.role,
81
+ agentId: null,
82
+ sessionId: null,
83
+ parentSessionId: input.parentSessionId,
84
+ missionId: input.missionId,
85
+ cwd: input.cwd ?? null,
86
+ branch: input.branch ?? null,
87
+ spawnTip: null,
88
+ state: 'reserved',
89
+ reservedAt: now,
90
+ spawnedAt: now,
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Validate a reservation request and project its row.
96
+ * @param {{ roster: RosterEntry[], input: any, now: number }} params
97
+ * @returns {RosterEntry}
98
+ */
99
+ export function reserveRosterEntry({ roster, input, now }) {
100
+ if (roster.some((entry) => entry.name === input.name)) fail(`roster name already used: ${input.name}`)
101
+ return reservedRosterEntry(input, now)
102
+ }
103
+
104
+ /**
105
+ * Validate and apply a spawn record. Mutates the roster (retiring prior worker
106
+ * rows and inserting/reusing the entry) and the mission owner/status/gen.
107
+ * @param {{ roster: RosterEntry[], mission: Mission, input: any, spawnTip: string | null, maxLiveWorkers: unknown, now: number }} params
108
+ * @returns {RosterEntry}
109
+ */
110
+ export function recordSpawnEntry({ roster, mission, input, spawnTip, maxLiveWorkers, now }) {
111
+ const reserved = roster.find((entry) => entry.name === input.name && entry.state === 'reserved' && !entry.agentId)
112
+ if (!reserved && roster.some((entry) => entry.name === input.name)) {
113
+ fail(`roster name already used: ${input.name}`)
114
+ }
115
+ if (roster.some((entry) => entry.agentId && entry.agentId === input.agentId)) {
116
+ fail(`agent ${input.agentId} is already in the roster`)
117
+ }
118
+ /** @type {RosterEntry} */
119
+ const entry = {
120
+ name: input.name,
121
+ role: input.role,
122
+ agentId: input.agentId,
123
+ sessionId: input.sessionId ?? input.agentId,
124
+ parentSessionId: input.parentSessionId,
125
+ missionId: input.missionId,
126
+ cwd: input.cwd ?? null,
127
+ branch: input.branch ?? null,
128
+ spawnTip,
129
+ state: 'active',
130
+ spawnedAt: now,
131
+ }
132
+ if (isWorkerRole(input.role)) {
133
+ const snapshot = /** @type {any} */ ({ roster })
134
+ const previous = workerEntries(snapshot, mission.id)
135
+ const live = previous.find((row) => !ROSTER_INACTIVE.has(row.state) && row.state !== 'reserved')
136
+ if (live && !input.takeover) {
137
+ fail(
138
+ `mission ${mission.id} already has a live worker "${live.name}"; stop it with tower_interrupt, or pass takeover:true after it is no longer running`,
139
+ )
140
+ }
141
+ // A new owner always retires the mission's older worker entries, including
142
+ // idle `inactive` ones; otherwise the next spawn would see several entries
143
+ // for the same mission and the old idle worker would block forever.
144
+ for (const old of previous) {
145
+ if (old.state === 'retired' || old.state === 'reserved') continue
146
+ old.state = 'retired'
147
+ old.outcome = `replaced by ${input.name}${input.takeover ? ' (takeover)' : ''}`
148
+ old.outcomeAt = now
149
+ clearStale(old)
150
+ }
151
+ const max = liveWorkerLimit(maxLiveWorkers)
152
+ if (countLiveWorkers(snapshot) >= max) {
153
+ fail(`live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`)
154
+ }
155
+ const generation = (mission.ownerGeneration ?? 0) + 1
156
+ entry.generation = generation
157
+ mission.ownerGeneration = generation
158
+ mission.owner = input.name
159
+ if (mission.status === 'planned') mission.status = 'active'
160
+ }
161
+ if (reserved) Object.assign(reserved, entry)
162
+ else roster.push(entry)
163
+ return reserved ?? entry
164
+ }
165
+
166
+ /**
167
+ * Pure read projection for `tower_roster`.
168
+ * @param {import('../../types/index.js').TowerState} state
169
+ * @returns {any}
170
+ */
171
+ export function rosterProjection(state) {
172
+ return {
173
+ revision: state.revision,
174
+ roster: (state.roster ?? []).map((entry) => ({ ...entry })),
175
+ missions: state.missions.map((mission) => ({
176
+ id: mission.id,
177
+ status: mission.status,
178
+ owner: mission.owner ?? null,
179
+ worktree: mission.worktree
180
+ ? {
181
+ path: mission.worktree.path,
182
+ branch: mission.worktree.branch,
183
+ baseCommit: mission.worktree.baseCommit,
184
+ }
185
+ : null,
186
+ })),
187
+ }
188
+ }