@defrex/autobuild 0.10.1 → 0.11.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.
- package/README.md +1 -1
- package/SPEC.md +15 -1
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/configuration.md +32 -3
- package/docs/operator-api.md +17 -0
- package/docs/remote-ticket-protocol.md +5 -0
- package/package.json +3 -3
- package/packages/core/src/cli/binary.ts +3 -3
- package/packages/core/src/cli/dashboard/model.ts +4 -0
- package/packages/core/src/cli/dashboard/render.ts +37 -7
- package/packages/core/src/cli/dispatch-frontend.ts +16 -1
- package/packages/core/src/cli/dispatch-process.ts +25 -7
- package/packages/core/src/cli/dispatch.ts +64 -6
- package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
- package/packages/core/src/cli/help.ts +8 -5
- package/packages/core/src/cli/repository-status.ts +45 -5
- package/packages/core/src/cli/watch.ts +159 -14
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/schema.ts +9 -2
- package/packages/core/src/events/globs.ts +8 -3
- package/packages/core/src/events/repository.ts +51 -2
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
- package/packages/core/src/kernel/dispatch-status.ts +9 -1
- package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
- package/packages/core/src/kernel/reducer-registry.ts +4 -0
- package/packages/core/src/kernel/ticket-source-health.ts +30 -0
- package/packages/core/src/operator/query.ts +6 -3
- package/packages/core/src/ports/tickets/contract.ts +25 -0
- package/packages/core/src/ports/tickets/fake.ts +10 -8
- package/packages/core/src/ports/tickets/file.ts +1 -0
- package/packages/core/src/ports/tickets/linear.ts +241 -49
- package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
- package/packages/core/src/ports/types.ts +17 -0
- package/packages/core/src/processes/dispatcher.ts +277 -73
- package/packages/core/src/processes/repo-view.ts +2 -1
- package/packages/core/src/store/adapter.ts +1 -0
- package/packages/core/src/store/contract.ts +109 -0
- package/packages/core/src/store/digest.ts +14 -2
- package/packages/core/src/store/local/store.ts +18 -5
- package/packages/core/src/store/remote/protocol.ts +1 -0
- package/packages/core/src/store/repo-state-events.ts +49 -59
- package/packages/core/src/store/types.ts +5 -1
- package/skills/guide/SKILL.md +31 -0
- package/skills/operate/SKILL.md +39 -4
package/README.md
CHANGED
package/SPEC.md
CHANGED
|
@@ -233,6 +233,19 @@ Small, independently runnable, crash-safe:
|
|
|
233
233
|
- **ingesters** — other outer-loop processes turning signals into proposals (§12).
|
|
234
234
|
- **operator** — UI process(es); see §14.
|
|
235
235
|
|
|
236
|
+
Ticket-source ready scans run immediately when no completed measurement exists,
|
|
237
|
+
then every
|
|
238
|
+
`ticketScanIntervalSeconds` (default 60 seconds, a positive integer). Actual
|
|
239
|
+
tick spacing is the floor when the configured interval is shorter. A
|
|
240
|
+
same-repository `pr.merged`, `build.completed`, or `build.aborted` fact forces
|
|
241
|
+
another scan on the next tick; changed ready criteria also force a scan.
|
|
242
|
+
Housekeeping keeps its ordinary tick cadence. Between scans, queue depth and
|
|
243
|
+
standing diagnostics repeat the last completed measurement, including across
|
|
244
|
+
process restarts. Tickets groomed directly in the tracker are picked up within
|
|
245
|
+
one scan interval plus one tick when capacity permits. Observation while intake
|
|
246
|
+
is off shares the cadence; an observation measurement cannot suppress a fresh
|
|
247
|
+
admission scan when intake is enabled.
|
|
248
|
+
|
|
236
249
|
### 3.4 The event log spine
|
|
237
250
|
|
|
238
251
|
Build processes append typed events to per-build logs; repository-scoped outer
|
|
@@ -2243,6 +2256,7 @@ ordinary boundary reload resumes.
|
|
|
2243
2256
|
```toml
|
|
2244
2257
|
baseBranch = "main"
|
|
2245
2258
|
capacity = 3 # concurrent builds for this repo
|
|
2259
|
+
ticketScanIntervalSeconds = 60 # positive integer; ready scans, with tick spacing as floor
|
|
2246
2260
|
forge = "github" # builtin: github | local-git; or a plugin name
|
|
2247
2261
|
plugins = ["./plugins/local.ts", "@acme/autobuild-plugin"]
|
|
2248
2262
|
|
|
@@ -2350,7 +2364,7 @@ decide where a build's effective configuration is composed from:
|
|
|
2350
2364
|
|---|---|---|
|
|
2351
2365
|
| Build-owned (pinned) | `[verify]` and `[verify.<step>]`, `[finalize]` and `[finalize.<step>]`, `[commands]`, `[workspace]` (provider and config, including `provisioning` and `runtimeProvisioning`) | The build's own branch: its branch head once it has published commits, otherwise the recorded base commit its workspace was cut from. The exact commit is recorded once per deposit in the effective-config artifact metadata — except in checkout mode when the worktree read cannot be attributed to the branch-head commit: because the worktree file has uncommitted edits, because the clean/dirty comparison could not be performed (non-git workspace or a transient git error), or because the branch head never carried the file. The deposit then records the source `worktree-dirty` and no commit, because no commit claims the worktree bytes (the build still runs them). |
|
|
2352
2366
|
| Deployment-owned (live) | `[roles]` (runtime, model, args, alternates, per-role `sessionBudgetSeconds`) and `[policy]` (budgets, retry bounds, stall and harvest knobs) | The dispatcher's live snapshot; an accepted reload delivers these to active builds. |
|
|
2353
|
-
| Dispatcher-owned | `baseBranch`, `capacity`, `forge`, `plugins`, `pr`, `tickets`, `orchestrator` | The dispatcher's live snapshot; the build runner never interprets them. |
|
|
2367
|
+
| Dispatcher-owned | `baseBranch`, `capacity`, `ticketScanIntervalSeconds`, `forge`, `plugins`, `pr`, `tickets`, `orchestrator` | The dispatcher's live snapshot; the build runner never interprets them. |
|
|
2354
2368
|
|
|
2355
2369
|
A build's pipeline definition is therefore composed from the build's own
|
|
2356
2370
|
branch: in origin mode the dispatcher reads `autobuild.toml` at the build's
|
|
@@ -23,7 +23,7 @@ try {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const stop = new AbortController()
|
|
26
|
-
const requestStop = (): void => stop.abort()
|
|
26
|
+
const requestStop = (signal?: string): void => stop.abort(signal)
|
|
27
27
|
// Keep both handlers installed through the complete graceful drain. The first
|
|
28
28
|
// targeted SIGINT comes from the supervisor; later foreground-group signals
|
|
29
29
|
// must not regain their default disposition inside an open claim tick.
|
package/docs/configuration.md
CHANGED
|
@@ -168,7 +168,7 @@ the branch head never carried the file — the deposit records the source
|
|
|
168
168
|
provenance declines to attribute them to any commit. **Deployment-owned** sections — `[roles]`
|
|
169
169
|
(runtime, model, args, alternates, per-role `sessionBudgetSeconds`) and
|
|
170
170
|
`[policy]` — keep flowing live. **Dispatcher-owned** sections (`baseBranch`,
|
|
171
|
-
`capacity`, `forge`, `plugins`, `pr`, `tickets`, `orchestrator`) configure the
|
|
171
|
+
`capacity`, `ticketScanIntervalSeconds`, `forge`, `plugins`, `pr`, `tickets`, `orchestrator`) configure the
|
|
172
172
|
dispatcher itself and are never interpreted by the build runner.
|
|
173
173
|
|
|
174
174
|
Consequently, an accepted base-branch reload re-deposits only the
|
|
@@ -195,6 +195,7 @@ These fields hot-reload:
|
|
|
195
195
|
| Field or table | Boundary where a new value applies |
|
|
196
196
|
|---|---|
|
|
197
197
|
| `baseBranch` | Next dispatch or base fallback decision; existing `build.created` facts remain immutable. |
|
|
198
|
+
| `ticketScanIntervalSeconds` | Next tick; evaluates elapsed time against the new interval. |
|
|
198
199
|
| `capacity` | Next dispatcher tick. |
|
|
199
200
|
| `[pr]` | Next build creation. |
|
|
200
201
|
| `[commands]` | Next setup, verify check, or finalize check selected for a build whose OWN branch carries it. A base-branch reload does not retarget an in-flight build. |
|
|
@@ -229,10 +230,24 @@ any table header.
|
|
|
229
230
|
| Field | Default | Constraints | Purpose |
|
|
230
231
|
|---|---:|---|---|
|
|
231
232
|
| `baseBranch` | `"main"` | nonempty string | Branch used to cut builds, target PRs, and merge during reconciliation. |
|
|
233
|
+
| `ticketScanIntervalSeconds` | `60` | positive integer seconds | Ticket-source ready scan cadence; tick spacing is the effective minimum. |
|
|
232
234
|
| `capacity` | `1` | positive integer | Maximum concurrent nonterminal builds for this repository. Paused and blocked builds still occupy capacity. |
|
|
233
235
|
| `forge` | `"github"` | nonblank string | Forge adapter name. Shipped values are `github` and `local-git`; configured plugins may register other names. |
|
|
234
236
|
| `plugins` | `[]` | array of unique nonblank module specifiers | Trusted Bun plugin modules loaded by dispatch, `ab ticket`, and scoped phase CLI processes. |
|
|
235
237
|
|
|
238
|
+
Ticket-source ready scans run immediately when no completed measurement exists,
|
|
239
|
+
then every
|
|
240
|
+
`ticketScanIntervalSeconds` (default 60 seconds, a positive integer). Actual
|
|
241
|
+
tick spacing is the floor when the configured interval is shorter. A
|
|
242
|
+
same-repository `pr.merged`, `build.completed`, or `build.aborted` fact forces
|
|
243
|
+
another scan on the next tick; changed ready criteria also force a scan.
|
|
244
|
+
Housekeeping keeps its ordinary tick cadence. Between scans, queue depth and
|
|
245
|
+
standing diagnostics repeat the last completed measurement, including across
|
|
246
|
+
process restarts. Tickets groomed directly in the tracker are picked up within
|
|
247
|
+
one scan interval plus one tick when capacity permits. Observation while intake
|
|
248
|
+
is off shares the cadence; an observation measurement cannot suppress a fresh
|
|
249
|
+
admission scan when intake is enabled.
|
|
250
|
+
|
|
236
251
|
### Plugin modules
|
|
237
252
|
|
|
238
253
|
<!-- config-fragment:plugins -->
|
|
@@ -313,7 +328,12 @@ without adding a runtime Autobuild dependency to the plugin. That entry point
|
|
|
313
328
|
exports the manifest/factory types, port types, fake adapters, and reusable
|
|
314
329
|
TicketSource, AgentRunner, WorkspaceProvider, Forge, BuildStore, and BlobStore
|
|
315
330
|
contract suites. Adapter values may use the backward-compatible bare factory or
|
|
316
|
-
carry a contract fixture descriptor.
|
|
331
|
+
carry a contract fixture descriptor.
|
|
332
|
+
TicketSource adapters must populate the required `Ticket.resolved` boolean on
|
|
333
|
+
listReady, get, and create projections, using the same native lifecycle rules as
|
|
334
|
+
`dependencyStates`. Plugin authors upgrading older adapters must add this field;
|
|
335
|
+
ready-state membership does not imply that a ticket is unresolved. Dispatch
|
|
336
|
+
reuses these facts only within the current tick. Ticket sources may also declare
|
|
317
337
|
`requiredEnv`; the host checks every declared variable for a nonempty value
|
|
318
338
|
before invoking the adapter factory. Plugins using Forge abort-cleanup and
|
|
319
339
|
AgentRunner turn-cancellation capabilities introduced in API 1.2 should require
|
|
@@ -1259,6 +1279,15 @@ triageState = "Backlog"
|
|
|
1259
1279
|
Linear credentials do not belong in this table. Set `LINEAR_API_KEY` in the
|
|
1260
1280
|
environment or local `.env` file described below.
|
|
1261
1281
|
|
|
1282
|
+
[Linear's request budget](https://linear.app/developers/rate-limiting) is shared
|
|
1283
|
+
by every dispatcher and `ab ticket` command using the API key, and by other keys
|
|
1284
|
+
belonging to the same authenticated user. A rate-limited ready scan or dependency
|
|
1285
|
+
read fails the evaluation tick without new intake claims or synthetic
|
|
1286
|
+
dependency-blocked counts. That source instance suppresses requests until the
|
|
1287
|
+
reported reset time; the next call then runs normally. Missing, invalid or expired
|
|
1288
|
+
reset headers use a conservative 60-second cooldown. Cooldowns are local to the
|
|
1289
|
+
instance: processes and restarts do not coordinate their budgets.
|
|
1290
|
+
|
|
1262
1291
|
For a hosted backend, repository policy remains local while credentials remain
|
|
1263
1292
|
at the service:
|
|
1264
1293
|
|
|
@@ -1323,7 +1352,7 @@ restart-classified as a whole.
|
|
|
1323
1352
|
| `model` | — | nonblank string, required when `enabled = true` | Provider-qualified model string for the turn runner, in the same vocabulary as `[roles]` model strings (a leading `vercel-ai-gateway/` prefix is accepted and stripped). Resolved through the deployment's gateway credential. |
|
|
1324
1353
|
| `invocationBudgetSeconds` | `240` | positive integer, clamped to 300 | Wall-clock budget for one turn invocation. A larger value is clamped — not rejected — to the 300-second hosted function limit the runner executes under (`ORCHESTRATOR_ROUTE_LIMIT_SECONDS`, the same number the operator route pins as `maxDuration`). |
|
|
1325
1354
|
| `approvals` | see below | array of `tool` or `tool:qualifier` strings | Registry tool names whose calls suspend a turn until the operator answers. Default: `["builds.control:abort", "builds.control:discard", "builds.answer:revise-spec", "sandbox.publish", "tickets.move:ready"]`. Empty (`[]`) means none — autonomous operation. An entry naming an absent or unknown tool is **inert by design**, never a config error: the list stays stable while the registry grows, and a tool's removal cannot break parsing. A `tool:qualifier` entry applies to the named discriminator value only (for example `tickets.move:ready` approves moving a ticket to `ready`); for a tool without a discriminator, only the bare form matches. |
|
|
1326
|
-
| `wake` | inherits the default | array of event-type globs | The attention events a **new** session wakes for. Kept schema-optional so absent (inherit the default attention set — the build events `ab watch` filters on by default plus the repository-journal events `ab watch
|
|
1355
|
+
| `wake` | inherits the default | array of event-type globs | The attention events a **new** session wakes for. Kept schema-optional so absent (inherit the default attention set — the build events `ab watch` filters on by default plus the repository-journal events `ab watch` follows) and `[]` (never wake; message-only) stay distinct. Each glob must match at least one build or repository-journal event type, validated with the same compiler `ab watch` uses. Existing sessions never retroactively inherit; an explicit `PUT .../wake` always wins. |
|
|
1327
1356
|
| `sandbox` | — | strict subtable; absence reads as the defaults | Sandbox behavior knobs. |
|
|
1328
1357
|
|
|
1329
1358
|
`[orchestrator.sandbox]` fields:
|
package/docs/operator-api.md
CHANGED
|
@@ -184,3 +184,20 @@ The API does not expose phase-session commands, runner startup, a push or
|
|
|
184
184
|
long-poll streaming transport, or a generic event-append operation; event
|
|
185
185
|
streaming is composed client-side by `ab watch`, which polls the store's
|
|
186
186
|
event reads.
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
Dispatcher liveness is also visible through `ab repository status --json`:
|
|
190
|
+
`dispatcher.holder` identifies the unexpired repository lease owner, independently
|
|
191
|
+
of `latestRun`. Heartbeat age measures attributable renewals; tick age measures
|
|
192
|
+
durable progress. Missing historical evidence is null. The liveness query uses
|
|
193
|
+
bounded repository facts first, with a full-history fallback when a later
|
|
194
|
+
invocation hides the holder start or the latest stop. Other state reads retain
|
|
195
|
+
their bounded semantics.
|
|
196
|
+
|
|
197
|
+
Repository-following `ab watch` additionally emits a read-only `dispatcher.lost`
|
|
198
|
+
observation when an observed lease expires without a matching stop. JSON carries
|
|
199
|
+
`observation` instead of `event`, with run, observedAt, leaseExpiresAt and available
|
|
200
|
+
identity, plus the usual repo/build/state/cursor fields. It invents no durable
|
|
201
|
+
sequence or actor. Retain the cursor to avoid repeat reports across invocations.
|
|
202
|
+
No lease alone is not evidence of death. SIGINT, SIGHUP and SIGTERM drain dispatch,
|
|
203
|
+
record the first signal in the stop fact, and release its lease before exit.
|
|
@@ -77,3 +77,8 @@ The shared `TicketSource` contract is normative for the database adapter and
|
|
|
77
77
|
the hosted client/server pair. External pass-through is tested with a fake
|
|
78
78
|
source behind HTTP. The existing opt-in live Linear contract remains the proof
|
|
79
79
|
of Linear's GraphQL behavior; the hosted service uses that adapter unchanged.
|
|
80
|
+
|
|
81
|
+
Every ticket returned by list, get, or create requires a boolean `resolved`,
|
|
82
|
+
with the same source-native completion semantics as `DependencyState.resolved`.
|
|
83
|
+
The client preserves it when remapping ticket references. Missing or non-boolean
|
|
84
|
+
values fail wire validation. Client and service still require exact version matches.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defrex/autobuild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Agent-driven software delivery from groomed ticket to merged PR",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -139,8 +139,8 @@
|
|
|
139
139
|
},
|
|
140
140
|
"devDependencies": {
|
|
141
141
|
"@biomejs/biome": "2.5.5",
|
|
142
|
-
"@defrex/autobuild-postgres-store": "0.
|
|
143
|
-
"@defrex/autobuild-vercel-sandbox": "0.
|
|
142
|
+
"@defrex/autobuild-postgres-store": "0.10.1",
|
|
143
|
+
"@defrex/autobuild-vercel-sandbox": "0.10.1",
|
|
144
144
|
"@resvg/resvg-js": "2.6.2",
|
|
145
145
|
"@types/bun": "^1.3.14",
|
|
146
146
|
"@types/node": "^24.0.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
|
-
|
|
122
|
+
(signal) => controller.abort(signal),
|
|
123
123
|
)
|
|
124
124
|
} finally {
|
|
125
125
|
removeSigint()
|
|
@@ -268,6 +268,8 @@ export interface DashboardModel {
|
|
|
268
268
|
harvestPaused: boolean
|
|
269
269
|
/** Stable row identity, never a row index. */
|
|
270
270
|
selection?: DashboardSelection
|
|
271
|
+
/** Invocation-local standby identity, independent of notices and build rows. */
|
|
272
|
+
standby?: string
|
|
271
273
|
/** Release courtesy, separate from transient and startup warnings so those
|
|
272
274
|
* notices can never overwrite one another. Store-only frontends project it
|
|
273
275
|
* from their correlated dispatcher run. */
|
|
@@ -1090,6 +1092,7 @@ interface DashboardFrameHeader {
|
|
|
1090
1092
|
queued: number
|
|
1091
1093
|
observationCount: number
|
|
1092
1094
|
selection?: DashboardSelection
|
|
1095
|
+
standby?: string
|
|
1093
1096
|
availableUpgrade?: string
|
|
1094
1097
|
warningLines?: readonly string[]
|
|
1095
1098
|
resumeInput?: ResumeInputView
|
|
@@ -1125,6 +1128,7 @@ export function buildDashboardFromProjected(
|
|
|
1125
1128
|
defaultAutoMerge: settings.defaultAutoMerge,
|
|
1126
1129
|
harvestPaused: harvestProjection.harvestPaused,
|
|
1127
1130
|
...(header.selection !== undefined ? { selection: header.selection } : {}),
|
|
1131
|
+
...(header.standby !== undefined ? { standby: header.standby } : {}),
|
|
1128
1132
|
...(header.availableUpgrade !== undefined ? { availableUpgrade: header.availableUpgrade } : {}),
|
|
1129
1133
|
...(header.warningLines !== undefined && header.warningLines.length > 0
|
|
1130
1134
|
? { 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 =
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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 &&
|
|
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([
|