@fitlab-ai/agent-infra 0.9.5 → 0.9.7

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 (172) hide show
  1. package/dist/bin/internal-cli.js +8 -3
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +171 -20
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +244 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +370 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +68 -8
  8. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  9. package/dist/lib/defaults.json +10 -2
  10. package/dist/lib/git/workflow.js +30 -4
  11. package/dist/lib/internal/codex-lifecycle.js +169 -3
  12. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  13. package/dist/lib/internal/release-workflow.js +108 -11
  14. package/dist/lib/internal/sandbox-control.js +43 -6
  15. package/dist/lib/internal/task-create.js +12 -4
  16. package/dist/lib/internal/task-orchestration.js +40 -7
  17. package/dist/lib/platform/context.js +6 -4
  18. package/dist/lib/platform/github-client.js +55 -2
  19. package/dist/lib/platform/issue-comments.js +2 -1
  20. package/dist/lib/process-data/checkpoint.js +319 -0
  21. package/dist/lib/process-data/index.js +128 -60
  22. package/dist/lib/process-data/materialize.js +84 -0
  23. package/dist/lib/process-data/normalize.js +34 -1
  24. package/dist/lib/process-data/reconcile.js +55 -1
  25. package/dist/lib/process-data/sources.js +422 -1
  26. package/dist/lib/process-data/store.js +341 -11
  27. package/dist/lib/release/workflow.js +29 -21
  28. package/dist/lib/sandbox/commands/create.js +111 -60
  29. package/dist/lib/sandbox/commands/prune.js +40 -16
  30. package/dist/lib/sandbox/commands/rm.js +372 -94
  31. package/dist/lib/sandbox/commands/start.js +4 -3
  32. package/dist/lib/sandbox/control/client.js +138 -22
  33. package/dist/lib/sandbox/control/container-identity.js +64 -0
  34. package/dist/lib/sandbox/control/executor.js +142 -0
  35. package/dist/lib/sandbox/control/lifecycle.js +557 -0
  36. package/dist/lib/sandbox/control/protocol.js +69 -26
  37. package/dist/lib/sandbox/control/server.js +395 -99
  38. package/dist/lib/sandbox/control/state.js +191 -0
  39. package/dist/lib/sandbox/control/timing.js +8 -0
  40. package/dist/lib/sandbox/index.js +2 -2
  41. package/dist/lib/sandbox/managed-fs.js +14 -2
  42. package/dist/lib/sandbox/mounts.js +22 -19
  43. package/dist/lib/sandbox/recovery.js +284 -50
  44. package/dist/lib/sandbox/workspace-view.js +90 -10
  45. package/dist/lib/sandbox/worktree-safety.js +196 -0
  46. package/dist/lib/server/daemon.js +1 -1
  47. package/dist/lib/server/process-control.js +17 -1
  48. package/dist/lib/server/process-state.js +57 -20
  49. package/dist/lib/task/artifact-lifecycle.js +87 -15
  50. package/dist/lib/task/artifact-receipts.js +128 -0
  51. package/dist/lib/task/codex-orchestration.js +236 -0
  52. package/dist/lib/task/commands/status.js +3 -0
  53. package/dist/lib/task/commands/validate.js +289 -0
  54. package/dist/lib/task/commit-finalization.js +36 -9
  55. package/dist/lib/task/create.js +10 -2
  56. package/dist/lib/task/delegation-receipts.js +109 -2
  57. package/dist/lib/task/events.js +149 -6
  58. package/dist/lib/task/finalization-artifacts.js +2 -1
  59. package/dist/lib/task/implementation-inputs.js +1 -7
  60. package/dist/lib/task/index.js +9 -0
  61. package/dist/lib/task/ledger.js +13 -1
  62. package/dist/lib/task/orchestration.js +365 -15
  63. package/dist/lib/task/sections.js +5 -1
  64. package/dist/lib/task/task-execution-lock.js +5 -4
  65. package/dist/lib/task/verification-engine.js +78 -14
  66. package/dist/lib/task/verification.js +1 -0
  67. package/dist/lifecycle-build-manifest.json +6 -0
  68. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +202 -18
  69. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  70. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +321 -0
  71. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
  72. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  73. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +453 -0
  74. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +83 -6
  75. package/lib/agent-clients/adapters/codex.ts +1 -1
  76. package/lib/defaults.json +10 -2
  77. package/lib/git/workflow.ts +25 -3
  78. package/lib/internal/codex-lifecycle.ts +175 -4
  79. package/lib/internal/codex-sandbox-controller.ts +109 -0
  80. package/lib/internal/release-workflow.ts +107 -11
  81. package/lib/internal/sandbox-control.ts +38 -6
  82. package/lib/internal/task-create.ts +16 -4
  83. package/lib/internal/task-orchestration.ts +34 -7
  84. package/lib/platform/context.ts +8 -5
  85. package/lib/platform/github-client.ts +61 -3
  86. package/lib/platform/issue-comments.ts +2 -1
  87. package/lib/process-data/checkpoint.ts +335 -0
  88. package/lib/process-data/index.ts +112 -60
  89. package/lib/process-data/materialize.ts +83 -0
  90. package/lib/process-data/normalize.ts +29 -1
  91. package/lib/process-data/reconcile.ts +64 -2
  92. package/lib/process-data/sources.ts +449 -2
  93. package/lib/process-data/store.ts +304 -11
  94. package/lib/process-data/types.ts +124 -2
  95. package/lib/release/workflow.ts +69 -20
  96. package/lib/sandbox/commands/create.ts +70 -25
  97. package/lib/sandbox/commands/prune.ts +53 -15
  98. package/lib/sandbox/commands/rm.ts +423 -108
  99. package/lib/sandbox/commands/start.ts +4 -3
  100. package/lib/sandbox/control/client.ts +152 -28
  101. package/lib/sandbox/control/container-identity.ts +93 -0
  102. package/lib/sandbox/control/executor.ts +167 -0
  103. package/lib/sandbox/control/lifecycle.ts +573 -0
  104. package/lib/sandbox/control/protocol.ts +121 -68
  105. package/lib/sandbox/control/server.ts +398 -109
  106. package/lib/sandbox/control/state.ts +213 -0
  107. package/lib/sandbox/control/timing.ts +12 -0
  108. package/lib/sandbox/index.ts +2 -2
  109. package/lib/sandbox/managed-fs.ts +17 -2
  110. package/lib/sandbox/mounts.ts +16 -8
  111. package/lib/sandbox/recovery.ts +335 -65
  112. package/lib/sandbox/workspace-view.ts +102 -11
  113. package/lib/sandbox/worktree-safety.ts +234 -0
  114. package/lib/server/daemon.ts +1 -1
  115. package/lib/server/process-control.ts +24 -3
  116. package/lib/server/process-state.ts +82 -27
  117. package/lib/task/artifact-lifecycle.ts +78 -14
  118. package/lib/task/artifact-receipts.ts +161 -0
  119. package/lib/task/codex-orchestration.ts +319 -0
  120. package/lib/task/commands/status.ts +2 -0
  121. package/lib/task/commands/validate.ts +282 -0
  122. package/lib/task/commit-finalization.ts +52 -9
  123. package/lib/task/create.ts +11 -2
  124. package/lib/task/delegation-receipts.ts +208 -7
  125. package/lib/task/events.ts +131 -5
  126. package/lib/task/finalization-artifacts.ts +2 -1
  127. package/lib/task/implementation-inputs.ts +1 -5
  128. package/lib/task/index.ts +8 -0
  129. package/lib/task/ledger.ts +16 -1
  130. package/lib/task/orchestration.ts +552 -18
  131. package/lib/task/sections.ts +6 -2
  132. package/lib/task/task-execution-lock.ts +11 -9
  133. package/lib/task/verification-engine.ts +93 -24
  134. package/lib/task/verification.ts +2 -1
  135. package/package.json +1 -1
  136. package/templates/.agents/README.en.md +7 -0
  137. package/templates/.agents/README.zh-CN.md +5 -0
  138. package/templates/.agents/hooks/lifecycle-delegation.js +72 -12
  139. package/templates/.agents/rules/lifecycle-orchestration.en.md +8 -2
  140. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +8 -2
  141. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  142. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  143. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  144. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  145. package/templates/.agents/rules/review-handshake.en.md +3 -1
  146. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  147. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  148. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  149. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  150. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  151. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  152. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  153. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  154. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  155. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +25 -0
  156. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +25 -0
  157. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  158. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  159. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +69 -0
  160. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +69 -0
  161. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  162. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  163. package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -3
  164. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -3
  165. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  166. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  167. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  168. package/templates/.codex/hooks.json +8 -8
  169. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  170. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
  171. package/templates/.agents/workspace/README.en.md +0 -26
  172. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -2,13 +2,15 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { nodeEntryArgs } from './control/executor.ts';
5
6
  import {
6
7
  containerNameCandidates,
7
8
  sandboxBranchLabel,
8
9
  sandboxLabel,
9
10
  sandboxRuntimeCapabilityLabel,
10
11
  sandboxTaskIdLabel,
11
- sandboxWorkspaceModeLabel
12
+ sandboxWorkspaceModeLabel,
13
+ worktreeDirCandidates
12
14
  } from './constants.ts';
13
15
  import { isAgentClientId } from '../agent-clients/types.ts';
14
16
  import {
@@ -29,9 +31,11 @@ import {
29
31
  import {
30
32
  parseSandboxWorkspaceIdentity,
31
33
  sameSandboxWorkspaceIdentity,
34
+ type SandboxContainerWorkspaceIdentity,
32
35
  type SandboxWorkspaceIdentity
33
36
  } from './workspace-identity.ts';
34
- import { runEngine, runOkEngine, runVerboseEngine } from './shell.ts';
37
+ import { inspectWorktree, type WorktreeSnapshot } from './worktree-safety.ts';
38
+ import { commandForEngine, runEngine, runOkEngine, runProbe, runVerboseEngine } from './shell.ts';
35
39
  import {
36
40
  declaredTmpfsSeedEntries,
37
41
  resolveTools,
@@ -45,10 +49,28 @@ import {
45
49
  startSandboxContainer,
46
50
  type SandboxRow
47
51
  } from './commands/list-running.ts';
48
- import { processIdentityMatches } from '../server/process-state.ts';
52
+ import { getProcessStartTime, processIdentityMatches, removePidFileIfMatches } from '../server/process-state.ts';
53
+ import {
54
+ acquireSandboxControlBrokerStartup,
55
+ garbageCollectSandboxControlRoot,
56
+ isSandboxControlRootQuiescing,
57
+ readSandboxControlManifest,
58
+ type BrokerOwner
59
+ } from './control/lifecycle.ts';
60
+ import {
61
+ appendSandboxControlAudit,
62
+ readSandboxControlStatus
63
+ } from './control/state.ts';
64
+ import { inspectSandboxControlContainer } from './control/container-identity.ts';
65
+ import {
66
+ SANDBOX_CONTROL_FUTURE_SKEW_MS,
67
+ SANDBOX_CONTROL_STATUS_STALE_MS,
68
+ type SandboxControlManifest
69
+ } from './control/protocol.ts';
49
70
 
50
71
  export type SandboxRecoveryFinding = {
51
72
  repairKind: 'permissions' | 'missing-seed' | 'builtin-link' | 'hard-failure';
73
+ code?: string;
52
74
  message: string;
53
75
  path?: string;
54
76
  seed?: TmpfsSeedEntry;
@@ -66,6 +88,11 @@ type SandboxAgentClientCheckSnapshot = {
66
88
 
67
89
  export type SandboxRecoverySnapshot = {
68
90
  identityOk: boolean;
91
+ containerIdValid: boolean;
92
+ expectedBranch: string;
93
+ actualBranch: string | null;
94
+ expectedWorkspace: SandboxWorkspaceIdentity;
95
+ actualWorkspace: SandboxContainerWorkspaceIdentity;
69
96
  runtimeCapabilityOk?: boolean;
70
97
  unexpectedCapabilityMounts?: string[];
71
98
  mounts: Array<{
@@ -118,8 +145,28 @@ type RecoveryCommandDeps = {
118
145
  runVerbose?: typeof runVerboseEngine;
119
146
  start?: typeof startSandboxContainer;
120
147
  fetchRows?: typeof fetchSandboxRows;
148
+ ensureControlBroker?: () => void | Promise<void>;
149
+ probe?: typeof runProbe;
121
150
  };
122
151
 
152
+ export function worktreeProbeForEngine(
153
+ engine: string,
154
+ probe: typeof runProbe = runProbe
155
+ ): typeof runProbe {
156
+ return (cmd, args, opts = {}) => {
157
+ const engineArgs = [...args];
158
+ if (engine === 'wsl2' && cmd === 'git') {
159
+ for (let index = 0; index < engineArgs.length - 1; index += 1) {
160
+ if (engineArgs[index] === '-C') {
161
+ engineArgs[index + 1] = toEnginePath(engine, engineArgs[index + 1]!);
162
+ }
163
+ }
164
+ }
165
+ const command = commandForEngine(engine, cmd, engineArgs);
166
+ return probe(command.cmd, command.args, opts);
167
+ };
168
+ }
169
+
123
170
  type EnsureSandboxReadyParams = {
124
171
  config: SandboxConfig;
125
172
  engine: string;
@@ -127,6 +174,7 @@ type EnsureSandboxReadyParams = {
127
174
  workspace?: SandboxWorkspaceIdentity;
128
175
  row: SandboxRow;
129
176
  allowRecreate?: boolean;
177
+ forceRecreate?: boolean;
130
178
  recreate?: (branch: string) => Promise<void>;
131
179
  writeWarning?: (message: string) => void;
132
180
  deps?: RecoveryCommandDeps;
@@ -139,11 +187,140 @@ type ExpectedMount = {
139
187
  expectedRW: boolean;
140
188
  };
141
189
 
142
- function ensureSandboxControlBroker(params: {
190
+ async function waitForSandboxControlBrokerStatus(params: {
191
+ root: string;
192
+ statusDir: string;
193
+ generation: string;
194
+ pid: number;
195
+ startTime?: number;
196
+ }, timeoutMs = 5_000): Promise<void> {
197
+ const deadline = Date.now() + timeoutMs;
198
+ while (Date.now() < deadline) {
199
+ if (isSandboxControlRootQuiescing(params.root)) throw new Error('SANDBOX_CONTROL_QUIESCING');
200
+ try {
201
+ const status = readSandboxControlStatus(params.statusDir);
202
+ if (status.generation === params.generation && status.broker.pid === params.pid
203
+ && (!params.startTime || status.broker.startTime === params.startTime)
204
+ && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
205
+ && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS) return;
206
+ } catch {
207
+ // The broker may still be publishing its initial atomic status.
208
+ }
209
+ await new Promise((resolve) => setTimeout(resolve, 25));
210
+ }
211
+ throw new Error('SANDBOX_CONTROL_BROKER_START_TIMEOUT');
212
+ }
213
+
214
+ export async function startSandboxControlBroker(repoRoot: string, manifestPath: string): Promise<void> {
215
+ const directory = path.dirname(fileURLToPath(import.meta.url));
216
+ const extension = path.extname(fileURLToPath(import.meta.url));
217
+ const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
218
+ const manifest = readSandboxControlManifest(manifestPath);
219
+ const root = path.dirname(manifestPath);
220
+ if (isSandboxControlRootQuiescing(root)) throw new Error('SANDBOX_CONTROL_QUIESCING');
221
+ const brokerPath = path.join(root, 'broker.json');
222
+ let brokerSnapshot: string | null = null;
223
+ let replacedBrokerRecord = false;
224
+ try {
225
+ brokerSnapshot = fs.readFileSync(brokerPath, 'utf8');
226
+ } catch (error) {
227
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
228
+ }
229
+ if (brokerSnapshot !== null) {
230
+ type ExistingBrokerRecord = {
231
+ version?: unknown;
232
+ pid?: unknown;
233
+ startTime?: unknown;
234
+ brokerId?: unknown;
235
+ token?: unknown;
236
+ generation?: unknown;
237
+ };
238
+ let broker: ExistingBrokerRecord | null = null;
239
+ try {
240
+ broker = JSON.parse(brokerSnapshot) as ExistingBrokerRecord;
241
+ } catch {
242
+ // Malformed owner records are stale and replaced below.
243
+ }
244
+ const owner = broker !== null && broker.version === 3 && typeof broker.pid === 'number'
245
+ && Number.isSafeInteger(broker.pid) && broker.pid > 0 && typeof broker.startTime === 'number'
246
+ && Number.isSafeInteger(broker.startTime) && typeof broker.brokerId === 'string'
247
+ && broker.brokerId.length > 0 && typeof broker.token === 'string'
248
+ && typeof broker.generation === 'string'
249
+ ? broker as BrokerOwner
250
+ : null;
251
+ if (broker === null || broker.version !== 3 || owner === null) {
252
+ throw new Error('SANDBOX_CONTROL_BROKER_VERSION_INVALID: rebuild the sandbox container before starting a new broker');
253
+ }
254
+ const live = owner !== null && processIdentityMatches({ pid: owner.pid, startTime: owner.startTime });
255
+ if (
256
+ live
257
+ && owner !== null
258
+ && broker !== null
259
+ && broker.version === 3
260
+ && broker.token === manifest.token
261
+ && broker.generation === manifest.generation
262
+ ) {
263
+ await waitForSandboxControlBrokerStatus({
264
+ root,
265
+ statusDir: manifest.publicStatusDir,
266
+ generation: manifest.generation,
267
+ pid: owner.pid,
268
+ startTime: owner.startTime
269
+ });
270
+ return;
271
+ }
272
+ }
273
+ const startupStartTime = getProcessStartTime(process.pid);
274
+ if (!startupStartTime) throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
275
+ const releaseStartup = await acquireSandboxControlBrokerStartup(root, {
276
+ pid: process.pid,
277
+ startTime: startupStartTime
278
+ });
279
+ let child: ReturnType<typeof spawn>;
280
+ try {
281
+ const currentBroker = fs.existsSync(brokerPath) ? fs.readFileSync(brokerPath, 'utf8') : null;
282
+ if (currentBroker !== brokerSnapshot) return startSandboxControlBroker(repoRoot, manifestPath);
283
+ if (brokerSnapshot !== null) {
284
+ replacedBrokerRecord = removePidFileIfMatches(brokerPath, brokerSnapshot);
285
+ if (!replacedBrokerRecord && fs.existsSync(brokerPath)) {
286
+ throw new Error('SANDBOX_CONTROL_BROKER_OWNER_TRANSITION');
287
+ }
288
+ }
289
+ if (replacedBrokerRecord) {
290
+ appendSandboxControlAudit(manifest, 'broker-observed-crash');
291
+ appendSandboxControlAudit(manifest, 'broker-restart');
292
+ }
293
+ child = spawn(
294
+ process.execPath,
295
+ nodeEntryArgs(internalCli, ['sandbox-control', 'serve', '--manifest', manifestPath]),
296
+ { cwd: repoRoot, detached: true, stdio: 'ignore' }
297
+ );
298
+ } finally {
299
+ releaseStartup();
300
+ }
301
+ let spawnError: Error | null = null;
302
+ child.once('error', (error) => { spawnError = error; });
303
+ child.unref();
304
+ if (!child.pid) throw new Error('SANDBOX_CONTROL_BROKER_START_FAILED');
305
+ try {
306
+ await waitForSandboxControlBrokerStatus({
307
+ root,
308
+ statusDir: manifest.publicStatusDir,
309
+ generation: manifest.generation,
310
+ pid: child.pid
311
+ });
312
+ } catch (error) {
313
+ child.kill('SIGTERM');
314
+ if (spawnError) throw spawnError;
315
+ throw error;
316
+ }
317
+ }
318
+
319
+ async function ensureSandboxControlBroker(params: {
143
320
  config: SandboxConfig;
144
321
  container: string;
145
322
  workspace: SandboxWorkspaceIdentity;
146
- }): void {
323
+ }): Promise<void> {
147
324
  const control = sandboxControlPaths({
148
325
  base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
149
326
  project: params.config.project,
@@ -151,40 +328,61 @@ function ensureSandboxControlBroker(params: {
151
328
  identity: params.workspace
152
329
  });
153
330
  if (!fs.existsSync(control.manifestPath)) return;
154
- const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as { token?: unknown };
331
+ const validatedManifest = readSandboxControlManifest(control.manifestPath);
332
+ const containerObservation = await inspectSandboxControlContainer(validatedManifest);
333
+ if (containerObservation.state === 'unknown') {
334
+ throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${containerObservation.reason}`);
335
+ }
336
+ if (containerObservation.state === 'absent') {
337
+ await garbageCollectSandboxControlRoot(control.root, {
338
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(validatedManifest, { timeoutMs })
339
+ });
340
+ throw new Error('SANDBOX_CONTROL_CONTAINER_ABSENT: recreate the sandbox container before broker recovery');
341
+ }
155
342
  const brokerPath = path.join(control.root, 'broker.json');
156
343
  try {
157
344
  const broker = JSON.parse(fs.readFileSync(brokerPath, 'utf8')) as {
158
345
  version?: unknown;
159
346
  pid?: unknown;
160
347
  startTime?: unknown;
348
+ brokerId?: unknown;
161
349
  token?: unknown;
350
+ generation?: unknown;
162
351
  };
163
352
  if (
164
- broker.version === 1
353
+ broker.version === 3
165
354
  && typeof broker.pid === 'number'
166
- && typeof broker.startTime === 'string'
167
- && broker.token === manifest.token
168
- && processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime })
169
- ) return;
355
+ && Number.isSafeInteger(broker.pid)
356
+ && typeof broker.startTime === 'number'
357
+ && Number.isSafeInteger(broker.startTime)
358
+ && typeof broker.brokerId === 'string'
359
+ && broker.brokerId.length > 0
360
+ && broker.token === validatedManifest.token
361
+ && broker.generation === validatedManifest.generation
362
+ && processIdentityMatches({ pid: broker.pid, startTime: broker.startTime })
363
+ ) {
364
+ try {
365
+ const status = readSandboxControlStatus(control.statusDir);
366
+ if (status.generation === validatedManifest.generation && status.broker.pid === broker.pid
367
+ && status.broker.startTime === broker.startTime
368
+ && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
369
+ && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS) return;
370
+ } catch {
371
+ // A verified live owner may still be publishing its initial status.
372
+ }
373
+ await waitForSandboxControlBrokerStatus({
374
+ root: control.root,
375
+ statusDir: control.statusDir,
376
+ generation: validatedManifest.generation,
377
+ pid: broker.pid,
378
+ startTime: broker.startTime
379
+ });
380
+ return;
381
+ }
170
382
  } catch {
171
383
  // A missing, stale, or malformed broker record is replaced below.
172
384
  }
173
- fs.rmSync(brokerPath, { force: true });
174
- const directory = path.dirname(fileURLToPath(import.meta.url));
175
- const extension = path.extname(fileURLToPath(import.meta.url));
176
- const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
177
- const child = spawn(
178
- process.execPath,
179
- [internalCli, 'sandbox-control', 'serve', '--manifest', control.manifestPath],
180
- { cwd: params.config.repoRoot, detached: true, stdio: 'ignore' }
181
- );
182
- child.once('error', () => {
183
- // Readiness remains fail-closed; a later control request reports the
184
- // unavailable broker without turning this detached spawn into an
185
- // unhandled process error.
186
- });
187
- child.unref();
385
+ await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
188
386
  }
189
387
 
190
388
  function findingKey(finding: SandboxRecoveryFinding): string {
@@ -194,9 +392,22 @@ function findingKey(finding: SandboxRecoveryFinding): string {
194
392
  export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): SandboxRecoveryFinding[] {
195
393
  const findings: SandboxRecoveryFinding[] = [];
196
394
  if (!snapshot.identityOk) {
395
+ const branchOnly = snapshot.containerIdValid
396
+ && snapshot.actualBranch === snapshot.expectedBranch
397
+ && snapshot.expectedWorkspace.mode === 'task-bound'
398
+ && snapshot.actualWorkspace.mode === 'branch-only';
399
+ const expectedIdentity = snapshot.expectedWorkspace.mode === 'task-bound'
400
+ ? `task-bound:${snapshot.expectedWorkspace.taskId}`
401
+ : snapshot.expectedWorkspace.mode;
402
+ const actualIdentity = snapshot.actualWorkspace.mode === 'task-bound'
403
+ ? `task-bound:${snapshot.actualWorkspace.taskId}`
404
+ : snapshot.actualWorkspace.mode;
197
405
  findings.push({
198
406
  repairKind: 'hard-failure',
199
- message: 'Container identity does not match the requested sandbox branch.'
407
+ code: branchOnly ? 'SANDBOX_CONTROL_BRANCH_ONLY' : 'SANDBOX_WORKSPACE_IDENTITY_CONFLICT',
408
+ message: branchOnly
409
+ ? `Container is branch-only but the requested sandbox is ${expectedIdentity}.`
410
+ : `Container identity ${actualIdentity} on branch ${snapshot.actualBranch ?? 'unknown'} does not match requested ${expectedIdentity} on branch ${snapshot.expectedBranch}.`
200
411
  });
201
412
  }
202
413
  if (snapshot.runtimeCapabilityOk === false) {
@@ -382,6 +593,7 @@ function expectedMounts(params: {
382
593
  const core = sandboxCoreBindMounts(config, branch, {
383
594
  workspaceViewRoot: view.root,
384
595
  controlDir: control.channelDir,
596
+ controlStatusDir: control.statusDir,
385
597
  ...(params.workspace.mode === 'task-bound'
386
598
  ? {
387
599
  taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
@@ -603,8 +815,12 @@ export function collectSandboxRecoverySnapshot(params: {
603
815
  });
604
816
 
605
817
  return {
606
- identityOk: typeof inspection.Id === 'string'
607
- && inspection.Id.length > 0
818
+ containerIdValid: typeof inspection.Id === 'string' && inspection.Id.length > 0,
819
+ expectedBranch: params.branch,
820
+ actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
821
+ expectedWorkspace: workspace,
822
+ actualWorkspace: containerWorkspace,
823
+ identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
608
824
  && branchLabel === params.branch
609
825
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
610
826
  runtimeCapabilityOk: enforceCanonicalPlan
@@ -762,7 +978,7 @@ function repairFindings(params: {
762
978
  }
763
979
 
764
980
  function describeFindings(findings: SandboxRecoveryFinding[]): string {
765
- return findings.map((finding) => finding.message).join(' ');
981
+ return findings.map((finding) => finding.code ? `${finding.code}: ${finding.message}` : finding.message).join(' ');
766
982
  }
767
983
 
768
984
  function assess(params: {
@@ -845,13 +1061,19 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
845
1061
  const startFn = deps?.start ?? startSandboxContainer;
846
1062
  const warnings: string[] = [];
847
1063
  let failure: Error | null = null;
848
- ensureSandboxControlBroker({
849
- config: params.config,
850
- container: params.row.name,
851
- workspace: params.workspace ?? { mode: 'branch-only' }
852
- });
853
-
854
1064
  try {
1065
+ if (deps?.ensureControlBroker) {
1066
+ await deps.ensureControlBroker();
1067
+ } else {
1068
+ await ensureSandboxControlBroker({
1069
+ config: params.config,
1070
+ container: params.row.name,
1071
+ workspace: params.workspace ?? { mode: 'branch-only' }
1072
+ });
1073
+ }
1074
+ if (params.forceRecreate) {
1075
+ throw new Error('Explicit container recreation requested.');
1076
+ }
855
1077
  if (!params.row.running) {
856
1078
  startFn(params.engine, params.row.name);
857
1079
  const initial = assess({
@@ -975,42 +1197,90 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
975
1197
 
976
1198
  const dataBoundary =
977
1199
  'The existing container writable layer, ordinary /tmp data, processes, tmux sessions, and RAM state may be lost by replacement; worktree and host-managed sandbox data are preserved.';
1200
+ const recoveryTarget = params.workspace?.mode === 'task-bound'
1201
+ ? params.workspace.taskId
1202
+ : params.branch;
978
1203
  if (!params.allowRecreate || !params.recreate) {
979
- throw new Error(`Sandbox recovery failed: ${failure.message} ${dataBoundary} Re-run with --recreate to authorize container-only replacement.`);
1204
+ throw new Error([
1205
+ `Sandbox recovery failed. Run 'ai sandbox start --recreate ${recoveryTarget}' on the host to authorize container-only replacement.`,
1206
+ dataBoundary,
1207
+ `Details: ${failure.message}`
1208
+ ].join('\n'));
1209
+ }
1210
+
1211
+ const existingWorktrees = worktreeDirCandidates(params.config, params.branch).filter((candidate) => fs.existsSync(candidate));
1212
+ if (existingWorktrees.length !== 1) {
1213
+ const remediation = existingWorktrees.length === 0
1214
+ ? `Restore the worktree for '${params.branch}' before recovering the container.`
1215
+ : `Found ${existingWorktrees.map((candidate) => JSON.stringify(candidate)).join(', ')}; `
1216
+ + 'keep the worktree in use, remove the stale directory, then retry.';
1217
+ throw new Error(
1218
+ `SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected exactly one existing worktree for '${params.branch}', found ${existingWorktrees.length}. ${remediation}`
1219
+ );
1220
+ }
1221
+ const worktreeProbe = worktreeProbeForEngine(params.engine, deps?.probe);
1222
+ const beforeInspection = inspectWorktree(existingWorktrees[0]!, { probe: worktreeProbe });
1223
+ if (beforeInspection.status === 'failed') {
1224
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: ${beforeInspection.message}`);
1225
+ }
1226
+ const before: WorktreeSnapshot = beforeInspection.snapshot;
1227
+ if (before.branch !== params.branch) {
1228
+ throw new Error(
1229
+ `SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected branch '${params.branch}', found '${before.branch}'. `
1230
+ + `Run 'git -C ${JSON.stringify(before.worktree)} checkout ${params.branch}' to restore the expected branch, then retry.`
1231
+ );
980
1232
  }
981
1233
 
982
- const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
1234
+ const warning = params.forceRecreate
1235
+ ? `Explicit sandbox recreation requested. Replacing only the container. ${dataBoundary}`
1236
+ : `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
983
1237
  warnings.push(warning);
984
1238
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
985
1239
  const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
986
- if (params.row.running) {
987
- runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
988
- }
989
- runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
990
- await params.recreate(params.branch);
1240
+ let replacementResult: SandboxReadyResult | null = null;
1241
+ let replacementFailure: unknown = null;
1242
+ try {
1243
+ if (params.row.running) {
1244
+ runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
1245
+ }
1246
+ runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
1247
+ await params.recreate(params.branch);
991
1248
 
992
- const rows = (deps?.fetchRows ?? fetchSandboxRows)(
993
- params.engine,
994
- sandboxLabel(params.config),
995
- sandboxBranchLabel(params.config)
996
- );
997
- const replacement = selectSandboxContainer(
998
- [...rows.running, ...rows.nonRunning],
999
- containerNameCandidates(params.config, params.branch)
1000
- );
1001
- if (!replacement?.running) {
1002
- throw new Error('Replacement sandbox container was not found in a running state.');
1249
+ const rows = (deps?.fetchRows ?? fetchSandboxRows)(
1250
+ params.engine,
1251
+ sandboxLabel(params.config),
1252
+ sandboxBranchLabel(params.config)
1253
+ );
1254
+ const replacement = selectSandboxContainer(
1255
+ [...rows.running, ...rows.nonRunning],
1256
+ containerNameCandidates(params.config, params.branch)
1257
+ );
1258
+ if (!replacement?.running) {
1259
+ throw new Error('Replacement sandbox container was not found in a running state.');
1260
+ }
1261
+ const final = assess({
1262
+ config: params.config,
1263
+ engine: params.engine,
1264
+ branch: params.branch,
1265
+ workspace: params.workspace,
1266
+ container: replacement.name,
1267
+ deps
1268
+ });
1269
+ if (final.findings.length > 0) {
1270
+ throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
1271
+ }
1272
+ replacementResult = { container: replacement.name, path: 'recreated', warnings };
1273
+ } catch (error) {
1274
+ replacementFailure = error;
1003
1275
  }
1004
- const final = assess({
1005
- config: params.config,
1006
- engine: params.engine,
1007
- branch: params.branch,
1008
- workspace: params.workspace,
1009
- container: replacement.name,
1010
- deps
1011
- });
1012
- if (final.findings.length > 0) {
1013
- throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
1276
+
1277
+ const afterInspection = inspectWorktree(before.worktree, { probe: worktreeProbe });
1278
+ if (afterInspection.status === 'failed' || afterInspection.snapshot.identity !== before.identity) {
1279
+ const detail = afterInspection.status === 'failed'
1280
+ ? afterInspection.message
1281
+ : `snapshot changed from ${before.identity} to ${afterInspection.snapshot.identity}`;
1282
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_CHANGED: ${detail}`, replacementFailure === null ? undefined : { cause: replacementFailure });
1014
1283
  }
1015
- return { container: replacement.name, path: 'recreated', warnings };
1284
+ if (replacementFailure !== null) throw replacementFailure;
1285
+ return replacementResult!;
1016
1286
  }