@fitlab-ai/agent-infra 0.9.4 → 0.9.6

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 (130) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +13 -3
  4. package/dist/lib/agent-clients/adapter.js +2 -2
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +492 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +175 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +255 -0
  8. package/dist/lib/agent-clients/adapters/codex.js +3 -3
  9. package/dist/lib/git/workflow.js +0 -1
  10. package/dist/lib/git/worktree-identity.js +64 -0
  11. package/dist/lib/internal/codex-lifecycle.js +300 -0
  12. package/dist/lib/internal/release-workflow.js +108 -11
  13. package/dist/lib/internal/sandbox-control.js +43 -6
  14. package/dist/lib/internal/task-create.js +56 -0
  15. package/dist/lib/internal/task-orchestration.js +71 -31
  16. package/dist/lib/platform/issue-comments.js +2 -1
  17. package/dist/lib/release/workflow.js +29 -21
  18. package/dist/lib/sandbox/commands/create.js +16 -16
  19. package/dist/lib/sandbox/commands/prune.js +40 -16
  20. package/dist/lib/sandbox/commands/rm.js +318 -89
  21. package/dist/lib/sandbox/control/client.js +152 -22
  22. package/dist/lib/sandbox/control/executor.js +136 -0
  23. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  24. package/dist/lib/sandbox/control/protocol.js +73 -18
  25. package/dist/lib/sandbox/control/server.js +235 -80
  26. package/dist/lib/sandbox/control/state.js +181 -0
  27. package/dist/lib/sandbox/index.js +2 -2
  28. package/dist/lib/sandbox/managed-fs.js +14 -2
  29. package/dist/lib/sandbox/mounts.js +35 -24
  30. package/dist/lib/sandbox/recovery.js +262 -45
  31. package/dist/lib/sandbox/workspace-view.js +80 -11
  32. package/dist/lib/sandbox/worktree-safety.js +196 -0
  33. package/dist/lib/server/process-control.js +6 -0
  34. package/dist/lib/task/activity-log.js +32 -5
  35. package/dist/lib/task/artifact-lifecycle.js +5 -4
  36. package/dist/lib/task/codex-orchestration.js +161 -0
  37. package/dist/lib/task/commands/status.js +3 -0
  38. package/dist/lib/task/commands/validate.js +289 -0
  39. package/dist/lib/task/commit-finalization.js +50 -12
  40. package/dist/lib/task/create-service.js +137 -0
  41. package/dist/lib/task/create.js +333 -0
  42. package/dist/lib/task/delegation-receipts.js +25 -0
  43. package/dist/lib/task/events.js +10 -4
  44. package/dist/lib/task/finalization-artifacts.js +2 -1
  45. package/dist/lib/task/implementation-inputs.js +1 -7
  46. package/dist/lib/task/index.js +9 -0
  47. package/dist/lib/task/orchestration.js +283 -18
  48. package/dist/lib/task/review-fingerprint.js +4 -0
  49. package/dist/lib/task/verification-engine.js +38 -6
  50. package/dist/lib/task/verification.js +1 -0
  51. package/dist/lib/task/workspace-snapshot.js +78 -3
  52. package/lib/agent-clients/adapter.ts +4 -4
  53. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +569 -0
  54. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +323 -0
  55. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +298 -0
  56. package/lib/agent-clients/adapters/codex.ts +3 -3
  57. package/lib/git/workflow.ts +0 -1
  58. package/lib/git/worktree-identity.ts +86 -0
  59. package/lib/internal/codex-lifecycle.ts +308 -0
  60. package/lib/internal/release-workflow.ts +107 -11
  61. package/lib/internal/sandbox-control.ts +38 -6
  62. package/lib/internal/task-create.ts +61 -0
  63. package/lib/internal/task-orchestration.ts +57 -31
  64. package/lib/platform/issue-comments.ts +2 -1
  65. package/lib/release/workflow.ts +69 -20
  66. package/lib/sandbox/commands/create.ts +18 -21
  67. package/lib/sandbox/commands/prune.ts +53 -15
  68. package/lib/sandbox/commands/rm.ts +368 -103
  69. package/lib/sandbox/control/client.ts +173 -25
  70. package/lib/sandbox/control/executor.ts +163 -0
  71. package/lib/sandbox/control/lifecycle.ts +342 -0
  72. package/lib/sandbox/control/protocol.ts +110 -48
  73. package/lib/sandbox/control/server.ts +252 -85
  74. package/lib/sandbox/control/state.ts +196 -0
  75. package/lib/sandbox/index.ts +2 -2
  76. package/lib/sandbox/managed-fs.ts +17 -2
  77. package/lib/sandbox/mounts.ts +31 -13
  78. package/lib/sandbox/recovery.ts +301 -60
  79. package/lib/sandbox/workspace-view.ts +89 -12
  80. package/lib/sandbox/worktree-safety.ts +234 -0
  81. package/lib/server/process-control.ts +11 -0
  82. package/lib/task/activity-log.ts +46 -7
  83. package/lib/task/artifact-lifecycle.ts +5 -4
  84. package/lib/task/codex-orchestration.ts +233 -0
  85. package/lib/task/commands/status.ts +2 -0
  86. package/lib/task/commands/validate.ts +282 -0
  87. package/lib/task/commit-finalization.ts +75 -12
  88. package/lib/task/create-service.ts +171 -0
  89. package/lib/task/create.ts +372 -0
  90. package/lib/task/delegation-receipts.ts +52 -3
  91. package/lib/task/events.ts +9 -4
  92. package/lib/task/finalization-artifacts.ts +2 -1
  93. package/lib/task/implementation-inputs.ts +1 -5
  94. package/lib/task/index.ts +8 -0
  95. package/lib/task/orchestration.ts +381 -22
  96. package/lib/task/review-fingerprint.ts +9 -0
  97. package/lib/task/verification-engine.ts +42 -8
  98. package/lib/task/verification.ts +2 -1
  99. package/lib/task/workspace-snapshot.ts +98 -4
  100. package/package.json +7 -7
  101. package/templates/.agents/hooks/lifecycle-delegation.js +88 -8
  102. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  103. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  104. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  105. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  106. package/templates/.agents/rules/task-management.en.md +1 -0
  107. package/templates/.agents/rules/task-management.zh-CN.md +1 -0
  108. package/templates/.agents/skills/commit/SKILL.en.md +3 -9
  109. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -10
  110. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +4 -3
  111. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +5 -4
  112. package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
  113. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
  114. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  115. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  116. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  117. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  118. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  119. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  120. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  121. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  122. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  123. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  124. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  125. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  126. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  127. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  128. package/templates/.codex/hooks.json +50 -0
  129. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  130. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -6,6 +6,7 @@ import {
6
6
  worktreeDirCandidates
7
7
  } from './constants.ts';
8
8
  import type { SandboxConfig } from './config.ts';
9
+ import { sandboxWorkspaceViewStatePaths } from './workspace-view.ts';
9
10
 
10
11
  export type SandboxBindMountDeclaration = {
11
12
  hostPaths: string[];
@@ -24,10 +25,25 @@ export function sandboxCoreBindMounts(
24
25
  shellConfigHostDir?: string;
25
26
  workspaceViewRoot: string;
26
27
  controlDir: string;
28
+ controlStatusDir: string;
27
29
  taskSource?: string;
28
30
  taskId?: string;
29
31
  }
30
32
  ): SandboxBindMountDeclaration[] {
33
+ const taskBound = Boolean(overrides.taskSource && overrides.taskId);
34
+ const workspaceMounts = sandboxWorkspaceViewStatePaths(overrides.workspaceViewRoot).map(
35
+ ({ state, hostPath }) => taskBound && state === 'active'
36
+ ? {
37
+ hostPaths: [path.join(hostPath, '.short-ids.json')],
38
+ containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
39
+ readOnly: true
40
+ }
41
+ : {
42
+ hostPaths: [hostPath],
43
+ containerPath: path.posix.join('/workspace/.agents/workspace', state),
44
+ readOnly: true
45
+ }
46
+ );
31
47
  const mounts: SandboxBindMountDeclaration[] = [
32
48
  {
33
49
  hostPaths: overrides.worktree
@@ -36,11 +52,16 @@ export function sandboxCoreBindMounts(
36
52
  containerPath: '/workspace',
37
53
  readOnly: false
38
54
  },
39
- {
40
- hostPaths: [overrides.workspaceViewRoot],
41
- containerPath: '/workspace/.agents/workspace',
42
- readOnly: true
43
- },
55
+ ...workspaceMounts,
56
+ ];
57
+ if (taskBound) {
58
+ mounts.push({
59
+ hostPaths: [overrides.taskSource!],
60
+ containerPath: path.posix.join('/workspace/.agents/workspace/active', overrides.taskId!),
61
+ readOnly: false
62
+ });
63
+ }
64
+ mounts.push(
44
65
  {
45
66
  hostPaths: [shareCommonDir(config)],
46
67
  containerPath: '/share/common',
@@ -58,15 +79,12 @@ export function sandboxCoreBindMounts(
58
79
  containerPath: '/home/devuser/.host-shell-config',
59
80
  readOnly: true
60
81
  }
61
- ];
62
- if (overrides.taskSource && overrides.taskId) {
63
- mounts.splice(2, 0, {
64
- hostPaths: [overrides.taskSource],
65
- containerPath: path.posix.join('/workspace/.agents/workspace/active', overrides.taskId),
66
- readOnly: false
67
- });
68
- }
82
+ );
69
83
  mounts.push({
84
+ hostPaths: [overrides.controlStatusDir],
85
+ containerPath: '/run/agent-infra/control-status',
86
+ readOnly: true
87
+ }, {
70
88
  hostPaths: [overrides.controlDir],
71
89
  containerPath: '/run/agent-infra/control',
72
90
  readOnly: false
@@ -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,8 +31,10 @@ import {
29
31
  import {
30
32
  parseSandboxWorkspaceIdentity,
31
33
  sameSandboxWorkspaceIdentity,
34
+ type SandboxContainerWorkspaceIdentity,
32
35
  type SandboxWorkspaceIdentity
33
36
  } from './workspace-identity.ts';
37
+ import { inspectWorktree, type WorktreeSnapshot } from './worktree-safety.ts';
34
38
  import { runEngine, runOkEngine, runVerboseEngine } from './shell.ts';
35
39
  import {
36
40
  declaredTmpfsSeedEntries,
@@ -45,10 +49,24 @@ 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
+ isSandboxControlRootQuiescing
56
+ } from './control/lifecycle.ts';
57
+ import {
58
+ appendSandboxControlAudit,
59
+ readSandboxControlStatus
60
+ } from './control/state.ts';
61
+ import {
62
+ SANDBOX_CONTROL_FUTURE_SKEW_MS,
63
+ SANDBOX_CONTROL_STATUS_STALE_MS,
64
+ type SandboxControlManifest
65
+ } from './control/protocol.ts';
49
66
 
50
67
  export type SandboxRecoveryFinding = {
51
68
  repairKind: 'permissions' | 'missing-seed' | 'builtin-link' | 'hard-failure';
69
+ code?: string;
52
70
  message: string;
53
71
  path?: string;
54
72
  seed?: TmpfsSeedEntry;
@@ -66,6 +84,11 @@ type SandboxAgentClientCheckSnapshot = {
66
84
 
67
85
  export type SandboxRecoverySnapshot = {
68
86
  identityOk: boolean;
87
+ containerIdValid: boolean;
88
+ expectedBranch: string;
89
+ actualBranch: string | null;
90
+ expectedWorkspace: SandboxWorkspaceIdentity;
91
+ actualWorkspace: SandboxContainerWorkspaceIdentity;
69
92
  runtimeCapabilityOk?: boolean;
70
93
  unexpectedCapabilityMounts?: string[];
71
94
  mounts: Array<{
@@ -118,6 +141,7 @@ type RecoveryCommandDeps = {
118
141
  runVerbose?: typeof runVerboseEngine;
119
142
  start?: typeof startSandboxContainer;
120
143
  fetchRows?: typeof fetchSandboxRows;
144
+ ensureControlBroker?: () => void | Promise<void>;
121
145
  };
122
146
 
123
147
  type EnsureSandboxReadyParams = {
@@ -139,11 +163,153 @@ type ExpectedMount = {
139
163
  expectedRW: boolean;
140
164
  };
141
165
 
142
- function ensureSandboxControlBroker(params: {
166
+ async function waitForSandboxControlBrokerStatus(params: {
167
+ root: string;
168
+ statusDir: string;
169
+ generation: string;
170
+ pid: number;
171
+ startTime?: string;
172
+ }, timeoutMs = 5_000): Promise<void> {
173
+ const deadline = Date.now() + timeoutMs;
174
+ while (Date.now() < deadline) {
175
+ if (isSandboxControlRootQuiescing(params.root)) throw new Error('SANDBOX_CONTROL_QUIESCING');
176
+ try {
177
+ const status = readSandboxControlStatus(params.statusDir);
178
+ if (status.generation === params.generation && status.broker.pid === params.pid
179
+ && (!params.startTime || status.broker.startTime === params.startTime)
180
+ && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
181
+ && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS) return;
182
+ } catch {
183
+ // The broker may still be publishing its initial atomic status.
184
+ }
185
+ await new Promise((resolve) => setTimeout(resolve, 25));
186
+ }
187
+ throw new Error('SANDBOX_CONTROL_BROKER_START_TIMEOUT');
188
+ }
189
+
190
+ export async function startSandboxControlBroker(repoRoot: string, manifestPath: string): Promise<void> {
191
+ const directory = path.dirname(fileURLToPath(import.meta.url));
192
+ const extension = path.extname(fileURLToPath(import.meta.url));
193
+ const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
194
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as SandboxControlManifest;
195
+ const root = path.dirname(manifestPath);
196
+ if (isSandboxControlRootQuiescing(root)) throw new Error('SANDBOX_CONTROL_QUIESCING');
197
+ const brokerPath = path.join(root, 'broker.json');
198
+ let brokerSnapshot: string | null = null;
199
+ let replacedBrokerRecord = false;
200
+ try {
201
+ brokerSnapshot = fs.readFileSync(brokerPath, 'utf8');
202
+ } catch (error) {
203
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
204
+ }
205
+ if (brokerSnapshot !== null) {
206
+ type ExistingBrokerRecord = {
207
+ version?: unknown;
208
+ pid?: unknown;
209
+ startTime?: unknown;
210
+ token?: unknown;
211
+ generation?: unknown;
212
+ };
213
+ let broker: ExistingBrokerRecord | null = null;
214
+ try {
215
+ broker = JSON.parse(brokerSnapshot) as ExistingBrokerRecord;
216
+ } catch {
217
+ // Malformed owner records are stale and replaced below.
218
+ }
219
+ const owner = broker !== null && typeof broker.pid === 'number' && typeof broker.startTime === 'string'
220
+ ? { version: 1 as const, pid: broker.pid, startTime: broker.startTime }
221
+ : null;
222
+ const live = owner !== null && processIdentityMatches(owner);
223
+ if (
224
+ live
225
+ && owner !== null
226
+ && broker !== null
227
+ && broker.version === 2
228
+ && broker.token === manifest.token
229
+ && broker.generation === manifest.generation
230
+ ) {
231
+ await waitForSandboxControlBrokerStatus({
232
+ root,
233
+ statusDir: manifest.publicStatusDir,
234
+ generation: manifest.generation,
235
+ pid: owner.pid,
236
+ startTime: owner.startTime
237
+ });
238
+ return;
239
+ }
240
+ if (live && owner) {
241
+ const deadline = Date.now() + 5_000;
242
+ while (Date.now() < deadline) {
243
+ await new Promise((resolve) => setTimeout(resolve, 25));
244
+ try {
245
+ if (fs.readFileSync(brokerPath, 'utf8') !== brokerSnapshot) {
246
+ return startSandboxControlBroker(repoRoot, manifestPath);
247
+ }
248
+ } catch (error) {
249
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
250
+ return startSandboxControlBroker(repoRoot, manifestPath);
251
+ }
252
+ throw error;
253
+ }
254
+ if (!processIdentityMatches(owner)) {
255
+ removePidFileIfMatches(brokerPath, brokerSnapshot);
256
+ return startSandboxControlBroker(repoRoot, manifestPath);
257
+ }
258
+ }
259
+ throw new Error('SANDBOX_CONTROL_BROKER_OWNER_ACTIVE');
260
+ }
261
+ }
262
+ const startupStartTime = getProcessStartTime(process.pid);
263
+ if (!startupStartTime) throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
264
+ const releaseStartup = await acquireSandboxControlBrokerStartup(root, {
265
+ pid: process.pid,
266
+ startTime: startupStartTime
267
+ });
268
+ let child: ReturnType<typeof spawn>;
269
+ try {
270
+ const currentBroker = fs.existsSync(brokerPath) ? fs.readFileSync(brokerPath, 'utf8') : null;
271
+ if (currentBroker !== brokerSnapshot) return startSandboxControlBroker(repoRoot, manifestPath);
272
+ if (brokerSnapshot !== null) {
273
+ replacedBrokerRecord = removePidFileIfMatches(brokerPath, brokerSnapshot);
274
+ if (!replacedBrokerRecord && fs.existsSync(brokerPath)) {
275
+ throw new Error('SANDBOX_CONTROL_BROKER_OWNER_TRANSITION');
276
+ }
277
+ }
278
+ if (replacedBrokerRecord) {
279
+ appendSandboxControlAudit(manifest, 'broker-observed-crash');
280
+ appendSandboxControlAudit(manifest, 'broker-restart');
281
+ }
282
+ child = spawn(
283
+ process.execPath,
284
+ nodeEntryArgs(internalCli, ['sandbox-control', 'serve', '--manifest', manifestPath]),
285
+ { cwd: repoRoot, detached: true, stdio: 'ignore' }
286
+ );
287
+ } finally {
288
+ releaseStartup();
289
+ }
290
+ let spawnError: Error | null = null;
291
+ child.once('error', (error) => { spawnError = error; });
292
+ child.unref();
293
+ if (!child.pid) throw new Error('SANDBOX_CONTROL_BROKER_START_FAILED');
294
+ try {
295
+ await waitForSandboxControlBrokerStatus({
296
+ root,
297
+ statusDir: manifest.publicStatusDir,
298
+ generation: manifest.generation,
299
+ pid: child.pid
300
+ });
301
+ } catch (error) {
302
+ child.kill('SIGTERM');
303
+ if (spawnError) throw spawnError;
304
+ throw error;
305
+ }
306
+ }
307
+
308
+ async function ensureSandboxControlBroker(params: {
143
309
  config: SandboxConfig;
144
310
  container: string;
145
311
  workspace: SandboxWorkspaceIdentity;
146
- }): void {
312
+ }): Promise<void> {
147
313
  const control = sandboxControlPaths({
148
314
  base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
149
315
  project: params.config.project,
@@ -151,7 +317,10 @@ function ensureSandboxControlBroker(params: {
151
317
  identity: params.workspace
152
318
  });
153
319
  if (!fs.existsSync(control.manifestPath)) return;
154
- const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as { token?: unknown };
320
+ const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as SandboxControlManifest;
321
+ if (manifest.version !== 3) {
322
+ throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 3; container-only recreation is required');
323
+ }
155
324
  const brokerPath = path.join(control.root, 'broker.json');
156
325
  try {
157
326
  const broker = JSON.parse(fs.readFileSync(brokerPath, 'utf8')) as {
@@ -159,32 +328,38 @@ function ensureSandboxControlBroker(params: {
159
328
  pid?: unknown;
160
329
  startTime?: unknown;
161
330
  token?: unknown;
331
+ generation?: unknown;
162
332
  };
163
333
  if (
164
- broker.version === 1
334
+ broker.version === 2
165
335
  && typeof broker.pid === 'number'
166
336
  && typeof broker.startTime === 'string'
167
337
  && broker.token === manifest.token
338
+ && broker.generation === manifest.generation
168
339
  && processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime })
169
- ) return;
340
+ ) {
341
+ try {
342
+ const status = readSandboxControlStatus(control.statusDir);
343
+ if (status.generation === manifest.generation && status.broker.pid === broker.pid
344
+ && status.broker.startTime === broker.startTime
345
+ && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
346
+ && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS) return;
347
+ } catch {
348
+ // A verified live owner may still be publishing its initial status.
349
+ }
350
+ await waitForSandboxControlBrokerStatus({
351
+ root: control.root,
352
+ statusDir: control.statusDir,
353
+ generation: manifest.generation,
354
+ pid: broker.pid,
355
+ startTime: broker.startTime
356
+ });
357
+ return;
358
+ }
170
359
  } catch {
171
360
  // A missing, stale, or malformed broker record is replaced below.
172
361
  }
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();
362
+ await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
188
363
  }
189
364
 
190
365
  function findingKey(finding: SandboxRecoveryFinding): string {
@@ -194,9 +369,22 @@ function findingKey(finding: SandboxRecoveryFinding): string {
194
369
  export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): SandboxRecoveryFinding[] {
195
370
  const findings: SandboxRecoveryFinding[] = [];
196
371
  if (!snapshot.identityOk) {
372
+ const branchOnly = snapshot.containerIdValid
373
+ && snapshot.actualBranch === snapshot.expectedBranch
374
+ && snapshot.expectedWorkspace.mode === 'task-bound'
375
+ && snapshot.actualWorkspace.mode === 'branch-only';
376
+ const expectedIdentity = snapshot.expectedWorkspace.mode === 'task-bound'
377
+ ? `task-bound:${snapshot.expectedWorkspace.taskId}`
378
+ : snapshot.expectedWorkspace.mode;
379
+ const actualIdentity = snapshot.actualWorkspace.mode === 'task-bound'
380
+ ? `task-bound:${snapshot.actualWorkspace.taskId}`
381
+ : snapshot.actualWorkspace.mode;
197
382
  findings.push({
198
383
  repairKind: 'hard-failure',
199
- message: 'Container identity does not match the requested sandbox branch.'
384
+ code: branchOnly ? 'SANDBOX_CONTROL_BRANCH_ONLY' : 'SANDBOX_WORKSPACE_IDENTITY_CONFLICT',
385
+ message: branchOnly
386
+ ? `Container is branch-only but the requested sandbox is ${expectedIdentity}.`
387
+ : `Container identity ${actualIdentity} on branch ${snapshot.actualBranch ?? 'unknown'} does not match requested ${expectedIdentity} on branch ${snapshot.expectedBranch}.`
200
388
  });
201
389
  }
202
390
  if (snapshot.runtimeCapabilityOk === false) {
@@ -382,6 +570,7 @@ function expectedMounts(params: {
382
570
  const core = sandboxCoreBindMounts(config, branch, {
383
571
  workspaceViewRoot: view.root,
384
572
  controlDir: control.channelDir,
573
+ controlStatusDir: control.statusDir,
385
574
  ...(params.workspace.mode === 'task-bound'
386
575
  ? {
387
576
  taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
@@ -603,8 +792,12 @@ export function collectSandboxRecoverySnapshot(params: {
603
792
  });
604
793
 
605
794
  return {
606
- identityOk: typeof inspection.Id === 'string'
607
- && inspection.Id.length > 0
795
+ containerIdValid: typeof inspection.Id === 'string' && inspection.Id.length > 0,
796
+ expectedBranch: params.branch,
797
+ actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
798
+ expectedWorkspace: workspace,
799
+ actualWorkspace: containerWorkspace,
800
+ identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
608
801
  && branchLabel === params.branch
609
802
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
610
803
  runtimeCapabilityOk: enforceCanonicalPlan
@@ -762,7 +955,7 @@ function repairFindings(params: {
762
955
  }
763
956
 
764
957
  function describeFindings(findings: SandboxRecoveryFinding[]): string {
765
- return findings.map((finding) => finding.message).join(' ');
958
+ return findings.map((finding) => finding.code ? `${finding.code}: ${finding.message}` : finding.message).join(' ');
766
959
  }
767
960
 
768
961
  function assess(params: {
@@ -845,13 +1038,16 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
845
1038
  const startFn = deps?.start ?? startSandboxContainer;
846
1039
  const warnings: string[] = [];
847
1040
  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
1041
  try {
1042
+ if (deps?.ensureControlBroker) {
1043
+ await deps.ensureControlBroker();
1044
+ } else {
1045
+ await ensureSandboxControlBroker({
1046
+ config: params.config,
1047
+ container: params.row.name,
1048
+ workspace: params.workspace ?? { mode: 'branch-only' }
1049
+ });
1050
+ }
855
1051
  if (!params.row.running) {
856
1052
  startFn(params.engine, params.row.name);
857
1053
  const initial = assess({
@@ -975,42 +1171,87 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
975
1171
 
976
1172
  const dataBoundary =
977
1173
  '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.';
1174
+ const recoveryTarget = params.workspace?.mode === 'task-bound'
1175
+ ? params.workspace.taskId
1176
+ : params.branch;
978
1177
  if (!params.allowRecreate || !params.recreate) {
979
- throw new Error(`Sandbox recovery failed: ${failure.message} ${dataBoundary} Re-run with --recreate to authorize container-only replacement.`);
1178
+ throw new Error([
1179
+ `Sandbox recovery failed. Run 'ai sandbox start --recreate ${recoveryTarget}' on the host to authorize container-only replacement.`,
1180
+ dataBoundary,
1181
+ `Details: ${failure.message}`
1182
+ ].join('\n'));
1183
+ }
1184
+
1185
+ const existingWorktrees = worktreeDirCandidates(params.config, params.branch).filter((candidate) => fs.existsSync(candidate));
1186
+ if (existingWorktrees.length !== 1) {
1187
+ const remediation = existingWorktrees.length === 0
1188
+ ? `Restore the worktree for '${params.branch}' before recovering the container.`
1189
+ : `Found ${existingWorktrees.map((candidate) => JSON.stringify(candidate)).join(', ')}; `
1190
+ + 'keep the worktree in use, remove the stale directory, then retry.';
1191
+ throw new Error(
1192
+ `SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected exactly one existing worktree for '${params.branch}', found ${existingWorktrees.length}. ${remediation}`
1193
+ );
1194
+ }
1195
+ const beforeInspection = inspectWorktree(existingWorktrees[0]!);
1196
+ if (beforeInspection.status === 'failed') {
1197
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: ${beforeInspection.message}`);
1198
+ }
1199
+ const before: WorktreeSnapshot = beforeInspection.snapshot;
1200
+ if (before.branch !== params.branch) {
1201
+ throw new Error(
1202
+ `SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected branch '${params.branch}', found '${before.branch}'. `
1203
+ + `Run 'git -C ${JSON.stringify(before.worktree)} checkout ${params.branch}' to restore the expected branch, then retry.`
1204
+ );
980
1205
  }
981
1206
 
982
1207
  const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
983
1208
  warnings.push(warning);
984
1209
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
985
1210
  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);
1211
+ let replacementResult: SandboxReadyResult | null = null;
1212
+ let replacementFailure: unknown = null;
1213
+ try {
1214
+ if (params.row.running) {
1215
+ runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
1216
+ }
1217
+ runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
1218
+ await params.recreate(params.branch);
991
1219
 
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.');
1220
+ const rows = (deps?.fetchRows ?? fetchSandboxRows)(
1221
+ params.engine,
1222
+ sandboxLabel(params.config),
1223
+ sandboxBranchLabel(params.config)
1224
+ );
1225
+ const replacement = selectSandboxContainer(
1226
+ [...rows.running, ...rows.nonRunning],
1227
+ containerNameCandidates(params.config, params.branch)
1228
+ );
1229
+ if (!replacement?.running) {
1230
+ throw new Error('Replacement sandbox container was not found in a running state.');
1231
+ }
1232
+ const final = assess({
1233
+ config: params.config,
1234
+ engine: params.engine,
1235
+ branch: params.branch,
1236
+ workspace: params.workspace,
1237
+ container: replacement.name,
1238
+ deps
1239
+ });
1240
+ if (final.findings.length > 0) {
1241
+ throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
1242
+ }
1243
+ replacementResult = { container: replacement.name, path: 'recreated', warnings };
1244
+ } catch (error) {
1245
+ replacementFailure = error;
1003
1246
  }
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)}`);
1247
+
1248
+ const afterInspection = inspectWorktree(before.worktree);
1249
+ if (afterInspection.status === 'failed' || afterInspection.snapshot.identity !== before.identity) {
1250
+ const detail = afterInspection.status === 'failed'
1251
+ ? afterInspection.message
1252
+ : `snapshot changed from ${before.identity} to ${afterInspection.snapshot.identity}`;
1253
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_CHANGED: ${detail}`, replacementFailure === null ? undefined : { cause: replacementFailure });
1014
1254
  }
1015
- return { container: replacement.name, path: 'recreated', warnings };
1255
+ if (replacementFailure !== null) throw replacementFailure;
1256
+ return replacementResult!;
1016
1257
  }