@defrex/autobuild 0.10.1 → 0.12.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 (120) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +237 -23
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/architecture.md +66 -4
  5. package/docs/configuration.md +137 -19
  6. package/docs/hosted-dispatcher.md +17 -2
  7. package/docs/hosted-service.md +214 -0
  8. package/docs/mcp.md +8 -2
  9. package/docs/operator-api.md +107 -7
  10. package/docs/remote-store-protocol.md +189 -144
  11. package/docs/remote-ticket-protocol.md +111 -18
  12. package/docs/setup.md +173 -16
  13. package/docs/ticket-dependencies.md +1 -1
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/binary.ts +3 -3
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +261 -0
  19. package/packages/core/src/cli/dashboard/model.ts +8 -5
  20. package/packages/core/src/cli/dashboard/render.ts +37 -7
  21. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  22. package/packages/core/src/cli/dispatch-process.ts +25 -7
  23. package/packages/core/src/cli/dispatch.ts +247 -47
  24. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  25. package/packages/core/src/cli/help.ts +45 -13
  26. package/packages/core/src/cli/init-validation.ts +27 -2
  27. package/packages/core/src/cli/main.ts +41 -15
  28. package/packages/core/src/cli/mcp.ts +7 -1
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/repository-status.ts +45 -5
  34. package/packages/core/src/cli/self-update.ts +10 -0
  35. package/packages/core/src/cli/store-opening.ts +22 -14
  36. package/packages/core/src/cli/terminals.ts +0 -10
  37. package/packages/core/src/cli/ticket.ts +8 -1
  38. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  39. package/packages/core/src/cli/wait.ts +84 -17
  40. package/packages/core/src/cli/watch.ts +350 -68
  41. package/packages/core/src/config/live.ts +4 -0
  42. package/packages/core/src/config/schema.ts +65 -11
  43. package/packages/core/src/connection.ts +86 -0
  44. package/packages/core/src/events/catalog.ts +10 -2
  45. package/packages/core/src/events/globs.ts +8 -3
  46. package/packages/core/src/events/payloads.ts +44 -0
  47. package/packages/core/src/events/repository.ts +64 -2
  48. package/packages/core/src/integration/harness.ts +2 -1
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
  50. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  51. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  52. package/packages/core/src/kernel/engine.ts +24 -0
  53. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  54. package/packages/core/src/kernel/log-index.ts +17 -0
  55. package/packages/core/src/kernel/pr-summary.ts +89 -0
  56. package/packages/core/src/kernel/reducer-registry.ts +12 -0
  57. package/packages/core/src/kernel/reducer.ts +8 -0
  58. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  59. package/packages/core/src/ontology.ts +1 -0
  60. package/packages/core/src/operator/index.ts +7 -0
  61. package/packages/core/src/operator/query.ts +44 -19
  62. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  63. package/packages/core/src/operator/tickets.ts +6 -8
  64. package/packages/core/src/plugin-sdk/index.ts +1 -0
  65. package/packages/core/src/plugins/manifest.ts +5 -3
  66. package/packages/core/src/plugins/runtimes.ts +3 -1
  67. package/packages/core/src/ports/forge/fake.ts +13 -1
  68. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  69. package/packages/core/src/ports/forge/github.ts +29 -5
  70. package/packages/core/src/ports/forge/local-git.ts +12 -4
  71. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  72. package/packages/core/src/ports/runner/session-env.ts +1 -0
  73. package/packages/core/src/ports/tickets/contract.ts +25 -0
  74. package/packages/core/src/ports/tickets/create.ts +11 -3
  75. package/packages/core/src/ports/tickets/fake.ts +10 -8
  76. package/packages/core/src/ports/tickets/file.ts +1 -0
  77. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  78. package/packages/core/src/ports/tickets/linear.ts +241 -49
  79. package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
  80. package/packages/core/src/ports/tickets/remote.ts +1 -0
  81. package/packages/core/src/ports/types.ts +17 -0
  82. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  83. package/packages/core/src/ports/workspace/create.ts +11 -2
  84. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  85. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  86. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  87. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  88. package/packages/core/src/processes/build-execution-state.ts +1 -1
  89. package/packages/core/src/processes/build-runner.ts +87 -26
  90. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  91. package/packages/core/src/processes/dispatcher.ts +336 -83
  92. package/packages/core/src/processes/harvest-child.ts +1 -0
  93. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  94. package/packages/core/src/processes/harvest-runner.ts +142 -100
  95. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  96. package/packages/core/src/processes/publication-loss.ts +18 -1
  97. package/packages/core/src/processes/repo-view.ts +2 -1
  98. package/packages/core/src/store/adapter.ts +1 -0
  99. package/packages/core/src/store/contract.ts +109 -0
  100. package/packages/core/src/store/digest.ts +14 -2
  101. package/packages/core/src/store/local/store.ts +18 -5
  102. package/packages/core/src/store/remote/client.ts +1 -1
  103. package/packages/core/src/store/remote/compatibility.ts +90 -0
  104. package/packages/core/src/store/remote/credentials.ts +28 -0
  105. package/packages/core/src/store/remote/index.ts +14 -1
  106. package/packages/core/src/store/remote/protocol.ts +26 -0
  107. package/packages/core/src/store/remote/token.ts +74 -13
  108. package/packages/core/src/store/repo-state-events.ts +49 -59
  109. package/packages/core/src/store/types.ts +5 -1
  110. package/packages/core/src/testing/index.ts +7 -0
  111. package/skills/guide/SKILL.md +175 -26
  112. package/skills/guide/references/operator-api.md +135 -13
  113. package/skills/guide/references/plugin-authoring.md +10 -2
  114. package/skills/guide/references/remote-store-protocol.md +189 -144
  115. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  116. package/skills/guide/references/setup.md +173 -16
  117. package/skills/guide/references/ticket-dependencies.md +1 -1
  118. package/skills/operate/SKILL.md +159 -34
  119. package/templates/hosted-service/package.json +13 -0
  120. package/templates/hosted-service/vercel.json +7 -0
@@ -53,7 +53,7 @@ export async function withDispatchTerminalRestore<T>(
53
53
  terminal: TerminalOut,
54
54
  run: () => Promise<T>,
55
55
  boundary: TerminalRestoreProcess = process as unknown as TerminalRestoreProcess,
56
- requestStop: () => void = () => {},
56
+ requestStop: (signal: NodeJS.Signals) => void = () => {},
57
57
  ): Promise<T> {
58
58
  const hook =
59
59
  command === 'dispatch'
@@ -96,7 +96,7 @@ export async function runBinary(
96
96
  // cleanly at the next tick boundary (§15.6-C: in-flight leases expire and
97
97
  // a future dispatch re-attaches).
98
98
  const controller = new AbortController()
99
- const onSigint = (): void => controller.abort()
99
+ const onSigint = (): void => controller.abort('SIGINT')
100
100
  const ownsSigint = usesGenericSessionlessSigintHandler(command)
101
101
  const terminal = processTerminal(process.stdout)
102
102
  const removeSigint = installSessionlessSigintHandler(command, onSigint)
@@ -119,7 +119,7 @@ export async function runBinary(
119
119
  ...(resolveDashboardRenderer !== undefined ? { resolveDashboardRenderer } : {}),
120
120
  }),
121
121
  process as unknown as TerminalRestoreProcess,
122
- onSigint,
122
+ (signal) => controller.abort(signal),
123
123
  )
124
124
  } finally {
125
125
  removeSigint()
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parse } from 'smol-toml'
4
+ import { storeSchema } from '../config/schema'
5
+
6
+ /** Store selection happens before full dispatch configuration is loaded. */
7
+ export function configuredStore(checkout: string): string | undefined {
8
+ let text: string
9
+ try {
10
+ text = readFileSync(join(checkout, 'autobuild.toml'), 'utf8')
11
+ } catch (error) {
12
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
13
+ throw error
14
+ }
15
+ const table = parse(text)
16
+ return table.store === undefined ? undefined : storeSchema.parse(table.store).url
17
+ }
@@ -0,0 +1,261 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
2
+ import { access, readFile, rename, unlink, writeFile } from 'node:fs/promises'
3
+ import { constants } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { parse } from 'smol-toml'
6
+ import {
7
+ normalizeConnectionRepository,
8
+ normalizeServiceURL,
9
+ type ConnectionStarted,
10
+ type ConnectionOutcome,
11
+ } from '../connection'
12
+ import type { Exec } from '../ports/workspace/git-worktree'
13
+ import { resolveMainRepo } from './repo-state'
14
+ import { ProjectCredentials } from './project-credentials'
15
+
16
+ interface ConnectOptions {
17
+ cwd: string
18
+ env: Record<string, string | undefined>
19
+ exec: Exec
20
+ print: (message: string) => void
21
+ signal?: AbortSignal
22
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>
23
+ browser?: (url: string) => Promise<void>
24
+ sleep?: (ms: number) => Promise<void>
25
+ now?: () => number
26
+ }
27
+ function requireOperator(env: ConnectOptions['env']) {
28
+ if (
29
+ ['AB_BUILD', 'AB_PHASE', 'AB_SESSION', 'AB_HARVEST', 'AB_REPO'].some(
30
+ (key) => env[key] !== undefined,
31
+ )
32
+ )
33
+ throw new Error('connect and disconnect are unavailable inside build or harvest sessions')
34
+ }
35
+ async function project(opts: ConnectOptions) {
36
+ requireOperator(opts.env)
37
+ const checkout = await resolveMainRepo(opts.cwd, opts.exec)
38
+ const remote = await opts.exec(['git', 'remote', 'get-url', 'origin'], { cwd: checkout })
39
+ if (remote.exitCode !== 0 || !remote.stdout.trim())
40
+ throw new Error('A network origin remote is required; add an origin before running ab connect')
41
+ const repo = normalizeConnectionRepository(remote.stdout.trim())
42
+ const path = join(checkout, 'autobuild.toml')
43
+ const text = await readFile(path, 'utf8')
44
+ const table = parse(text)
45
+ const store = table.store as { url?: unknown } | undefined
46
+ if (store && (typeof store !== 'object' || Array.isArray(store)))
47
+ throw new Error('Invalid [store] configuration')
48
+ const service = store?.url === undefined ? undefined : normalizeServiceURL(String(store.url))
49
+ return { checkout, repo, path, text, table, service }
50
+ }
51
+ async function request(
52
+ opts: ConnectOptions,
53
+ service: string,
54
+ path: string,
55
+ body?: unknown,
56
+ token?: string,
57
+ ) {
58
+ const response = await (opts.fetch ?? fetch)(`${service}/connect/v1/${path}`, {
59
+ method: body === undefined ? 'GET' : 'POST',
60
+ headers: {
61
+ 'content-type': 'application/json',
62
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
63
+ },
64
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
65
+ signal: AbortSignal.timeout(15000),
66
+ redirect: 'error',
67
+ })
68
+ return response
69
+ }
70
+ async function revoke(opts: ConnectOptions, service: string, token: string) {
71
+ const response = await request(opts, service, 'revoke', {}, token)
72
+ if (!response.ok && response.status !== 401)
73
+ throw new Error('Remote revocation failed; retry ab disconnect when the service is reachable')
74
+ }
75
+ async function atomicConfig(path: string, before: string, after: string) {
76
+ const temporary = `${path}.${randomUUID()}.tmp`
77
+ try {
78
+ await writeFile(temporary, after, { flag: 'wx' })
79
+ if ((await readFile(path, 'utf8')) !== before)
80
+ throw new Error('autobuild.toml changed during connection; retry ab connect')
81
+ await rename(temporary, path)
82
+ } finally {
83
+ await unlink(temporary).catch(() => {})
84
+ }
85
+ }
86
+ export async function abConnect(raw: string | undefined, opts: ConnectOptions): Promise<void> {
87
+ const p = await project(opts)
88
+ const credentials = new ProjectCredentials(opts.env)
89
+ if (raw === undefined) {
90
+ if (!p.service) {
91
+ opts.print(
92
+ `Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
93
+ )
94
+ return
95
+ }
96
+ const entry = credentials.load(p.service, p.repo)
97
+ let status = 'missing; run ab connect'
98
+ if (entry) {
99
+ try {
100
+ const response = await request(opts, p.service, 'status', undefined, entry.token)
101
+ if (response.ok) {
102
+ const body = (await response.json()) as {
103
+ repo?: string
104
+ credentialId?: string
105
+ accepted?: boolean
106
+ }
107
+ if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
108
+ throw new Error('Invalid status response')
109
+ }
110
+ status = response.ok
111
+ ? 'accepted'
112
+ : response.status === 401 || response.status === 403
113
+ ? 'rejected; run ab connect'
114
+ : 'unreachable'
115
+ } catch {
116
+ status = 'unreachable'
117
+ }
118
+ }
119
+ opts.print(`Service: ${p.service}\nProject: ${p.repo}\nCredential: ${status}`)
120
+ return
121
+ }
122
+ const service = normalizeServiceURL(raw)
123
+ if (p.service && p.service !== service)
124
+ throw new Error(
125
+ 'Configured [store] url differs; update it explicitly before connecting to another service',
126
+ )
127
+ await access(p.path, constants.W_OK)
128
+ await access(dirname(p.path), constants.W_OK)
129
+ const previous = credentials.load(service, p.repo)
130
+ const id = randomBytes(16).toString('hex'),
131
+ secret = randomBytes(32).toString('hex')
132
+ const token = `abp_1_${id}_${secret}`
133
+ const response = await request(opts, service, 'requests', {
134
+ repo: p.repo,
135
+ credentialId: id,
136
+ digest: createHash('sha256').update(secret).digest('hex'),
137
+ })
138
+ if (!response.ok)
139
+ throw new Error('Could not start connection approval; verify the web-enabled service URL')
140
+ const started = (await response.json()) as ConnectionStarted
141
+ if (
142
+ !/^[a-f0-9]{32}$/.test(started.id) ||
143
+ !/^[a-f0-9]{64}$/.test(started.capability) ||
144
+ !/^[A-F0-9]{10}$/.test(started.code) ||
145
+ !Number.isFinite(Date.parse(started.expiresAt))
146
+ )
147
+ throw new Error('Invalid connection response')
148
+ const verification = `${service}/connect?code=${started.code}`
149
+ opts.print(`Approve project ${p.repo}\nOpen ${verification}\nCode: ${started.code}`)
150
+ try {
151
+ if (opts.browser) await opts.browser(verification)
152
+ else {
153
+ const command =
154
+ process.platform === 'darwin'
155
+ ? ['open', verification]
156
+ : process.platform === 'win32'
157
+ ? ['rundll32', 'url.dll,FileProtocolHandler', verification]
158
+ : ['xdg-open', verification]
159
+ const child = Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' })
160
+ child.unref()
161
+ }
162
+ } catch {
163
+ opts.print('Browser could not be opened. Use the URL and code on another device.')
164
+ }
165
+ const capability = { id: started.id, capability: started.capability }
166
+ const now = opts.now ?? Date.now
167
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
168
+ const interval = Math.max(5, Math.min(30, started.interval || 5)) * 1000
169
+ let approved = false
170
+ let configChanged = false
171
+ let saved = false
172
+ try {
173
+ let outcome: ConnectionOutcome | undefined
174
+ while (!opts.signal?.aborted && now() <= Date.parse(started.expiresAt) + interval) {
175
+ await sleep(interval)
176
+ const polled = await request(opts, service, 'poll', capability)
177
+ if (polled.status === 429) continue
178
+ if (!polled.ok) throw new Error('Approval polling failed; retry ab connect')
179
+ outcome = (await polled.json()) as ConnectionOutcome
180
+ if (outcome.status !== 'pending') break
181
+ }
182
+ if (!outcome || outcome.status === 'pending' || opts.signal?.aborted) {
183
+ const cancelled = await request(opts, service, 'cancel', capability)
184
+ if (!cancelled.ok)
185
+ throw new Error(
186
+ 'Could not confirm cancellation; use the owner credential list to check this connection',
187
+ )
188
+ opts.print(
189
+ opts.signal?.aborted
190
+ ? 'Connection cancelled; local configuration unchanged.'
191
+ : 'Connection timed out; local configuration unchanged.',
192
+ )
193
+ return
194
+ }
195
+ if (outcome.status !== 'approved') {
196
+ opts.print(`Connection ${outcome.status}; local configuration unchanged.`)
197
+ return
198
+ }
199
+ approved = true
200
+ if (outcome.repo !== p.repo || outcome.credentialId !== id)
201
+ throw new Error('Approval identity mismatch')
202
+ if (!p.service) {
203
+ // Dotted root key also supports an existing empty [store] table after it.
204
+ const after = `store.url = ${JSON.stringify(service)}\n${p.text}`
205
+ const parsed = parse(after)
206
+ if ((parsed.store as { url?: string })?.url !== service)
207
+ throw new Error('Cannot safely update [store] url; add it manually and retry')
208
+ await atomicConfig(p.path, p.text, after)
209
+ configChanged = true
210
+ }
211
+ credentials.save({ service, repo: p.repo, id, token })
212
+ saved = true
213
+ if (previous) {
214
+ try {
215
+ await revoke(opts, service, previous.token)
216
+ } catch {
217
+ opts.print(
218
+ `Connected, but the previous credential ${previous.id} could not be revoked; revoke it using the service administrative tools.`,
219
+ )
220
+ }
221
+ }
222
+ opts.print(`Connected ${p.repo} to ${service}.`)
223
+ if (configChanged) opts.print(`Updated ${p.path}; review and commit this non-secret setting.`)
224
+ } catch (error) {
225
+ if (!saved) {
226
+ try {
227
+ const cancelled = await request(opts, service, 'cancel', capability)
228
+ if (!cancelled.ok) throw new Error()
229
+ } catch {
230
+ opts.print(
231
+ `Could not confirm remote cleanup for credential ${id}; check the service administrative credential list.`,
232
+ )
233
+ }
234
+ if (configChanged) {
235
+ try {
236
+ await atomicConfig(p.path, `store.url = ${JSON.stringify(service)}\n${p.text}`, p.text)
237
+ } catch {
238
+ opts.print('Restore [store] url manually if needed; configuration changed concurrently.')
239
+ }
240
+ }
241
+ }
242
+ throw new Error(
243
+ approved
244
+ ? 'Connection could not be saved; previous credential retained. Repair local storage and run ab connect again.'
245
+ : (error as Error).message,
246
+ )
247
+ }
248
+ }
249
+ export async function abDisconnect(opts: ConnectOptions): Promise<void> {
250
+ const p = await project(opts)
251
+ if (!p.service) throw new Error('No configured service')
252
+ const credentials = new ProjectCredentials(opts.env)
253
+ const entry = credentials.load(p.service, p.repo)
254
+ if (!entry) {
255
+ opts.print('No stored credential; project configuration and service data retained.')
256
+ return
257
+ }
258
+ await revoke(opts, p.service, entry.token)
259
+ credentials.remove(p.service, p.repo)
260
+ opts.print('Disconnected; project registration, data, and [store] url retained.')
261
+ }
@@ -116,11 +116,9 @@ export interface PipelineStep {
116
116
  /** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
117
117
  * shown (AC 6). The renderer composes the elapsed segment from this. */
118
118
  timing?: StepTiming
119
- /** Display-only, like `qualifier`, and never consulted for routing: the
120
- * parked merge step's no-consent explanation — why nothing will happen
121
- * without operator action, and which control unblocks it. Attached when the
122
- * build is current at merge with effective auto-merge off; the terminal
123
- * row/detail and the web row/detail render the same words. */
119
+ /** Display-only policy skip reason or parked merge explanation. The shared
120
+ * row/detail renderers use this wrapping prose segment for longer reasons;
121
+ * it is never consulted for routing. */
124
122
  reason?: string
125
123
  }
126
124
 
@@ -268,6 +266,8 @@ export interface DashboardModel {
268
266
  harvestPaused: boolean
269
267
  /** Stable row identity, never a row index. */
270
268
  selection?: DashboardSelection
269
+ /** Invocation-local standby identity, independent of notices and build rows. */
270
+ standby?: string
271
271
  /** Release courtesy, separate from transient and startup warnings so those
272
272
  * notices can never overwrite one another. Store-only frontends project it
273
273
  * from their correlated dispatcher run. */
@@ -750,6 +750,7 @@ export function projectBuildFromFacts(
750
750
  : stepResults.some((r) => r.outcome === 'skipped')
751
751
  ? 'skipped'
752
752
  : undefined,
753
+ reason: stepResults.find((r) => r.skipCause === 'post-reconcile-policy')?.reason,
753
754
  count,
754
755
  timing: timingFor(intervals, phase, cycleSince, frozenNow),
755
756
  }),
@@ -1090,6 +1091,7 @@ interface DashboardFrameHeader {
1090
1091
  queued: number
1091
1092
  observationCount: number
1092
1093
  selection?: DashboardSelection
1094
+ standby?: string
1093
1095
  availableUpgrade?: string
1094
1096
  warningLines?: readonly string[]
1095
1097
  resumeInput?: ResumeInputView
@@ -1125,6 +1127,7 @@ export function buildDashboardFromProjected(
1125
1127
  defaultAutoMerge: settings.defaultAutoMerge,
1126
1128
  harvestPaused: harvestProjection.harvestPaused,
1127
1129
  ...(header.selection !== undefined ? { selection: header.selection } : {}),
1130
+ ...(header.standby !== undefined ? { standby: header.standby } : {}),
1128
1131
  ...(header.availableUpgrade !== undefined ? { availableUpgrade: header.availableUpgrade } : {}),
1129
1132
  ...(header.warningLines !== undefined && header.warningLines.length > 0
1130
1133
  ? { warningLines: header.warningLines }
@@ -722,6 +722,21 @@ interface DetailGeometry extends DetailBodyLayout {
722
722
  limit: number
723
723
  }
724
724
 
725
+ function standbyLine(model: DashboardModel, color: boolean, width: number): string[] {
726
+ return model.standby === undefined
727
+ ? []
728
+ : [
729
+ truncate(
730
+ paint(
731
+ ` Dispatcher on standby: repository held by ${displayText(model.standby)}`,
732
+ 'yellow',
733
+ color,
734
+ ),
735
+ width,
736
+ ),
737
+ ]
738
+ }
739
+
725
740
  function upgradeNoticeLine(
726
741
  model: DashboardModel,
727
742
  color: boolean,
@@ -749,7 +764,8 @@ function detailGeometry(
749
764
  const build = model.builds.find((candidate) => candidate.slug === view.slug)
750
765
  if (build === undefined) return undefined
751
766
  const layout = detailBody(build, view, { color: false, width, height, now: 0 })
752
- const topLength = 2 + (model.availableUpgrade === undefined ? 0 : 1)
767
+ const topLength =
768
+ 2 + (model.standby === undefined ? 0 : 1) + (model.availableUpgrade === undefined ? 0 : 1)
753
769
  const controls =
754
770
  model.resumeInput !== undefined || model.abortConfirmation !== undefined
755
771
  ? dashboardControls(model, false, width, controlsCapacity(height, topLength))
@@ -808,6 +824,7 @@ function renderDetail(model: DashboardModel, opts: RenderOpts): string[] {
808
824
  ` status ${paint(build.status.toUpperCase(), STATUS_COLOR[build.status], color)}${paused}`,
809
825
  width,
810
826
  ),
827
+ ...standbyLine(model, color, width),
811
828
  ...(notice === undefined ? [] : [notice]),
812
829
  ]
813
830
  const { body } = detailBody(build, view, opts)
@@ -862,11 +879,12 @@ export function transcriptScrollLimit(
862
879
  terminalWidth: number,
863
880
  height: number,
864
881
  hasUpgradeNotice = false,
882
+ hasStandby = false,
865
883
  ): number {
866
884
  const contentLines = transcriptContent(presentation, dashboardContentWidth(terminalWidth)).length
867
- // Transcript chrome is two header rows, the optional upgrade row, two
885
+ // Transcript chrome is two header rows, optional standby/upgrade rows, two
868
886
  // separators, and controls. Keep this in lockstep with renderTranscript.
869
- const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
887
+ const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0) - (hasStandby ? 1 : 0))
870
888
  return Math.max(0, contentLines - capacity)
871
889
  }
872
890
 
@@ -877,8 +895,15 @@ export function moveTranscriptScroll(
877
895
  current: number,
878
896
  delta: number,
879
897
  hasUpgradeNotice = false,
898
+ hasStandby = false,
880
899
  ): number {
881
- const limit = transcriptScrollLimit(presentation, terminalWidth, height, hasUpgradeNotice)
900
+ const limit = transcriptScrollLimit(
901
+ presentation,
902
+ terminalWidth,
903
+ height,
904
+ hasUpgradeNotice,
905
+ hasStandby,
906
+ )
882
907
  // Clamp current first because a resize, or state from an older controller,
883
908
  // may leave it beyond the freshly wrapped viewport's end.
884
909
  return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
@@ -909,6 +934,7 @@ function renderTranscript(model: DashboardModel, opts: RenderOpts): string[] {
909
934
  const top = [
910
935
  truncate(`${paint('Transcript', 'bold', color)} ${displayText(view.slug)}`, width),
911
936
  truncate(` session ${displayText(view.sessionId)}`, width),
937
+ ...standbyLine(model, color, width),
912
938
  ...(notice === undefined ? [] : [notice]),
913
939
  ]
914
940
  const content = transcriptContent(view.transcript, width).map((line) => truncate(line, width))
@@ -948,11 +974,12 @@ export function sessionScrollLimit(
948
974
  terminalWidth: number,
949
975
  height: number,
950
976
  hasUpgradeNotice = false,
977
+ hasStandby = false,
951
978
  ): number {
952
979
  const contentLines = sessionContentLines(view, dashboardContentWidth(terminalWidth)).length
953
- // Session chrome is two header rows, the optional upgrade row, two
980
+ // Session chrome is two header rows, optional standby/upgrade rows, two
954
981
  // separators, and controls — the transcript's exact arithmetic.
955
- const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
982
+ const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0) - (hasStandby ? 1 : 0))
956
983
  return Math.max(0, contentLines - capacity)
957
984
  }
958
985
 
@@ -963,8 +990,9 @@ export function moveSessionScroll(
963
990
  current: number,
964
991
  delta: number,
965
992
  hasUpgradeNotice = false,
993
+ hasStandby = false,
966
994
  ): number {
967
- const limit = sessionScrollLimit(view, terminalWidth, height, hasUpgradeNotice)
995
+ const limit = sessionScrollLimit(view, terminalWidth, height, hasUpgradeNotice, hasStandby)
968
996
  // Clamp current first: a resize, a feed apply, or state from an older
969
997
  // controller may leave it beyond the freshly wrapped viewport's end.
970
998
  return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
@@ -983,6 +1011,7 @@ function renderSessionView(model: DashboardModel, opts: RenderOpts): string[] {
983
1011
  }`,
984
1012
  width,
985
1013
  ),
1014
+ ...standbyLine(model, color, width),
986
1015
  ...(notice === undefined ? [] : [notice]),
987
1016
  ]
988
1017
  const content = sessionContentLines(view, width).map((line) => truncate(line, width))
@@ -1417,6 +1446,7 @@ function renderDashboardContent(model: DashboardModel, opts: RenderOpts): string
1417
1446
  const top = [
1418
1447
  summary,
1419
1448
  toggles,
1449
+ ...standbyLine(model, color, width),
1420
1450
  ...(upgradeNotice === undefined ? [] : [upgradeNotice]),
1421
1451
  ...warnings,
1422
1452
  ]
@@ -1,3 +1,4 @@
1
+ import { StandbyIdentityResolver } from './dispatcher-liveness'
1
2
  import { configSchema, type Config } from '../config/schema'
2
3
  import { humanActor } from '../events/envelope'
3
4
  import type { RepositoryEvent } from '../events/repository'
@@ -114,6 +115,7 @@ export interface DispatchFrontendOptions {
114
115
  /** Terminal controller with a deliberately narrow wiring surface: terminal,
115
116
  * BuildStore, immutable repository identity, clock, and child supervision. */
116
117
  export class DispatchFrontend {
118
+ private readonly standbyResolver: StandbyIdentityResolver
117
119
  private readonly runId = crypto.randomUUID()
118
120
  private readonly clock: Clock
119
121
  private readonly region: LiveRegion
@@ -153,6 +155,7 @@ export class DispatchFrontend {
153
155
  private operatorStopRequested = false
154
156
 
155
157
  constructor(private readonly opts: DispatchFrontendOptions) {
158
+ this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo)
156
159
  this.clock = opts.clock ?? systemClock
157
160
  this.keyboard = createKeyboardProtocol(
158
161
  (chunk) => opts.terminal.write(chunk),
@@ -191,6 +194,7 @@ export class DispatchFrontend {
191
194
  this.opts.store.getRepoEvents(this.opts.repo, 0),
192
195
  )
193
196
  : await this.opts.store.getRepoEvents(this.opts.repo, 0)
197
+ this.standbyResolver.ingest(seed)
194
198
  this.repositorySeq = seed.at(-1)?.seq ?? 0
195
199
  this.dispatchStatus = reduceDispatchStatus(seed, this.runId)
196
200
  this.repositoryEvents.push(...seed.filter(isDashboardRepositoryEvent))
@@ -199,6 +203,7 @@ export class DispatchFrontend {
199
203
  }
200
204
  const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
201
205
  if (delta.length === 0) return
206
+ this.standbyResolver.ingest(delta)
202
207
  this.repositorySeq = delta.at(-1)!.seq
203
208
  this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
204
209
  this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
@@ -367,10 +372,14 @@ export class DispatchFrontend {
367
372
  ...status.roleWarnings,
368
373
  ...(status.warningNotice !== undefined ? [status.warningNotice] : []),
369
374
  ]
375
+ const standby = this.opts.once
376
+ ? undefined
377
+ : await this.standbyResolver.refresh(this.runId, status.yieldedHolder, this.clock())
370
378
  const projected = buildDashboardFromProjected(
371
379
  snapshot.builds,
372
380
  {
373
381
  repo: this.opts.repo,
382
+ ...(standby !== undefined ? { standby } : {}),
374
383
  queued: status.queued ?? 0,
375
384
  activeCount: [...snapshot.states.values()].filter(
376
385
  (state) => state.status !== 'done' && state.status !== 'aborted',
@@ -409,6 +418,7 @@ export class DispatchFrontend {
409
418
  this.opts.terminal.columns,
410
419
  paintableRows(this.opts.terminal.rows),
411
420
  upgrade,
421
+ this.model?.standby !== undefined,
412
422
  )
413
423
  const current = view.follow ? limit : view.scroll
414
424
  const scroll = moveSessionScroll(
@@ -418,6 +428,7 @@ export class DispatchFrontend {
418
428
  current,
419
429
  delta,
420
430
  upgrade,
431
+ this.model?.standby !== undefined,
421
432
  )
422
433
  let follow = view.follow
423
434
  if (delta < 0 && follow && scroll < limit) follow = false
@@ -437,6 +448,7 @@ export class DispatchFrontend {
437
448
  this.view.scroll,
438
449
  delta,
439
450
  this.model?.availableUpgrade !== undefined,
451
+ this.model?.standby !== undefined,
440
452
  ),
441
453
  }
442
454
  } else if (this.view?.kind === 'detail' && this.model !== undefined) {
@@ -598,6 +610,7 @@ export class DispatchFrontend {
598
610
  terminal.columns,
599
611
  paintableRows(terminal.rows),
600
612
  this.model?.availableUpgrade !== undefined,
613
+ this.model?.standby !== undefined,
601
614
  )
602
615
  this.view = {
603
616
  ...next,
@@ -977,7 +990,9 @@ export class DispatchFrontend {
977
990
  private requestOperatorStop(): void {
978
991
  this.operatorStopRequested = true
979
992
  this.restorePresentationForStop()
980
- void this.child?.stop()
993
+ void this.child?.stop(
994
+ typeof this.opts.signal?.reason === 'string' ? this.opts.signal.reason : undefined,
995
+ )
981
996
  }
982
997
 
983
998
  /** Stop terminal ownership synchronously before awaiting a kernel that may
@@ -25,7 +25,7 @@ export interface DispatchChildResult {
25
25
 
26
26
  export interface DispatchChildHandle {
27
27
  completed: Promise<DispatchChildResult>
28
- stop(): Promise<void>
28
+ stop(signal?: string): Promise<void>
29
29
  }
30
30
 
31
31
  export interface DispatchSubprocess {
@@ -57,16 +57,20 @@ export interface DispatchKernelSignalBoundary {
57
57
  * one-shot handlers would expose the child to the default disposition on a
58
58
  * repeated Ctrl-C while it is still crossing an unsafe claim boundary. */
59
59
  export function installDispatchKernelSignalHandlers(
60
- onStop: () => void,
60
+ onStop: (signal: DispatchKernelSignal) => void,
61
61
  boundary: DispatchKernelSignalBoundary = process,
62
62
  ): DispatchProcessHook {
63
- for (const signal of DISPATCH_KERNEL_SIGNALS) boundary.on(signal, onStop)
63
+ const listeners = DISPATCH_KERNEL_SIGNALS.map((signal) => ({
64
+ signal,
65
+ listener: () => onStop(signal),
66
+ }))
67
+ for (const { signal, listener } of listeners) boundary.on(signal, listener)
64
68
  let closed = false
65
69
  return {
66
70
  close(): void {
67
71
  if (closed) return
68
72
  closed = true
69
- for (const signal of DISPATCH_KERNEL_SIGNALS) boundary.removeListener(signal, onStop)
73
+ for (const { signal, listener } of listeners) boundary.removeListener(signal, listener)
70
74
  },
71
75
  }
72
76
  }
@@ -156,13 +160,14 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
156
160
  env[DISPATCH_CHILD_OPTIONS_ENV] = JSON.stringify(deps.options)
157
161
  const child = (deps.spawn ?? defaultSpawn)({ entrypoint, cwd: deps.cwd ?? deps.repo, env })
158
162
 
163
+ let requestedSignal: string | undefined
159
164
  let stopping = false
160
165
  let forced = false
161
166
  let stopPromise: Promise<void> | undefined
162
167
  // This is the sole supervisor writer of a missing stop fact. `stop()` only
163
168
  // chooses/awaits process termination, avoiding check-then-append races.
164
169
  const completed = child.exited.then(async (exitCode): Promise<DispatchChildResult> => {
165
- const processSignal = child.signalCode === null ? undefined : String(child.signalCode)
170
+ const processSignal = child.signalCode === null ? requestedSignal : String(child.signalCode)
166
171
  const events = await deps.store.getRepoEvents(deps.repo)
167
172
  const recorded = events.findLast(
168
173
  (event) => event.type === 'dispatcher.run-stopped' && event.payload.run === deps.run,
@@ -179,7 +184,7 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
179
184
 
180
185
  const outcome: DispatchChildOutcome = forced
181
186
  ? 'forced'
182
- : stopping && exitCode === 0 && processSignal === undefined
187
+ : stopping && exitCode === 0 && child.signalCode === null
183
188
  ? 'normal'
184
189
  : 'abnormal'
185
190
  const error =
@@ -209,11 +214,24 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
209
214
 
210
215
  return {
211
216
  completed,
212
- stop(): Promise<void> {
217
+ stop(signal?: string): Promise<void> {
213
218
  if (stopPromise !== undefined) return stopPromise
214
219
  stopPromise = (async () => {
215
220
  if (child.exitCode !== null) return
216
221
  stopping = true
222
+ requestedSignal = signal
223
+ if (signal !== undefined) {
224
+ try {
225
+ await deps.store.appendRepo(deps.repo, {
226
+ actor: DISPATCHER,
227
+ type: 'dispatcher.stop-requested',
228
+ payload: { run: deps.run, signal },
229
+ })
230
+ } catch {
231
+ // Signal delivery must survive a transient Store failure. The
232
+ // kernel can still record its own abort reason after draining.
233
+ }
234
+ }
217
235
  child.kill('SIGINT')
218
236
  const timeout = deps.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
219
237
  const stopped = await Promise.race([