@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,146 @@
1
+ import type { AbEvent } from '../events/catalog'
2
+ import { DISPATCHER } from '../events/envelope'
3
+ import type { EventPayload } from '../events/payloads'
4
+ import type { IdSource } from '../ids'
5
+ import type { BuildStore } from '../store/types'
6
+
7
+ export type InfrastructureOperation = EventPayload<'infrastructure.failed'>['operation']
8
+ export type InfrastructureCause = EventPayload<'infrastructure.failed'>['cause']
9
+
10
+ export interface InfrastructureFailureInput {
11
+ slug: string
12
+ events: readonly AbEvent[]
13
+ maxAttempts: number
14
+ provider: string
15
+ workspaceRef: string
16
+ instance: string
17
+ environmentId?: string
18
+ sessionId?: string
19
+ operation: InfrastructureOperation
20
+ error: unknown
21
+ cleanupPending: boolean
22
+ }
23
+
24
+ export interface InfrastructureFailureDependencies {
25
+ store: Pick<BuildStore, 'append' | 'getEvents'>
26
+ ids: IdSource
27
+ }
28
+
29
+ /** Infrastructure retries are re-armed only by a confirmed execution boundary
30
+ * or a retry answer to this policy's own previously-raised exhaustion escalation. */
31
+ export function infrastructureFailureResetSeq(events: readonly AbEvent[]): number {
32
+ const infrastructureEscalations = new Set<string>()
33
+ let resetSeq = 0
34
+ for (const event of events) {
35
+ if (
36
+ event.type === 'escalation.raised' &&
37
+ event.payload.policyCause === 'infrastructure-failure-limit'
38
+ ) {
39
+ infrastructureEscalations.add(event.payload.id)
40
+ } else if (
41
+ event.type === 'execution.ended' ||
42
+ (event.type === 'escalation.answered' &&
43
+ event.payload.resolution === 'retry' &&
44
+ infrastructureEscalations.has(event.payload.id))
45
+ ) {
46
+ resetSeq = event.seq
47
+ }
48
+ }
49
+ return resetSeq
50
+ }
51
+
52
+ export function normalizeInfrastructureError(error: unknown): string {
53
+ const seen = new Set<unknown>()
54
+ const collect = (value: unknown): string[] => {
55
+ if ((typeof value === 'object' && value !== null) || typeof value === 'function') {
56
+ if (seen.has(value)) return []
57
+ seen.add(value)
58
+ }
59
+ if (value instanceof AggregateError) {
60
+ return [value.message, ...value.errors.flatMap(collect), ...collect(value.cause)].filter(
61
+ (part) => part.trim().length > 0,
62
+ )
63
+ }
64
+ if (value instanceof Error) {
65
+ return [value.message, ...collect(value.cause)].filter((part) => part.trim().length > 0)
66
+ }
67
+ if (value === undefined) return []
68
+ const text = String(value).trim()
69
+ return text === '' ? [] : [text]
70
+ }
71
+ const messages = [...new Set(collect(error).map((part) => part.trim()))]
72
+ return messages.join('; ') || 'provider operation failed without an error message'
73
+ }
74
+
75
+ /** One deterministic provider-failure taxonomy shared by every dispatch path. */
76
+ export function classifyInfrastructureFailure(
77
+ message: string,
78
+ cleanupPending: boolean,
79
+ ): InfrastructureCause {
80
+ const lower = message.toLowerCase()
81
+ if (/limit|quota|cpu|duration/.test(lower)) return 'provider-limit'
82
+ if (/timeout|abort/.test(lower)) return 'timeout'
83
+ if (/no longer exists|not found|missing/.test(lower)) return 'missing'
84
+ return cleanupPending ? 'unknown-outcome' : 'provider-error'
85
+ }
86
+
87
+ /** Append one durable infrastructure failure and, when its epoch is exhausted,
88
+ * the epoch's single policy escalation. Returned events let snapshot-based
89
+ * callers immediately include these writes in the rest of their tick. */
90
+ export async function recordInfrastructureFailure(
91
+ deps: InfrastructureFailureDependencies,
92
+ input: InfrastructureFailureInput,
93
+ ): Promise<AbEvent[]> {
94
+ const lastReset = infrastructureFailureResetSeq(input.events)
95
+ const attempt =
96
+ input.events.filter((event) => event.type === 'infrastructure.failed' && event.seq > lastReset)
97
+ .length + 1
98
+ const message = normalizeInfrastructureError(input.error)
99
+ const failure = await deps.store.append(input.slug, {
100
+ actor: DISPATCHER,
101
+ type: 'infrastructure.failed',
102
+ payload: {
103
+ provider: input.provider,
104
+ workspaceRef: input.workspaceRef,
105
+ instance: input.instance,
106
+ ...(input.environmentId !== undefined ? { environmentId: input.environmentId } : {}),
107
+ ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
108
+ operation: input.operation,
109
+ cause: classifyInfrastructureFailure(message, input.cleanupPending),
110
+ attempt,
111
+ retryable: true,
112
+ cleanupPending: input.cleanupPending,
113
+ error: message,
114
+ },
115
+ })
116
+ const appended: AbEvent[] = [failure]
117
+
118
+ if (attempt < input.maxAttempts) return appended
119
+
120
+ const currentEvents = await deps.store.getEvents(input.slug)
121
+ if (
122
+ !currentEvents.some(
123
+ (event) =>
124
+ event.seq > lastReset &&
125
+ event.type === 'escalation.raised' &&
126
+ event.payload.policyCause === 'infrastructure-failure-limit',
127
+ )
128
+ ) {
129
+ appended.push(
130
+ await deps.store.append(input.slug, {
131
+ actor: DISPATCHER,
132
+ type: 'escalation.raised',
133
+ payload: {
134
+ id: deps.ids('esc'),
135
+ phase: 'setup',
136
+ source: 'policy',
137
+ policyCause: 'infrastructure-failure-limit',
138
+ question: `maxInfrastructureAttempts (${input.maxAttempts}) exhausted during ${input.operation}; provider cleanup/recovery must succeed before retry: ${message}`,
139
+ refs: [input.workspaceRef],
140
+ },
141
+ }),
142
+ )
143
+ }
144
+
145
+ return appended
146
+ }
@@ -0,0 +1,52 @@
1
+ import type { EscalationSource, EscalationTarget, Phase, PolicyEscalationCause } from '../ontology'
2
+
3
+ export type PhaseFailureBudgetResetScope = 'none' | 'matching-round' | 'all-phase-rounds'
4
+
5
+ /**
6
+ * Closed policy semantics for answering a phase-failure budget escalation.
7
+ * Adding a policy cause must choose its effect here rather than inheriting one
8
+ * from the incidental presence or absence of `round`.
9
+ */
10
+ export const POLICY_PHASE_FAILURE_RESET_SCOPES = {
11
+ 'review-round-limit': 'matching-round',
12
+ 'verify-failure-limit': 'all-phase-rounds',
13
+ 'reconcile-no-progress': 'none',
14
+ 'setup-failure-limit': 'none',
15
+ 'infrastructure-failure-limit': 'none',
16
+ 'phase-attempt-limit': 'matching-round',
17
+ 'non-retryable-phase-failure': 'matching-round',
18
+ } as const satisfies Record<PolicyEscalationCause, PhaseFailureBudgetResetScope>
19
+
20
+ export interface PhaseFailureResetEscalation {
21
+ phase: EscalationTarget
22
+ round?: number
23
+ source: EscalationSource
24
+ policyCause?: PolicyEscalationCause
25
+ }
26
+
27
+ /**
28
+ * Whether answering one raise re-arms the queried phase+round failure budget.
29
+ * Cause-less historical policy raises and non-policy raises retain the legacy
30
+ * shape rule so old logs replay without migration.
31
+ */
32
+ export function resetsPhaseFailureBudget(
33
+ raise: PhaseFailureResetEscalation,
34
+ phase: Phase,
35
+ round: number,
36
+ ): boolean {
37
+ if (raise.phase !== phase) return false
38
+
39
+ if (raise.source !== 'policy' || raise.policyCause === undefined) {
40
+ return raise.round === undefined || raise.round === round
41
+ }
42
+
43
+ const scope = POLICY_PHASE_FAILURE_RESET_SCOPES[raise.policyCause]
44
+ switch (scope) {
45
+ case 'none':
46
+ return false
47
+ case 'matching-round':
48
+ return raise.round === round
49
+ case 'all-phase-rounds':
50
+ return true
51
+ }
52
+ }
@@ -0,0 +1,35 @@
1
+ import { distributionPath } from '../distribution'
2
+ import { DEFAULT_PROCESS_GROUP_STOP_TIMEOUT_MS } from './process-group'
3
+
4
+ export const PROCESS_GROUP_REAPER_OPTIONS_ENV = 'AB_PROCESS_GROUP_REAPER_OPTIONS'
5
+
6
+ export interface ProcessGroupReaperOptions {
7
+ groupId: number
8
+ stopTimeoutMs: number
9
+ }
10
+
11
+ export function processGroupReaperOptions(groupId: number): ProcessGroupReaperOptions {
12
+ return { groupId, stopTimeoutMs: DEFAULT_PROCESS_GROUP_STOP_TIMEOUT_MS }
13
+ }
14
+
15
+ /** Start an owner outside the dying build's session so group-wide SIGKILL
16
+ * cannot kill the process responsible for escalating to it. The synchronous
17
+ * return means the target leader can retain its process-group identity until
18
+ * the helper has been created. */
19
+ export function launchProcessGroupReaper(
20
+ options: ProcessGroupReaperOptions,
21
+ spawn: typeof Bun.spawn = Bun.spawn,
22
+ ): number {
23
+ const child = spawn([process.execPath, distributionPath('bin', 'ab-process-group-reaper.ts')], {
24
+ env: {
25
+ ...process.env,
26
+ [PROCESS_GROUP_REAPER_OPTIONS_ENV]: JSON.stringify(options),
27
+ },
28
+ stdin: 'ignore',
29
+ stdout: 'ignore',
30
+ stderr: 'ignore',
31
+ detached: true,
32
+ })
33
+ child.unref()
34
+ return child.pid
35
+ }
@@ -0,0 +1,65 @@
1
+ export const DEFAULT_PROCESS_GROUP_STOP_TIMEOUT_MS = 5_000
2
+
3
+ function validateGroupId(groupId: number): void {
4
+ if (!Number.isSafeInteger(groupId) || groupId <= 0) {
5
+ throw new Error(`invalid process group id ${JSON.stringify(groupId)}`)
6
+ }
7
+ }
8
+
9
+ function isGone(error: unknown): boolean {
10
+ return error instanceof Error && 'code' in error && error.code === 'ESRCH'
11
+ }
12
+
13
+ /** Probe a POSIX process group without ever falling back to its leader PID. */
14
+ export function processGroupAlive(groupId: number): boolean {
15
+ validateGroupId(groupId)
16
+ try {
17
+ process.kill(-groupId, 0)
18
+ return true
19
+ } catch (error) {
20
+ if (isGone(error)) return false
21
+ throw error
22
+ }
23
+ }
24
+
25
+ /** Signal a complete POSIX process group. An already-gone group is success. */
26
+ export function signalProcessGroup(groupId: number, signal: NodeJS.Signals): boolean {
27
+ validateGroupId(groupId)
28
+ try {
29
+ process.kill(-groupId, signal)
30
+ return true
31
+ } catch (error) {
32
+ if (isGone(error)) return false
33
+ throw error
34
+ }
35
+ }
36
+
37
+ async function waitForGroupExit(groupId: number, deadline: number): Promise<boolean> {
38
+ while (processGroupAlive(groupId)) {
39
+ const remaining = deadline - Date.now()
40
+ if (remaining <= 0) return false
41
+ await Bun.sleep(Math.min(10, remaining))
42
+ }
43
+ return true
44
+ }
45
+
46
+ /**
47
+ * Gracefully stop a complete local execution group, then force the same group
48
+ * after the bounded stop delay. The force path waits until no member remains;
49
+ * callers may therefore use resolution as the workspace-teardown boundary.
50
+ */
51
+ export async function terminateProcessGroup(
52
+ groupId: number,
53
+ stopTimeoutMs = DEFAULT_PROCESS_GROUP_STOP_TIMEOUT_MS,
54
+ ): Promise<void> {
55
+ validateGroupId(groupId)
56
+ if (!Number.isFinite(stopTimeoutMs) || stopTimeoutMs < 0) {
57
+ throw new Error(`invalid process-group stop timeout ${JSON.stringify(stopTimeoutMs)}`)
58
+ }
59
+ if (!signalProcessGroup(groupId, 'SIGTERM')) return
60
+
61
+ if (await waitForGroupExit(groupId, Date.now() + stopTimeoutMs)) return
62
+ if (!signalProcessGroup(groupId, 'SIGKILL')) return
63
+
64
+ while (processGroupAlive(groupId)) await Bun.sleep(10)
65
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Last-chance publication settlement at the workspace-release boundary
3
+ * (AUT-328). A pending `publication.requested` whose workspace is destroyed
4
+ * takes its only copy of un-pushed commits with it, so every destructive
5
+ * release path must first attempt trusted settlement and then, if the request
6
+ * still cannot be completed, durably record the loss BEFORE the release fact.
7
+ * A release must never proceed past an unrecorded pending request: if the
8
+ * loss record cannot be written, the append failure propagates and the caller
9
+ * aborts the release (the next tick retries it).
10
+ */
11
+ import { DISPATCHER } from '../events/envelope'
12
+ import type { AbEvent } from '../events/catalog'
13
+ import {
14
+ latestUncompletedPublicationRequest,
15
+ publicationLostRecorded,
16
+ publicationRequestCompleted,
17
+ } from './publication-state'
18
+ import type { BuildStore } from '../store/types'
19
+
20
+ export interface PublicationLossDeps {
21
+ store: BuildStore
22
+ /** Trusted publication settlement (dispatcher-injected). A throw means the
23
+ * request is uncompleted — the guard continues to the loss record. */
24
+ settlePublication?: (slug: string) => Promise<void>
25
+ }
26
+
27
+ /**
28
+ * Settle or record the loss for the latest uncompleted publication request
29
+ * ahead of a workspace release. Returns refreshed events for the caller's
30
+ * subsequent reads. Idempotent: a completed or already-recorded request
31
+ * changes nothing, and repeated runs append at most one `publication.lost`.
32
+ */
33
+ export async function settlePublicationBeforeRelease(
34
+ deps: PublicationLossDeps,
35
+ slug: string,
36
+ events: AbEvent[],
37
+ reason: string,
38
+ ): Promise<AbEvent[]> {
39
+ const request = latestUncompletedPublicationRequest(events)
40
+ if (request === undefined) return events
41
+ try {
42
+ await deps.settlePublication?.(slug)
43
+ } catch {
44
+ // A throw means the request is still uncompleted (or settlement failed);
45
+ // either way the loss record below is what makes the release safe.
46
+ }
47
+ const refreshed = await deps.store.getEvents(slug)
48
+ if (publicationRequestCompleted(refreshed, request)) return refreshed
49
+ if (publicationLostRecorded(refreshed, request)) return refreshed
50
+ const appended = await deps.store.append(slug, {
51
+ actor: DISPATCHER,
52
+ type: 'publication.lost',
53
+ payload: {
54
+ request: request.seq,
55
+ operation: request.payload.operation,
56
+ branch: request.payload.branch,
57
+ sha: request.payload.sha,
58
+ reason,
59
+ },
60
+ })
61
+ return [...refreshed, appended]
62
+ }
@@ -0,0 +1,93 @@
1
+ import type { AbEvent } from '../events/catalog'
2
+
3
+ export type PublicationRequest = Extract<AbEvent, { type: 'publication.requested' }>
4
+
5
+ export function publicationRequestCompleted(
6
+ events: readonly AbEvent[],
7
+ request: PublicationRequest,
8
+ ): boolean {
9
+ return events.some((event) => {
10
+ if (event.seq <= request.seq) return false
11
+ if (request.payload.operation === 'implement')
12
+ return (
13
+ event.type === 'implement.completed' &&
14
+ event.payload.round === request.payload.round &&
15
+ event.payload.commits.base === request.payload.base &&
16
+ event.payload.commits.head === request.payload.sha
17
+ )
18
+ if (request.payload.operation === 'reconcile')
19
+ return (
20
+ event.type === 'reconcile.completed' && event.payload.mergeCommit === request.payload.sha
21
+ )
22
+ if (request.payload.operation === 'finalize')
23
+ return event.type === 'finalize.completed' && event.payload.pr.headSha === request.payload.sha
24
+ return (
25
+ event.type === 'finalize.step-completed' &&
26
+ event.payload.step === request.payload.step &&
27
+ (!event.payload.ok || event.payload.headSha === request.payload.sha)
28
+ )
29
+ })
30
+ }
31
+
32
+ /** A request is no longer publishable after its workspace is released. The
33
+ * commit may have existed only in that disposable environment, so replacement
34
+ * runners must rerun the phase rather than trying to settle the stale SHA. */
35
+ export function publicationRequestSettled(
36
+ events: readonly AbEvent[],
37
+ request: PublicationRequest,
38
+ ): boolean {
39
+ let workspaceRef: string | undefined
40
+ for (const event of events) {
41
+ if (event.seq > request.seq) break
42
+ if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
43
+ else if (event.type === 'workspace.released') workspaceRef = undefined
44
+ }
45
+ return (
46
+ publicationRequestCompleted(events, request) ||
47
+ events.some(
48
+ (event) =>
49
+ event.seq > request.seq &&
50
+ event.type === 'workspace.released' &&
51
+ (!('ref' in event.payload) ||
52
+ workspaceRef === undefined ||
53
+ event.payload.ref === workspaceRef),
54
+ )
55
+ )
56
+ }
57
+
58
+ /** The latest `publication.requested` with no settling completion fact — the
59
+ * single settlement target for every caller that would previously re-derive it
60
+ * with a private `findLast`. */
61
+ export function latestUncompletedPublicationRequest(
62
+ events: readonly AbEvent[],
63
+ ): PublicationRequest | undefined {
64
+ const request = events.findLast(
65
+ (event) =>
66
+ event.type === 'publication.requested' && !publicationRequestCompleted(events, event),
67
+ )
68
+ return request?.type === 'publication.requested' ? request : undefined
69
+ }
70
+
71
+ /** Whether the durable loss record (AUT-328) has already been appended for
72
+ * `request`. The release guard and the settlement backstop both consult this
73
+ * so repeated ticks append at most one `publication.lost` per request. */
74
+ export function publicationLostRecorded(
75
+ events: readonly AbEvent[],
76
+ request: PublicationRequest,
77
+ ): boolean {
78
+ return events.some(
79
+ (event) => event.type === 'publication.lost' && event.payload.request === request.seq,
80
+ )
81
+ }
82
+
83
+ export function abandonedPublicationPending(events: readonly AbEvent[]): boolean {
84
+ const request = latestUncompletedPublicationRequest(events)
85
+ return request !== undefined && publicationRequestSettled(events, request)
86
+ }
87
+
88
+ export function publicationPending(events: readonly AbEvent[]): boolean {
89
+ return events.some(
90
+ (request) =>
91
+ request.type === 'publication.requested' && !publicationRequestSettled(events, request),
92
+ )
93
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Durable operator-sandbox state (AUT-340): the repository-journal projection
3
+ * the sandbox service, the dispatcher's idle-settlement stage, and the
4
+ * repository status projection read instead of process memory. The lifecycle
5
+ * facts (`orchestrator.sandbox.provisioned`/`resumed`/`activity`/`stopped`/
6
+ * `released`/`reset`) bracket one environment per operator × repository.
7
+ * Every helper here is a pure function over the journal, mirroring the
8
+ * harvest execution settlement, so any dispatcher invocation can settle an
9
+ * orphaned environment.
10
+ */
11
+ import type { RepositoryEvent } from '../events/repository'
12
+
13
+ export type SandboxEnvironmentState = 'live' | 'stopped' | 'released'
14
+
15
+ export interface SandboxEnvironmentSnapshot {
16
+ operator: string
17
+ environmentId: string
18
+ provider: string
19
+ state: SandboxEnvironmentState
20
+ /** Journal timestamp of the latest lifecycle/activity fact for this
21
+ * environment — the evidence the idle settlement compares against
22
+ * `[orchestrator].sandbox.idleMinutes`. */
23
+ lastEvidenceTs: string
24
+ sessionId?: string
25
+ }
26
+
27
+ type MutableSnapshot = {
28
+ operator: string
29
+ environmentId: string
30
+ provider: string
31
+ state: SandboxEnvironmentState
32
+ lastEvidenceTs: string
33
+ sessionId?: string
34
+ }
35
+
36
+ /** Per-environment sandbox state, keyed deterministically by
37
+ * `environmentId` (one environment per operator × repository, and the id is
38
+ * derived from both). Latest fact wins; `released` environments stay in the
39
+ * result with their closing evidence so callers can distinguish them. */
40
+ export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnvironmentSnapshot[] {
41
+ const byEnvironment = new Map<string, MutableSnapshot>()
42
+ for (const event of events) {
43
+ const ts = event.ts
44
+ if (event.type === 'orchestrator.sandbox.provisioned') {
45
+ byEnvironment.set(event.payload.environmentId, {
46
+ operator: event.payload.operator,
47
+ environmentId: event.payload.environmentId,
48
+ provider: event.payload.provider,
49
+ state: 'live',
50
+ lastEvidenceTs: ts,
51
+ ...(event.payload.sessionId !== undefined ? { sessionId: event.payload.sessionId } : {}),
52
+ })
53
+ } else if (event.type === 'orchestrator.sandbox.resumed') {
54
+ const previous = byEnvironment.get(event.payload.environmentId)
55
+ byEnvironment.set(event.payload.environmentId, {
56
+ operator: event.payload.operator,
57
+ environmentId: event.payload.environmentId,
58
+ provider: event.payload.provider,
59
+ state: 'live',
60
+ lastEvidenceTs: ts,
61
+ ...(event.payload.sessionId !== undefined
62
+ ? { sessionId: event.payload.sessionId }
63
+ : previous?.sessionId !== undefined
64
+ ? { sessionId: previous.sessionId }
65
+ : {}),
66
+ })
67
+ } else if (event.type === 'orchestrator.sandbox.activity') {
68
+ const current = byEnvironment.get(event.payload.environmentId)
69
+ if (current !== undefined && current.lastEvidenceTs <= ts) {
70
+ current.lastEvidenceTs = ts
71
+ }
72
+ } else if (event.type === 'orchestrator.sandbox.stopped') {
73
+ const current = byEnvironment.get(event.payload.environmentId)
74
+ if (current !== undefined && current.lastEvidenceTs <= ts) {
75
+ current.state = 'stopped'
76
+ current.lastEvidenceTs = ts
77
+ }
78
+ } else if (event.type === 'orchestrator.sandbox.reset') {
79
+ // Destructive marker only: the following released/provisioned facts
80
+ // carry the evidence. Refresh the evidence timestamp so a reset alone
81
+ // counts as recent activity for the environment it names.
82
+ const current = byEnvironment.get(event.payload.environmentId)
83
+ if (current !== undefined && current.lastEvidenceTs <= ts) {
84
+ current.lastEvidenceTs = ts
85
+ }
86
+ } else if (event.type === 'orchestrator.sandbox.released') {
87
+ // The released payload carries no provider; keep the one the
88
+ // environment's earlier facts recorded, when one exists.
89
+ const existing = byEnvironment.get(event.payload.environmentId)
90
+ byEnvironment.set(event.payload.environmentId, {
91
+ operator: event.payload.operator,
92
+ environmentId: event.payload.environmentId,
93
+ provider: existing?.provider ?? '',
94
+ state: 'released',
95
+ lastEvidenceTs: ts,
96
+ ...(existing?.sessionId !== undefined ? { sessionId: existing.sessionId } : {}),
97
+ })
98
+ }
99
+ }
100
+ return [...byEnvironment.values()]
101
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * npm registry addressing shared by self-update (version lookup) and the
3
+ * guest distribution source (tarball download). Pure URL arithmetic; the
4
+ * callers own their transport seams.
5
+ */
6
+
7
+ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
8
+
9
+ /** The registry to read: the operator's configured `NPM_CONFIG_REGISTRY`
10
+ * when set, else the public registry, without a trailing slash. */
11
+ export function registryBaseUrl(
12
+ env: Readonly<Record<string, string | undefined>> | undefined,
13
+ ): string {
14
+ const configured = env?.NPM_CONFIG_REGISTRY?.trim()
15
+ return (
16
+ configured !== undefined && configured !== '' ? configured : DEFAULT_NPM_REGISTRY
17
+ ).replace(/\/+$/, '')
18
+ }
19
+
20
+ /** `<registry>/<encoded name>/<version|latest>` — the registry's version
21
+ * document endpoint, with the scope separator encoded as npm clients do. */
22
+ export function registryVersionUrl(base: string, packageName: string, version?: string): string {
23
+ return `${base}/${packageName.replace('/', '%2f')}/${version ?? 'latest'}`
24
+ }