@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,848 @@
1
+ /**
2
+ * Workspace adapter: git worktrees (SPEC §3.2). The worktree is disposable
3
+ * scratch (§7); the branch is the build's durable code and travels through
4
+ * the Forge, never the store (§15.2.4 [D3]) — so `release` removes only the
5
+ * worktree and never touches the branch. Idempotent `provision` is what makes
6
+ * resume-after-sandbox-death (§15.6-C) a re-run, not a special path
7
+ * (constitution #2).
8
+ */
9
+ import { mkdir, realpath } from 'node:fs/promises'
10
+ import { createHash, randomBytes } from 'node:crypto'
11
+ import { readFile as fsReadFile, rm, stat, writeFile as fsWriteFile } from 'node:fs/promises'
12
+ import { join, resolve, sep } from 'node:path'
13
+ import type { WorkspaceBase } from '../../ontology'
14
+ import {
15
+ SandboxOperationError,
16
+ type OperatorSandboxExecution,
17
+ type SandboxCommandRequest,
18
+ type SandboxCommandResult,
19
+ type SandboxEnvironmentIdentity,
20
+ type SandboxWaitResult,
21
+ } from './operator-sandbox'
22
+ import type { WorkspaceHandle, WorkspaceProvider, WorkspaceProvisionResult } from '../types'
23
+
24
+ export interface ExecResult {
25
+ stdout: string
26
+ stderr: string
27
+ exitCode: number
28
+ }
29
+
30
+ /** Injectable exec seam — the default shells out via Bun.spawn. */
31
+ export type Exec = (
32
+ cmd: string[],
33
+ opts: { cwd?: string; signal?: AbortSignal },
34
+ ) => Promise<ExecResult>
35
+
36
+ export const spawnExec: Exec = async (cmd, opts) => {
37
+ const proc = Bun.spawn(cmd, {
38
+ cwd: opts.cwd,
39
+ stdin: 'ignore',
40
+ stdout: 'pipe',
41
+ stderr: 'pipe',
42
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
43
+ })
44
+ const [stdout, stderr, exitCode] = await Promise.all([
45
+ new Response(proc.stdout).text(),
46
+ new Response(proc.stderr).text(),
47
+ proc.exited,
48
+ ])
49
+ return { stdout, stderr, exitCode }
50
+ }
51
+
52
+ export class GitError extends Error {
53
+ constructor(
54
+ readonly args: string[],
55
+ readonly result: ExecResult,
56
+ ) {
57
+ super(
58
+ `git ${args.join(' ')} exited ${result.exitCode}: ${
59
+ result.stderr.trim() || result.stdout.trim() || '(no output)'
60
+ }`,
61
+ )
62
+ this.name = 'GitError'
63
+ }
64
+ }
65
+
66
+ /** `ab/build-123` → `ab-build-123` — one subdir per branch under the root. */
67
+ function sanitizeBranch(branch: string): string {
68
+ const cleaned = branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '')
69
+ return cleaned || 'branch'
70
+ }
71
+
72
+ interface WorktreeEntry {
73
+ path: string
74
+ head?: string
75
+ branch?: string
76
+ detached: boolean
77
+ }
78
+
79
+ interface WorktreeSelection {
80
+ /** Requested branch attached inside this provider's root. */
81
+ attached?: WorktreeEntry
82
+ /** Detached registration at the requested branch's deterministic path. */
83
+ detached?: WorktreeEntry
84
+ /** The requested branch is attached anywhere, including outside our root. */
85
+ branchAttached: boolean
86
+ }
87
+
88
+ function parseWorktreeList(porcelain: string): WorktreeEntry[] {
89
+ const entries: WorktreeEntry[] = []
90
+ for (const block of porcelain.split('\n\n')) {
91
+ let path: string | undefined
92
+ let head: string | undefined
93
+ let branch: string | undefined
94
+ let detached = false
95
+ for (const line of block.split('\n')) {
96
+ if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
97
+ else if (line.startsWith('HEAD ')) head = line.slice('HEAD '.length)
98
+ else if (line.startsWith('branch refs/heads/'))
99
+ branch = line.slice('branch refs/heads/'.length)
100
+ else if (line === 'detached') detached = true
101
+ }
102
+ if (path) entries.push({ path, head, branch, detached })
103
+ }
104
+ return entries
105
+ }
106
+
107
+ export class GitWorktreeProvider implements WorkspaceProvider {
108
+ readonly name = 'git-worktree'
109
+ readonly orchestratorSandbox: OperatorSandboxExecution
110
+ private readonly root: string
111
+ private readonly sandboxRoot: string
112
+ private readonly setupCommand: string | undefined
113
+ private readonly sandboxEnvironmentVariables: readonly string[]
114
+ private readonly sandboxEnvSource: Record<string, string | undefined>
115
+ /** Canonicalized (symlink-free) root, resolved lazily — git reports
116
+ * worktree paths canonicalized, so comparisons must be too. */
117
+ private rootReal: string | null = null
118
+ private readonly exec: Exec
119
+ /** worktree path → repo it was provisioned from, so `release` can find the
120
+ * repo without re-deriving it. Rebuilt lazily after a restart (release of a
121
+ * still-on-disk worktree rediscovers the repo via git-common-dir). */
122
+ private readonly repos = new Map<string, string>()
123
+
124
+ constructor(opts: {
125
+ root: string
126
+ exec?: Exec
127
+ /** Operator-sandbox worktree root; defaults to a sibling of the build
128
+ * worktree root inside the local state tree. */
129
+ sandboxRoot?: string
130
+ /** The repository's raw `[commands].setup` shell string, run once per
131
+ * fresh operator-sandbox worktree. */
132
+ setupCommand?: string
133
+ /** Forwarded non-secret variable names, resolved from `envSource`. */
134
+ sandboxEnvironmentVariables?: readonly string[]
135
+ /** Host environment the guest PATH and forwarded names resolve from.
136
+ * The guest environment is otherwise built from an empty record. */
137
+ envSource?: Record<string, string | undefined>
138
+ }) {
139
+ this.root = resolve(opts.root)
140
+ this.sandboxRoot = resolve(opts.sandboxRoot ?? join(opts.root, '..', 'orchestrator-sandboxes'))
141
+ this.setupCommand = opts.setupCommand
142
+ this.sandboxEnvironmentVariables = opts.sandboxEnvironmentVariables ?? []
143
+ this.sandboxEnvSource = opts.envSource ?? {}
144
+ this.exec = opts.exec ?? spawnExec
145
+ this.orchestratorSandbox = {
146
+ describe: (input) => Promise.resolve(this.sandboxIdentity(input.repo, input.operator)),
147
+ ensure: (input) => this.ensureSandbox(input),
148
+ exec: (handle, request) => this.sandboxExec(handle, request),
149
+ start: (handle, request) => this.sandboxStart(handle, request),
150
+ wait: (handle, input) => this.sandboxWait(handle, input),
151
+ readFile: (handle, path) => this.sandboxReadFile(handle, path),
152
+ writeFile: (handle, path, content) => this.sandboxWriteFile(handle, path, content),
153
+ stop: () => Promise.resolve({ outcome: 'unsupported' as const }),
154
+ release: (input) => this.sandboxRelease(input),
155
+ }
156
+ }
157
+
158
+ private async realRoot(): Promise<string> {
159
+ if (!this.rootReal) {
160
+ await mkdir(this.root, { recursive: true })
161
+ this.rootReal = await realpath(this.root)
162
+ }
163
+ return this.rootReal
164
+ }
165
+
166
+ /** Runs git against `dir` via `-C` so a nonexistent dir surfaces as git's
167
+ * own stderr (`cannot change to …`) rather than a spawn failure. */
168
+ private async git(dir: string, args: string[]): Promise<ExecResult> {
169
+ return this.exec(['git', '-C', dir, ...args], {})
170
+ }
171
+
172
+ private async gitOrThrow(dir: string, args: string[]): Promise<ExecResult> {
173
+ const result = await this.git(dir, args)
174
+ if (result.exitCode !== 0) throw new GitError(['-C', dir, ...args], result)
175
+ return result
176
+ }
177
+
178
+ private shaFrom(dir: string, args: string[], result: ExecResult): string {
179
+ if (result.exitCode !== 0) {
180
+ throw new GitError(['-C', dir, ...args], result)
181
+ }
182
+ const sha = result.stdout.trim()
183
+ if (sha === '') {
184
+ throw new GitError(['-C', dir, ...args], {
185
+ ...result,
186
+ exitCode: 1,
187
+ stderr: 'git returned no commit SHA',
188
+ })
189
+ }
190
+ return sha
191
+ }
192
+
193
+ private async resolveCommit(repo: string, ref: string): Promise<string> {
194
+ const args = ['rev-parse', '--verify', `${ref}^{commit}`]
195
+ return this.shaFrom(repo, args, await this.git(repo, args))
196
+ }
197
+
198
+ private async resolveOptionalCommit(repo: string, ref: string): Promise<string | null> {
199
+ const args = ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]
200
+ const result = await this.git(repo, args)
201
+ if (result.exitCode === 0) return this.shaFrom(repo, args, result)
202
+ if (result.exitCode === 1) return null
203
+ throw new GitError(['-C', repo, ...args], result)
204
+ }
205
+
206
+ private async isAncestor(repo: string, ancestor: string, descendant: string): Promise<boolean> {
207
+ const args = ['merge-base', '--is-ancestor', ancestor, descendant]
208
+ const result = await this.git(repo, args)
209
+ if (result.exitCode === 0) return true
210
+ if (result.exitCode === 1) return false
211
+ throw new GitError(['-C', repo, ...args], result)
212
+ }
213
+
214
+ /** Reconcile a stale local build ref from the ref updated by a successful
215
+ * Forge push. Both ancestry checks and the expected-old update make this a
216
+ * normal fast-forward that cannot overwrite divergent or concurrent work. */
217
+ private async recoverPublishedBranch(
218
+ repo: string,
219
+ branch: string,
220
+ localSha: string,
221
+ ): Promise<string> {
222
+ const branchRef = `refs/heads/${branch}`
223
+ const publishedRef = `refs/remotes/origin/${branch}`
224
+ const publishedSha = await this.resolveOptionalCommit(repo, publishedRef)
225
+ if (!publishedSha || publishedSha === localSha) return localSha
226
+
227
+ if (await this.isAncestor(repo, localSha, publishedSha)) {
228
+ await this.gitOrThrow(repo, ['update-ref', branchRef, publishedSha, localSha])
229
+ return publishedSha
230
+ }
231
+
232
+ if (await this.isAncestor(repo, publishedSha, localSha)) return localSha
233
+
234
+ throw new Error(
235
+ `cannot recover ${branchRef}: local tip ${localSha} and published ` +
236
+ `${publishedRef} tip ${publishedSha} have diverged; refusing to rewrite either ref`,
237
+ )
238
+ }
239
+
240
+ /** Attach the one detached registration this provider owns. The published
241
+ * ref must first have made its HEAD exactly the durable branch tip; an
242
+ * arbitrary detached commit is never adopted or discarded. */
243
+ private async reattachDetachedWorktree(
244
+ repo: string,
245
+ entry: WorktreeEntry,
246
+ branch: string,
247
+ expectedSha: string,
248
+ ): Promise<string> {
249
+ if (!entry.head) {
250
+ throw new Error(
251
+ `cannot recover detached worktree ${entry.path}: its registered HEAD is missing`,
252
+ )
253
+ }
254
+
255
+ const liveHead = await this.resolveCommit(entry.path, 'HEAD')
256
+ const branchRef = `refs/heads/${branch}`
257
+ const branchSha = await this.resolveCommit(repo, branchRef)
258
+ if (entry.head !== liveHead || branchSha !== expectedSha) {
259
+ throw new Error(
260
+ `cannot recover detached worktree ${entry.path}: its registration or ` +
261
+ `${branchRef} changed during provisioning; refusing to move it`,
262
+ )
263
+ }
264
+ if (liveHead !== branchSha) {
265
+ throw new Error(
266
+ `cannot recover detached worktree ${entry.path}: detached HEAD ${liveHead} ` +
267
+ `does not match durable ${branchRef} tip ${branchSha}; refusing to discard either commit`,
268
+ )
269
+ }
270
+
271
+ await this.gitOrThrow(entry.path, ['checkout', '--quiet', branch])
272
+
273
+ const symbolicArgs = ['symbolic-ref', '--quiet', '--short', 'HEAD']
274
+ const symbolic = await this.git(entry.path, symbolicArgs)
275
+ const actualHead = await this.resolveCommit(entry.path, 'HEAD')
276
+ const actualBranch = await this.resolveCommit(repo, branchRef)
277
+ if (
278
+ symbolic.exitCode !== 0 ||
279
+ symbolic.stdout.trim() !== branch ||
280
+ actualHead !== expectedSha ||
281
+ actualBranch !== expectedSha
282
+ ) {
283
+ throw new Error(
284
+ `detached worktree recovery at ${entry.path} did not produce ${branch} ` +
285
+ `at expected commit ${expectedSha}`,
286
+ )
287
+ }
288
+ return actualHead
289
+ }
290
+
291
+ /** Select a new branch's base without mutating any operator or shared
292
+ * remote-tracking ref. A remote failure is evidence, not a dispatch gate. */
293
+ private async selectNewBranchBase(
294
+ repo: string,
295
+ baseBranch: string,
296
+ branch: string,
297
+ ): Promise<WorkspaceBase> {
298
+ const fetchedRef = `refs/autobuild/provision/${branch}/base`
299
+ const fetchArgs = [
300
+ 'fetch',
301
+ '--no-tags',
302
+ '--no-write-fetch-head',
303
+ '--refmap=',
304
+ 'origin',
305
+ `+refs/heads/${baseBranch}:${fetchedRef}`,
306
+ ]
307
+ const fetched = await this.git(repo, fetchArgs)
308
+
309
+ let remoteError: string | undefined
310
+ if (fetched.exitCode !== 0) {
311
+ remoteError = new GitError(['-C', repo, ...fetchArgs], fetched).message
312
+ } else {
313
+ const resolveArgs = ['rev-parse', '--verify', `${fetchedRef}^{commit}`]
314
+ const resolved = await this.git(repo, resolveArgs)
315
+ if (resolved.exitCode === 0) {
316
+ return { source: 'remote', sha: this.shaFrom(repo, resolveArgs, resolved) }
317
+ }
318
+ remoteError = new GitError(['-C', repo, ...resolveArgs], resolved).message
319
+ }
320
+
321
+ // Remote refresh failures are non-fatal only while the fully qualified
322
+ // local base is still a usable commit. A missing local base remains a
323
+ // provisioning error rather than being hidden behind the fallback.
324
+ const sha = await this.resolveCommit(repo, `refs/heads/${baseBranch}`)
325
+ return { source: 'local', sha, remoteError }
326
+ }
327
+
328
+ async provision(opts: {
329
+ repo: string
330
+ baseBranch: string
331
+ branch: string
332
+ }): Promise<WorkspaceProvisionResult> {
333
+ const { repo, baseBranch, branch } = opts
334
+ // Fails with git's stderr for a missing path or a non-repo directory.
335
+ await this.gitOrThrow(repo, ['rev-parse', '--git-dir'])
336
+ // Drop registrations whose directories were deleted out-of-band, so
337
+ // re-provision after a hand-cleaned sandbox still succeeds.
338
+ await this.gitOrThrow(repo, ['worktree', 'prune'])
339
+
340
+ const rootDir = await this.realRoot()
341
+ const worktreePath = join(rootDir, sanitizeBranch(branch))
342
+ const worktrees = await this.findWorktrees(repo, branch, rootDir, worktreePath)
343
+ if (worktrees.attached) {
344
+ // Idempotent provision (constitution #2): the registered worktree — at
345
+ // the branch's current tip — is the resume point, not a fresh checkout.
346
+ const sha = await this.resolveCommit(repo, `refs/heads/${branch}`)
347
+ this.repos.set(worktrees.attached.path, repo)
348
+ return {
349
+ provider: this.name,
350
+ ref: worktrees.attached.path,
351
+ path: worktrees.attached.path,
352
+ localPath: worktrees.attached.path,
353
+ branch,
354
+ base: { source: 'existing', sha },
355
+ }
356
+ }
357
+
358
+ const branchRef = `refs/heads/${branch}`
359
+ const localSha = await this.resolveOptionalCommit(repo, branchRef)
360
+ if (localSha) {
361
+ // A branch attached outside this provider's root is not ours to repair
362
+ // or reuse. The ordinary worktree-add/checkout error below remains the
363
+ // non-destructive diagnostic for that unsupported registration.
364
+ const sha = worktrees.branchAttached
365
+ ? localSha
366
+ : await this.recoverPublishedBranch(repo, branch, localSha)
367
+
368
+ if (worktrees.detached) {
369
+ if (worktrees.branchAttached) {
370
+ throw new Error(
371
+ `cannot recover detached worktree ${worktrees.detached.path}: ` +
372
+ `${branchRef} is already attached to another worktree`,
373
+ )
374
+ }
375
+ const recoveredSha = await this.reattachDetachedWorktree(
376
+ repo,
377
+ worktrees.detached,
378
+ branch,
379
+ sha,
380
+ )
381
+ this.repos.set(worktrees.detached.path, repo)
382
+ return {
383
+ provider: this.name,
384
+ ref: worktrees.detached.path,
385
+ path: worktrees.detached.path,
386
+ localPath: worktrees.detached.path,
387
+ branch,
388
+ base: { source: 'existing', sha: recoveredSha },
389
+ }
390
+ }
391
+
392
+ // Resume (§15.6-C): rematerialize only after the local branch has been
393
+ // monotonically reconciled with any push-updated publication evidence.
394
+ // This path performs no fetch and no remote write.
395
+ await this.gitOrThrow(repo, ['worktree', 'add', worktreePath, branch])
396
+ this.repos.set(worktreePath, repo)
397
+ return {
398
+ provider: this.name,
399
+ ref: worktreePath,
400
+ path: worktreePath,
401
+ localPath: worktreePath,
402
+ branch,
403
+ base: { source: 'existing', sha },
404
+ }
405
+ }
406
+
407
+ if (worktrees.detached) {
408
+ throw new Error(
409
+ `cannot recover detached worktree ${worktrees.detached.path}: ` +
410
+ `${branchRef} does not exist; refusing to adopt an arbitrary detached commit`,
411
+ )
412
+ }
413
+
414
+ const base = await this.selectNewBranchBase(repo, baseBranch, branch)
415
+ // Consume the selected immutable commit, not a mutable name that could
416
+ // move between fetch and branch creation.
417
+ await this.gitOrThrow(repo, ['worktree', 'add', '-b', branch, worktreePath, base.sha])
418
+ const actualSha = await this.resolveCommit(repo, branchRef)
419
+ if (actualSha !== base.sha) {
420
+ throw new Error(`created ${branchRef} at ${actualSha}, expected selected base ${base.sha}`)
421
+ }
422
+
423
+ this.repos.set(worktreePath, repo)
424
+ return {
425
+ provider: this.name,
426
+ ref: worktreePath,
427
+ path: worktreePath,
428
+ localPath: worktreePath,
429
+ branch,
430
+ base: { ...base, sha: actualSha },
431
+ }
432
+ }
433
+
434
+ async release(handle: WorkspaceHandle): Promise<void> {
435
+ const path = resolve(handle.ref)
436
+ const repo = this.repos.get(path) ?? (await this.discoverRepo(path))
437
+ // No repo to prune against means nothing was ever provisioned here (or it
438
+ // is already fully gone) — releasing it is a no-op, not an error.
439
+ if (!repo) return
440
+ const removed = await this.git(repo, ['worktree', 'remove', '--force', path])
441
+ // Already-gone worktrees are a no-op: unregistered paths say "is not a
442
+ // working tree"; registered-but-deleted dirs say "validation failed …"
443
+ // on older gits (2.50 removes them silently). Prune below cleans up.
444
+ if (
445
+ removed.exitCode !== 0 &&
446
+ !/is not a working tree|validation failed, cannot remove working tree/i.test(removed.stderr)
447
+ ) {
448
+ throw new GitError(['-C', repo, 'worktree', 'remove', '--force', path], removed)
449
+ }
450
+ await this.gitOrThrow(repo, ['worktree', 'prune'])
451
+ this.repos.delete(path)
452
+ }
453
+
454
+ /** Discover only registrations relevant to the requested branch. Attached
455
+ * reuse stays root-scoped (and therefore excludes the main checkout); a
456
+ * detached registration is recoverable only at this branch's exact,
457
+ * deterministic provider-owned path. */
458
+ private async findWorktrees(
459
+ repo: string,
460
+ branch: string,
461
+ rootDir: string,
462
+ worktreePath: string,
463
+ ): Promise<WorktreeSelection> {
464
+ const list = await this.gitOrThrow(repo, ['worktree', 'list', '--porcelain'])
465
+ const entries = parseWorktreeList(list.stdout).map((entry) => ({
466
+ ...entry,
467
+ path: resolve(entry.path),
468
+ }))
469
+ return {
470
+ attached: entries.find(
471
+ (entry) => entry.branch === branch && entry.path.startsWith(rootDir + sep),
472
+ ),
473
+ detached: entries.find((entry) => entry.detached && entry.path === worktreePath),
474
+ branchAttached: entries.some((entry) => entry.branch === branch),
475
+ }
476
+ }
477
+
478
+ /** Main-repo directory owning the worktree at `path`, or null if the path
479
+ * is gone or not a worktree — the release-is-a-no-op cases. Needed when a
480
+ * restarted process releases a worktree it did not provision (§7.4). */
481
+ private async discoverRepo(path: string): Promise<string | null> {
482
+ const result = await this.git(path, ['rev-parse', '--git-common-dir'])
483
+ if (result.exitCode !== 0) return null
484
+ const commonDir = resolve(path, result.stdout.trim())
485
+ // The common dir is the main repo's `.git`; worktree commands need the
486
+ // repo directory itself (a bare common dir already is one).
487
+ return commonDir.endsWith(`${sep}.git`) ? commonDir.slice(0, -`${sep}.git`.length) : commonDir
488
+ }
489
+
490
+ // ── Operator sandbox capability (AUT-340) ──────────────────────
491
+ //
492
+ // One worktree per operator × repository under the local state tree,
493
+ // provisioned with the repository's setup command; the same registry tools
494
+ // operate on it. A local worktree has no stop/snapshot lifecycle, so `stop`
495
+ // declines honestly and the idle settlement skips it.
496
+
497
+ private sandboxPath(repo: string, operator: string): string {
498
+ const digest = createHash('sha256').update(`${repo}\0${operator}`).digest('hex').slice(0, 10)
499
+ return join(this.sandboxRoot, digest)
500
+ }
501
+
502
+ private sandboxIdentity(repo: string, operator: string): SandboxEnvironmentIdentity {
503
+ const path = this.sandboxPath(repo, operator)
504
+ return { provider: this.name, environmentId: path, workspacePath: path }
505
+ }
506
+
507
+ /** The credential-free guest environment: one fresh record per call —
508
+ * never the host environment wholesale — holding the host PATH plus the
509
+ * forwarded names. */
510
+ private sandboxEnv(): Record<string, string> {
511
+ const env: Record<string, string> = {
512
+ PATH: this.sandboxEnvSource.PATH ?? '/usr/local/bin:/usr/bin:/bin',
513
+ }
514
+ for (const name of this.sandboxEnvironmentVariables) {
515
+ const value = this.sandboxEnvSource[name]
516
+ if (value === undefined || value === '') {
517
+ throw new SandboxOperationError(
518
+ 'environment',
519
+ `operator sandbox environment variable ${name} is not set in the host environment`,
520
+ )
521
+ }
522
+ env[name] = value
523
+ }
524
+ return env
525
+ }
526
+
527
+ private async isRegisteredWorktree(path: string, repo: string): Promise<boolean> {
528
+ const list = await this.git(repo, ['worktree', 'list', '--porcelain'])
529
+ if (list.exitCode !== 0) return false
530
+ return parseWorktreeList(list.stdout).some((entry) => resolve(entry.path) === resolve(path))
531
+ }
532
+
533
+ private async ensureSandbox(input: {
534
+ repo: string
535
+ operator: string
536
+ baseBranch: string
537
+ }): Promise<SandboxEnvironmentIdentity> {
538
+ const identity = this.sandboxIdentity(input.repo, input.operator)
539
+ if (await this.isRegisteredWorktree(identity.environmentId, input.repo)) return identity
540
+ await mkdir(this.sandboxRoot, { recursive: true })
541
+ const baseHead = await this.resolveCommit(input.repo, `refs/heads/${input.baseBranch}`)
542
+ await this.gitOrThrow(input.repo, [
543
+ 'worktree',
544
+ 'add',
545
+ '--detach',
546
+ identity.environmentId,
547
+ baseHead,
548
+ ])
549
+ try {
550
+ const marker = join(identity.workspacePath, '.autobuild-sandbox-provisioned')
551
+ const provisioned = await stat(marker).then(
552
+ () => true,
553
+ () => false,
554
+ )
555
+ if (!provisioned && this.setupCommand !== undefined && this.setupCommand.trim() !== '') {
556
+ // The setup command runs with the same credential-free guest
557
+ // environment as every tool exec — never the host environment
558
+ // wholesale, or a setup script would be a second route to build
559
+ // state. Unbounded: provisioning may install for a long time.
560
+ const result = await this.spawnSandboxCommand(this.setupCommand, identity.workspacePath)
561
+ if (result.exitCode !== 0) {
562
+ throw new SandboxOperationError(
563
+ 'provision',
564
+ `operator sandbox setup failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`,
565
+ )
566
+ }
567
+ }
568
+ await fsWriteFile(marker, '')
569
+ } catch (error) {
570
+ // A half-provisioned worktree is removed so the next call starts over.
571
+ await this.git(input.repo, ['worktree', 'remove', '--force', identity.environmentId])
572
+ await this.git(input.repo, ['worktree', 'prune'])
573
+ throw error
574
+ }
575
+ return identity
576
+ }
577
+
578
+ /** One `sh -c` command inside the sandbox worktree with the credential-free
579
+ * guest environment (an empty record plus the host PATH and the forwarded
580
+ * names — never the host environment wholesale). `timeoutSeconds` bounds
581
+ * the child and kills it past the bound; undefined runs unbounded (the
582
+ * setup command may install for a long time). */
583
+ private async spawnSandboxCommand(
584
+ command: string,
585
+ cwd: string,
586
+ timeoutSeconds?: number,
587
+ ): Promise<SandboxCommandResult> {
588
+ const proc = Bun.spawn(['sh', '-c', command], {
589
+ cwd,
590
+ stdin: 'ignore',
591
+ stdout: 'pipe',
592
+ stderr: 'pipe',
593
+ env: this.sandboxEnv(),
594
+ })
595
+ if (timeoutSeconds === undefined) {
596
+ const [stdout, stderr, exitCode] = await Promise.all([
597
+ new Response(proc.stdout).text(),
598
+ new Response(proc.stderr).text(),
599
+ proc.exited,
600
+ ])
601
+ return { exitCode: exitCode ?? -1, stdout, stderr }
602
+ }
603
+ const timer = AbortSignal.timeout(timeoutSeconds * 1000)
604
+ const exitedOrTimeout = Promise.race([
605
+ proc.exited.then((code) => ({ kind: 'exited' as const, code })),
606
+ new Promise<never>((_, reject) => {
607
+ timer.addEventListener(
608
+ 'abort',
609
+ () =>
610
+ reject(
611
+ new SandboxOperationError(
612
+ 'exec-timeout',
613
+ `sandbox command exceeded its ${timeoutSeconds}s bound and was killed`,
614
+ ),
615
+ ),
616
+ { once: true },
617
+ )
618
+ }),
619
+ ])
620
+ const outcome = await exitedOrTimeout.then(
621
+ (value) => value,
622
+ (error) => {
623
+ try {
624
+ proc.kill('SIGTERM')
625
+ } catch {
626
+ /* already exited */
627
+ }
628
+ throw error
629
+ },
630
+ )
631
+ const [stdout, stderr] = await Promise.all([
632
+ new Response(proc.stdout).text(),
633
+ new Response(proc.stderr).text(),
634
+ ])
635
+ return { exitCode: outcome.code ?? -1, stdout, stderr }
636
+ }
637
+
638
+ private async sandboxExec(
639
+ handle: SandboxEnvironmentIdentity,
640
+ request: SandboxCommandRequest,
641
+ ): Promise<SandboxCommandResult> {
642
+ const timeoutSeconds = Math.min(Math.max(request.timeoutSeconds ?? 120, 1), 300)
643
+ const cwd =
644
+ request.cwd === undefined ? handle.workspacePath : join(handle.workspacePath, request.cwd)
645
+ return this.spawnSandboxCommand(request.command, cwd, timeoutSeconds)
646
+ }
647
+
648
+ /** Bound on retained finished detached commands. A finished entry whose
649
+ * result was delivered (`wait` observed `exited`) is consumed immediately;
650
+ * this cap covers the fire-and-forget case — commands that finish but are
651
+ * never waited on — so the map (and its retained stdout/stderr strings)
652
+ * stays bounded at ~cap entries' worth of output regardless of how many
653
+ * commands the process ever runs. Running entries are never evicted. */
654
+ private static readonly MAX_RETAINED_SANDBOX_COMMANDS = 50
655
+
656
+ /** Detached children tracked in-process only; a restarted host reports a
657
+ * recorded command as gone (`wait` fails typed `not-found`). Eviction uses
658
+ * the same semantics: the first `wait` that observes `exited` consumes the
659
+ * entry (a later `wait` on the same id fails typed `not-found`, exactly as
660
+ * after a restart), and finished-but-never-waited entries are bounded by
661
+ * `MAX_RETAINED_SANDBOX_COMMANDS`. */
662
+ private readonly sandboxChildren = new Map<
663
+ string,
664
+ {
665
+ proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
666
+ stdout: string
667
+ stderr: string
668
+ exitCode: number | null
669
+ }
670
+ >()
671
+
672
+ /** Append a chunk to an entry's captured stream, tolerating an entry that
673
+ * was already evicted (consumed by `wait` or removed by the cap): late
674
+ * chunks for a delivered command are dropped, never a crash. */
675
+ private appendChildStream(commandId: string, stream: 'stdout' | 'stderr', chunk: string): void {
676
+ const tracked = this.sandboxChildren.get(commandId)
677
+ if (tracked === undefined) return
678
+ tracked[stream] += chunk
679
+ }
680
+
681
+ /** While the map exceeds the retention cap, delete the oldest finished
682
+ * entries in insertion order (`Map` iterates in insertion order). Running
683
+ * entries are never evicted — the live procs themselves are the resource. */
684
+ private evictFinishedBeyondCap(): void {
685
+ if (this.sandboxChildren.size <= GitWorktreeProvider.MAX_RETAINED_SANDBOX_COMMANDS) return
686
+ for (const [id, tracked] of this.sandboxChildren) {
687
+ if (this.sandboxChildren.size <= GitWorktreeProvider.MAX_RETAINED_SANDBOX_COMMANDS) break
688
+ if (tracked.exitCode !== null) this.sandboxChildren.delete(id)
689
+ }
690
+ }
691
+
692
+ private trackChildStreams(
693
+ commandId: string,
694
+ child: { proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'> },
695
+ ): void {
696
+ void (async () => {
697
+ const reader = child.proc.stdout.getReader()
698
+ const decoder = new TextDecoder()
699
+ for (;;) {
700
+ const { done, value } = await reader.read()
701
+ if (done) break
702
+ this.appendChildStream(commandId, 'stdout', decoder.decode(value, { stream: true }))
703
+ }
704
+ })()
705
+ void (async () => {
706
+ const reader = child.proc.stderr.getReader()
707
+ const decoder = new TextDecoder()
708
+ for (;;) {
709
+ const { done, value } = await reader.read()
710
+ if (done) break
711
+ this.appendChildStream(commandId, 'stderr', decoder.decode(value, { stream: true }))
712
+ }
713
+ })()
714
+ void child.proc.exited.then((code) => {
715
+ const tracked = this.sandboxChildren.get(commandId)
716
+ if (tracked === undefined) return
717
+ tracked.exitCode = code ?? -1
718
+ // Enforce the cap on the stamp path too: a burst of fire-and-forget
719
+ // commands whose exits land after the last start's eviction pass must
720
+ // still settle within bound.
721
+ this.evictFinishedBeyondCap()
722
+ })
723
+ }
724
+
725
+ private async sandboxStart(
726
+ handle: SandboxEnvironmentIdentity,
727
+ request: SandboxCommandRequest,
728
+ ): Promise<{ commandId: string }> {
729
+ const cwd =
730
+ request.cwd === undefined ? handle.workspacePath : join(handle.workspacePath, request.cwd)
731
+ const proc = Bun.spawn(['sh', '-c', request.command], {
732
+ cwd,
733
+ stdin: 'ignore',
734
+ stdout: 'pipe',
735
+ stderr: 'pipe',
736
+ env: this.sandboxEnv(),
737
+ })
738
+ const commandId = `sbcmd-${randomBytes(8).toString('hex')}`
739
+ this.sandboxChildren.set(commandId, { proc, stdout: '', stderr: '', exitCode: null })
740
+ this.evictFinishedBeyondCap()
741
+ this.trackChildStreams(commandId, { proc })
742
+ return { commandId }
743
+ }
744
+
745
+ private async sandboxWait(
746
+ _handle: SandboxEnvironmentIdentity,
747
+ input: { commandId: string; waitSeconds: number },
748
+ ): Promise<SandboxWaitResult> {
749
+ const child = this.sandboxChildren.get(input.commandId)
750
+ if (child === undefined) {
751
+ throw new SandboxOperationError(
752
+ 'not-found',
753
+ `unknown sandbox command ${JSON.stringify(input.commandId)}; a restarted host loses detached-command tracking`,
754
+ )
755
+ }
756
+ const deadline = Date.now() + Math.min(Math.max(input.waitSeconds, 0), 300) * 1000
757
+ while (child.exitCode === null && Date.now() < deadline) {
758
+ await new Promise((resolve) => setTimeout(resolve, 25))
759
+ }
760
+ if (child.exitCode === null)
761
+ return { state: 'running', stdout: child.stdout, stderr: child.stderr }
762
+ // Consume-on-delivery: the terminal `wait` snapshots the result and drops
763
+ // the entry, so a second `wait` on the same id fails typed `not-found` —
764
+ // indistinguishable from the restarted-host case. `running` waits never
765
+ // consume, so poll loops keep working unchanged.
766
+ const result: SandboxWaitResult = {
767
+ state: 'exited',
768
+ exitCode: child.exitCode,
769
+ stdout: child.stdout,
770
+ stderr: child.stderr,
771
+ }
772
+ this.sandboxChildren.delete(input.commandId)
773
+ return result
774
+ }
775
+
776
+ private async sandboxRootedPath(
777
+ handle: SandboxEnvironmentIdentity,
778
+ path: string,
779
+ ): Promise<string> {
780
+ if (path.includes('\\') || path.startsWith('/')) {
781
+ throw new SandboxOperationError(
782
+ 'exec',
783
+ `sandbox path ${JSON.stringify(path)} must be relative to the checkout and use "/" separators`,
784
+ )
785
+ }
786
+ const rooted = resolve(handle.workspacePath, path)
787
+ if (
788
+ rooted !== handle.workspacePath &&
789
+ !rooted.startsWith(resolve(handle.workspacePath) + sep)
790
+ ) {
791
+ throw new SandboxOperationError(
792
+ 'exec',
793
+ `sandbox path ${JSON.stringify(path)} escapes the checkout`,
794
+ )
795
+ }
796
+ return rooted
797
+ }
798
+
799
+ private async sandboxReadFile(
800
+ handle: SandboxEnvironmentIdentity,
801
+ path: string,
802
+ ): Promise<Uint8Array> {
803
+ const rooted = await this.sandboxRootedPath(handle, path)
804
+ try {
805
+ return new Uint8Array(await fsReadFile(rooted))
806
+ } catch (error) {
807
+ throw new SandboxOperationError(
808
+ 'not-found',
809
+ `could not read sandbox file ${JSON.stringify(path)}: ${error instanceof Error ? error.message : String(error)}`,
810
+ { cause: error },
811
+ )
812
+ }
813
+ }
814
+
815
+ private async sandboxWriteFile(
816
+ handle: SandboxEnvironmentIdentity,
817
+ path: string,
818
+ content: Uint8Array,
819
+ ): Promise<void> {
820
+ const rooted = await this.sandboxRootedPath(handle, path)
821
+ await mkdir(resolve(rooted, '..'), { recursive: true })
822
+ await fsWriteFile(rooted, content)
823
+ }
824
+
825
+ private async sandboxRelease(input: {
826
+ repo: string
827
+ operator: string
828
+ environmentId: string
829
+ }): Promise<{ snapshots: { outcome: 'confirmed' } }> {
830
+ const path = resolve(input.environmentId)
831
+ const repo = this.repos.get(path) ?? (await this.discoverRepo(path)) ?? input.repo
832
+ const removed = await this.git(repo, ['worktree', 'remove', '--force', path])
833
+ if (
834
+ removed.exitCode !== 0 &&
835
+ !/is not a working tree|validation failed, cannot remove working tree/i.test(removed.stderr)
836
+ ) {
837
+ throw new SandboxOperationError(
838
+ 'release',
839
+ `could not remove operator sandbox worktree ${path}: ${removed.stderr.trim() || removed.stdout.trim() || `exit ${removed.exitCode}`}`,
840
+ )
841
+ }
842
+ await this.git(repo, ['worktree', 'prune'])
843
+ await rm(path, { recursive: true, force: true }).catch(() => {})
844
+ this.repos.delete(path)
845
+ // A local worktree has no snapshot concept; release is always complete.
846
+ return { snapshots: { outcome: 'confirmed' } }
847
+ }
848
+ }