@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -48,8 +48,10 @@ import type { Config } from '../config/schema'
48
48
  import type { AbEvent, EventWrite } from '../events/catalog'
49
49
  import type { EventPayload } from '../events/payloads'
50
50
  import { KERNEL, agentActor, type Actor } from '../events/envelope'
51
+ import { isRemoteWorkspace } from '../events/workspace-remote'
51
52
  import type { IdSource } from '../ids'
52
53
  import { decideNext, type Decision } from '../kernel/engine'
54
+ import { defineReducer } from '../kernel/incremental'
53
55
  import { PHASE_SPECS } from '../kernel/phases'
54
56
  import { reduceBuild, type BuildState } from '../kernel/reducer'
55
57
  import { evaluateVerifyApplicability } from '../kernel/verify-applicability'
@@ -240,6 +242,12 @@ interface SessionSpec {
240
242
  /** Longest tail of check output preserved in a verify report (§8.2). */
241
243
  const REPORT_TAIL_CHARS = 10_000
242
244
 
245
+ /** Per-section cap for the finalize preflight's dirty-worktree diff capture:
246
+ * each diff view (working tree, staged) is truncated at this many characters
247
+ * so a large dirty diff cannot bloat the failure record unboundedly — worst
248
+ * case ≈ porcelain + 2 × (cap + header). */
249
+ const FINALIZE_DIFF_CAPTURE_CAP = 20_000
250
+
243
251
  /**
244
252
  * One session bracket's live stream (SPEC §9): the sink the build-runner
245
253
  * opened through the resolved target's streaming capability, the store id it
@@ -260,6 +268,14 @@ function errorMessage(error: unknown): string {
260
268
  return error instanceof Error ? error.message : String(error)
261
269
  }
262
270
 
271
+ /** Bound one captured diff section: unchanged within the cap, otherwise the
272
+ * first FINALIZE_DIFF_CAPTURE_CAP characters plus an explicit truncation
273
+ * marker, so truncation is visible in the record itself. */
274
+ function capDiffOutput(output: string): string {
275
+ if (output.length <= FINALIZE_DIFF_CAPTURE_CAP) return output
276
+ return `${output.slice(0, FINALIZE_DIFF_CAPTURE_CAP)}\n... (truncated at ${FINALIZE_DIFF_CAPTURE_CAP} characters)`
277
+ }
278
+
263
279
  function sessionBudgetError(seconds: number): string {
264
280
  return `phase session budget expired after ${seconds} seconds`
265
281
  }
@@ -299,22 +315,155 @@ function providerAttemptSummary(attempts: readonly ProviderAttempt[] | undefined
299
315
  )
300
316
  }
301
317
 
318
+ export interface PhaseFailureTally {
319
+ count: number
320
+ lastError?: string
321
+ lastWillRetry?: boolean
322
+ lastProviderAttempts?: readonly ProviderAttempt[]
323
+ }
324
+
325
+ export const PHASE_FAILURES_REDUCER_VERSION = 1
326
+
327
+ /**
328
+ * phase.failed tally for one phase+round — D5's retry-guard input. A factory
329
+ * because phase and round are parameters of the projection.
330
+ *
331
+ * An answered escalation re-arms this budget only when its semantic class
332
+ * acknowledges phase-runner failures (§15.6-B). Runner-failure causes reset
333
+ * their matching round, the phase-level verify cause resets all rounds, and
334
+ * unrelated policy causes reset nothing. Cause-less historical and
335
+ * non-policy raises retain the legacy round-shaped behavior. Failures after
336
+ * an applicable answer count again, so a still-failing phase re-escalates on
337
+ * new evidence instead of deadlocking before its recovery session starts.
338
+ */
339
+ export function phaseFailuresReducer(phase: Phase, round: number) {
340
+ return defineReducer<
341
+ {
342
+ /** id → the raise's reset semantics — answers are matched by id (§15.3). */
343
+ raised: Record<string, PhaseFailureResetEscalation>
344
+ count: number
345
+ lastError?: string
346
+ lastWillRetry?: boolean
347
+ lastProviderAttempts?: readonly ProviderAttempt[]
348
+ },
349
+ AbEvent,
350
+ PhaseFailureTally
351
+ >({
352
+ version: PHASE_FAILURES_REDUCER_VERSION,
353
+ initial: () => ({ raised: {}, count: 0 }),
354
+ fold(acc, events) {
355
+ for (const event of events) {
356
+ switch (event.type) {
357
+ case 'escalation.raised':
358
+ acc.raised[event.payload.id] = {
359
+ phase: event.payload.phase,
360
+ source: event.payload.source,
361
+ ...(event.payload.policyCause !== undefined
362
+ ? { policyCause: event.payload.policyCause }
363
+ : {}),
364
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
365
+ }
366
+ break
367
+ case 'escalation.answered': {
368
+ const raise = acc.raised[event.payload.id]
369
+ if (raise !== undefined && resetsPhaseFailureBudget(raise, phase, round)) {
370
+ acc.count = 0
371
+ delete acc.lastError
372
+ delete acc.lastWillRetry
373
+ delete acc.lastProviderAttempts
374
+ }
375
+ break
376
+ }
377
+ case 'phase.failed':
378
+ if (event.payload.phase === phase && event.payload.round === round) {
379
+ acc.count += 1
380
+ acc.lastError = event.payload.error
381
+ acc.lastWillRetry = event.payload.willRetry
382
+ acc.lastProviderAttempts = event.payload.providerAttempts
383
+ }
384
+ break
385
+ default:
386
+ break
387
+ }
388
+ }
389
+ },
390
+ finish: (acc) => ({
391
+ count: acc.count,
392
+ ...(acc.lastError !== undefined ? { lastError: acc.lastError } : {}),
393
+ ...(acc.lastWillRetry !== undefined ? { lastWillRetry: acc.lastWillRetry } : {}),
394
+ ...(acc.lastProviderAttempts !== undefined
395
+ ? { lastProviderAttempts: acc.lastProviderAttempts }
396
+ : {}),
397
+ }),
398
+ })
399
+ }
400
+
401
+ export const SETUP_STREAK_REDUCER_VERSION = 1
402
+
403
+ /** Consecutive durable setup failures since the latest successful attachment
404
+ * or explicit human answer. The accumulator keeps the boundary seq and the
405
+ * failure seqs, so it is exact for any array order. */
406
+ export const setupStreakReducer = defineReducer<
407
+ { escalations: string[]; boundary: number; failures: number[] },
408
+ AbEvent,
409
+ number
410
+ >({
411
+ version: SETUP_STREAK_REDUCER_VERSION,
412
+ initial: () => ({ escalations: [], boundary: 0, failures: [] }),
413
+ fold(acc, events) {
414
+ for (const event of events) {
415
+ if (event.type === 'runner.attached') acc.boundary = event.seq
416
+ else if (
417
+ event.type === 'escalation.raised' &&
418
+ event.payload.phase === 'setup' &&
419
+ event.payload.source === 'policy'
420
+ ) {
421
+ if (!acc.escalations.includes(event.payload.id)) acc.escalations.push(event.payload.id)
422
+ } else if (
423
+ event.type === 'escalation.answered' &&
424
+ acc.escalations.includes(event.payload.id)
425
+ ) {
426
+ acc.boundary = event.seq
427
+ } else if (event.type === 'runner.setup-failed') {
428
+ acc.failures.push(event.seq)
429
+ }
430
+ }
431
+ },
432
+ finish: (acc) => acc.failures.filter((seq) => seq > acc.boundary).length,
433
+ })
434
+
435
+ export const PUBLISHED_BRANCH_HEAD_REDUCER_VERSION = 1
436
+
437
+ /** Latest event-checkpointed branch head that kernel plumbing published;
438
+ * undefined while the build has none. */
439
+ export const publishedBranchHeadReducer = defineReducer<
440
+ { head?: string },
441
+ AbEvent,
442
+ string | undefined
443
+ >({
444
+ version: PUBLISHED_BRANCH_HEAD_REDUCER_VERSION,
445
+ initial: () => ({}),
446
+ fold(acc, events) {
447
+ for (const event of events) {
448
+ if (event.type === 'implement.completed') {
449
+ acc.head = event.payload.commits.head
450
+ } else if (event.type === 'reconcile.completed') {
451
+ acc.head = event.payload.mergeCommit
452
+ } else if (
453
+ event.type === 'finalize.step-completed' &&
454
+ event.payload.ok &&
455
+ event.payload.headSha !== undefined
456
+ ) {
457
+ acc.head = event.payload.headSha
458
+ }
459
+ }
460
+ },
461
+ finish: (acc) => acc.head,
462
+ })
463
+
302
464
  /** Latest event-checkpointed branch head that kernel plumbing published. */
303
465
  export function selectPublishedBranchHead(events: readonly AbEvent[]): string {
304
- let head: string | undefined
305
- for (const event of events) {
306
- if (event.type === 'implement.completed') {
307
- head = event.payload.commits.head
308
- } else if (event.type === 'reconcile.completed') {
309
- head = event.payload.mergeCommit
310
- } else if (
311
- event.type === 'finalize.step-completed' &&
312
- event.payload.ok &&
313
- event.payload.headSha !== undefined
314
- ) {
315
- head = event.payload.headSha
316
- }
317
- }
466
+ const head = publishedBranchHeadReducer.reduce(events)
318
467
  if (head === undefined) {
319
468
  throw new Error(
320
469
  'finalize publication requires a prior implement.completed, reconcile.completed, ' +
@@ -324,6 +473,37 @@ export function selectPublishedBranchHead(events: readonly AbEvent[]): string {
324
473
  return head
325
474
  }
326
475
 
476
+ export const VERIFY_DIFF_BASE_REDUCER_VERSION = 1
477
+
478
+ /**
479
+ * The durable tree against which a conditional verifier evaluates the
480
+ * branch's current HEAD, or undefined until a `workspace.provisioned` exists.
481
+ * The accumulator keeps the first provisioned base and the latest promoted
482
+ * reconcile base separately, so the result is exact for any array order (a
483
+ * promotion that precedes the provisioned fact in the array still wins).
484
+ */
485
+ export const verifyDiffBaseReducer = defineReducer<
486
+ { provisioned?: string; promoted?: string; pendingReconcileBase?: string },
487
+ AbEvent,
488
+ string | undefined
489
+ >({
490
+ version: VERIFY_DIFF_BASE_REDUCER_VERSION,
491
+ initial: () => ({}),
492
+ fold(acc, events) {
493
+ for (const event of events) {
494
+ if (event.type === 'workspace.provisioned') {
495
+ if (acc.provisioned === undefined) acc.provisioned = event.payload.base.sha
496
+ } else if (event.type === 'reconcile.started') {
497
+ acc.pendingReconcileBase = event.payload.baseSha
498
+ } else if (event.type === 'reconcile.completed') {
499
+ if (acc.pendingReconcileBase !== undefined) acc.promoted = acc.pendingReconcileBase
500
+ delete acc.pendingReconcileBase
501
+ }
502
+ }
503
+ },
504
+ finish: (acc) => (acc.provisioned === undefined ? undefined : (acc.promoted ?? acc.provisioned)),
505
+ })
506
+
327
507
  /**
328
508
  * Select the durable tree against which a conditional verifier evaluates the
329
509
  * branch's current HEAD. The initial branch-cut SHA is authoritative until a
@@ -331,24 +511,13 @@ export function selectPublishedBranchHead(events: readonly AbEvent[]): string {
331
511
  * refreshed base SHA. A dangling start is intentionally ignored.
332
512
  */
333
513
  export function selectVerifyDiffBase(events: readonly AbEvent[]): string {
334
- const provisioned = events.find((event) => event.type === 'workspace.provisioned')
335
- if (provisioned === undefined || provisioned.type !== 'workspace.provisioned') {
514
+ const base = verifyDiffBaseReducer.reduce(events)
515
+ if (base === undefined) {
336
516
  throw new Error(
337
517
  'conditional verify requires a workspace.provisioned base SHA, but this build has none',
338
518
  )
339
519
  }
340
-
341
- let baseSha = provisioned.payload.base.sha
342
- let pendingReconcileBase: string | undefined
343
- for (const event of events) {
344
- if (event.type === 'reconcile.started') {
345
- pendingReconcileBase = event.payload.baseSha
346
- } else if (event.type === 'reconcile.completed') {
347
- if (pendingReconcileBase !== undefined) baseSha = pendingReconcileBase
348
- pendingReconcileBase = undefined
349
- }
350
- }
351
- return baseSha
520
+ return base
352
521
  }
353
522
 
354
523
  /** Parse `git diff --name-only -z`; malformed output fails closed. */
@@ -468,22 +637,7 @@ export class BuildRunner {
468
637
  * or explicit human answer. The answer re-arms the budget but deliberately
469
638
  * does not erase the current diagnostic while recovery is still running. */
470
639
  private setupStreak(events: readonly AbEvent[]): number {
471
- const setupEscalations = new Set<string>()
472
- let boundary = 0
473
- for (const event of events) {
474
- if (event.type === 'runner.attached') boundary = event.seq
475
- else if (
476
- event.type === 'escalation.raised' &&
477
- event.payload.phase === 'setup' &&
478
- event.payload.source === 'policy'
479
- ) {
480
- setupEscalations.add(event.payload.id)
481
- } else if (event.type === 'escalation.answered' && setupEscalations.has(event.payload.id)) {
482
- boundary = event.seq
483
- }
484
- }
485
- return events.filter((event) => event.type === 'runner.setup-failed' && event.seq > boundary)
486
- .length
640
+ return setupStreakReducer.reduce(events)
487
641
  }
488
642
 
489
643
  private setupQuestion(failure: EventPayload<'runner.setup-failed'>): string {
@@ -1137,8 +1291,7 @@ export class BuildRunner {
1137
1291
  )
1138
1292
  if (scratchPaths.length > 0) throw phaseScratchRejection(scratchPaths)
1139
1293
  const remote = events.some(
1140
- (event) =>
1141
- event.type === 'workspace.provisioned' && event.payload.provider === 'vercel-sandbox',
1294
+ (event) => event.type === 'workspace.provisioned' && isRemoteWorkspace(event.payload),
1142
1295
  )
1143
1296
  if (remote) {
1144
1297
  await this.deps.store.append(this.deps.slug, {
@@ -1490,13 +1643,50 @@ export class BuildRunner {
1490
1643
  )
1491
1644
  }
1492
1645
  if (result.stdout.length > 0) {
1646
+ // The failure record must characterize the dirtiness on its own: the
1647
+ // build workspace is disposable, and porcelain lists paths and status
1648
+ // codes only — a mode-only flip (AUT-439) is indistinguishable from a
1649
+ // content modification once the workspace is gone. Append both diff
1650
+ // views so the record shows what actually changed.
1493
1651
  throw new Error(
1494
1652
  `workspace must be clean ${when}; commit intended files and remove ` +
1495
- `temporary files before finishing:\n${result.stdout.trimEnd()}`,
1653
+ `temporary files before finishing:\n${result.stdout.trimEnd()}\n\n` +
1654
+ (await this.collectWorktreeDiffSections()),
1496
1655
  )
1497
1656
  }
1498
1657
  }
1499
1658
 
1659
+ /** Labeled working-tree and staged diff sections for a dirty finalize
1660
+ * preflight record. Runs only on the dirty branch (a clean worktree or a
1661
+ * failed status command never executes a diff), caps each section at
1662
+ * FINALIZE_DIFF_CAPTURE_CAP characters, and is best-effort: a failing `git
1663
+ * diff` command annotates its own section instead of masking the
1664
+ * dirty-worktree failure it is annotating. */
1665
+ private async collectWorktreeDiffSections(): Promise<string> {
1666
+ const views = [
1667
+ { args: ['git', 'diff'], header: '--- git diff (working tree) ---' },
1668
+ { args: ['git', 'diff', '--cached'], header: '--- git diff --cached (staged) ---' },
1669
+ ] as const
1670
+ const sections: string[] = []
1671
+ for (const { args, header } of views) {
1672
+ const command = args.join(' ')
1673
+ let body: string
1674
+ try {
1675
+ const result = await this.deps.exec([...args], { cwd: this.deps.workspacePath })
1676
+ if (result.exitCode !== 0) {
1677
+ const detail = result.stderr.trim() || result.stdout.trim() || '(no output)'
1678
+ body = `(${command} failed: exit ${result.exitCode}: ${capDiffOutput(detail)})`
1679
+ } else {
1680
+ body = capDiffOutput(result.stdout) || '(no output)'
1681
+ }
1682
+ } catch (error) {
1683
+ body = `(${command} failed: ${capDiffOutput(errorMessage(error))})`
1684
+ }
1685
+ sections.push(`${header}\n${body}`)
1686
+ }
1687
+ return sections.join('\n\n')
1688
+ }
1689
+
1500
1690
  private async resolveFinalizeHead(): Promise<string> {
1501
1691
  const args = ['git', 'rev-parse', 'HEAD']
1502
1692
  const result = await this.deps.exec(args, { cwd: this.deps.workspacePath })
@@ -2401,52 +2591,7 @@ export class BuildRunner {
2401
2591
  lastWillRetry?: boolean
2402
2592
  lastProviderAttempts?: readonly ProviderAttempt[]
2403
2593
  } {
2404
- /** id → the raise's reset semantics — answers are matched by id (§15.3). */
2405
- const raised = new Map<string, PhaseFailureResetEscalation>()
2406
- let count = 0
2407
- let lastError: string | undefined
2408
- let lastWillRetry: boolean | undefined
2409
- let lastProviderAttempts: readonly ProviderAttempt[] | undefined
2410
- for (const event of events) {
2411
- switch (event.type) {
2412
- case 'escalation.raised':
2413
- raised.set(event.payload.id, {
2414
- phase: event.payload.phase,
2415
- source: event.payload.source,
2416
- ...(event.payload.policyCause !== undefined
2417
- ? { policyCause: event.payload.policyCause }
2418
- : {}),
2419
- ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
2420
- })
2421
- break
2422
- case 'escalation.answered': {
2423
- const raise = raised.get(event.payload.id)
2424
- if (raise !== undefined && resetsPhaseFailureBudget(raise, phase, round)) {
2425
- count = 0
2426
- lastError = undefined
2427
- lastWillRetry = undefined
2428
- lastProviderAttempts = undefined
2429
- }
2430
- break
2431
- }
2432
- case 'phase.failed':
2433
- if (event.payload.phase === phase && event.payload.round === round) {
2434
- count += 1
2435
- lastError = event.payload.error
2436
- lastWillRetry = event.payload.willRetry
2437
- lastProviderAttempts = event.payload.providerAttempts
2438
- }
2439
- break
2440
- default:
2441
- break
2442
- }
2443
- }
2444
- return {
2445
- count,
2446
- ...(lastError !== undefined ? { lastError } : {}),
2447
- ...(lastWillRetry !== undefined ? { lastWillRetry } : {}),
2448
- ...(lastProviderAttempts !== undefined ? { lastProviderAttempts } : {}),
2449
- }
2594
+ return phaseFailuresReducer(phase, round).reduce(events)
2450
2595
  }
2451
2596
 
2452
2597
  private async failPhase(
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Pure event-log projections the dispatcher and janitor read, extracted from
3
+ * `dispatcher.ts` so each is an incremental reducer (see
4
+ * `kernel/incremental.ts`). The dispatcher's private helpers delegate here.
5
+ */
6
+ import type { AbEvent } from '../events/catalog'
7
+ import type { EventPayload } from '../events/payloads'
8
+ import { defineReducer } from '../kernel/incremental'
9
+
10
+ export type OpenBuildWorkspace = EventPayload<'workspace.provisioned'>
11
+
12
+ export const OPEN_BUILD_WORKSPACE_REDUCER_VERSION = 1
13
+
14
+ /** Latest `workspace.provisioned` not followed by a `workspace.released` —
15
+ * the reducer deliberately ignores workspace events (liveness is the
16
+ * dispatcher's concern), so the janitor scans the raw log. */
17
+ export const openBuildWorkspaceReducer = defineReducer<
18
+ { open: OpenBuildWorkspace | null },
19
+ AbEvent,
20
+ OpenBuildWorkspace | null
21
+ >({
22
+ version: OPEN_BUILD_WORKSPACE_REDUCER_VERSION,
23
+ initial: () => ({ open: null }),
24
+ fold(acc, events) {
25
+ for (const event of events) {
26
+ if (event.type === 'workspace.provisioned') acc.open = event.payload
27
+ else if (event.type === 'workspace.released') acc.open = null
28
+ }
29
+ },
30
+ finish: (acc) => (acc.open === null ? null : { ...acc.open }),
31
+ })
32
+
33
+ export function openBuildWorkspace(events: readonly AbEvent[]): OpenBuildWorkspace | null {
34
+ return openBuildWorkspaceReducer.reduce(events)
35
+ }
36
+
37
+ export const BASE_BRANCH_REDUCER_VERSION = 1
38
+
39
+ /** The base branch recorded on the FIRST `build.created` in array order, or
40
+ * undefined for logs missing one (callers fall back to the repo config). */
41
+ export const baseBranchReducer = defineReducer<
42
+ { baseBranch?: string },
43
+ AbEvent,
44
+ string | undefined
45
+ >({
46
+ version: BASE_BRANCH_REDUCER_VERSION,
47
+ initial: () => ({}),
48
+ fold(acc, events) {
49
+ if (acc.baseBranch !== undefined) return
50
+ for (const event of events) {
51
+ if (event.type === 'build.created') {
52
+ acc.baseBranch = event.payload.baseBranch
53
+ return
54
+ }
55
+ }
56
+ },
57
+ finish: (acc) => acc.baseBranch,
58
+ })
59
+
60
+ export interface ProvisionMarker {
61
+ provider: string
62
+ branch: string
63
+ generation: number
64
+ ts: string
65
+ seq: number
66
+ }
67
+
68
+ export const PROVISION_MARKER_REDUCER_VERSION = 1
69
+
70
+ /** The latest `workspace.provision-started` not yet followed by a
71
+ * provisioned/released fact — an open provisioning marker. */
72
+ export const provisionMarkerReducer = defineReducer<
73
+ { marker: ProvisionMarker | null },
74
+ AbEvent,
75
+ ProvisionMarker | undefined
76
+ >({
77
+ version: PROVISION_MARKER_REDUCER_VERSION,
78
+ initial: () => ({ marker: null }),
79
+ fold(acc, events) {
80
+ for (const event of events) {
81
+ if (event.type === 'workspace.provision-started') {
82
+ acc.marker = { ...event.payload, ts: event.ts, seq: event.seq }
83
+ } else if (
84
+ acc.marker !== null &&
85
+ (event.type === 'workspace.provisioned' || event.type === 'workspace.released')
86
+ ) {
87
+ acc.marker = null
88
+ }
89
+ }
90
+ },
91
+ finish: (acc) => (acc.marker === null ? undefined : { ...acc.marker }),
92
+ })
93
+
94
+ export const RECOVERY_CHECKPOINT_REDUCER_VERSION = 1
95
+
96
+ /** The commit a recovered workspace is re-cut from: the latest settled
97
+ * publication head, else the first provisioned base. */
98
+ export const recoveryCheckpointReducer = defineReducer<
99
+ { settled?: string; original?: string },
100
+ AbEvent,
101
+ string | undefined
102
+ >({
103
+ version: RECOVERY_CHECKPOINT_REDUCER_VERSION,
104
+ initial: () => ({}),
105
+ fold(acc, events) {
106
+ for (const event of events) {
107
+ if (event.type === 'workspace.provisioned' && acc.original === undefined) {
108
+ acc.original = event.payload.base.sha
109
+ } else if (event.type === 'implement.completed') {
110
+ acc.settled = event.payload.commits.head
111
+ } else if (event.type === 'reconcile.completed') {
112
+ acc.settled = event.payload.mergeCommit
113
+ } else if (
114
+ event.type === 'finalize.step-completed' &&
115
+ event.payload.ok &&
116
+ event.payload.headSha !== undefined
117
+ ) {
118
+ acc.settled = event.payload.headSha
119
+ }
120
+ }
121
+ },
122
+ finish: (acc) => acc.settled ?? acc.original,
123
+ })