@defrex/autobuild 0.10.0 → 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 +97 -113
- package/SPEC.md +22 -5
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/configuration.md +34 -7
- 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/init.ts +3 -2
- 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/orchestrator/prompt.ts +3 -1
- 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 +43 -14
- package/skills/operate/SKILL.md +180 -18
|
@@ -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([
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readDispatcherLiveness, StandbyIdentityResolver } from './dispatcher-liveness'
|
|
2
|
+
import { formatDispatcherIdentity } from '../kernel/dispatcher-liveness'
|
|
1
3
|
/**
|
|
2
4
|
* `ab dispatch` — the operator's entry into the outer loop (SPEC §3.3, §12).
|
|
3
5
|
* Runs OUTSIDE build sessions like init/upgrade/ticket (§16.3): it takes a
|
|
@@ -691,6 +693,9 @@ class DispatchLoop {
|
|
|
691
693
|
* discarded on Escape. All read semantics live in the shared feed module. */
|
|
692
694
|
private sessionFeed: SessionStreamFeed | undefined
|
|
693
695
|
private warningLine: string | undefined
|
|
696
|
+
/** Invocation-local ownership; independent of every notice slot. */
|
|
697
|
+
private standby: string | undefined
|
|
698
|
+
private standbyResolver: StandbyIdentityResolver | undefined
|
|
694
699
|
/** Process-local, persistent release notice. It never shares the replaceable
|
|
695
700
|
* warning slot and is re-applied to every store projection. */
|
|
696
701
|
private availableUpgrade: string | undefined
|
|
@@ -1190,6 +1195,7 @@ class DispatchLoop {
|
|
|
1190
1195
|
selection: _oldSelection,
|
|
1191
1196
|
warningLines: _oldWarningLines,
|
|
1192
1197
|
availableUpgrade: _oldAvailableUpgrade,
|
|
1198
|
+
standby: _oldStandby,
|
|
1193
1199
|
resumeInput: _oldResumeInput,
|
|
1194
1200
|
abortConfirmation: _oldAbortConfirmation,
|
|
1195
1201
|
view: _oldView,
|
|
@@ -1204,6 +1210,7 @@ class DispatchLoop {
|
|
|
1204
1210
|
]
|
|
1205
1211
|
let effective: DashboardModel = {
|
|
1206
1212
|
...base,
|
|
1213
|
+
...(this.standby !== undefined ? { standby: this.standby } : {}),
|
|
1207
1214
|
...(this.availableUpgrade !== undefined ? { availableUpgrade: this.availableUpgrade } : {}),
|
|
1208
1215
|
...(warningLines.length > 0 ? { warningLines } : {}),
|
|
1209
1216
|
...(this.selection !== undefined ? { selection: this.selection } : {}),
|
|
@@ -1258,7 +1265,13 @@ class DispatchLoop {
|
|
|
1258
1265
|
const limit =
|
|
1259
1266
|
terminal === undefined
|
|
1260
1267
|
? 0
|
|
1261
|
-
: sessionScrollLimit(
|
|
1268
|
+
: sessionScrollLimit(
|
|
1269
|
+
view,
|
|
1270
|
+
terminal.columns,
|
|
1271
|
+
paintableRows(terminal.rows),
|
|
1272
|
+
upgrade,
|
|
1273
|
+
this.standby !== undefined,
|
|
1274
|
+
)
|
|
1262
1275
|
const current = view.follow ? limit : view.scroll
|
|
1263
1276
|
const scroll =
|
|
1264
1277
|
terminal === undefined
|
|
@@ -1270,6 +1283,7 @@ class DispatchLoop {
|
|
|
1270
1283
|
current,
|
|
1271
1284
|
delta,
|
|
1272
1285
|
upgrade,
|
|
1286
|
+
this.standby !== undefined,
|
|
1273
1287
|
)
|
|
1274
1288
|
let follow = view.follow
|
|
1275
1289
|
if (delta < 0 && follow && scroll < limit) follow = false
|
|
@@ -1291,6 +1305,7 @@ class DispatchLoop {
|
|
|
1291
1305
|
terminal.columns,
|
|
1292
1306
|
paintableRows(terminal.rows),
|
|
1293
1307
|
this.availableUpgrade !== undefined,
|
|
1308
|
+
this.standby !== undefined,
|
|
1294
1309
|
)
|
|
1295
1310
|
return { ...view, scroll: view.follow ? limit : Math.min(view.scroll, limit) }
|
|
1296
1311
|
}
|
|
@@ -1350,6 +1365,7 @@ class DispatchLoop {
|
|
|
1350
1365
|
this.view.scroll,
|
|
1351
1366
|
delta,
|
|
1352
1367
|
this.availableUpgrade !== undefined,
|
|
1368
|
+
this.standby !== undefined,
|
|
1353
1369
|
),
|
|
1354
1370
|
}
|
|
1355
1371
|
this.syncModelControls()
|
|
@@ -3389,6 +3405,18 @@ class DispatchLoop {
|
|
|
3389
3405
|
const claimed = await this.wiring.store
|
|
3390
3406
|
.claimRepoLease(this.repoIdentity, holder, REPO_LEASE_TTL_MS)
|
|
3391
3407
|
.catch(() => false)
|
|
3408
|
+
if (this.dashboard && !this.opts.once) {
|
|
3409
|
+
this.standbyResolver ??= new StandbyIdentityResolver(this.wiring.store, this.repoIdentity)
|
|
3410
|
+
this.standby = claimed
|
|
3411
|
+
? undefined
|
|
3412
|
+
: await this.standbyResolver.refresh(
|
|
3413
|
+
this.runId,
|
|
3414
|
+
this.yieldedTo ?? 'another invocation',
|
|
3415
|
+
this.wiring.clock(),
|
|
3416
|
+
)
|
|
3417
|
+
this.syncModelControls()
|
|
3418
|
+
this.paint()
|
|
3419
|
+
}
|
|
3392
3420
|
if (!claimed) {
|
|
3393
3421
|
if (this.repoLeaseHolder !== undefined) {
|
|
3394
3422
|
// We held the lease and the store says it is gone: a peer took over.
|
|
@@ -3474,10 +3502,17 @@ class DispatchLoop {
|
|
|
3474
3502
|
const holder = this.runId
|
|
3475
3503
|
if (!(await this.ensureRepoLease(holder))) {
|
|
3476
3504
|
await this.recordTickYielded()
|
|
3477
|
-
|
|
3478
|
-
?.
|
|
3479
|
-
|
|
3480
|
-
|
|
3505
|
+
if ((!this.dashboard && this.opts.kernelRunId === undefined) || this.opts.once) {
|
|
3506
|
+
const peer = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))?.lease
|
|
3507
|
+
?.holder
|
|
3508
|
+
const liveness = await readDispatcherLiveness(
|
|
3509
|
+
this.wiring.store,
|
|
3510
|
+
this.repoIdentity,
|
|
3511
|
+
this.wiring.clock(),
|
|
3512
|
+
).catch(() => null)
|
|
3513
|
+
const message = `tick yielded: repository held by ${liveness?.dispatcher.holder ? formatDispatcherIdentity(liveness.dispatcher.holder) : (peer ?? 'another invocation')}`
|
|
3514
|
+
this.warn(message)
|
|
3515
|
+
}
|
|
3481
3516
|
if (this.opts.once === true) return
|
|
3482
3517
|
// Watch mode stays alive and retries the claim at every tick.
|
|
3483
3518
|
}
|
|
@@ -3498,7 +3533,17 @@ class DispatchLoop {
|
|
|
3498
3533
|
// lease and any provisioning state are released for the next
|
|
3499
3534
|
// invocation.
|
|
3500
3535
|
if (this.pastDeadline()) return
|
|
3501
|
-
const initial = await this.dispatcherTick(true)
|
|
3536
|
+
const initial = await this.dispatcherTick(true).catch(async (error: unknown) => {
|
|
3537
|
+
await this.appendStatus({
|
|
3538
|
+
actor: DISPATCHER,
|
|
3539
|
+
type: 'dispatcher.tick-failed',
|
|
3540
|
+
payload: {
|
|
3541
|
+
run: this.runId,
|
|
3542
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3543
|
+
},
|
|
3544
|
+
})
|
|
3545
|
+
throw error
|
|
3546
|
+
})
|
|
3502
3547
|
const initialPrinted = this.printReport(initial, false)
|
|
3503
3548
|
await this.drainInFlight()
|
|
3504
3549
|
const settledReports = await this.publishSettlementReports()
|
|
@@ -3963,6 +4008,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3963
4008
|
payload: {
|
|
3964
4009
|
run: runId,
|
|
3965
4010
|
pid: process.pid,
|
|
4011
|
+
host: hostname(),
|
|
3966
4012
|
effectiveConfig: { kind: artifact.kind, rev: artifact.revision },
|
|
3967
4013
|
roleWarnings: roleKeyWarnings(config),
|
|
3968
4014
|
},
|
|
@@ -3973,6 +4019,17 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3973
4019
|
const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts, view)
|
|
3974
4020
|
try {
|
|
3975
4021
|
await loop.run()
|
|
4022
|
+
const stopRequest = (
|
|
4023
|
+
resolvedOpts.kernelRunId && resolvedOpts.signal?.aborted
|
|
4024
|
+
? await wiring.store.getRepoEvents(state.repo)
|
|
4025
|
+
: []
|
|
4026
|
+
).find((event) => event.type === 'dispatcher.stop-requested' && event.payload.run === runId)
|
|
4027
|
+
const stoppedSignal =
|
|
4028
|
+
stopRequest?.type === 'dispatcher.stop-requested'
|
|
4029
|
+
? stopRequest.payload.signal
|
|
4030
|
+
: typeof resolvedOpts.signal?.reason === 'string'
|
|
4031
|
+
? resolvedOpts.signal.reason
|
|
4032
|
+
: undefined
|
|
3976
4033
|
await view.appendRepo(state.repo, {
|
|
3977
4034
|
actor: DISPATCHER,
|
|
3978
4035
|
type: 'dispatcher.run-stopped',
|
|
@@ -3980,6 +4037,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3980
4037
|
run: runId,
|
|
3981
4038
|
outcome: 'normal',
|
|
3982
4039
|
exitCode: 0,
|
|
4040
|
+
...(stoppedSignal === undefined ? {} : { signal: stoppedSignal }),
|
|
3983
4041
|
...(loop.supersededByPeer ? { reason: 'superseded' as const } : {}),
|
|
3984
4042
|
},
|
|
3985
4043
|
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
2
|
+
import type { BuildStore, RepositoryRecord } from '../store/types'
|
|
3
|
+
import { formatDispatcherIdentity, projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
|
|
4
|
+
|
|
5
|
+
/** Bounded first; a later yielding invocation can hide the owner's identity or
|
|
6
|
+
* an earlier stop. Only this correlation query falls back to full history. */
|
|
7
|
+
export async function readDispatcherLiveness(
|
|
8
|
+
store: BuildStore,
|
|
9
|
+
repo: string,
|
|
10
|
+
now = new Date(),
|
|
11
|
+
subset?: RepositoryEvent[],
|
|
12
|
+
) {
|
|
13
|
+
const record = await store.getRepo(repo)
|
|
14
|
+
let events = record === null ? [] : (subset ?? (await store.getRepoStateEvents(repo)))
|
|
15
|
+
const bounded = projectDispatcherLiveness(record, events, now)
|
|
16
|
+
if (
|
|
17
|
+
record &&
|
|
18
|
+
((record.lease &&
|
|
19
|
+
!events.some(
|
|
20
|
+
(e) => e.type === 'dispatcher.run-started' && e.payload.run === record.lease!.holder,
|
|
21
|
+
)) ||
|
|
22
|
+
(bounded.state !== 'held' &&
|
|
23
|
+
events.some((e) => e.type === 'dispatcher.run-started') &&
|
|
24
|
+
!events.some((e) => e.type === 'dispatcher.run-stopped')))
|
|
25
|
+
) {
|
|
26
|
+
events = await store.getRepoEvents(repo)
|
|
27
|
+
}
|
|
28
|
+
return { record, events, dispatcher: projectDispatcherLiveness(record, events, now) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Invocation-local identity cache. Only start facts survive refreshes; ages
|
|
32
|
+
* always come from the current lease record and clock. Failed correlations are
|
|
33
|
+
* negatively cached so a standby cannot repeatedly replay a large journal. */
|
|
34
|
+
export class StandbyIdentityResolver {
|
|
35
|
+
private readonly starts = new Map<string, RepositoryEvent>()
|
|
36
|
+
private readonly attempted = new Set<string>()
|
|
37
|
+
private readonly pending = new Map<string, Promise<void>>()
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly store: BuildStore,
|
|
41
|
+
private readonly repo: string,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
ingest(events: readonly RepositoryEvent[]): void {
|
|
45
|
+
for (const event of events) {
|
|
46
|
+
if (event.type === 'dispatcher.run-started') this.starts.set(event.payload.run, event)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async correlate(holder: string): Promise<void> {
|
|
51
|
+
const pending = this.pending.get(holder)
|
|
52
|
+
if (pending !== undefined) return pending
|
|
53
|
+
if (this.starts.has(holder) || this.attempted.has(holder)) return
|
|
54
|
+
this.attempted.add(holder)
|
|
55
|
+
const lookup = (async () => {
|
|
56
|
+
try {
|
|
57
|
+
this.ingest(await this.store.getRepoStateEvents(this.repo))
|
|
58
|
+
if (!this.starts.has(holder)) this.ingest(await this.store.getRepoEvents(this.repo, 0))
|
|
59
|
+
} catch {
|
|
60
|
+
// Identity is best effort; the sampled holder id remains sufficient.
|
|
61
|
+
}
|
|
62
|
+
})()
|
|
63
|
+
this.pending.set(holder, lookup)
|
|
64
|
+
try {
|
|
65
|
+
await lookup
|
|
66
|
+
} finally {
|
|
67
|
+
this.pending.delete(holder)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async refresh(
|
|
72
|
+
run: string,
|
|
73
|
+
yieldedHolder: string | undefined,
|
|
74
|
+
now: Date,
|
|
75
|
+
): Promise<string | undefined> {
|
|
76
|
+
let record: RepositoryRecord | null
|
|
77
|
+
try {
|
|
78
|
+
record = await this.store.getRepo(this.repo)
|
|
79
|
+
} catch {
|
|
80
|
+
return yieldedHolder
|
|
81
|
+
}
|
|
82
|
+
const holder = record?.lease?.holder
|
|
83
|
+
if (holder === run) return undefined
|
|
84
|
+
if (holder === undefined) return yieldedHolder
|
|
85
|
+
await this.correlate(holder)
|
|
86
|
+
if (!this.starts.has(holder)) return holder
|
|
87
|
+
const projection = projectDispatcherLiveness(record, [...this.starts.values()], now)
|
|
88
|
+
const identity = projection.holder ?? projection.expiredHolder
|
|
89
|
+
return identity?.run === holder ? formatDispatcherIdentity(identity) : holder
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -282,7 +282,7 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
282
282
|
' ab watch [<slug>...] [--repository] [--event <glob>]... [--since <cursor>] [--timeout <duration>] [--interval <duration>] [--count <n>] [--json] [--store <ref>]',
|
|
283
283
|
'',
|
|
284
284
|
'Follow the event logs of one, several, or all active builds in this repository —',
|
|
285
|
-
'
|
|
285
|
+
'the repository journal by default with no slugs — and emit one record per matching event as it',
|
|
286
286
|
'lands. With no slug, every nonterminal build (queued, running, paused, blocked) is',
|
|
287
287
|
'watched, and a build created while the watch runs joins it without a restart.',
|
|
288
288
|
'Naming one or more slugs watches exactly those builds; an unknown slug is an error',
|
|
@@ -291,16 +291,18 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
291
291
|
'',
|
|
292
292
|
'Options:',
|
|
293
293
|
' --repository Also follow the repository journal (dispatcher and harvest',
|
|
294
|
-
' events).
|
|
294
|
+
' events) for named watches. No-slug watches include it by default.',
|
|
295
295
|
' --event <glob> Repeatable glob over event type names: * matches any run of',
|
|
296
296
|
' characters, ? one character (escalation.*, pr.merged, *). Every',
|
|
297
297
|
' glob must match at least one type in the build catalog — or,',
|
|
298
|
-
' with --repository, the repository catalog — or the command fails',
|
|
298
|
+
' with no slugs or --repository, the repository catalog — or the command fails',
|
|
299
299
|
' before any store read. Any --event replaces the default filter.',
|
|
300
300
|
' --since <cursor> Resume from a cursor emitted by an earlier record; every matching',
|
|
301
301
|
' event after that cursor is delivered exactly once, including',
|
|
302
302
|
' events that landed while no watch was running. A cursor from a',
|
|
303
|
-
' different repository or store is rejected.',
|
|
303
|
+
' different repository or store is rejected. A legacy cursor without',
|
|
304
|
+
' a repository position baselines that stream now; reconcile earlier',
|
|
305
|
+
' source conditions with ab repository status.',
|
|
304
306
|
' --timeout <dur> End the watch after this duration (30, 45s, 5m, 1h). Default',
|
|
305
307
|
' 30 minutes; 0 means unbounded.',
|
|
306
308
|
' --interval <dur> Poll cadence. Default: 5s for an http(s) store, 1s for a local store.',
|
|
@@ -313,7 +315,8 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
313
315
|
'infrastructure.failed, runner.setup-failed, dispatch.failed, publication.lost,',
|
|
314
316
|
'finalize.completed, pr.conflicted, pr.merged, pr.closed, build.completed,',
|
|
315
317
|
'build.aborted. For the repository journal: harvest.escalated, harvest.failed,',
|
|
316
|
-
'dispatcher.tick-failed, dispatcher.config-rejected, dispatcher.harvest-runner-failed
|
|
318
|
+
'dispatcher.tick-failed, dispatcher.config-rejected, dispatcher.harvest-runner-failed,',
|
|
319
|
+
'dispatcher.ticket-source-degraded, dispatcher.ticket-source-recovered.',
|
|
317
320
|
'',
|
|
318
321
|
'Record shape (--json): build (slug, or null for a repository event), repo (repository',
|
|
319
322
|
'id, present only for repository events), event (the complete durable envelope: build,',
|
|
@@ -161,11 +161,12 @@ export async function ensureLocalStateIgnored(targetRepo: string): Promise<boole
|
|
|
161
161
|
* pattern-matching a description. Membership here is reserved for skills that
|
|
162
162
|
* drive NO phase — `spec` is the conversation that writes a spec before a build
|
|
163
163
|
* exists, `tickets` is how "move ticket X to ready" becomes an action, and
|
|
164
|
-
* `guide` is read-only reference material about the system
|
|
164
|
+
* `guide` is read-only reference material about the system, and `operate` is the
|
|
165
|
+
* orchestrator role's manual (a role any agent takes on, not a pipeline phase). For these, model
|
|
165
166
|
* invocation is precisely the point. Keep this set small; widening it needs the
|
|
166
167
|
* §16.3 criterion, not convenience.
|
|
167
168
|
*/
|
|
168
|
-
export const MODEL_INVOCABLE_SKILLS = new Set(['spec', 'tickets', 'guide'])
|
|
169
|
+
export const MODEL_INVOCABLE_SKILLS = new Set(['spec', 'tickets', 'guide', 'operate'])
|
|
169
170
|
|
|
170
171
|
/**
|
|
171
172
|
* Rewrite a canonical skill's YAML frontmatter for installation (§16.3):
|