@fitlab-ai/agent-infra 0.9.6 → 0.9.8

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 (178) hide show
  1. package/dist/bin/internal-cli.js +10 -0
  2. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +47 -5
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +245 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +398 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +23 -6
  8. package/dist/lib/decide.js +68 -14
  9. package/dist/lib/defaults.json +10 -2
  10. package/dist/lib/git/workflow.js +30 -3
  11. package/dist/lib/init.js +1 -1
  12. package/dist/lib/internal/codex-lifecycle.js +69 -1
  13. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  14. package/dist/lib/internal/release-workflow.js +1 -3
  15. package/dist/lib/internal/task-activity.js +57 -7
  16. package/dist/lib/internal/task-event.js +57 -4
  17. package/dist/lib/internal/task-ledger.js +59 -5
  18. package/dist/lib/internal/task-lifecycle.js +59 -8
  19. package/dist/lib/internal/task-orchestration.js +48 -8
  20. package/dist/lib/internal/task-override.js +131 -0
  21. package/dist/lib/internal/task-review.js +64 -6
  22. package/dist/lib/internal/task-verify.js +65 -3
  23. package/dist/lib/internal/task-warning.js +57 -4
  24. package/dist/lib/platform/context.js +6 -4
  25. package/dist/lib/platform/github-client.js +55 -2
  26. package/dist/lib/platform/pr-summary.js +5 -3
  27. package/dist/lib/process-data/checkpoint.js +319 -0
  28. package/dist/lib/process-data/index.js +128 -60
  29. package/dist/lib/process-data/materialize.js +84 -0
  30. package/dist/lib/process-data/normalize.js +34 -1
  31. package/dist/lib/process-data/reconcile.js +55 -1
  32. package/dist/lib/process-data/sources.js +422 -1
  33. package/dist/lib/process-data/store.js +341 -11
  34. package/dist/lib/runtime/agent-runtime.js +56 -0
  35. package/dist/lib/sandbox/commands/create.js +394 -264
  36. package/dist/lib/sandbox/commands/rm.js +64 -15
  37. package/dist/lib/sandbox/commands/start.js +4 -3
  38. package/dist/lib/sandbox/control/container-identity.js +64 -0
  39. package/dist/lib/sandbox/control/executor.js +23 -9
  40. package/dist/lib/sandbox/control/lifecycle.js +590 -61
  41. package/dist/lib/sandbox/control/protocol.js +7 -0
  42. package/dist/lib/sandbox/control/server.js +202 -26
  43. package/dist/lib/sandbox/control/state.js +51 -25
  44. package/dist/lib/sandbox/control/timing.js +8 -0
  45. package/dist/lib/sandbox/engine.js +14 -7
  46. package/dist/lib/sandbox/mounts.js +7 -0
  47. package/dist/lib/sandbox/recovery.js +135 -50
  48. package/dist/lib/sandbox/workspace-view.js +59 -20
  49. package/dist/lib/server/daemon.js +1 -1
  50. package/dist/lib/server/process-control.js +11 -1
  51. package/dist/lib/server/process-state.js +107 -20
  52. package/dist/lib/task/activity-intent.js +4 -1
  53. package/dist/lib/task/artifact-lifecycle.js +82 -11
  54. package/dist/lib/task/artifact-receipts.js +128 -0
  55. package/dist/lib/task/codex-orchestration.js +81 -6
  56. package/dist/lib/task/commands/validate.js +1 -1
  57. package/dist/lib/task/create.js +10 -2
  58. package/dist/lib/task/decision-intents.js +4 -2
  59. package/dist/lib/task/delegation-receipts.js +120 -17
  60. package/dist/lib/task/events.js +148 -7
  61. package/dist/lib/task/guard-override.js +31 -0
  62. package/dist/lib/task/human-override.js +1240 -0
  63. package/dist/lib/task/ledger-intents.js +6 -3
  64. package/dist/lib/task/ledger.js +13 -1
  65. package/dist/lib/task/lifecycle.js +69 -14
  66. package/dist/lib/task/orchestration.js +293 -18
  67. package/dist/lib/task/review-finalization.js +3 -2
  68. package/dist/lib/task/sections.js +5 -1
  69. package/dist/lib/task/task-execution-lock.js +5 -4
  70. package/dist/lib/task/verification-engine.js +80 -22
  71. package/dist/lib/task/verification.js +13 -1
  72. package/dist/lib/task/workflow-warning-intents.js +4 -2
  73. package/dist/lib/task/write.js +2 -1
  74. package/dist/lifecycle-build-manifest.json +6 -0
  75. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  76. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +62 -4
  77. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  78. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +322 -0
  79. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  80. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +480 -0
  81. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +35 -5
  82. package/lib/decide.ts +59 -14
  83. package/lib/defaults.json +10 -2
  84. package/lib/git/workflow.ts +25 -2
  85. package/lib/init.ts +1 -1
  86. package/lib/internal/codex-lifecycle.ts +68 -1
  87. package/lib/internal/codex-sandbox-controller.ts +109 -0
  88. package/lib/internal/release-workflow.ts +1 -3
  89. package/lib/internal/task-activity.ts +46 -6
  90. package/lib/internal/task-event.ts +53 -4
  91. package/lib/internal/task-ledger.ts +48 -4
  92. package/lib/internal/task-lifecycle.ts +56 -12
  93. package/lib/internal/task-orchestration.ts +43 -8
  94. package/lib/internal/task-override.ts +138 -0
  95. package/lib/internal/task-review.ts +51 -6
  96. package/lib/internal/task-verify.ts +52 -3
  97. package/lib/internal/task-warning.ts +44 -3
  98. package/lib/platform/context.ts +8 -5
  99. package/lib/platform/github-client.ts +61 -3
  100. package/lib/platform/pr-summary.ts +10 -3
  101. package/lib/process-data/checkpoint.ts +335 -0
  102. package/lib/process-data/index.ts +112 -60
  103. package/lib/process-data/materialize.ts +83 -0
  104. package/lib/process-data/normalize.ts +29 -1
  105. package/lib/process-data/reconcile.ts +64 -2
  106. package/lib/process-data/sources.ts +449 -2
  107. package/lib/process-data/store.ts +304 -11
  108. package/lib/process-data/types.ts +124 -2
  109. package/lib/runtime/agent-runtime.ts +76 -0
  110. package/lib/sandbox/commands/create.ts +139 -6
  111. package/lib/sandbox/commands/rm.ts +64 -14
  112. package/lib/sandbox/commands/start.ts +4 -3
  113. package/lib/sandbox/config.ts +2 -1
  114. package/lib/sandbox/control/container-identity.ts +93 -0
  115. package/lib/sandbox/control/executor.ts +24 -12
  116. package/lib/sandbox/control/lifecycle.ts +685 -67
  117. package/lib/sandbox/control/protocol.ts +35 -6
  118. package/lib/sandbox/control/server.ts +184 -24
  119. package/lib/sandbox/control/state.ts +67 -33
  120. package/lib/sandbox/control/timing.ts +12 -0
  121. package/lib/sandbox/engine.ts +20 -9
  122. package/lib/sandbox/mounts.ts +8 -0
  123. package/lib/sandbox/recovery.ts +159 -49
  124. package/lib/sandbox/workspace-view.ts +73 -22
  125. package/lib/server/daemon.ts +1 -1
  126. package/lib/server/process-control.ts +13 -3
  127. package/lib/server/process-state.ts +129 -27
  128. package/lib/task/activity-intent.ts +4 -2
  129. package/lib/task/artifact-lifecycle.ts +73 -10
  130. package/lib/task/artifact-receipts.ts +161 -0
  131. package/lib/task/codex-orchestration.ts +91 -5
  132. package/lib/task/commands/validate.ts +1 -1
  133. package/lib/task/create.ts +11 -2
  134. package/lib/task/decision-intents.ts +4 -2
  135. package/lib/task/delegation-receipts.ts +205 -25
  136. package/lib/task/events.ts +132 -6
  137. package/lib/task/guard-override.ts +50 -0
  138. package/lib/task/human-override.ts +1503 -0
  139. package/lib/task/ledger-intents.ts +6 -3
  140. package/lib/task/ledger.ts +16 -1
  141. package/lib/task/lifecycle.ts +71 -14
  142. package/lib/task/orchestration.ts +443 -22
  143. package/lib/task/review-finalization.ts +6 -2
  144. package/lib/task/sections.ts +6 -2
  145. package/lib/task/task-execution-lock.ts +11 -9
  146. package/lib/task/verification-engine.ts +93 -32
  147. package/lib/task/verification.ts +15 -1
  148. package/lib/task/workflow-warning-intents.ts +4 -2
  149. package/lib/task/write.ts +4 -1
  150. package/package.json +1 -1
  151. package/templates/.agents/README.en.md +7 -0
  152. package/templates/.agents/README.zh-CN.md +5 -0
  153. package/templates/.agents/hooks/lifecycle-delegation.js +33 -4
  154. package/templates/.agents/rules/lifecycle-orchestration.en.md +10 -3
  155. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +10 -3
  156. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  157. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  158. package/templates/.agents/rules/review-handshake.en.md +3 -1
  159. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  160. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  161. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  162. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  163. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  164. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +6 -6
  165. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +6 -6
  166. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -3
  167. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -3
  168. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  169. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  170. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +25 -0
  171. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +25 -0
  172. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  173. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  174. package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -1
  175. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -1
  176. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  177. package/templates/.agents/workspace/README.en.md +0 -26
  178. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -69,13 +69,26 @@ import {
69
69
  prepareTmpfsMounts,
70
70
  startSandboxControlBroker
71
71
  } from '../recovery.ts';
72
+ import {
73
+ acquireSandboxControlReplacement,
74
+ assertSandboxControlCutoverSnapshot,
75
+ beginSandboxControlReplacement,
76
+ captureSandboxControlCutoverSnapshot,
77
+ commitSandboxControlReplacement,
78
+ quiesceSandboxControlRoot,
79
+ recoverSandboxControlReplacement,
80
+ readSandboxControlManifestForTransition
81
+ } from '../control/lifecycle.ts';
82
+ import { inspectSandboxControlContainer } from '../control/container-identity.ts';
72
83
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
73
84
  import { sandboxCoreBindMounts } from '../mounts.ts';
74
85
  import {
75
86
  assertSandboxTaskSource,
87
+ finalizeSandboxControlManifest,
76
88
  materializeSandboxControl,
77
89
  materializeSandboxWorkspaceView,
78
- prepareSandboxWorkspaceMountTargets
90
+ prepareSandboxWorkspaceMountTargets,
91
+ sandboxControlPaths,
79
92
  } from '../workspace-view.ts';
80
93
  import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.ts';
81
94
  import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
@@ -1035,6 +1048,12 @@ export async function create(args: string[]): Promise<void> {
1035
1048
  }
1036
1049
  const effectiveResolvedTools = resolvedTools;
1037
1050
  const container = containerName(effectiveConfig, branch);
1051
+ const controlPaths = sandboxControlPaths({
1052
+ base: effectiveConfig.controlBase,
1053
+ project: effectiveConfig.project,
1054
+ container,
1055
+ identity: target.workspace
1056
+ });
1038
1057
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
1039
1058
  const shareCommon = shareCommonDir(effectiveConfig);
1040
1059
  const shareBranch = shareBranchDir(effectiveConfig, branch);
@@ -1223,6 +1242,22 @@ export async function create(args: string[]): Promise<void> {
1223
1242
  {
1224
1243
  title: `Starting container '${container}'`,
1225
1244
  task: async (message: (text: string) => void) => {
1245
+ const hadControlRootBeforeAcquire = fs.existsSync(controlPaths.root);
1246
+ const replacementLease = acquireSandboxControlReplacement(controlPaths.root);
1247
+ let replacementLeaseHeld = true;
1248
+ let replacementCutover: ReturnType<typeof beginSandboxControlReplacement> | null = null;
1249
+ try {
1250
+ const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
1251
+ const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
1252
+ const previousManifest = fs.existsSync(controlPaths.manifestPath)
1253
+ ? readSandboxControlManifestForTransition(controlPaths.manifestPath)
1254
+ : null;
1255
+ if (hadExistingControlRoot) {
1256
+ await quiesceSandboxControlRoot(controlPaths.root);
1257
+ }
1258
+ const previousCutoverSnapshot = hadExistingControlRoot
1259
+ ? captureSandboxControlCutoverSnapshot(controlPaths.root)
1260
+ : null;
1226
1261
  const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
1227
1262
  const matchedContainers = containerNameCandidates(effectiveConfig, branch)
1228
1263
  .filter((name) => existing.includes(name));
@@ -1265,6 +1300,14 @@ export async function create(args: string[]): Promise<void> {
1265
1300
  runSafeEngine(engine, 'docker', ['rm', name]);
1266
1301
  }
1267
1302
  }
1303
+ if (previousManifest) {
1304
+ const oldContainer = await inspectSandboxControlContainer(previousManifest);
1305
+ if (oldContainer.state !== 'absent') {
1306
+ throw new Error(oldContainer.state === 'unknown'
1307
+ ? `SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${oldContainer.reason}`
1308
+ : 'SANDBOX_CONTROL_CONTAINER_STILL_EXISTS');
1309
+ }
1310
+ }
1268
1311
 
1269
1312
  const aliasesFile = ensureSandboxAliasesFile(
1270
1313
  effectiveConfig.home,
@@ -1342,6 +1385,18 @@ export async function create(args: string[]): Promise<void> {
1342
1385
  identity: target.workspace
1343
1386
  });
1344
1387
  prepareSandboxWorkspaceMountTargets(worktree);
1388
+ if (previousCutoverSnapshot) {
1389
+ assertSandboxControlCutoverSnapshot(previousCutoverSnapshot);
1390
+ if (previousManifest) {
1391
+ const finalOldContainer = await inspectSandboxControlContainer(previousManifest);
1392
+ if (finalOldContainer.state !== 'absent') {
1393
+ throw new Error(finalOldContainer.state === 'unknown'
1394
+ ? `SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${finalOldContainer.reason}`
1395
+ : 'SANDBOX_CONTROL_CONTAINER_REAPPEARED');
1396
+ }
1397
+ }
1398
+ replacementCutover = beginSandboxControlReplacement(controlPaths.root, replacementLease);
1399
+ }
1345
1400
  const control = materializeSandboxControl({
1346
1401
  base: effectiveConfig.controlBase,
1347
1402
  repoRoot: effectiveConfig.repoRoot,
@@ -1349,7 +1404,9 @@ export async function create(args: string[]): Promise<void> {
1349
1404
  project: effectiveConfig.project,
1350
1405
  container,
1351
1406
  branch,
1352
- identity: target.workspace
1407
+ identity: target.workspace,
1408
+ engine,
1409
+ replacementLease
1353
1410
  });
1354
1411
  hostShellConfig = prepareHostShellConfig({
1355
1412
  home: effectiveConfig.home,
@@ -1363,6 +1420,7 @@ export async function create(args: string[]): Promise<void> {
1363
1420
  workspaceViewRoot: workspaceView.root,
1364
1421
  controlDir: control.channelDir,
1365
1422
  controlStatusDir: control.statusDir,
1423
+ ...(target.workspace.mode === 'task-bound' ? { runtimeDir: control.runtimeDir } : {}),
1366
1424
  ...(target.workspace.mode === 'task-bound'
1367
1425
  ? {
1368
1426
  taskSource: assertSandboxTaskSource(
@@ -1422,9 +1480,12 @@ export async function create(args: string[]): Promise<void> {
1422
1480
  const hostTz = detectHostTimezone();
1423
1481
  const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
1424
1482
 
1425
- runEngineTaskCommand(engine, 'docker', [
1483
+ let createdContainerRef: string | null = null;
1484
+ try {
1485
+ const dockerRunId = runEngineTaskCommand(engine, 'docker', [
1426
1486
  'run',
1427
1487
  '-d',
1488
+ '--init',
1428
1489
  '--name',
1429
1490
  container,
1430
1491
  '--hostname',
@@ -1451,6 +1512,9 @@ export async function create(args: string[]): Promise<void> {
1451
1512
  'AGENT_INFRA_CONTROL_DIR=/run/agent-infra/control',
1452
1513
  '-e',
1453
1514
  'AGENT_INFRA_CONTROL_STATUS_DIR=/run/agent-infra/control-status',
1515
+ ...(target.workspace.mode === 'task-bound'
1516
+ ? ['-e', 'AGENT_INFRA_RUNTIME_DIR=/run/agent-infra/runtime']
1517
+ : []),
1454
1518
  '--label',
1455
1519
  `${sandboxRuntimeCapabilityLabel(effectiveConfig)}=${capabilityPlan.runtimeSignature}`,
1456
1520
  ...coreVolumes,
@@ -1471,9 +1535,66 @@ export async function create(args: string[]): Promise<void> {
1471
1535
  '-w',
1472
1536
  '/workspace',
1473
1537
  effectiveConfig.imageName
1474
- ]);
1475
- await startSandboxControlBroker(effectiveConfig.repoRoot, control.manifestPath);
1476
- createdTmpfsSeedPlan = tmpfsSeedPlan;
1538
+ ]);
1539
+ createdContainerRef = dockerRunId.trim();
1540
+ const containerId = runEngineTaskCommand(engine, 'docker', [
1541
+ 'inspect', '--format', '{{.Id}}', dockerRunId.trim()
1542
+ ]).trim();
1543
+ if (!containerId) throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
1544
+ const rawContainerLabels = runEngineTaskCommand(engine, 'docker', [
1545
+ 'inspect', '--format', '{{json .Config.Labels}}', containerId
1546
+ ]).trim();
1547
+ let inspectedLabels: unknown;
1548
+ try {
1549
+ inspectedLabels = JSON.parse(rawContainerLabels);
1550
+ } catch {
1551
+ throw new Error('SANDBOX_CONTROL_CONTAINER_LABELS_INVALID');
1552
+ }
1553
+ if (!inspectedLabels || typeof inspectedLabels !== 'object' || Array.isArray(inspectedLabels)) {
1554
+ throw new Error('SANDBOX_CONTROL_CONTAINER_LABELS_INVALID');
1555
+ }
1556
+ const expectedLabels: Record<string, string> = {
1557
+ [sandboxLabel(effectiveConfig)]: '',
1558
+ [sandboxBranchLabel(effectiveConfig)]: branch,
1559
+ [sandboxWorkspaceModeLabel(effectiveConfig)]: target.workspace.mode,
1560
+ [sandboxRuntimeCapabilityLabel(effectiveConfig)]: capabilityPlan.runtimeSignature,
1561
+ ...(target.workspace.mode === 'task-bound'
1562
+ ? { [sandboxTaskIdLabel(effectiveConfig)]: target.workspace.taskId }
1563
+ : {})
1564
+ };
1565
+ const labels = inspectedLabels as Record<string, unknown>;
1566
+ for (const [key, expected] of Object.entries(expectedLabels)) {
1567
+ if (labels[key] !== expected) throw new Error('SANDBOX_CONTROL_CONTAINER_IDENTITY_MISMATCH');
1568
+ }
1569
+ finalizeSandboxControlManifest(control, {
1570
+ engine,
1571
+ id: containerId,
1572
+ labels: expectedLabels
1573
+ });
1574
+ replacementLease.clearQuiescing();
1575
+ await startSandboxControlBroker(effectiveConfig.repoRoot, control.manifestPath);
1576
+ if (replacementCutover) {
1577
+ commitSandboxControlReplacement(control.root, replacementLease);
1578
+ replacementCutover = null;
1579
+ }
1580
+ createdTmpfsSeedPlan = tmpfsSeedPlan;
1581
+ } catch (error) {
1582
+ if (createdContainerRef) {
1583
+ runSafeEngine(engine, 'docker', ['stop', createdContainerRef]);
1584
+ runSafeEngine(engine, 'docker', ['rm', createdContainerRef]);
1585
+ }
1586
+ if (!hadExistingControlRoot) removeDirRecursive(control.root);
1587
+ if (replacementCutover) {
1588
+ try {
1589
+ await recoverSandboxControlReplacement(control.root, replacementLease);
1590
+ replacementCutover = null;
1591
+ } catch {
1592
+ // Preserve the cutover state for the next explicit recreate to recover.
1593
+ }
1594
+ }
1595
+ removeDirRecursive(workspaceView.root);
1596
+ throw error;
1597
+ }
1477
1598
  } finally {
1478
1599
  envFile.cleanup();
1479
1600
  }
@@ -1566,7 +1687,19 @@ export async function create(args: string[]): Promise<void> {
1566
1687
  );
1567
1688
  }
1568
1689
 
1690
+ replacementLease.release();
1691
+ replacementLeaseHeld = false;
1569
1692
  return 'Container started';
1693
+ } catch (error) {
1694
+ if (replacementLeaseHeld) {
1695
+ try {
1696
+ replacementLease.release();
1697
+ } catch {
1698
+ // Preserve the original cutover failure and its evidence.
1699
+ }
1700
+ }
1701
+ throw error;
1702
+ }
1570
1703
  }
1571
1704
  }
1572
1705
  ]);
@@ -20,7 +20,8 @@ import { assertManagedPath, removeManagedDir, removeWorktreeDir } from '../manag
20
20
  import { runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.ts';
21
21
  import { resolveSandboxTarget, type SandboxWorkspaceIdentity } from '../workspace-identity.ts';
22
22
  import { sandboxControlPaths, sandboxWorkspaceViewPaths } from '../workspace-view.ts';
23
- import { quiesceSandboxControlRoot, readSandboxControlManifest } from '../control/lifecycle.ts';
23
+ import { removeSandboxControlRoot, readSandboxControlManifest } from '../control/lifecycle.ts';
24
+ import { inspectSandboxControlContainer } from '../control/container-identity.ts';
24
25
  import { toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
25
26
  import { createSandboxCapabilityPlan } from '../agent-client-reconciler.ts';
26
27
  import type { SandboxTool } from '../tools.ts';
@@ -172,9 +173,40 @@ function removeEmptyManagedParent(base: string, directory: string): void {
172
173
  assertRemoved(parent, 'Empty sandbox container directory');
173
174
  }
174
175
 
175
- async function quiesceProjectControlRoots(config: SandboxConfig): Promise<void> {
176
+ async function removeExactSandboxContainer(
177
+ engine: string,
178
+ manifest: ReturnType<typeof readSandboxControlManifest>,
179
+ timeoutMs: number
180
+ ): Promise<void> {
181
+ const deadlineAt = Date.now() + timeoutMs;
182
+ const remaining = (): number => Math.max(1, deadlineAt - Date.now());
183
+ const inspect = () => inspectSandboxControlContainer(manifest, { timeoutMs: remaining() });
184
+ const before = await inspect();
185
+ if (before.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
186
+ if (before.state === 'absent') return;
187
+ if (before.running && !runOkEngine(engine, 'docker', ['stop', manifest.containerIdentity.id], { timeout: remaining() })) {
188
+ const afterStop = await inspect();
189
+ if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
190
+ throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
191
+ }
192
+ if (afterStop.state === 'found' && afterStop.running) {
193
+ throw new Error(`Failed to stop sandbox container: ${manifest.containerIdentity.id}`);
194
+ }
195
+ }
196
+ const afterStop = await inspect();
197
+ if (afterStop.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
198
+ if (afterStop.state === 'found' && !runOkEngine(engine, 'docker', ['rm', manifest.containerIdentity.id], { timeout: remaining() })) {
199
+ const afterRemove = await inspect();
200
+ if (afterRemove.state !== 'absent') {
201
+ throw new Error(`Failed to remove sandbox container: ${manifest.containerIdentity.id}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ async function removeProjectControlRoots(config: SandboxConfig, engine: string): Promise<Set<string>> {
207
+ const containers = new Set<string>();
176
208
  const projectRoot = path.join(config.controlBase, config.project);
177
- if (!fs.existsSync(projectRoot)) return;
209
+ if (!fs.existsSync(projectRoot)) return containers;
178
210
  assertManagedPath(config.controlBase, projectRoot);
179
211
  const projectStat = fs.lstatSync(projectRoot);
180
212
  if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
@@ -183,9 +215,18 @@ async function quiesceProjectControlRoots(config: SandboxConfig): Promise<void>
183
215
  const containerRoot = path.join(projectRoot, container.name);
184
216
  for (const identity of fs.readdirSync(containerRoot, { withFileTypes: true })) {
185
217
  if (!identity.isDirectory() || identity.isSymbolicLink()) continue;
186
- await quiesceSandboxControlRoot(path.join(containerRoot, identity.name));
218
+ const root = path.join(containerRoot, identity.name);
219
+ const manifestPath = path.join(root, 'manifest.json');
220
+ if (!fs.existsSync(manifestPath)) throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
221
+ const manifest = readSandboxControlManifest(manifestPath);
222
+ containers.add(manifest.container);
223
+ await removeSandboxControlRoot(root, {
224
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
225
+ removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
226
+ });
187
227
  }
188
228
  }
229
+ return containers;
189
230
  }
190
231
 
191
232
  function inspectionBlockers(inspections: readonly WorktreeInspection[]): WorktreeInspection[] {
@@ -277,23 +318,30 @@ async function rmOne(
277
318
  return;
278
319
  }
279
320
 
321
+ const coordinatedContainers = new Set<string>();
280
322
  for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
281
323
  assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
282
324
  assertControlRootMatchesTarget(root, effectiveBranch, workspace);
283
- await quiesceSandboxControlRoot(root);
325
+ const manifest = readSandboxControlManifest(path.join(root, 'manifest.json'));
326
+ coordinatedContainers.add(manifest.container);
327
+ await removeSandboxControlRoot(root, {
328
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
329
+ removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
330
+ });
284
331
  }
285
332
 
286
- if (matchedContainers.length > 0) {
333
+ const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
334
+ if (uncoordinatedContainers.length > 0) {
287
335
  const spinner = p.spinner();
288
- spinner.start(`Stopping container(s): ${matchedContainers.join(', ')}`);
289
- for (const name of matchedContainers) {
336
+ spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
337
+ for (const name of uncoordinatedContainers) {
290
338
  if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
291
339
  if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
292
340
  }
293
341
  const remaining = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
294
- const leftovers = matchedContainers.filter((name) => remaining.includes(name));
342
+ const leftovers = uncoordinatedContainers.filter((name) => remaining.includes(name));
295
343
  if (leftovers.length > 0) throw new Error(`Sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
296
- spinner.stop(pc.green(`Removed container(s): ${matchedContainers.join(', ')}`));
344
+ spinner.stop(pc.green(`Removed container(s): ${uncoordinatedContainers.join(', ')}`));
297
345
  } else {
298
346
  p.log.warn(`No sandbox container found for '${branch}'`);
299
347
  }
@@ -400,7 +448,7 @@ async function rmPurge(
400
448
  }
401
449
  const permits = cleanPermits(inspections);
402
450
 
403
- await quiesceProjectControlRoots(config);
451
+ const coordinatedContainers = await removeProjectControlRoots(config, engine);
404
452
 
405
453
  const containers = runEngine(engine, 'docker', [
406
454
  'ps',
@@ -410,17 +458,19 @@ async function rmPurge(
410
458
  '--format',
411
459
  '{{.Names}}'
412
460
  ]);
413
- if (containers) {
461
+ const uncoordinatedContainers = containers.split('\n').filter((name) => name && !coordinatedContainers.has(name));
462
+ if (uncoordinatedContainers.length > 0) {
414
463
  const spinner = p.spinner();
415
464
  spinner.start('Stopping project sandbox containers...');
416
- for (const name of containers.split('\n').filter(Boolean)) {
465
+ for (const name of uncoordinatedContainers) {
417
466
  if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
418
467
  if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
419
468
  }
420
469
  const remaining = runEngine(engine, 'docker', [
421
470
  'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
422
471
  ]);
423
- if (remaining) throw new Error(`Project sandbox container(s) still exist after removal: ${remaining.replaceAll('\n', ', ')}`);
472
+ const leftovers = remaining.split('\n').filter((name) => name && !coordinatedContainers.has(name));
473
+ if (leftovers.length > 0) throw new Error(`Project sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
424
474
  spinner.stop(pc.green('Project sandbox containers removed'));
425
475
  } else {
426
476
  p.log.warn('No project sandbox containers found');
@@ -17,9 +17,9 @@ const USAGE = `Usage: ai sandbox start [--recreate] <branch | TASK-id | N>
17
17
 
18
18
  Start an existing sandbox container that has stopped (for example after the
19
19
  Docker daemon was restarted or replaced). The container must already exist:
20
- if none is found, run 'ai sandbox create <branch>' first. A container that is
21
- already running is checked without modifying healthy runtime state. --recreate
22
- allows container-only replacement after in-place recovery fails.`;
20
+ if none is found, run 'ai sandbox create <branch>' first. Without --recreate, a
21
+ container that is already running is checked without modifying healthy runtime
22
+ state. --recreate replaces the existing container even when it is healthy.`;
23
23
 
24
24
  export function parseStartArgs(args: string[]): { target: string; recreate: boolean; help: boolean } {
25
25
  let recreate = false;
@@ -83,6 +83,7 @@ export async function start(args: string[]): Promise<void> {
83
83
  workspace: target.workspace,
84
84
  row: found,
85
85
  allowRecreate: parsed.recreate,
86
+ forceRecreate: parsed.recreate,
86
87
  recreate: async () => {
87
88
  const { create } = await import('./create.ts');
88
89
  await create([target.requestedRef, '--no-refresh']);
@@ -7,6 +7,7 @@ import { normalizeAgentClients } from '../agent-clients/config.ts';
7
7
  import { isAgentClientId } from '../agent-clients/types.ts';
8
8
  import type { AgentClientState } from '../agent-clients/types.ts';
9
9
  import { validateSandboxEngine } from './engine.ts';
10
+ import type { SandboxEngineInput } from './engine.ts';
10
11
  import { hostJoin } from './engines/wsl2-paths.ts';
11
12
  import { findRuntimeEngineMismatches } from './runtime-engines.ts';
12
13
  import { parseCustomTools } from './tools.ts';
@@ -29,7 +30,7 @@ type PlatformFn = typeof platform;
29
30
  type WriteStderr = (chunk: string) => unknown;
30
31
 
31
32
  type SandboxConfigInput = {
32
- engine?: string | null;
33
+ engine?: SandboxEngineInput;
33
34
  runtimes?: string[];
34
35
  tools?: string[];
35
36
  customTools?: unknown;
@@ -0,0 +1,93 @@
1
+ import type { SandboxControlManifest, SandboxControlManifestLike } from './protocol.ts';
2
+ import { commandForEngine, runEngine, runProbe } from '../shell.ts';
3
+
4
+ export type ContainerObservation =
5
+ | Readonly<{ state: 'found'; id: string; running: boolean; labels: Readonly<Record<string, string>> }>
6
+ | Readonly<{ state: 'absent'; id: string }>
7
+ | Readonly<{ state: 'unknown'; reason: string }>;
8
+
9
+ export type ContainerInspection = Readonly<{
10
+ id?: unknown;
11
+ running?: unknown;
12
+ labels?: unknown;
13
+ }>;
14
+
15
+ function isAuthoritativeNotFound(error: unknown): boolean {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return /(?:no such (?:container|object)|container .* not found|cannot find container)/i.test(message);
18
+ }
19
+
20
+ export function classifySandboxContainerInspection(
21
+ expected: SandboxControlManifest['containerIdentity'],
22
+ inspection: ContainerInspection | { notFound: true } | { error: unknown }
23
+ ): ContainerObservation {
24
+ if ('notFound' in inspection) return { state: 'absent', id: expected.id };
25
+ if ('error' in inspection) {
26
+ return isAuthoritativeNotFound(inspection.error)
27
+ ? { state: 'absent', id: expected.id }
28
+ : { state: 'unknown', reason: `container inspection failed: ${inspection.error instanceof Error ? inspection.error.message : String(inspection.error)}` };
29
+ }
30
+ const response = inspection as ContainerInspection;
31
+ if (typeof response.id !== 'string' || response.id !== expected.id
32
+ || typeof response.running !== 'boolean' || !response.labels
33
+ || typeof response.labels !== 'object' || Array.isArray(response.labels)) {
34
+ return { state: 'unknown', reason: 'container inspection response is incomplete' };
35
+ }
36
+ const labels = response.labels as Record<string, unknown>;
37
+ for (const [key, value] of Object.entries(expected.labels)) {
38
+ if (labels[key] !== value) return { state: 'unknown', reason: 'container labels do not match the manifest' };
39
+ }
40
+ return {
41
+ state: 'found',
42
+ id: response.id,
43
+ running: response.running,
44
+ labels: Object.fromEntries(
45
+ Object.entries(labels).flatMap(([key, value]) => typeof value === 'string' ? [[key, value]] : [])
46
+ )
47
+ };
48
+ }
49
+
50
+ export async function inspectSandboxControlContainer(
51
+ manifest: SandboxControlManifestLike,
52
+ options: { run?: typeof runEngine; probe?: typeof runProbe; timeoutMs?: number } = {}
53
+ ): Promise<ContainerObservation> {
54
+ try {
55
+ const args = ['inspect', '--format', '{{json .}}', manifest.containerIdentity.id];
56
+ let raw: string;
57
+ if (options.run) {
58
+ raw = options.run(manifest.engine, 'docker', args, options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs });
59
+ } else {
60
+ const command = commandForEngine(manifest.engine, 'docker', args);
61
+ const result = (options.probe ?? runProbe)(
62
+ command.cmd,
63
+ command.args,
64
+ options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }
65
+ );
66
+ if (result.status !== 0) {
67
+ const stderr = typeof result.stderr === 'string' ? result.stderr : result.stderr?.toString('utf8') ?? '';
68
+ return classifySandboxContainerInspection(manifest.containerIdentity, {
69
+ error: new Error(stderr || `container inspection exited with ${result.status ?? 'unknown'}`)
70
+ });
71
+ }
72
+ raw = typeof result.stdout === 'string' ? result.stdout : result.stdout?.toString('utf8') ?? '';
73
+ }
74
+ const parsed = JSON.parse(raw) as {
75
+ Id?: unknown;
76
+ State?: { Running?: unknown };
77
+ Config?: { Labels?: unknown };
78
+ } | Array<{
79
+ Id?: unknown;
80
+ State?: { Running?: unknown };
81
+ Config?: { Labels?: unknown };
82
+ }>;
83
+ const value = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
84
+ if (!value || Array.isArray(value)) return { state: 'unknown', reason: 'container inspection response is incomplete' };
85
+ return classifySandboxContainerInspection(manifest.containerIdentity, {
86
+ id: value.Id,
87
+ running: value.State?.Running,
88
+ labels: value.Config?.Labels
89
+ });
90
+ } catch (error) {
91
+ return classifySandboxContainerInspection(manifest.containerIdentity, { error });
92
+ }
93
+ }
@@ -15,9 +15,9 @@ export type SandboxControlExecutionResult = {
15
15
 
16
16
  export type PreparedSandboxControlExecution = {
17
17
  execution: SandboxControlExecution;
18
- start(): void;
18
+ start(canWrite?: () => boolean): void;
19
19
  completion: Promise<SandboxControlExecutionResult>;
20
- terminate(): boolean;
20
+ terminate(updateState?: boolean): boolean;
21
21
  };
22
22
 
23
23
  function safeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
@@ -30,11 +30,11 @@ export function nodeEntryArgs(entry: string, args: string[]): string[] {
30
30
  : [entry, ...args];
31
31
  }
32
32
 
33
- async function waitForStartTime(pid: number, timeoutMs = 2_000): Promise<string> {
33
+ async function waitForStartTime(pid: number, timeoutMs = 2_000): Promise<number> {
34
34
  const deadline = Date.now() + timeoutMs;
35
35
  while (Date.now() < deadline) {
36
36
  const startTime = getProcessStartTime(pid);
37
- if (startTime) return startTime;
37
+ if (startTime !== null) return startTime;
38
38
  await new Promise<void>((resolve) => setTimeout(resolve, 10));
39
39
  }
40
40
  throw new Error('SANDBOX_CONTROL_EXECUTOR_IDENTITY_UNAVAILABLE');
@@ -57,7 +57,11 @@ export async function prepareSandboxControlExecution(params: {
57
57
  cwd: params.manifest.repoRoot,
58
58
  detached: process.platform !== 'win32',
59
59
  stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
60
- env: { ...safeEnv(process.env), AGENT_INFRA_EXECUTOR_MANIFEST: params.manifestPath }
60
+ env: {
61
+ ...safeEnv(process.env),
62
+ AGENT_INFRA_EXECUTOR_MANIFEST: params.manifestPath,
63
+ AGENT_INFRA_RUNTIME_DIR: params.manifest.runtimeDir
64
+ }
61
65
  }
62
66
  );
63
67
  if (typeof child.pid !== 'number') throw new Error('SANDBOX_CONTROL_EXECUTOR_SPAWN_FAILED');
@@ -73,7 +77,7 @@ export async function prepareSandboxControlExecution(params: {
73
77
  });
74
78
  const startTime = await waitForStartTime(child.pid);
75
79
  const execution: SandboxControlExecution = {
76
- version: 1,
80
+ version: 2,
77
81
  generation: params.manifest.generation,
78
82
  requestId: params.request.id,
79
83
  nonce,
@@ -88,16 +92,20 @@ export async function prepareSandboxControlExecution(params: {
88
92
  atomicWriteJson(executionPath(params.manifest, params.request.id), execution);
89
93
  return {
90
94
  execution,
91
- start() {
95
+ start(canWrite = () => true) {
92
96
  if (!child.connected) throw new Error('SANDBOX_CONTROL_EXECUTOR_GATE_CLOSED');
97
+ if (!canWrite()) throw new Error('SANDBOX_CONTROL_OWNER_LOST');
93
98
  atomicWriteJson(executionPath(params.manifest, params.request.id), { ...execution, phase: 'running', updatedAt: Date.now() });
99
+ if (!canWrite()) throw new Error('SANDBOX_CONTROL_OWNER_LOST');
94
100
  child.send({ version: 1, nonce });
95
101
  },
96
102
  completion,
97
- terminate() {
98
- atomicWriteJson(executionPath(params.manifest, params.request.id), {
99
- ...execution, phase: 'terminating', updatedAt: Date.now()
100
- });
103
+ terminate(updateState = true) {
104
+ if (updateState) {
105
+ atomicWriteJson(executionPath(params.manifest, params.request.id), {
106
+ ...execution, phase: 'terminating', updatedAt: Date.now()
107
+ });
108
+ }
101
109
  return terminateSandboxControlExecution(execution);
102
110
  }
103
111
  };
@@ -138,7 +146,11 @@ function executeRequest(manifest: SandboxControlManifest, request: SandboxContro
138
146
  const result = spawnSync(
139
147
  process.execPath,
140
148
  nodeEntryArgs(process.argv[1]!, [request.family, ...boundArgs]),
141
- { cwd: manifest.repoRoot, encoding: 'utf8', env: safeEnv(process.env) }
149
+ {
150
+ cwd: manifest.repoRoot,
151
+ encoding: 'utf8',
152
+ env: { ...safeEnv(process.env), AGENT_INFRA_RUNTIME_DIR: manifest.runtimeDir }
153
+ }
142
154
  );
143
155
  return { exitCode: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
144
156
  }