@defrex/autobuild 0.8.0 → 0.9.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 +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dispatcher tick's orchestrator step (AUT-342): resume suspended turns,
|
|
3
|
+
* reap crashed ones, and wake idle sessions from build attention events.
|
|
4
|
+
*
|
|
5
|
+
* Two independent gates keep this inert where it does not belong, and BOTH
|
|
6
|
+
* are required:
|
|
7
|
+
*
|
|
8
|
+
* 1. The step is constructed only in origin mode — the gate lives in
|
|
9
|
+
* `processes/dispatcher.ts` on `DispatcherDeps.repoOrigin` (set
|
|
10
|
+
* exclusively by origin mode), so a local `ab dispatch` never constructs
|
|
11
|
+
* the step at all: an `enabled = true` in a local repository's
|
|
12
|
+
* autobuild.toml lists no sessions, builds no registry, and calls no
|
|
13
|
+
* model.
|
|
14
|
+
* 2. In origin mode the step returns immediately unless the repository's
|
|
15
|
+
* effective config enables the orchestrator — the disabled-repository
|
|
16
|
+
* no-op.
|
|
17
|
+
*
|
|
18
|
+
* The wake cursor advances only through the recorded trigger (the reducer
|
|
19
|
+
* takes `max`), so a crash between scan and start cannot skip or duplicate a
|
|
20
|
+
* wake; other builds' — and the journal's — matching events re-trigger on
|
|
21
|
+
* later ticks. One turn per session per tick, never concurrent.
|
|
22
|
+
*/
|
|
23
|
+
import { ORCHESTRATOR_MIN_TURN_SECONDS, type Config } from '../config/schema'
|
|
24
|
+
import { compileEventGlobs } from '../events/globs'
|
|
25
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
26
|
+
import type { TicketSource } from '../ports/types'
|
|
27
|
+
import { buildRegistry, type OperatorToolRegistry } from '../operator/registry'
|
|
28
|
+
import type { OperatorSandboxService } from '../operator/sandbox'
|
|
29
|
+
import { ticketBackendFromSource } from '../operator/ticket-source-backend'
|
|
30
|
+
import { reduceSession, type SessionTurnTrigger } from '../store/session-reducer'
|
|
31
|
+
import { reduceBuild } from '../kernel/reducer'
|
|
32
|
+
import type { AbEvent } from '../events/catalog'
|
|
33
|
+
import type { BuildStore, Clock } from '../store/types'
|
|
34
|
+
import type { IdSource } from '../ids'
|
|
35
|
+
import type { LanguageModel } from 'ai'
|
|
36
|
+
import {
|
|
37
|
+
createOrchestratorTurnRunner,
|
|
38
|
+
type OrchestratorTurnRunner,
|
|
39
|
+
type WakeTurnInput,
|
|
40
|
+
} from '../orchestrator/turn-runner'
|
|
41
|
+
|
|
42
|
+
/** Minutes an open, unsuspended turn's stream may stay silent before the
|
|
43
|
+
* reaper fails it: a crashed invocation leaves the session `running` with an
|
|
44
|
+
* open stream and no runner. Suspended turns are NEVER reaped — a turn
|
|
45
|
+
* suspended for approval is precisely an open stream that goes quiet for
|
|
46
|
+
* however long the operator takes. */
|
|
47
|
+
export const ORCHESTRATOR_STALE_TURN_MINUTES = 15
|
|
48
|
+
|
|
49
|
+
export interface OrchestratorTickOptions {
|
|
50
|
+
store: BuildStore
|
|
51
|
+
repo: string
|
|
52
|
+
config: Config
|
|
53
|
+
clock: Clock
|
|
54
|
+
ids: IdSource
|
|
55
|
+
/** The dispatcher's ticket source, adapted into the registry's ticket
|
|
56
|
+
* backend so wake and resumed turns keep the full ticket surface. */
|
|
57
|
+
tickets: TicketSource
|
|
58
|
+
/** Injected language model; defaults to the configured gateway model. */
|
|
59
|
+
model?: LanguageModel
|
|
60
|
+
/** The operator-sandbox backend (AUT-584): when supplied, the tick's turns
|
|
61
|
+
* serve the `sandbox.*` tools under the same configuration/approval gating
|
|
62
|
+
* every binding applies. Absent (the compatibility case) → the registry
|
|
63
|
+
* filters them from advertisement AND dispatch. The origin-mode dispatcher
|
|
64
|
+
* builds it from its own wired provider; every other caller omits it. */
|
|
65
|
+
sandbox?: OperatorSandboxService
|
|
66
|
+
/** Remaining tick budget in seconds at step entry; every turn's deadline
|
|
67
|
+
* is recomputed from this against the clock, so N sessions draw down the
|
|
68
|
+
* SAME budget instead of each getting a fresh full slice (f_9c1161ae).
|
|
69
|
+
* Less than ORCHESTRATOR_MIN_TURN_SECONDS skips the whole step
|
|
70
|
+
* (suspension state is durable — skipping is safe). */
|
|
71
|
+
remainingBudgetSeconds?: number
|
|
72
|
+
log?: (message: string) => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface OrchestratorTickReport {
|
|
76
|
+
resumed: number
|
|
77
|
+
reaped: number
|
|
78
|
+
woken: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The registry the tick's turns run against: the in-process binding over
|
|
82
|
+
* the dispatcher's store, with the ticket source adapted into the backend
|
|
83
|
+
* `buildRegistry`'s ticket tools require (`buildMcpTicketBackend` reads a
|
|
84
|
+
* checkout's autobuild.toml and cannot run in origin mode). */
|
|
85
|
+
export function orchestratorTickRegistry(options: OrchestratorTickOptions): OperatorToolRegistry {
|
|
86
|
+
return buildRegistry({
|
|
87
|
+
store: options.store,
|
|
88
|
+
tickets: ticketBackendFromSource({ source: options.tickets, config: options.config }),
|
|
89
|
+
...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}),
|
|
90
|
+
clock: options.clock,
|
|
91
|
+
allowedRepo: options.repo,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function orchestratorTickRunner(options: OrchestratorTickOptions): OrchestratorTurnRunner {
|
|
96
|
+
return createOrchestratorTurnRunner({
|
|
97
|
+
store: options.store,
|
|
98
|
+
registry: orchestratorTickRegistry(options),
|
|
99
|
+
repo: options.repo,
|
|
100
|
+
config: options.config,
|
|
101
|
+
clock: options.clock,
|
|
102
|
+
ids: options.ids,
|
|
103
|
+
...(options.model !== undefined ? { model: options.model } : {}),
|
|
104
|
+
...(options.remainingBudgetSeconds !== undefined
|
|
105
|
+
? { remainingBudgetSeconds: options.remainingBudgetSeconds }
|
|
106
|
+
: {}),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One pass of the orchestrator step. The caller has already evaluated both
|
|
111
|
+
* gates (origin mode and `enabled`); this function assumes it may run. */
|
|
112
|
+
export async function runOrchestratorTickStep(
|
|
113
|
+
options: OrchestratorTickOptions,
|
|
114
|
+
): Promise<OrchestratorTickReport> {
|
|
115
|
+
const { store, repo, config, clock } = options
|
|
116
|
+
const report: OrchestratorTickReport = { resumed: 0, reaped: 0, woken: 0 }
|
|
117
|
+
const entry =
|
|
118
|
+
options.remainingBudgetSeconds === undefined
|
|
119
|
+
? config.orchestrator.invocationBudgetSeconds
|
|
120
|
+
: options.remainingBudgetSeconds
|
|
121
|
+
if (entry < ORCHESTRATOR_MIN_TURN_SECONDS) return report
|
|
122
|
+
|
|
123
|
+
// The step's deadline, fixed at entry: every turn's slice is recomputed
|
|
124
|
+
// from the clock against THIS deadline, so the step can only consume the
|
|
125
|
+
// remaining slack no matter how many sessions it serves. (A fixed
|
|
126
|
+
// per-session value handed to each turn would let N sessions run
|
|
127
|
+
// N × remaining seconds past the tick deadline — f_9c1161ae.)
|
|
128
|
+
const deadlineMs = clock().getTime() + entry * 1000
|
|
129
|
+
const remainingNow = (): number =>
|
|
130
|
+
Math.max(0, Math.floor((deadlineMs - clock().getTime()) / 1000))
|
|
131
|
+
|
|
132
|
+
const runner = orchestratorTickRunner(options)
|
|
133
|
+
const sessions = await store.listSessions(repo)
|
|
134
|
+
|
|
135
|
+
// ── Resume pass ─────────────────────────────────────────────────────────
|
|
136
|
+
for (const record of sessions) {
|
|
137
|
+
const events = await store.getSessionEvents(record.id)
|
|
138
|
+
const state = reduceSession(events)
|
|
139
|
+
if (state.status !== 'suspended' || state.openTurn === undefined) continue
|
|
140
|
+
// Budget suspensions resume always; approval suspensions only once the
|
|
141
|
+
// answer has landed (`pendingApproval` gone — the answered-approval
|
|
142
|
+
// fallback when the resuming operator-route invocation died). An
|
|
143
|
+
// unanswered approval stays put until the operator answers.
|
|
144
|
+
if (state.pendingApproval !== undefined) continue
|
|
145
|
+
// An answered approval resumes with the recorded decision, so the
|
|
146
|
+
// runner recovers the wire approvalId and appends the response part.
|
|
147
|
+
let approval: { decision: 'approve' | 'deny'; toolCallId: string } | undefined
|
|
148
|
+
if (state.suspendedCause === 'approval') {
|
|
149
|
+
for (const event of [...events].reverse()) {
|
|
150
|
+
if (event.type === 'approval.answered' && event.payload.turn === state.openTurn.turn) {
|
|
151
|
+
approval = { decision: event.payload.decision, toolCallId: event.payload.toolCallId }
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (approval === undefined) continue
|
|
156
|
+
}
|
|
157
|
+
// Draw down the shared deadline: a session is skipped — suspension state
|
|
158
|
+
// is durable, so skipping is always safe — when the step's remaining
|
|
159
|
+
// slack no longer leaves the floor for a meaningful turn slice.
|
|
160
|
+
const remaining = remainingNow() - ORCHESTRATOR_MIN_TURN_SECONDS
|
|
161
|
+
if (remaining < ORCHESTRATOR_MIN_TURN_SECONDS) break
|
|
162
|
+
const resumed = await runner.resumeTurn(record.id, {
|
|
163
|
+
...(approval !== undefined ? { approval } : {}),
|
|
164
|
+
remainingBudgetSeconds: remaining,
|
|
165
|
+
})
|
|
166
|
+
if (resumed.resumed) {
|
|
167
|
+
report.resumed += 1
|
|
168
|
+
// One turn per session per tick, never concurrent: the loop outcome
|
|
169
|
+
// runs to its suspension/completion before the next session.
|
|
170
|
+
await resumed.outcome
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Crash reaper ────────────────────────────────────────────────────────
|
|
175
|
+
const staleMs = ORCHESTRATOR_STALE_TURN_MINUTES * 60_000
|
|
176
|
+
for (const record of sessions) {
|
|
177
|
+
const events = await store.getSessionEvents(record.id)
|
|
178
|
+
const state = reduceSession(events)
|
|
179
|
+
// Reap only a session whose reduced status is exactly `running` — an
|
|
180
|
+
// open turn with no pending approval and no suspension. A late approve
|
|
181
|
+
// answer must still find its pending approval and its turn.
|
|
182
|
+
if (state.status !== 'running' || state.openTurn === undefined) continue
|
|
183
|
+
const lastChunkTs = await lastStreamActivity(store, state.openTurn.stream)
|
|
184
|
+
if (lastChunkTs === null) continue
|
|
185
|
+
if (clock().getTime() - Date.parse(lastChunkTs) < staleMs) continue
|
|
186
|
+
const lastSeq = events[events.length - 1]?.seq ?? 0
|
|
187
|
+
const reaped = await store
|
|
188
|
+
.appendSessionEventIfCurrent(record.id, lastSeq, {
|
|
189
|
+
actor: { kind: 'agent', role: 'orchestrator', session: state.openTurn.turn },
|
|
190
|
+
type: 'turn.failed',
|
|
191
|
+
payload: {
|
|
192
|
+
turn: state.openTurn.turn,
|
|
193
|
+
kind: 'internal',
|
|
194
|
+
error: `turn invocation ended without an outcome (no stream activity for ${ORCHESTRATOR_STALE_TURN_MINUTES} minutes)`,
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
.catch(() => null)
|
|
198
|
+
if (reaped === null) continue // a resume landed first
|
|
199
|
+
await store.closeStream(state.openTurn.stream, 'aborted').catch(() => undefined)
|
|
200
|
+
report.reaped += 1
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Wake pass ───────────────────────────────────────────────────────────
|
|
204
|
+
const builds = (await store.listBuilds()).filter((candidate) => candidate.repo === repo)
|
|
205
|
+
for (const record of sessions) {
|
|
206
|
+
const events = await store.getSessionEvents(record.id)
|
|
207
|
+
const state = reduceSession(events)
|
|
208
|
+
if (state.status !== 'idle') continue
|
|
209
|
+
if (state.wakeGlobs.length === 0) continue
|
|
210
|
+
let filters: RegExp[]
|
|
211
|
+
try {
|
|
212
|
+
filters = compileEventGlobs(state.wakeGlobs, {
|
|
213
|
+
repository: true,
|
|
214
|
+
usage: '[orchestrator].wake',
|
|
215
|
+
})
|
|
216
|
+
} catch {
|
|
217
|
+
continue // unmatchable settings wake nothing
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Scan every build's events after this session's per-build wake cursor,
|
|
221
|
+
// then the repository journal after its own cursor; select the single
|
|
222
|
+
// newest matching event across all scanned sources.
|
|
223
|
+
let newest:
|
|
224
|
+
| { build: string; event: AbEvent }
|
|
225
|
+
| { journal: true; event: RepositoryEvent }
|
|
226
|
+
| undefined
|
|
227
|
+
for (const build of builds) {
|
|
228
|
+
const cursor = state.wakeCursors[build.slug] ?? 0
|
|
229
|
+
const buildEvents = await store.getEvents(build.slug)
|
|
230
|
+
for (const event of buildEvents) {
|
|
231
|
+
if (event.seq <= cursor) continue
|
|
232
|
+
if (!filters.some((regex) => regex.test(event.type))) continue
|
|
233
|
+
if (
|
|
234
|
+
newest === undefined ||
|
|
235
|
+
event.ts > newest.event.ts ||
|
|
236
|
+
(event.ts === newest.event.ts && event.seq > newest.event.seq)
|
|
237
|
+
) {
|
|
238
|
+
newest = { build: build.slug, event }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// The journal's seq space is independent of every build's; a `seq`
|
|
243
|
+
// tie-break across sources compares numbers from different spaces —
|
|
244
|
+
// deterministic, and only matters when two sources' newest matches share
|
|
245
|
+
// a millisecond timestamp.
|
|
246
|
+
const journalCursor = state.journalWakeCursor
|
|
247
|
+
const journalEvents = await store.getRepoEvents(repo, journalCursor)
|
|
248
|
+
for (const event of journalEvents) {
|
|
249
|
+
if (event.seq <= journalCursor) continue
|
|
250
|
+
if (!filters.some((regex) => regex.test(event.type))) continue
|
|
251
|
+
if (
|
|
252
|
+
newest === undefined ||
|
|
253
|
+
event.ts > newest.event.ts ||
|
|
254
|
+
(event.ts === newest.event.ts && event.seq > newest.event.seq)
|
|
255
|
+
) {
|
|
256
|
+
newest = { journal: true, event }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (newest === undefined) continue
|
|
260
|
+
|
|
261
|
+
// Same drawdown for wake turns: one turn per session per tick, bounded
|
|
262
|
+
// by the step's shared deadline; past the floor the wake defers to a
|
|
263
|
+
// later tick.
|
|
264
|
+
const remaining = remainingNow() - ORCHESTRATOR_MIN_TURN_SECONDS
|
|
265
|
+
if (remaining < ORCHESTRATOR_MIN_TURN_SECONDS) break
|
|
266
|
+
|
|
267
|
+
// A journal wake delivers the frozen event record only — the turn's
|
|
268
|
+
// operator registry already exposes bounded repository reads, so no
|
|
269
|
+
// reduced state is coupled in. A build wake delivers the build's reduced
|
|
270
|
+
// state as before.
|
|
271
|
+
const event = {
|
|
272
|
+
seq: newest.event.seq,
|
|
273
|
+
ts: newest.event.ts,
|
|
274
|
+
type: newest.event.type,
|
|
275
|
+
payload: newest.event.payload,
|
|
276
|
+
}
|
|
277
|
+
const trigger: SessionTurnTrigger =
|
|
278
|
+
'journal' in newest
|
|
279
|
+
? { kind: 'wake', journal: true, seq: newest.event.seq, type: newest.event.type }
|
|
280
|
+
: { kind: 'wake', build: newest.build, seq: newest.event.seq, type: newest.event.type }
|
|
281
|
+
const wake: WakeTurnInput =
|
|
282
|
+
'journal' in newest
|
|
283
|
+
? { event }
|
|
284
|
+
: {
|
|
285
|
+
event,
|
|
286
|
+
buildState: reduceBuild(await store.getEvents(newest.build)) as unknown as Record<
|
|
287
|
+
string,
|
|
288
|
+
unknown
|
|
289
|
+
>,
|
|
290
|
+
}
|
|
291
|
+
const start = await runner.startTurn(record.id, trigger, wake, {
|
|
292
|
+
remainingBudgetSeconds: remaining,
|
|
293
|
+
})
|
|
294
|
+
if (start.started) {
|
|
295
|
+
report.woken += 1
|
|
296
|
+
// The cursor advances only through the recorded trigger (the reducer
|
|
297
|
+
// takes `max`), so a crash between scan and start cannot skip or
|
|
298
|
+
// duplicate a wake — and other builds' matching events re-trigger on
|
|
299
|
+
// later ticks.
|
|
300
|
+
await start.outcome
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
options.log?.(
|
|
305
|
+
`orchestrator step: resumed=${report.resumed} reaped=${report.reaped} woken=${report.woken}`,
|
|
306
|
+
)
|
|
307
|
+
return report
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The newest chunk timestamp on a stream, or its creation time when the
|
|
311
|
+
* stream has no chunks yet — the liveness signal the reaper reads. */
|
|
312
|
+
async function lastStreamActivity(store: BuildStore, streamId: string): Promise<string | null> {
|
|
313
|
+
const read = await store.readStream(streamId).catch(() => null)
|
|
314
|
+
if (read === null) return null
|
|
315
|
+
const last = read.chunks[read.chunks.length - 1]
|
|
316
|
+
if (last !== undefined) return last.ts
|
|
317
|
+
const record = await store.getStream(streamId)
|
|
318
|
+
return record?.createdAt ?? null
|
|
319
|
+
}
|
|
@@ -22,6 +22,12 @@ export interface SandboxEnvironmentSnapshot {
|
|
|
22
22
|
* `[orchestrator].sandbox.idleMinutes`. */
|
|
23
23
|
lastEvidenceTs: string
|
|
24
24
|
sessionId?: string
|
|
25
|
+
/** The base branch head the fresh provision selected; carried forward
|
|
26
|
+
* through later lifecycle facts (the base head cannot change without a
|
|
27
|
+
* fresh provision, which overwrites it) — the publication precondition's
|
|
28
|
+
* reference point, read by the publish operation. Absent on snapshots
|
|
29
|
+
* provisioned before this field existed. */
|
|
30
|
+
baseSha?: string
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
type MutableSnapshot = {
|
|
@@ -31,6 +37,7 @@ type MutableSnapshot = {
|
|
|
31
37
|
state: SandboxEnvironmentState
|
|
32
38
|
lastEvidenceTs: string
|
|
33
39
|
sessionId?: string
|
|
40
|
+
baseSha?: string
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/** Per-environment sandbox state, keyed deterministically by
|
|
@@ -49,6 +56,7 @@ export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnviro
|
|
|
49
56
|
state: 'live',
|
|
50
57
|
lastEvidenceTs: ts,
|
|
51
58
|
...(event.payload.sessionId !== undefined ? { sessionId: event.payload.sessionId } : {}),
|
|
59
|
+
...(event.payload.baseSha !== undefined ? { baseSha: event.payload.baseSha } : {}),
|
|
52
60
|
})
|
|
53
61
|
} else if (event.type === 'orchestrator.sandbox.resumed') {
|
|
54
62
|
const previous = byEnvironment.get(event.payload.environmentId)
|
|
@@ -63,6 +71,7 @@ export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnviro
|
|
|
63
71
|
: previous?.sessionId !== undefined
|
|
64
72
|
? { sessionId: previous.sessionId }
|
|
65
73
|
: {}),
|
|
74
|
+
...(previous?.baseSha !== undefined ? { baseSha: previous.baseSha } : {}),
|
|
66
75
|
})
|
|
67
76
|
} else if (event.type === 'orchestrator.sandbox.activity') {
|
|
68
77
|
const current = byEnvironment.get(event.payload.environmentId)
|
|
@@ -94,6 +103,7 @@ export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnviro
|
|
|
94
103
|
state: 'released',
|
|
95
104
|
lastEvidenceTs: ts,
|
|
96
105
|
...(existing?.sessionId !== undefined ? { sessionId: existing.sessionId } : {}),
|
|
106
|
+
...(existing?.baseSha !== undefined ? { baseSha: existing.baseSha } : {}),
|
|
97
107
|
})
|
|
98
108
|
}
|
|
99
109
|
}
|
|
@@ -16,8 +16,39 @@ export type {
|
|
|
16
16
|
SessionEventType,
|
|
17
17
|
SessionEventWrite,
|
|
18
18
|
} from '../events/sessions'
|
|
19
|
+
export {
|
|
20
|
+
DEFAULT_TICKET_ASSET_LIMITS,
|
|
21
|
+
effectiveTicketAssetLimits,
|
|
22
|
+
loadTicketAsset,
|
|
23
|
+
resolvePinnedAsset,
|
|
24
|
+
storeTicketAssetBlobs,
|
|
25
|
+
summarizeTicketAsset,
|
|
26
|
+
TICKET_ASSET_MAX_BYTES,
|
|
27
|
+
TICKET_ASSET_MAX_ENTRIES,
|
|
28
|
+
TicketAssetValidationError,
|
|
29
|
+
validateTicketAssetInput,
|
|
30
|
+
} from './ticket-assets'
|
|
31
|
+
export type {
|
|
32
|
+
TicketAsset,
|
|
33
|
+
TicketAssetContentEntry,
|
|
34
|
+
TicketAssetEntry,
|
|
35
|
+
TicketAssetEntryInput,
|
|
36
|
+
TicketAssetInput,
|
|
37
|
+
TicketAssetLayout,
|
|
38
|
+
TicketAssetLimits,
|
|
39
|
+
TicketAssetMeta,
|
|
40
|
+
TicketAssetSummary,
|
|
41
|
+
} from './ticket-assets'
|
|
42
|
+
export { validateCreatedEvent } from './new-build'
|
|
19
43
|
export { createBuildScopedStore } from './build-scope'
|
|
20
44
|
export { createSessionScopedStore } from './session-handle'
|
|
45
|
+
export { DIGEST_EVENT_TYPES, reduceBuildDigest } from './digest'
|
|
46
|
+
export {
|
|
47
|
+
projectRepositoryStateEvents,
|
|
48
|
+
REPOSITORY_RUN_SCOPED_EVENT_TYPES,
|
|
49
|
+
REPOSITORY_STATE_EVENT_TYPES,
|
|
50
|
+
readRepoStateEventsWithAnchorRecheck,
|
|
51
|
+
} from './repo-state-events'
|
|
21
52
|
export {
|
|
22
53
|
DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
|
|
23
54
|
DISPATCHER_RETENTION_BUILD_KINDS,
|
|
@@ -61,6 +92,7 @@ export type {
|
|
|
61
92
|
ArtifactInput,
|
|
62
93
|
ArtifactMeta,
|
|
63
94
|
BlobStore,
|
|
95
|
+
BuildDigest,
|
|
64
96
|
BuildRecord,
|
|
65
97
|
BuildScopedStore,
|
|
66
98
|
BuildStore,
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
Artifact,
|
|
14
14
|
ArtifactInput,
|
|
15
15
|
ArtifactMeta,
|
|
16
|
+
BuildDigest,
|
|
16
17
|
BuildRecord,
|
|
17
18
|
BuildScopedStore,
|
|
18
19
|
BuildStore,
|
|
@@ -25,6 +26,13 @@ import type {
|
|
|
25
26
|
SubscribeOptions,
|
|
26
27
|
Unsubscribe,
|
|
27
28
|
} from './types'
|
|
29
|
+
import type {
|
|
30
|
+
TicketAsset,
|
|
31
|
+
TicketAssetInput,
|
|
32
|
+
TicketAssetLimits,
|
|
33
|
+
TicketAssetMeta,
|
|
34
|
+
TicketAssetSummary,
|
|
35
|
+
} from './ticket-assets'
|
|
28
36
|
import type {
|
|
29
37
|
StreamChunk,
|
|
30
38
|
StreamOutcome,
|
|
@@ -179,6 +187,12 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
|
|
|
179
187
|
getRepoEvents(repo: string, _sinceSeq?: number): Promise<RepositoryEvent[]> {
|
|
180
188
|
return Promise.reject(new BuildScopeError(scope, 'getRepoEvents', repo))
|
|
181
189
|
},
|
|
190
|
+
getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
191
|
+
return Promise.reject(new BuildScopeError(scope, 'getRepoStateEvents', repo))
|
|
192
|
+
},
|
|
193
|
+
getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
|
|
194
|
+
return Promise.reject(new BuildScopeError(scope, 'getRepoBuildDigests', repo))
|
|
195
|
+
},
|
|
182
196
|
putRepoArtifact(repo: string, _artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
|
|
183
197
|
return Promise.reject(new BuildScopeError(scope, 'putRepoArtifact', repo))
|
|
184
198
|
},
|
|
@@ -192,6 +206,49 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
|
|
|
192
206
|
listRepoArtifacts(repo: string, _kind?: string): Promise<RepositoryArtifactMeta[]> {
|
|
193
207
|
return Promise.reject(new BuildScopeError(scope, 'listRepoArtifacts', repo))
|
|
194
208
|
},
|
|
209
|
+
ticketAssetLimits(repo: string): Promise<TicketAssetLimits> {
|
|
210
|
+
return Promise.reject(new BuildScopeError(scope, 'ticketAssetLimits', repo))
|
|
211
|
+
},
|
|
212
|
+
putTicketAsset(
|
|
213
|
+
repo: string,
|
|
214
|
+
_ticketId: string,
|
|
215
|
+
_asset: TicketAssetInput,
|
|
216
|
+
): Promise<TicketAssetMeta> {
|
|
217
|
+
return Promise.reject(new BuildScopeError(scope, 'putTicketAsset', repo))
|
|
218
|
+
},
|
|
219
|
+
getTicketAsset(
|
|
220
|
+
repo: string,
|
|
221
|
+
_ticketId: string,
|
|
222
|
+
_kind: string,
|
|
223
|
+
_name: string,
|
|
224
|
+
_rev?: number,
|
|
225
|
+
): Promise<TicketAsset | null> {
|
|
226
|
+
return Promise.reject(new BuildScopeError(scope, 'getTicketAsset', repo))
|
|
227
|
+
},
|
|
228
|
+
async getPinnedTicketAsset(
|
|
229
|
+
slug: string,
|
|
230
|
+
kind: string,
|
|
231
|
+
name: string,
|
|
232
|
+
rev?: number,
|
|
233
|
+
): Promise<TicketAsset | null> {
|
|
234
|
+
own('getPinnedTicketAsset', slug)
|
|
235
|
+
return store.getPinnedTicketAsset(slug, kind, name, rev)
|
|
236
|
+
},
|
|
237
|
+
listTicketAssets(
|
|
238
|
+
repo: string,
|
|
239
|
+
_ticketId: string,
|
|
240
|
+
_opts?: { revisions?: boolean },
|
|
241
|
+
): Promise<TicketAssetSummary[]> {
|
|
242
|
+
return Promise.reject(new BuildScopeError(scope, 'listTicketAssets', repo))
|
|
243
|
+
},
|
|
244
|
+
removeTicketAsset(
|
|
245
|
+
repo: string,
|
|
246
|
+
_ticketId: string,
|
|
247
|
+
_kind: string,
|
|
248
|
+
_name: string,
|
|
249
|
+
): Promise<TicketAssetMeta | null> {
|
|
250
|
+
return Promise.reject(new BuildScopeError(scope, 'removeTicketAsset', repo))
|
|
251
|
+
},
|
|
195
252
|
claimRepoLease(repo: string, _holder: string, _ttlMs: number): Promise<boolean> {
|
|
196
253
|
return Promise.reject(new BuildScopeError(scope, 'claimRepoLease', repo))
|
|
197
254
|
},
|
|
@@ -216,6 +273,13 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
|
|
|
216
273
|
): Promise<SessionEventEnvelope<T>> {
|
|
217
274
|
return Promise.reject(new BuildScopeError(scope, 'appendSessionEvent', id))
|
|
218
275
|
},
|
|
276
|
+
appendSessionEventIfCurrent<T extends SessionEventType>(
|
|
277
|
+
id: string,
|
|
278
|
+
_expectedSeq: number,
|
|
279
|
+
_event: SessionEventWrite<T>,
|
|
280
|
+
): Promise<SessionEventEnvelope<T> | null> {
|
|
281
|
+
return Promise.reject(new BuildScopeError(scope, 'appendSessionEventIfCurrent', id))
|
|
282
|
+
},
|
|
219
283
|
getSessionEvents(id: string, _sinceSeq?: number): Promise<SessionEvent[]> {
|
|
220
284
|
return Promise.reject(new BuildScopeError(scope, 'getSessionEvents', id))
|
|
221
285
|
},
|