@defrex/autobuild 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +237 -23
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/architecture.md +66 -4
  5. package/docs/configuration.md +137 -19
  6. package/docs/hosted-dispatcher.md +17 -2
  7. package/docs/hosted-service.md +214 -0
  8. package/docs/mcp.md +8 -2
  9. package/docs/operator-api.md +107 -7
  10. package/docs/remote-store-protocol.md +189 -144
  11. package/docs/remote-ticket-protocol.md +111 -18
  12. package/docs/setup.md +173 -16
  13. package/docs/ticket-dependencies.md +1 -1
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/binary.ts +3 -3
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +261 -0
  19. package/packages/core/src/cli/dashboard/model.ts +8 -5
  20. package/packages/core/src/cli/dashboard/render.ts +37 -7
  21. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  22. package/packages/core/src/cli/dispatch-process.ts +25 -7
  23. package/packages/core/src/cli/dispatch.ts +247 -47
  24. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  25. package/packages/core/src/cli/help.ts +45 -13
  26. package/packages/core/src/cli/init-validation.ts +27 -2
  27. package/packages/core/src/cli/main.ts +41 -15
  28. package/packages/core/src/cli/mcp.ts +7 -1
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/repository-status.ts +45 -5
  34. package/packages/core/src/cli/self-update.ts +10 -0
  35. package/packages/core/src/cli/store-opening.ts +22 -14
  36. package/packages/core/src/cli/terminals.ts +0 -10
  37. package/packages/core/src/cli/ticket.ts +8 -1
  38. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  39. package/packages/core/src/cli/wait.ts +84 -17
  40. package/packages/core/src/cli/watch.ts +350 -68
  41. package/packages/core/src/config/live.ts +4 -0
  42. package/packages/core/src/config/schema.ts +65 -11
  43. package/packages/core/src/connection.ts +86 -0
  44. package/packages/core/src/events/catalog.ts +10 -2
  45. package/packages/core/src/events/globs.ts +8 -3
  46. package/packages/core/src/events/payloads.ts +44 -0
  47. package/packages/core/src/events/repository.ts +64 -2
  48. package/packages/core/src/integration/harness.ts +2 -1
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
  50. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  51. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  52. package/packages/core/src/kernel/engine.ts +24 -0
  53. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  54. package/packages/core/src/kernel/log-index.ts +17 -0
  55. package/packages/core/src/kernel/pr-summary.ts +89 -0
  56. package/packages/core/src/kernel/reducer-registry.ts +12 -0
  57. package/packages/core/src/kernel/reducer.ts +8 -0
  58. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  59. package/packages/core/src/ontology.ts +1 -0
  60. package/packages/core/src/operator/index.ts +7 -0
  61. package/packages/core/src/operator/query.ts +44 -19
  62. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  63. package/packages/core/src/operator/tickets.ts +6 -8
  64. package/packages/core/src/plugin-sdk/index.ts +1 -0
  65. package/packages/core/src/plugins/manifest.ts +5 -3
  66. package/packages/core/src/plugins/runtimes.ts +3 -1
  67. package/packages/core/src/ports/forge/fake.ts +13 -1
  68. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  69. package/packages/core/src/ports/forge/github.ts +29 -5
  70. package/packages/core/src/ports/forge/local-git.ts +12 -4
  71. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  72. package/packages/core/src/ports/runner/session-env.ts +1 -0
  73. package/packages/core/src/ports/tickets/contract.ts +25 -0
  74. package/packages/core/src/ports/tickets/create.ts +11 -3
  75. package/packages/core/src/ports/tickets/fake.ts +10 -8
  76. package/packages/core/src/ports/tickets/file.ts +1 -0
  77. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  78. package/packages/core/src/ports/tickets/linear.ts +241 -49
  79. package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
  80. package/packages/core/src/ports/tickets/remote.ts +1 -0
  81. package/packages/core/src/ports/types.ts +17 -0
  82. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  83. package/packages/core/src/ports/workspace/create.ts +11 -2
  84. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  85. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  86. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  87. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  88. package/packages/core/src/processes/build-execution-state.ts +1 -1
  89. package/packages/core/src/processes/build-runner.ts +87 -26
  90. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  91. package/packages/core/src/processes/dispatcher.ts +336 -83
  92. package/packages/core/src/processes/harvest-child.ts +1 -0
  93. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  94. package/packages/core/src/processes/harvest-runner.ts +142 -100
  95. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  96. package/packages/core/src/processes/publication-loss.ts +18 -1
  97. package/packages/core/src/processes/repo-view.ts +2 -1
  98. package/packages/core/src/store/adapter.ts +1 -0
  99. package/packages/core/src/store/contract.ts +109 -0
  100. package/packages/core/src/store/digest.ts +14 -2
  101. package/packages/core/src/store/local/store.ts +18 -5
  102. package/packages/core/src/store/remote/client.ts +1 -1
  103. package/packages/core/src/store/remote/compatibility.ts +90 -0
  104. package/packages/core/src/store/remote/credentials.ts +28 -0
  105. package/packages/core/src/store/remote/index.ts +14 -1
  106. package/packages/core/src/store/remote/protocol.ts +26 -0
  107. package/packages/core/src/store/remote/token.ts +74 -13
  108. package/packages/core/src/store/repo-state-events.ts +49 -59
  109. package/packages/core/src/store/types.ts +5 -1
  110. package/packages/core/src/testing/index.ts +7 -0
  111. package/skills/guide/SKILL.md +175 -26
  112. package/skills/guide/references/operator-api.md +135 -13
  113. package/skills/guide/references/plugin-authoring.md +10 -2
  114. package/skills/guide/references/remote-store-protocol.md +189 -144
  115. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  116. package/skills/guide/references/setup.md +173 -16
  117. package/skills/guide/references/ticket-dependencies.md +1 -1
  118. package/skills/operate/SKILL.md +159 -34
  119. package/templates/hosted-service/package.json +13 -0
  120. package/templates/hosted-service/vercel.json +7 -0
@@ -1,3 +1,4 @@
1
+ import { delegateCredential } from '../../store/remote/credentials'
1
2
  import { resolve } from 'node:path'
2
3
  import { SANDBOX_FORBIDDEN_ENV } from './operator-sandbox'
3
4
  import type { WorkspaceConfig } from '../../config/schema'
@@ -131,10 +132,18 @@ export async function createWorkspaceProvider(
131
132
  // receives through `opts` reach the plugin factory through the context.
132
133
  return await factory({
133
134
  config: config.config,
134
- env: opts.env,
135
+ env: Object.fromEntries(Object.entries(opts.env).filter(([key]) => key !== 'AB_TOKEN')),
135
136
  repoRoot: resolve(opts.repoRoot),
136
137
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
137
- ...(opts.storeToken !== undefined ? { storeToken: opts.storeToken } : {}),
138
+ ...(opts.storeRef !== undefined && opts.storeToken !== undefined
139
+ ? {
140
+ delegateCredential: (
141
+ _ref: string,
142
+ _token: string | undefined,
143
+ target: Parameters<typeof delegateCredential>[2],
144
+ ) => delegateCredential(opts.storeRef!, opts.storeToken, target),
145
+ }
146
+ : {}),
138
147
  ...(opts.runtimeReferences !== undefined
139
148
  ? { runtimeReferences: opts.runtimeReferences }
140
149
  : {}),
@@ -18,11 +18,9 @@
18
18
  * version in both modes: origin mode's release-asset tag is the running
19
19
  * version by construction, and source mode packs `distributionRoot()`, whose
20
20
  * package.json is the same file `readDistributionIdentity()` reads. The hosted
21
- * remote store enforces exact version lockstep — a client whose
22
- * `x-autobuild-version` or `x-autobuild-protocol-version` differs from the
23
- * server's is rejected (409) — so guest, store, and dispatcher versions must
24
- * move together through a release cut (`tools/release.ts`) plus
25
- * store/dispatcher upgrades; there is no code path that safely mixes versions.
21
+ * remote store accepts only its published client release-line window and known
22
+ * protocol identities (409 outside that contract). Upgrade the service before
23
+ * a dispatcher moves beyond its window; projects can then upgrade independently.
26
24
  * Provisioning records the installed distribution's identity stamp
27
25
  * (`readDistributionIdentityStamp()`) in the guest's `.distribution-version`
28
26
  * marker and reinstalls the archive on a later mismatch, so an upgraded
@@ -2,6 +2,7 @@ import type {
2
2
  BuildExecutionHandle,
3
3
  BuildExecutionIdentity,
4
4
  ExecutionObservation,
5
+ ExecutionLaunchAuth,
5
6
  } from './build-execution'
6
7
 
7
8
  /** Guest environment variable carrying the serialized harvest launch
@@ -23,6 +24,7 @@ export interface HarvestExecutionEnvironment {
23
24
  * advisory. Run state, config, and outcomes are obtained only through the
24
25
  * repository journal in the Store. */
25
26
  export interface HarvestExecutionStart {
27
+ preparedRun?: { run: string }
26
28
  storeRef: string
27
29
  /** Repository identity (normalized origin) the guest serves. */
28
30
  repo: string
@@ -101,7 +103,7 @@ export function parseHarvestRunnerLaunch(value: unknown): HarvestRunnerLaunch |
101
103
  * observation types so supervision semantics (long-poll wait, teardown
102
104
  * outcome, provider liveness) are identical to builds. */
103
105
  export interface HarvestExecution {
104
- start(input: HarvestExecutionStart): Promise<BuildExecutionHandle>
106
+ start(input: HarvestExecutionStart, auth?: ExecutionLaunchAuth): Promise<BuildExecutionHandle>
105
107
  /** Optional liveness observation of a previously recorded execution, from
106
108
  * provider state alone. Absent = this provider cannot observe; callers
107
109
  * supervise exactly as before ("cannot observe" never means "act"). */
@@ -8,6 +8,7 @@ import type {
8
8
  BuildExecutionExit,
9
9
  BuildExecutionHandle,
10
10
  BuildExecutionStart,
11
+ ExecutionLaunchAuth,
11
12
  } from './build-execution'
12
13
 
13
14
  export const BUILD_RUNNER_OPTIONS_ENV = 'AB_BUILD_RUNNER_OPTIONS'
@@ -34,10 +35,16 @@ export class LocalBuildExecution implements BuildExecution {
34
35
  this.spawn = opts.spawn ?? Bun.spawn
35
36
  }
36
37
 
37
- async start(input: BuildExecutionStart): Promise<BuildExecutionHandle> {
38
+ async start(
39
+ input: BuildExecutionStart,
40
+ auth?: ExecutionLaunchAuth,
41
+ ): Promise<BuildExecutionHandle> {
42
+ if (/^https?:/i.test(input.storeRef) && !auth)
43
+ throw new Error('remote build launch requires delegated authority')
38
44
  const child = this.spawn([process.execPath, this.entrypoint], {
39
45
  env: {
40
46
  ...this.env,
47
+ AB_TOKEN: auth?.token,
41
48
  [BUILD_RUNNER_OPTIONS_ENV]: JSON.stringify({
42
49
  ...input,
43
50
  supervision: { kind: 'local-parent', parentPid: process.pid },
@@ -1,3 +1,5 @@
1
+ import { delegateCredential } from '../../store/remote/credentials'
2
+ import type { ExecutionLaunchAuth } from './build-execution'
1
3
  import { createHash } from 'node:crypto'
2
4
  import { Sandbox, Snapshot, type NetworkPolicy, type SandboxRegion } from '@vercel/sandbox'
3
5
  import { displayName, tomlKey, type RuntimeReferenceGroup } from '../../config/roles'
@@ -638,7 +640,9 @@ function runtimeEnvironment(
638
640
  env: Record<string, string | undefined>,
639
641
  ): Record<string, string> {
640
642
  return Object.fromEntries(
641
- config.environmentVariables.map((name) => [name, requireVercelEnvironmentValue(env, name)]),
643
+ config.environmentVariables
644
+ .filter((name) => name !== 'AB_TOKEN')
645
+ .map((name) => [name, requireVercelEnvironmentValue(env, name)]),
642
646
  )
643
647
  }
644
648
 
@@ -942,10 +946,9 @@ export async function validateVercelSandbox(
942
946
  env: Object.fromEntries([
943
947
  ['AB_STORE', options.storeRef],
944
948
  ['AB_TOKEN', options.storeToken],
945
- ...config.environmentVariables.map((name) => [
946
- name,
947
- requireVercelEnvironmentValue(options.env, name),
948
- ]),
949
+ ...config.environmentVariables
950
+ .filter((name) => name !== 'AB_TOKEN')
951
+ .map((name) => [name, requireVercelEnvironmentValue(options.env, name)]),
949
952
  ]),
950
953
  }),
951
954
  )
@@ -1106,10 +1109,11 @@ async function effectiveArchive(
1106
1109
  }
1107
1110
 
1108
1111
  export interface VercelSandboxProviderOptions {
1112
+ delegateCredential?: typeof delegateCredential
1109
1113
  config: VercelSandboxConfig
1110
1114
  env: Record<string, string | undefined>
1111
1115
  storeRef: string
1112
- storeToken: string
1116
+ storeToken?: string
1113
1117
  /** Dispatcher-local main checkout, used only for origin discovery and verification.
1114
1118
  * With the injected `origin`/`remoteBranchHead` seams (checkout-less origin
1115
1119
  * mode) it is never touched on the host. */
@@ -1188,18 +1192,21 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1188
1192
  }
1189
1193
 
1190
1194
  constructor(private readonly options: VercelSandboxProviderOptions) {
1191
- if (!/^https:\/\//i.test(options.storeRef) || options.storeToken === '') {
1195
+ if (
1196
+ !/^https:\/\//i.test(options.storeRef) ||
1197
+ (!options.storeToken && !options.delegateCredential)
1198
+ ) {
1192
1199
  throw new Error('vercel-sandbox requires an HTTPS BuildStore and scoped AB_TOKEN authority')
1193
1200
  }
1194
1201
  this.facade = options.facade ?? createVercelSdkFacade(options.env)
1195
1202
  this.exec = options.exec ?? spawnExec
1196
1203
  this.observeWaitMs = options.observeWaitMs ?? VERCEL_OBSERVE_WAIT_MS
1197
1204
  this.buildExecution = {
1198
- start: (input) => this.start(input),
1205
+ start: (input, auth) => this.start(input, auth),
1199
1206
  observe: (identity) => this.observeExecution(identity),
1200
1207
  }
1201
1208
  this.harvestExecution = {
1202
- start: (input) => this.startHarvestExecution(input),
1209
+ start: (input, auth) => this.startHarvestExecution(input, auth),
1203
1210
  observe: (identity) => this.observeExecution(identity),
1204
1211
  }
1205
1212
  this.orchestratorSandbox = {
@@ -1244,6 +1251,11 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1244
1251
  revision?: string
1245
1252
  generation?: number
1246
1253
  }): Promise<WorkspaceProvisionResult> {
1254
+ const scopedToken = await (this.options.delegateCredential ?? delegateCredential)(
1255
+ this.options.storeRef,
1256
+ this.options.storeToken,
1257
+ { kind: 'build-runner', id: opts.branch.replace(/^ab\//, '') },
1258
+ )
1247
1259
  const rawOrigin = await this.origin()
1248
1260
  const origin = cleanGithubOrigin(rawOrigin)
1249
1261
  const name = sandboxName(origin.url, opts.branch, opts.generation)
@@ -1385,7 +1397,7 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1385
1397
  await bootstrapRuntimes(
1386
1398
  sandbox,
1387
1399
  this.options.config,
1388
- runtimeEnvironment(this.options.config, this.options.env),
1400
+ { ...runtimeEnvironment(this.options.config, this.options.env), AB_TOKEN: scopedToken },
1389
1401
  currentRuntimeReferences(this.options.runtimeReferences),
1390
1402
  true,
1391
1403
  )
@@ -1521,7 +1533,17 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1521
1533
  }
1522
1534
  }
1523
1535
 
1524
- private async start(input: BuildExecutionStart): Promise<BuildExecutionHandle> {
1536
+ private async start(
1537
+ input: BuildExecutionStart,
1538
+ auth?: ExecutionLaunchAuth,
1539
+ ): Promise<BuildExecutionHandle> {
1540
+ const scopedToken =
1541
+ auth?.token ??
1542
+ (await (this.options.delegateCredential ?? delegateCredential)(
1543
+ this.options.storeRef,
1544
+ this.options.storeToken,
1545
+ { kind: 'build-runner', id: input.slug },
1546
+ ))
1525
1547
  const ref = input.workspaceRef
1526
1548
  if (this.active.has(ref)) throw new Error(`sandbox ${ref} already has a live execution`)
1527
1549
  const sandbox = this.sessions.get(ref) ?? (await this.facade.get(ref, this.operationSignal()))
@@ -1566,21 +1588,21 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1566
1588
  await bootstrapRuntimes(
1567
1589
  sandbox,
1568
1590
  this.options.config,
1569
- runtimeEnvironment(this.options.config, this.options.env),
1591
+ { ...runtimeEnvironment(this.options.config, this.options.env), AB_TOKEN: scopedToken },
1570
1592
  currentRuntimeReferences(this.options.runtimeReferences),
1571
1593
  false,
1572
1594
  )
1573
1595
  this.sessions.set(ref, sandbox)
1574
1596
  const env: Record<string, string> = {
1575
1597
  AB_STORE: this.options.storeRef,
1576
- AB_TOKEN: this.options.storeToken,
1598
+ AB_TOKEN: scopedToken,
1577
1599
  [BUILD_RUNNER_OPTIONS_ENV]: JSON.stringify({
1578
1600
  ...input,
1579
1601
  supervision: { kind: 'environment' },
1580
1602
  }),
1581
1603
  }
1582
1604
  for (const name of this.options.config.environmentVariables)
1583
- env[name] = requireVercelEnvironmentValue(this.options.env, name)
1605
+ if (name !== 'AB_TOKEN') env[name] = requireVercelEnvironmentValue(this.options.env, name)
1584
1606
  const command = (await sandbox.runCommand({
1585
1607
  cmd: 'sh',
1586
1608
  args: [
@@ -1696,7 +1718,12 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1696
1718
  * build execution. Provisioning reuses the module-local build chain so a
1697
1719
  * harvest guest is identical to a build guest; release is the provider's
1698
1720
  * existing reap path (stop, snapshot purge, delete). */
1699
- private async startHarvestExecution(input: HarvestExecutionStart): Promise<BuildExecutionHandle> {
1721
+ private async startHarvestExecution(
1722
+ input: HarvestExecutionStart,
1723
+ auth?: ExecutionLaunchAuth,
1724
+ ): Promise<BuildExecutionHandle> {
1725
+ if (!auth) throw new Error('harvest launch requires delegated authority')
1726
+ const scopedToken = auth.token
1700
1727
  const origin = cleanGithubOrigin(await this.origin())
1701
1728
  const name = harvestSandboxName(origin.url)
1702
1729
  if (this.active.has(name)) throw new Error(`sandbox ${name} already has a live execution`)
@@ -1824,7 +1851,7 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1824
1851
  await bootstrapRuntimes(
1825
1852
  sandbox,
1826
1853
  this.options.config,
1827
- runtimeEnvironment(this.options.config, this.options.env),
1854
+ { ...runtimeEnvironment(this.options.config, this.options.env), AB_TOKEN: scopedToken },
1828
1855
  currentRuntimeReferences(this.options.runtimeReferences),
1829
1856
  true,
1830
1857
  )
@@ -1861,7 +1888,7 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1861
1888
  await bootstrapRuntimes(
1862
1889
  sandbox,
1863
1890
  this.options.config,
1864
- runtimeEnvironment(this.options.config, this.options.env),
1891
+ { ...runtimeEnvironment(this.options.config, this.options.env), AB_TOKEN: scopedToken },
1865
1892
  currentRuntimeReferences(this.options.runtimeReferences),
1866
1893
  false,
1867
1894
  )
@@ -1878,11 +1905,12 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1878
1905
  }
1879
1906
  const env: Record<string, string> = {
1880
1907
  AB_STORE: this.options.storeRef,
1881
- AB_TOKEN: this.options.storeToken,
1908
+ AB_TOKEN: scopedToken,
1882
1909
  [HARVEST_RUNNER_OPTIONS_ENV]: JSON.stringify(envelope),
1883
1910
  }
1884
1911
  for (const envName of this.options.config.environmentVariables)
1885
- env[envName] = requireVercelEnvironmentValue(this.options.env, envName)
1912
+ if (envName !== 'AB_TOKEN')
1913
+ env[envName] = requireVercelEnvironmentValue(this.options.env, envName)
1886
1914
  const command = (await sandbox.runCommand({
1887
1915
  cmd: 'sh',
1888
1916
  args: [
@@ -22,7 +22,7 @@ export function effectiveBuildConfigContent(config: Config): string {
22
22
  const { verify, finalize, ...root } = config
23
23
  return JSON.stringify({
24
24
  ...root,
25
- verify: { steps: verify.steps, ...verify.stepConfigs },
25
+ verify: { steps: verify.steps, afterReconcile: verify.afterReconcile, ...verify.stepConfigs },
26
26
  finalize: { steps: finalize.steps, ...finalize.stepConfigs },
27
27
  })
28
28
  }
@@ -1,3 +1,5 @@
1
+ import { delegateCredential } from '../store/remote/credentials'
2
+ import { refreshSummaryBoundary } from '../cli/pr-summary-publication'
1
3
  /**
2
4
  * The build-runner process (SPEC §3.3): one per build, owns one pipeline
3
5
  * execution end to end. The kernel's job reduced to a loop (§8): read the
@@ -170,6 +172,7 @@ export interface BuildRunnerDeps {
170
172
  /** Extra AB_* the launcher wants in every session env (e.g. AB_TOKEN,
171
173
  * AB_STORE for a remote store) — D8. May override AB_STORE; never the
172
174
  * per-session identity keys (AB_BUILD/AB_PHASE/AB_SESSION). */
175
+ delegateCredential?: typeof delegateCredential
173
176
  sessionEnv?: Record<string, string>
174
177
  opts?: BuildRunnerOpts
175
178
  }
@@ -819,6 +822,26 @@ export class BuildRunner {
819
822
  const events = await store.getEvents(slug)
820
823
  const decision = decideNext(events, config)
821
824
  switch (decision.kind) {
825
+ case 'capture-reconcile-policy':
826
+ await store.append(slug, {
827
+ actor: KERNEL,
828
+ type: 'verify.reconcile-policy-captured',
829
+ payload: {
830
+ reconcileSeq: decision.reconcileSeq,
831
+ steps: config.verify.steps.map((step) => {
832
+ const override = config.verify.stepConfigs[step]?.afterReconcile
833
+ return {
834
+ step,
835
+ enabled: override ?? config.verify.afterReconcile,
836
+ source:
837
+ override === undefined
838
+ ? 'verify.afterReconcile'
839
+ : `verify.${step}.afterReconcile`,
840
+ }
841
+ }),
842
+ },
843
+ })
844
+ return decision
822
845
  case 'wait':
823
846
  return decision
824
847
  case 'acknowledge':
@@ -866,12 +889,15 @@ export class BuildRunner {
866
889
  }
867
890
  }
868
891
  try {
892
+ if (await this.refreshSummary())
893
+ return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
869
894
  if (publicationPending(await this.deps.store.getEvents(this.deps.slug))) {
870
895
  return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
871
896
  }
872
897
  for (;;) {
873
898
  await this.ensureLease()
874
899
  const decision = await this.step()
900
+ if (await this.refreshSummary()) break
875
901
  if (decision.kind === 'wait') break
876
902
  if (publicationPending(await this.deps.store.getEvents(this.deps.slug))) break
877
903
  }
@@ -882,6 +908,22 @@ export class BuildRunner {
882
908
  }
883
909
  }
884
910
 
911
+ private async refreshSummary(): Promise<boolean> {
912
+ try {
913
+ await this.ensureLease()
914
+ } catch {
915
+ return false
916
+ }
917
+ return refreshSummaryBoundary({
918
+ store: this.deps.store,
919
+ forge: this.deps.forge,
920
+ slug: this.deps.slug,
921
+ workspacePath: this.deps.workspacePath,
922
+ storeRef: this.deps.sessionEnv?.AB_STORE ?? 'local',
923
+ clock: this.deps.clock,
924
+ })
925
+ }
926
+
885
927
  /**
886
928
  * §7.4 exclusivity, holder side: a lapsed lease means the sweep may already
887
929
  * have launched a replacement, so this runner must re-claim before doing
@@ -1146,6 +1188,7 @@ export class BuildRunner {
1146
1188
  attempt: decision.attempt,
1147
1189
  outcome: 'skipped',
1148
1190
  reason: decision.reason,
1191
+ ...(decision.skipCause !== undefined ? { skipCause: decision.skipCause } : {}),
1149
1192
  },
1150
1193
  } satisfies EventWrite<'verify.completed'>)
1151
1194
  }
@@ -1254,6 +1297,11 @@ export class BuildRunner {
1254
1297
  events: AbEvent[],
1255
1298
  ): Promise<void> {
1256
1299
  const { step, action } = decision
1300
+ await this.deps.store.append(this.deps.slug, {
1301
+ actor: KERNEL,
1302
+ type: 'finalize.step-started',
1303
+ payload: { step },
1304
+ })
1257
1305
  let actor: Actor = KERNEL
1258
1306
  let durableHead: string | undefined
1259
1307
  let failureNote: string | undefined
@@ -1383,7 +1431,7 @@ export class BuildRunner {
1383
1431
  const turn = await this.runBudgetedTurn(
1384
1432
  started.seq,
1385
1433
  resolved.sessionBudgetSeconds,
1386
- (signal) =>
1434
+ async (signal) =>
1387
1435
  target.runner.start({
1388
1436
  skill,
1389
1437
  invocation: slug,
@@ -1391,7 +1439,7 @@ export class BuildRunner {
1391
1439
  workspacePath,
1392
1440
  ...(target.model !== undefined ? { model: target.model } : {}),
1393
1441
  ...(target.args !== undefined ? { args: target.args } : {}),
1394
- env: this.sessionEnvFor('finalize@1', session),
1442
+ env: await this.sessionEnvFor('finalize@1', session),
1395
1443
  signal,
1396
1444
  ...(stream !== undefined ? { stream } : {}),
1397
1445
  }),
@@ -1523,7 +1571,7 @@ export class BuildRunner {
1523
1571
 
1524
1572
  let handle: AgentSessionHandle | undefined
1525
1573
  let failureNote: string | undefined
1526
- const turn = await this.runBudgetedTurn(started.seq, sessionBudgetSeconds, (signal) =>
1574
+ const turn = await this.runBudgetedTurn(started.seq, sessionBudgetSeconds, async (signal) =>
1527
1575
  runner.start({
1528
1576
  skill,
1529
1577
  invocation: slug,
@@ -1531,7 +1579,7 @@ export class BuildRunner {
1531
1579
  workspacePath,
1532
1580
  ...(model !== undefined ? { model } : {}),
1533
1581
  ...(args !== undefined ? { args } : {}),
1534
- env: this.sessionEnvFor('finalize@1', session),
1582
+ env: await this.sessionEnvFor('finalize@1', session),
1535
1583
  signal,
1536
1584
  ...(stream !== undefined ? { stream } : {}),
1537
1585
  }),
@@ -1907,26 +1955,29 @@ export class BuildRunner {
1907
1955
  let handle: AgentSessionHandle | undefined = live?.handle
1908
1956
  let result: AgentTurnResult | undefined
1909
1957
  let turnError: unknown
1910
- const turn = await this.runBudgetedTurn(preSeq, primary.sessionBudgetSeconds, (signal) =>
1911
- live !== undefined
1912
- ? live.runner
1913
- .continue(live.handle, continueMessage(spec), {
1914
- env: this.sessionEnvFor(spec.abPhase, session),
1958
+ const turn = await this.runBudgetedTurn(
1959
+ preSeq,
1960
+ primary.sessionBudgetSeconds,
1961
+ async (signal) =>
1962
+ live !== undefined
1963
+ ? live.runner
1964
+ .continue(live.handle, continueMessage(spec), {
1965
+ env: await this.sessionEnvFor(spec.abPhase, session),
1966
+ signal,
1967
+ ...(stream !== undefined ? { stream } : {}),
1968
+ })
1969
+ .then((continued) => ({ session: live.handle, result: continued }))
1970
+ : target.runner.start({
1971
+ skill: spec.skill,
1972
+ invocation: slug,
1973
+ buildSlug: slug,
1974
+ workspacePath,
1975
+ ...(target.model !== undefined ? { model: target.model } : {}),
1976
+ ...(target.args !== undefined ? { args: target.args } : {}),
1977
+ env: await this.sessionEnvFor(spec.abPhase, session),
1915
1978
  signal,
1916
1979
  ...(stream !== undefined ? { stream } : {}),
1917
- })
1918
- .then((continued) => ({ session: live.handle, result: continued }))
1919
- : target.runner.start({
1920
- skill: spec.skill,
1921
- invocation: slug,
1922
- buildSlug: slug,
1923
- workspacePath,
1924
- ...(target.model !== undefined ? { model: target.model } : {}),
1925
- ...(target.args !== undefined ? { args: target.args } : {}),
1926
- env: this.sessionEnvFor(spec.abPhase, session),
1927
- signal,
1928
- ...(stream !== undefined ? { stream } : {}),
1929
- }),
1980
+ }),
1930
1981
  )
1931
1982
  if (turn.settlement.kind === 'value') {
1932
1983
  handle = turn.settlement.value.session
@@ -2192,7 +2243,7 @@ export class BuildRunner {
2192
2243
  let handle: AgentSessionHandle | undefined = live?.handle
2193
2244
  let result: AgentTurnResult | undefined
2194
2245
  let turnError: unknown
2195
- const turn = await this.runBudgetedTurn(preSeq, sessionBudgetSeconds, (signal) => {
2246
+ const turn = await this.runBudgetedTurn(preSeq, sessionBudgetSeconds, async (signal) => {
2196
2247
  if (live !== undefined) {
2197
2248
  // §10: the producer continues its session; the feedback message
2198
2249
  // points at the .ab/ inputs — `ab context` is the real carrier. The
@@ -2202,7 +2253,7 @@ export class BuildRunner {
2202
2253
  // 1's second terminal (§8.4 D5).
2203
2254
  return live.runner
2204
2255
  .continue(live.handle, continueMessage(spec), {
2205
- env: this.sessionEnvFor(spec.abPhase, session),
2256
+ env: await this.sessionEnvFor(spec.abPhase, session),
2206
2257
  signal,
2207
2258
  ...(stream !== undefined ? { stream } : {}),
2208
2259
  })
@@ -2215,7 +2266,7 @@ export class BuildRunner {
2215
2266
  workspacePath,
2216
2267
  ...(model !== undefined ? { model } : {}),
2217
2268
  ...(args !== undefined ? { args } : {}),
2218
- env: this.sessionEnvFor(spec.abPhase, session),
2269
+ env: await this.sessionEnvFor(spec.abPhase, session),
2219
2270
  signal,
2220
2271
  ...(stream !== undefined ? { stream } : {}),
2221
2272
  })
@@ -2560,10 +2611,20 @@ export class BuildRunner {
2560
2611
 
2561
2612
  /** D8 ambient auth: sessionEnv may override AB_STORE (remote stores) and
2562
2613
  * add AB_TOKEN, but never the per-session identity keys. */
2563
- private sessionEnvFor(abPhase: string, session: string): Record<string, string> {
2614
+ private async sessionEnvFor(abPhase: string, session: string): Promise<Record<string, string>> {
2615
+ const parent = this.deps.sessionEnv
2616
+ const token =
2617
+ parent?.AB_STORE && /^https?:/i.test(parent.AB_STORE)
2618
+ ? await (this.deps.delegateCredential ?? delegateCredential)(
2619
+ parent.AB_STORE,
2620
+ parent.AB_TOKEN,
2621
+ { kind: 'build-phase', id: this.deps.slug, session },
2622
+ )
2623
+ : undefined
2564
2624
  return {
2565
2625
  AB_STORE: 'local',
2566
2626
  ...this.deps.sessionEnv,
2627
+ ...(token ? { AB_TOKEN: token } : {}),
2567
2628
  AB_BUILD: this.deps.slug,
2568
2629
  AB_PHASE: abPhase,
2569
2630
  AB_SESSION: session,
@@ -0,0 +1,83 @@
1
+ import type { AbEvent } from '../events/catalog'
2
+ import { DISPATCHER } from '../events/envelope'
3
+ import type { IdSource } from '../ids'
4
+ import { defineReducer } from '../kernel/incremental'
5
+ import type { BuildStore } from '../store/types'
6
+
7
+ interface DispatchBudgetAccumulator {
8
+ failures: Extract<AbEvent, { type: 'dispatch.failed' }>[]
9
+ open: string[]
10
+ aborted: boolean
11
+ }
12
+
13
+ export function dispatchFailureBudgetReducer(infrastructureOwnsWorkspace: boolean) {
14
+ return defineReducer({
15
+ version: 1,
16
+ initial: (): DispatchBudgetAccumulator => ({ failures: [], open: [], aborted: false }),
17
+ fold(acc, events: readonly AbEvent[]) {
18
+ for (const event of events) {
19
+ if (event.type === 'runner.attached') {
20
+ acc.failures = []
21
+ } else if (
22
+ event.type === 'escalation.raised' &&
23
+ event.payload.source === 'policy' &&
24
+ event.payload.policyCause === 'dispatch-failure-limit'
25
+ ) {
26
+ if (!acc.open.includes(event.payload.id)) acc.open.push(event.payload.id)
27
+ } else if (event.type === 'escalation.answered' && acc.open.includes(event.payload.id)) {
28
+ acc.open = acc.open.filter((id) => id !== event.payload.id)
29
+ if (event.payload.resolution !== 'abort') acc.failures = []
30
+ else acc.aborted = true
31
+ } else if (
32
+ event.type === 'dispatch.failed' &&
33
+ !(infrastructureOwnsWorkspace && event.payload.stage === 'workspace')
34
+ ) {
35
+ acc.failures.push(event)
36
+ }
37
+ }
38
+ },
39
+ finish: (acc) => ({
40
+ count: acc.failures.length,
41
+ latest: acc.failures.at(-1),
42
+ parked: acc.aborted || acc.open.length > 0,
43
+ }),
44
+ })
45
+ }
46
+
47
+ /** Lifetime diagnostic attempt numbers are deliberately independent of this epoch. */
48
+ export function dispatchFailureBudget(
49
+ events: readonly AbEvent[],
50
+ infrastructureOwnsWorkspace: boolean,
51
+ ) {
52
+ return dispatchFailureBudgetReducer(infrastructureOwnsWorkspace).reduce(events)
53
+ }
54
+
55
+ /** Repair the failure/raise crash gap before allowing another provider call. */
56
+ export async function ensureDispatchBudget(
57
+ store: Pick<BuildStore, 'getEvents' | 'appendIfCurrent'>,
58
+ ids: IdSource,
59
+ slug: string,
60
+ maxAttempts: number,
61
+ infrastructureOwnsWorkspace: boolean,
62
+ ): Promise<boolean> {
63
+ for (;;) {
64
+ const events = await store.getEvents(slug)
65
+ const budget = dispatchFailureBudget(events, infrastructureOwnsWorkspace)
66
+ if (budget.parked) return false
67
+ if (budget.count < maxAttempts || budget.latest === undefined) return true
68
+ const failure = budget.latest.payload
69
+ const raised = await store.appendIfCurrent(slug, events.at(-1)?.seq ?? 0, {
70
+ actor: DISPATCHER,
71
+ type: 'escalation.raised',
72
+ payload: {
73
+ id: ids('esc'),
74
+ phase: 'setup',
75
+ source: 'policy',
76
+ policyCause: 'dispatch-failure-limit',
77
+ question: `maxDispatchAttempts (${maxAttempts}) exhausted during ${failure.stage}: ${failure.error}`,
78
+ refs: [],
79
+ },
80
+ })
81
+ if (raised !== null) return false
82
+ }
83
+ }