@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
@@ -17,9 +17,11 @@ import { parseSandboxWorkspaceIdentity, resolveSandboxTarget, sameSandboxWorkspa
17
17
  import { createSandboxCapabilityPlan, runSandboxHooks, runBoundedSandboxHookCommand } from '../agent-client-reconciler.js';
18
18
  import { resolveTools, tmpfsSeedStagingPath, tmpfsSeedTargetPath, toolConfigDirCandidates } from '../tools.js';
19
19
  import { assertFreshSandboxReady, hydrateTmpfsSeedEntries, prepareTmpfsMounts, startSandboxControlBroker } from '../recovery.js';
20
+ import { acquireSandboxControlReplacement, assertSandboxControlCutoverSnapshot, beginSandboxControlReplacement, captureSandboxControlCutoverSnapshot, commitSandboxControlReplacement, quiesceSandboxControlRoot, recoverSandboxControlReplacement, readSandboxControlManifestForTransition } from '../control/lifecycle.js';
21
+ import { inspectSandboxControlContainer } from '../control/container-identity.js';
20
22
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
21
23
  import { sandboxCoreBindMounts } from '../mounts.js';
22
- import { assertSandboxTaskSource, materializeSandboxControl, materializeSandboxWorkspaceView, prepareSandboxWorkspaceMountTargets } from '../workspace-view.js';
24
+ import { assertSandboxTaskSource, finalizeSandboxControlManifest, materializeSandboxControl, materializeSandboxWorkspaceView, prepareSandboxWorkspaceMountTargets, sandboxControlPaths, } from '../workspace-view.js';
23
25
  import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.js';
24
26
  import { validateSelinuxDisableEnv } from '../engines/selinux.js';
25
27
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
@@ -702,6 +704,12 @@ export async function create(args) {
702
704
  }
703
705
  const effectiveResolvedTools = resolvedTools;
704
706
  const container = containerName(effectiveConfig, branch);
707
+ const controlPaths = sandboxControlPaths({
708
+ base: effectiveConfig.controlBase,
709
+ project: effectiveConfig.project,
710
+ container,
711
+ identity: target.workspace
712
+ });
705
713
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
706
714
  const shareCommon = shareCommonDir(effectiveConfig);
707
715
  const shareBranch = shareBranchDir(effectiveConfig, branch);
@@ -863,292 +871,414 @@ export async function create(args) {
863
871
  {
864
872
  title: `Starting container '${container}'`,
865
873
  task: async (message) => {
866
- const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
867
- const matchedContainers = containerNameCandidates(effectiveConfig, branch)
868
- .filter((name) => existing.includes(name));
869
- if (matchedContainers.length > 0) {
870
- for (const name of matchedContainers) {
871
- const rawLabels = runSafeEngine(engine, 'docker', [
872
- 'inspect', '--format', '{{json .Config.Labels}}', name
873
- ]).trim();
874
- let labels = {};
875
- try {
876
- labels = JSON.parse(rawLabels);
874
+ const hadControlRootBeforeAcquire = fs.existsSync(controlPaths.root);
875
+ const replacementLease = acquireSandboxControlReplacement(controlPaths.root);
876
+ let replacementLeaseHeld = true;
877
+ let replacementCutover = null;
878
+ try {
879
+ const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
880
+ const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
881
+ const previousManifest = fs.existsSync(controlPaths.manifestPath)
882
+ ? readSandboxControlManifestForTransition(controlPaths.manifestPath)
883
+ : null;
884
+ if (hadExistingControlRoot) {
885
+ await quiesceSandboxControlRoot(controlPaths.root);
886
+ }
887
+ const previousCutoverSnapshot = hadExistingControlRoot
888
+ ? captureSandboxControlCutoverSnapshot(controlPaths.root)
889
+ : null;
890
+ const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
891
+ const matchedContainers = containerNameCandidates(effectiveConfig, branch)
892
+ .filter((name) => existing.includes(name));
893
+ if (matchedContainers.length > 0) {
894
+ for (const name of matchedContainers) {
895
+ const rawLabels = runSafeEngine(engine, 'docker', [
896
+ 'inspect', '--format', '{{json .Config.Labels}}', name
897
+ ]).trim();
898
+ let labels = {};
899
+ try {
900
+ labels = JSON.parse(rawLabels);
901
+ }
902
+ catch {
903
+ labels = {};
904
+ }
905
+ const existingIdentity = parseSandboxWorkspaceIdentity(labels, {
906
+ mode: sandboxWorkspaceModeLabel(effectiveConfig),
907
+ taskId: sandboxTaskIdLabel(effectiveConfig)
908
+ });
909
+ if (!sameSandboxWorkspaceIdentity(existingIdentity, target.workspace)) {
910
+ const existingDescription = existingIdentity.mode === 'task-bound'
911
+ ? `task-bound:${existingIdentity.taskId}`
912
+ : existingIdentity.mode;
913
+ const requestedDescription = target.workspace.mode === 'task-bound'
914
+ ? `task-bound:${target.workspace.taskId}`
915
+ : target.workspace.mode;
916
+ const recoveryTarget = target.workspace.mode === 'task-bound'
917
+ ? target.workspace.taskId
918
+ : branch;
919
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${name}' is ${existingDescription}, `
920
+ + `but this request is ${requestedDescription}. `
921
+ + `Run 'ai sandbox start --recreate ${recoveryTarget}' on the host, then retry.`);
922
+ }
877
923
  }
878
- catch {
879
- labels = {};
924
+ message('Removing old container instance...');
925
+ for (const name of matchedContainers) {
926
+ runSafeEngine(engine, 'docker', ['stop', name]);
927
+ runSafeEngine(engine, 'docker', ['rm', name]);
880
928
  }
881
- const existingIdentity = parseSandboxWorkspaceIdentity(labels, {
882
- mode: sandboxWorkspaceModeLabel(effectiveConfig),
883
- taskId: sandboxTaskIdLabel(effectiveConfig)
884
- });
885
- if (!sameSandboxWorkspaceIdentity(existingIdentity, target.workspace)) {
886
- const existingDescription = existingIdentity.mode === 'task-bound'
887
- ? `task-bound:${existingIdentity.taskId}`
888
- : existingIdentity.mode;
889
- const requestedDescription = target.workspace.mode === 'task-bound'
890
- ? `task-bound:${target.workspace.taskId}`
891
- : target.workspace.mode;
892
- const recoveryTarget = target.workspace.mode === 'task-bound'
893
- ? target.workspace.taskId
894
- : branch;
895
- throw new Error(`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${name}' is ${existingDescription}, `
896
- + `but this request is ${requestedDescription}. `
897
- + `Run 'ai sandbox start --recreate ${recoveryTarget}' on the host, then retry.`);
929
+ }
930
+ if (previousManifest) {
931
+ const oldContainer = await inspectSandboxControlContainer(previousManifest);
932
+ if (oldContainer.state !== 'absent') {
933
+ throw new Error(oldContainer.state === 'unknown'
934
+ ? `SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${oldContainer.reason}`
935
+ : 'SANDBOX_CONTROL_CONTAINER_STILL_EXISTS');
898
936
  }
899
937
  }
900
- message('Removing old container instance...');
901
- for (const name of matchedContainers) {
902
- runSafeEngine(engine, 'docker', ['stop', name]);
903
- runSafeEngine(engine, 'docker', ['rm', name]);
938
+ const aliasesFile = ensureSandboxAliasesFile(effectiveConfig.home, capabilityPlan.aliases);
939
+ if (aliasesFile.created) {
940
+ message(`Created default sandbox aliases at ${aliasesFile.path}`);
904
941
  }
905
- }
906
- const aliasesFile = ensureSandboxAliasesFile(effectiveConfig.home, capabilityPlan.aliases);
907
- if (aliasesFile.created) {
908
- message(`Created default sandbox aliases at ${aliasesFile.path}`);
909
- }
910
- const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
911
- for (const { created, path: readmePath } of readmeResults) {
912
- if (created) {
913
- message(`Created discovery README at ${readmePath}`);
942
+ const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
943
+ for (const { created, path: readmePath } of readmeResults) {
944
+ if (created) {
945
+ message(`Created discovery README at ${readmePath}`);
946
+ }
914
947
  }
915
- }
916
- const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
917
- const gitconfigContent = fs.existsSync(gitconfigPath)
918
- ? fs.readFileSync(gitconfigPath, 'utf8')
919
- : '';
920
- const needsGpg = detectGpgConfig(gitconfigContent);
921
- const hasHostGpgKeys = needsGpg && hostHasGpgKeys(effectiveConfig.home);
922
- const signingKey = needsGpg
923
- ? getGitSigningKey({ repoPath: worktree, home: effectiveConfig.home })
924
- : null;
925
- const cachedGpg = needsGpg
926
- ? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
927
- : null;
928
- const proxyEntries = values['inherit-proxy'] ? collectHostProxyEntries(process.env) : [];
929
- const envFile = buildContainerEnvFile(effectiveResolvedTools, engine, undefined, {
930
- additionalEntries: proxyEntries
931
- });
932
- let hostShellConfig;
933
- let tmpfsSeedPlan = [];
934
- try {
935
- const beforeCreateResults = await runSandboxHooks({
936
- hooks: capabilityPlan.hooksByPhase['before-container-create'],
937
- phase: 'before-container-create',
938
- context: {
939
- config: effectiveConfig,
940
- plan: capabilityPlan,
941
- create: {
942
- hostHome: effectiveConfig.home,
943
- hostEnv: { ...process.env },
944
- project: effectiveConfig.project,
945
- resolvedTools: effectiveResolvedTools
946
- }
947
- },
948
- runCommand: runBoundedSandboxHookCommand
948
+ const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
949
+ const gitconfigContent = fs.existsSync(gitconfigPath)
950
+ ? fs.readFileSync(gitconfigPath, 'utf8')
951
+ : '';
952
+ const needsGpg = detectGpgConfig(gitconfigContent);
953
+ const hasHostGpgKeys = needsGpg && hostHasGpgKeys(effectiveConfig.home);
954
+ const signingKey = needsGpg
955
+ ? getGitSigningKey({ repoPath: worktree, home: effectiveConfig.home })
956
+ : null;
957
+ const cachedGpg = needsGpg
958
+ ? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
959
+ : null;
960
+ const proxyEntries = values['inherit-proxy'] ? collectHostProxyEntries(process.env) : [];
961
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine, undefined, {
962
+ additionalEntries: proxyEntries
949
963
  });
950
- const beforeCreateFailure = beforeCreateResults.find((result) => result.status === 'fatal');
951
- if (beforeCreateFailure) {
952
- throw new Error(beforeCreateFailure.message
953
- ?? `Sandbox hook '${beforeCreateFailure.hookId}' failed.`);
964
+ let hostShellConfig;
965
+ let tmpfsSeedPlan = [];
966
+ try {
967
+ const beforeCreateResults = await runSandboxHooks({
968
+ hooks: capabilityPlan.hooksByPhase['before-container-create'],
969
+ phase: 'before-container-create',
970
+ context: {
971
+ config: effectiveConfig,
972
+ plan: capabilityPlan,
973
+ create: {
974
+ hostHome: effectiveConfig.home,
975
+ hostEnv: { ...process.env },
976
+ project: effectiveConfig.project,
977
+ resolvedTools: effectiveResolvedTools
978
+ }
979
+ },
980
+ runCommand: runBoundedSandboxHookCommand
981
+ });
982
+ const beforeCreateFailure = beforeCreateResults.find((result) => result.status === 'fatal');
983
+ if (beforeCreateFailure) {
984
+ throw new Error(beforeCreateFailure.message
985
+ ?? `Sandbox hook '${beforeCreateFailure.hookId}' failed.`);
986
+ }
987
+ const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => tool.tmpfs ? [] : ['-v', volumeArg(engine, dir, tool.containerMount)]);
988
+ const tmpfsArgs = effectiveResolvedTools.flatMap(({ tool }) => tool.tmpfs ? buildTmpfsRunArgs(tool.containerMount, tool.tmpfs) : []);
989
+ const workspaceView = materializeSandboxWorkspaceView({
990
+ base: effectiveConfig.workspaceViewBase,
991
+ project: effectiveConfig.project,
992
+ container,
993
+ identity: target.workspace
994
+ });
995
+ prepareSandboxWorkspaceMountTargets(worktree);
996
+ if (previousCutoverSnapshot) {
997
+ assertSandboxControlCutoverSnapshot(previousCutoverSnapshot);
998
+ if (previousManifest) {
999
+ const finalOldContainer = await inspectSandboxControlContainer(previousManifest);
1000
+ if (finalOldContainer.state !== 'absent') {
1001
+ throw new Error(finalOldContainer.state === 'unknown'
1002
+ ? `SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${finalOldContainer.reason}`
1003
+ : 'SANDBOX_CONTROL_CONTAINER_REAPPEARED');
1004
+ }
1005
+ }
1006
+ replacementCutover = beginSandboxControlReplacement(controlPaths.root, replacementLease);
1007
+ }
1008
+ const control = materializeSandboxControl({
1009
+ base: effectiveConfig.controlBase,
1010
+ repoRoot: effectiveConfig.repoRoot,
1011
+ worktreeRoot: worktree,
1012
+ project: effectiveConfig.project,
1013
+ container,
1014
+ branch,
1015
+ identity: target.workspace,
1016
+ engine,
1017
+ replacementLease
1018
+ });
1019
+ hostShellConfig = prepareHostShellConfig({
1020
+ home: effectiveConfig.home,
1021
+ project: effectiveConfig.project,
1022
+ branch,
1023
+ repoRoot: effectiveConfig.repoRoot
1024
+ });
1025
+ const coreVolumes = sandboxCoreBindMounts(effectiveConfig, branch, {
1026
+ worktree,
1027
+ shellConfigHostDir: hostShellConfig.hostDir,
1028
+ workspaceViewRoot: workspaceView.root,
1029
+ controlDir: control.channelDir,
1030
+ controlStatusDir: control.statusDir,
1031
+ ...(target.workspace.mode === 'task-bound' ? { runtimeDir: control.runtimeDir } : {}),
1032
+ ...(target.workspace.mode === 'task-bound'
1033
+ ? {
1034
+ taskSource: assertSandboxTaskSource(effectiveConfig.repoRoot, target.workspace.taskId),
1035
+ taskId: target.workspace.taskId
1036
+ }
1037
+ : {})
1038
+ }).flatMap(({ hostPaths, containerPath, readOnly }) => [
1039
+ '-v',
1040
+ volumeArg(engine, hostPaths[0], containerPath, readOnly ? ':ro' : '')
1041
+ ]);
1042
+ // Mount each declared seed read-only at an isolated staging path.
1043
+ // After docker run mounts the empty tmpfs, the container's default
1044
+ // user copies these entries into it so the runtime targets are normal,
1045
+ // writable files rather than nested mount points. The allowlist keeps
1046
+ // stale runtime files (logs_2.sqlite, sessions, etc.) on the host from
1047
+ // being exposed or written through.
1048
+ tmpfsSeedPlan = effectiveResolvedTools.flatMap(({ tool, dir }) => (tool.tmpfs?.seed ?? []).flatMap((entry, index) => {
1049
+ const targetPath = tmpfsSeedTargetPath(tool.containerMount, entry);
1050
+ const hostPath = path.join(dir, entry);
1051
+ if (!fs.existsSync(hostPath)) {
1052
+ return [];
1053
+ }
1054
+ const stagingPath = tmpfsSeedStagingPath(tool.id, index);
1055
+ return [{
1056
+ toolId: tool.id,
1057
+ containerMount: tool.containerMount,
1058
+ stagingPath,
1059
+ targetPath,
1060
+ volumeArgs: ['-v', volumeArg(engine, hostPath, stagingPath, ':ro')]
1061
+ }];
1062
+ }));
1063
+ const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
1064
+ .filter(({ hostPath }) => fs.existsSync(hostPath))
1065
+ .flatMap(({ hostPath, containerSubpath }) => [
1066
+ '-v',
1067
+ volumeArg(engine, hostPath, path.posix.join(tool.containerMount, containerSubpath))
1068
+ ]));
1069
+ fs.mkdirSync(shareCommon, { recursive: true });
1070
+ fs.mkdirSync(shareBranch, { recursive: true });
1071
+ fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
1072
+ const dotfilesSnapshot = materializeDotfiles(effectiveConfig.dotfilesDir, dotfilesCacheDir(effectiveConfig.home, effectiveConfig.project));
1073
+ const dotfilesMount = dotfilesSnapshot
1074
+ ? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
1075
+ : [];
1076
+ const hostTz = detectHostTimezone();
1077
+ const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
1078
+ let createdContainerRef = null;
1079
+ try {
1080
+ const dockerRunId = runEngineTaskCommand(engine, 'docker', [
1081
+ 'run',
1082
+ '-d',
1083
+ '--init',
1084
+ '--name',
1085
+ container,
1086
+ '--hostname',
1087
+ `${effectiveConfig.project}-sandbox`,
1088
+ '--label',
1089
+ sandboxLabel(effectiveConfig),
1090
+ '--label',
1091
+ `${sandboxBranchLabel(effectiveConfig)}=${branch}`,
1092
+ '--label',
1093
+ `${sandboxWorkspaceModeLabel(effectiveConfig)}=${target.workspace.mode}`,
1094
+ ...(target.workspace.mode === 'task-bound'
1095
+ ? [
1096
+ '--label',
1097
+ `${sandboxTaskIdLabel(effectiveConfig)}=${target.workspace.taskId}`,
1098
+ '-e',
1099
+ `AGENT_INFRA_TASK_ID=${target.workspace.taskId}`
1100
+ ]
1101
+ : []),
1102
+ '-e',
1103
+ `AGENT_INFRA_CONTROL_TOKEN=${control.token}`,
1104
+ '-e',
1105
+ `AGENT_INFRA_CONTROL_GENERATION=${control.generation}`,
1106
+ '-e',
1107
+ 'AGENT_INFRA_CONTROL_DIR=/run/agent-infra/control',
1108
+ '-e',
1109
+ 'AGENT_INFRA_CONTROL_STATUS_DIR=/run/agent-infra/control-status',
1110
+ ...(target.workspace.mode === 'task-bound'
1111
+ ? ['-e', 'AGENT_INFRA_RUNTIME_DIR=/run/agent-infra/runtime']
1112
+ : []),
1113
+ '--label',
1114
+ `${sandboxRuntimeCapabilityLabel(effectiveConfig)}=${capabilityPlan.runtimeSignature}`,
1115
+ ...coreVolumes,
1116
+ ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1117
+ '-v',
1118
+ volumeArg(engine, path.join(effectiveConfig.repoRoot, '.git'), `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`),
1119
+ ...dotfilesMount,
1120
+ ...toolVolumes,
1121
+ ...tmpfsArgs,
1122
+ ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1123
+ ...liveMountVolumes,
1124
+ ...envFile.dockerArgs,
1125
+ ...tzFlags,
1126
+ '-w',
1127
+ '/workspace',
1128
+ effectiveConfig.imageName
1129
+ ]);
1130
+ createdContainerRef = dockerRunId.trim();
1131
+ const containerId = runEngineTaskCommand(engine, 'docker', [
1132
+ 'inspect', '--format', '{{.Id}}', dockerRunId.trim()
1133
+ ]).trim();
1134
+ if (!containerId)
1135
+ throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
1136
+ const rawContainerLabels = runEngineTaskCommand(engine, 'docker', [
1137
+ 'inspect', '--format', '{{json .Config.Labels}}', containerId
1138
+ ]).trim();
1139
+ let inspectedLabels;
1140
+ try {
1141
+ inspectedLabels = JSON.parse(rawContainerLabels);
1142
+ }
1143
+ catch {
1144
+ throw new Error('SANDBOX_CONTROL_CONTAINER_LABELS_INVALID');
1145
+ }
1146
+ if (!inspectedLabels || typeof inspectedLabels !== 'object' || Array.isArray(inspectedLabels)) {
1147
+ throw new Error('SANDBOX_CONTROL_CONTAINER_LABELS_INVALID');
1148
+ }
1149
+ const expectedLabels = {
1150
+ [sandboxLabel(effectiveConfig)]: '',
1151
+ [sandboxBranchLabel(effectiveConfig)]: branch,
1152
+ [sandboxWorkspaceModeLabel(effectiveConfig)]: target.workspace.mode,
1153
+ [sandboxRuntimeCapabilityLabel(effectiveConfig)]: capabilityPlan.runtimeSignature,
1154
+ ...(target.workspace.mode === 'task-bound'
1155
+ ? { [sandboxTaskIdLabel(effectiveConfig)]: target.workspace.taskId }
1156
+ : {})
1157
+ };
1158
+ const labels = inspectedLabels;
1159
+ for (const [key, expected] of Object.entries(expectedLabels)) {
1160
+ if (labels[key] !== expected)
1161
+ throw new Error('SANDBOX_CONTROL_CONTAINER_IDENTITY_MISMATCH');
1162
+ }
1163
+ finalizeSandboxControlManifest(control, {
1164
+ engine,
1165
+ id: containerId,
1166
+ labels: expectedLabels
1167
+ });
1168
+ replacementLease.clearQuiescing();
1169
+ await startSandboxControlBroker(effectiveConfig.repoRoot, control.manifestPath);
1170
+ if (replacementCutover) {
1171
+ commitSandboxControlReplacement(control.root, replacementLease);
1172
+ replacementCutover = null;
1173
+ }
1174
+ createdTmpfsSeedPlan = tmpfsSeedPlan;
1175
+ }
1176
+ catch (error) {
1177
+ if (createdContainerRef) {
1178
+ runSafeEngine(engine, 'docker', ['stop', createdContainerRef]);
1179
+ runSafeEngine(engine, 'docker', ['rm', createdContainerRef]);
1180
+ }
1181
+ if (!hadExistingControlRoot)
1182
+ removeDirRecursive(control.root);
1183
+ if (replacementCutover) {
1184
+ try {
1185
+ await recoverSandboxControlReplacement(control.root, replacementLease);
1186
+ replacementCutover = null;
1187
+ }
1188
+ catch {
1189
+ // Preserve the cutover state for the next explicit recreate to recover.
1190
+ }
1191
+ }
1192
+ removeDirRecursive(workspaceView.root);
1193
+ throw error;
1194
+ }
1195
+ }
1196
+ finally {
1197
+ envFile.cleanup();
954
1198
  }
955
- const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => tool.tmpfs ? [] : ['-v', volumeArg(engine, dir, tool.containerMount)]);
956
- const tmpfsArgs = effectiveResolvedTools.flatMap(({ tool }) => tool.tmpfs ? buildTmpfsRunArgs(tool.containerMount, tool.tmpfs) : []);
957
- const workspaceView = materializeSandboxWorkspaceView({
958
- base: effectiveConfig.workspaceViewBase,
959
- project: effectiveConfig.project,
1199
+ prepareTmpfsMounts({
1200
+ engine,
960
1201
  container,
961
- identity: target.workspace
1202
+ mountPaths: effectiveResolvedTools
1203
+ .filter(({ tool }) => tool.tmpfs)
1204
+ .map(({ tool }) => tool.containerMount)
962
1205
  });
963
- prepareSandboxWorkspaceMountTargets(worktree);
964
- const control = materializeSandboxControl({
965
- base: effectiveConfig.controlBase,
966
- repoRoot: effectiveConfig.repoRoot,
967
- worktreeRoot: worktree,
968
- project: effectiveConfig.project,
1206
+ hydrateTmpfsSeedEntries({
1207
+ engine,
969
1208
  container,
970
- branch,
971
- identity: target.workspace
972
- });
973
- hostShellConfig = prepareHostShellConfig({
974
- home: effectiveConfig.home,
975
- project: effectiveConfig.project,
976
- branch,
977
- repoRoot: effectiveConfig.repoRoot
1209
+ entries: tmpfsSeedPlan,
1210
+ replace: true
978
1211
  });
979
- const coreVolumes = sandboxCoreBindMounts(effectiveConfig, branch, {
980
- worktree,
981
- shellConfigHostDir: hostShellConfig.hostDir,
982
- workspaceViewRoot: workspaceView.root,
983
- controlDir: control.channelDir,
984
- controlStatusDir: control.statusDir,
985
- ...(target.workspace.mode === 'task-bound'
986
- ? {
987
- taskSource: assertSandboxTaskSource(effectiveConfig.repoRoot, target.workspace.taskId),
988
- taskId: target.workspace.taskId
1212
+ // Belt-and-suspenders: re-create the four shell-config symlinks at
1213
+ // runtime so users with a custom `sandbox.dockerfile` (which won't
1214
+ // include the ai-tools.dockerfile symlink fragment) still get
1215
+ // ~/.gitconfig and friends pointing into the host bind-mount.
1216
+ // `ln -sf` is idempotent for the default image.
1217
+ ensureShellConfigSymlinks(engine, container);
1218
+ if (needsGpg) {
1219
+ message(cachedGpg
1220
+ ? 'Syncing GPG keys from cache...'
1221
+ : hasHostGpgKeys
1222
+ ? 'Syncing GPG keys (you may be prompted for your passphrase)...'
1223
+ : 'Checking GPG cache before falling back to stripped git config...');
1224
+ try {
1225
+ if (syncGpgKeys(container, effectiveConfig.home, effectiveConfig.project, undefined, undefined, {
1226
+ cachedOverride: cachedGpg,
1227
+ repoPath: worktree,
1228
+ signingKey,
1229
+ dockerExecFn: (cmd, args, opts) => execEngine(engine, cmd, args, opts),
1230
+ dockerRunSafeFn: (cmd, args, opts) => runSafeEngine(engine, cmd, args, opts)
1231
+ })) {
1232
+ writeSanitizedGitconfig({
1233
+ home: effectiveConfig.home,
1234
+ hostConfigDir: hostShellConfig.hostDir,
1235
+ stripGpg: false,
1236
+ repoRoot: effectiveConfig.repoRoot
1237
+ });
1238
+ }
1239
+ else {
1240
+ message(hasHostGpgKeys
1241
+ ? 'GPG key sync failed; using stripped git config fallback...'
1242
+ : 'Host GPG keys unavailable; using stripped git config fallback...');
989
1243
  }
990
- : {})
991
- }).flatMap(({ hostPaths, containerPath, readOnly }) => [
992
- '-v',
993
- volumeArg(engine, hostPaths[0], containerPath, readOnly ? ':ro' : '')
994
- ]);
995
- // Mount each declared seed read-only at an isolated staging path.
996
- // After docker run mounts the empty tmpfs, the container's default
997
- // user copies these entries into it so the runtime targets are normal,
998
- // writable files rather than nested mount points. The allowlist keeps
999
- // stale runtime files (logs_2.sqlite, sessions, etc.) on the host from
1000
- // being exposed or written through.
1001
- tmpfsSeedPlan = effectiveResolvedTools.flatMap(({ tool, dir }) => (tool.tmpfs?.seed ?? []).flatMap((entry, index) => {
1002
- const targetPath = tmpfsSeedTargetPath(tool.containerMount, entry);
1003
- const hostPath = path.join(dir, entry);
1004
- if (!fs.existsSync(hostPath)) {
1005
- return [];
1006
- }
1007
- const stagingPath = tmpfsSeedStagingPath(tool.id, index);
1008
- return [{
1009
- toolId: tool.id,
1010
- containerMount: tool.containerMount,
1011
- stagingPath,
1012
- targetPath,
1013
- volumeArgs: ['-v', volumeArg(engine, hostPath, stagingPath, ':ro')]
1014
- }];
1015
- }));
1016
- const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
1017
- .filter(({ hostPath }) => fs.existsSync(hostPath))
1018
- .flatMap(({ hostPath, containerSubpath }) => [
1019
- '-v',
1020
- volumeArg(engine, hostPath, path.posix.join(tool.containerMount, containerSubpath))
1021
- ]));
1022
- fs.mkdirSync(shareCommon, { recursive: true });
1023
- fs.mkdirSync(shareBranch, { recursive: true });
1024
- fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
1025
- const dotfilesSnapshot = materializeDotfiles(effectiveConfig.dotfilesDir, dotfilesCacheDir(effectiveConfig.home, effectiveConfig.project));
1026
- const dotfilesMount = dotfilesSnapshot
1027
- ? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
1028
- : [];
1029
- const hostTz = detectHostTimezone();
1030
- const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
1031
- runEngineTaskCommand(engine, 'docker', [
1032
- 'run',
1033
- '-d',
1034
- '--name',
1035
- container,
1036
- '--hostname',
1037
- `${effectiveConfig.project}-sandbox`,
1038
- '--label',
1039
- sandboxLabel(effectiveConfig),
1040
- '--label',
1041
- `${sandboxBranchLabel(effectiveConfig)}=${branch}`,
1042
- '--label',
1043
- `${sandboxWorkspaceModeLabel(effectiveConfig)}=${target.workspace.mode}`,
1044
- ...(target.workspace.mode === 'task-bound'
1045
- ? [
1046
- '--label',
1047
- `${sandboxTaskIdLabel(effectiveConfig)}=${target.workspace.taskId}`,
1048
- '-e',
1049
- `AGENT_INFRA_TASK_ID=${target.workspace.taskId}`
1050
- ]
1051
- : []),
1052
- '-e',
1053
- `AGENT_INFRA_CONTROL_TOKEN=${control.token}`,
1054
- '-e',
1055
- `AGENT_INFRA_CONTROL_GENERATION=${control.generation}`,
1056
- '-e',
1057
- 'AGENT_INFRA_CONTROL_DIR=/run/agent-infra/control',
1058
- '-e',
1059
- 'AGENT_INFRA_CONTROL_STATUS_DIR=/run/agent-infra/control-status',
1060
- '--label',
1061
- `${sandboxRuntimeCapabilityLabel(effectiveConfig)}=${capabilityPlan.runtimeSignature}`,
1062
- ...coreVolumes,
1063
- ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1064
- '-v',
1065
- volumeArg(engine, path.join(effectiveConfig.repoRoot, '.git'), `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`),
1066
- ...dotfilesMount,
1067
- ...toolVolumes,
1068
- ...tmpfsArgs,
1069
- ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1070
- ...liveMountVolumes,
1071
- ...envFile.dockerArgs,
1072
- ...tzFlags,
1073
- '-w',
1074
- '/workspace',
1075
- effectiveConfig.imageName
1076
- ]);
1077
- await startSandboxControlBroker(effectiveConfig.repoRoot, control.manifestPath);
1078
- createdTmpfsSeedPlan = tmpfsSeedPlan;
1079
- }
1080
- finally {
1081
- envFile.cleanup();
1082
- }
1083
- prepareTmpfsMounts({
1084
- engine,
1085
- container,
1086
- mountPaths: effectiveResolvedTools
1087
- .filter(({ tool }) => tool.tmpfs)
1088
- .map(({ tool }) => tool.containerMount)
1089
- });
1090
- hydrateTmpfsSeedEntries({
1091
- engine,
1092
- container,
1093
- entries: tmpfsSeedPlan,
1094
- replace: true
1095
- });
1096
- // Belt-and-suspenders: re-create the four shell-config symlinks at
1097
- // runtime so users with a custom `sandbox.dockerfile` (which won't
1098
- // include the ai-tools.dockerfile symlink fragment) still get
1099
- // ~/.gitconfig and friends pointing into the host bind-mount.
1100
- // `ln -sf` is idempotent for the default image.
1101
- ensureShellConfigSymlinks(engine, container);
1102
- if (needsGpg) {
1103
- message(cachedGpg
1104
- ? 'Syncing GPG keys from cache...'
1105
- : hasHostGpgKeys
1106
- ? 'Syncing GPG keys (you may be prompted for your passphrase)...'
1107
- : 'Checking GPG cache before falling back to stripped git config...');
1108
- try {
1109
- if (syncGpgKeys(container, effectiveConfig.home, effectiveConfig.project, undefined, undefined, {
1110
- cachedOverride: cachedGpg,
1111
- repoPath: worktree,
1112
- signingKey,
1113
- dockerExecFn: (cmd, args, opts) => execEngine(engine, cmd, args, opts),
1114
- dockerRunSafeFn: (cmd, args, opts) => runSafeEngine(engine, cmd, args, opts)
1115
- })) {
1116
- writeSanitizedGitconfig({
1117
- home: effectiveConfig.home,
1118
- hostConfigDir: hostShellConfig.hostDir,
1119
- stripGpg: false,
1120
- repoRoot: effectiveConfig.repoRoot
1121
- });
1122
1244
  }
1123
- else {
1245
+ catch {
1124
1246
  message(hasHostGpgKeys
1125
1247
  ? 'GPG key sync failed; using stripped git config fallback...'
1126
1248
  : 'Host GPG keys unavailable; using stripped git config fallback...');
1127
1249
  }
1128
1250
  }
1129
- catch {
1130
- message(hasHostGpgKeys
1131
- ? 'GPG key sync failed; using stripped git config fallback...'
1132
- : 'Host GPG keys unavailable; using stripped git config fallback...');
1251
+ for (const { tool } of effectiveResolvedTools) {
1252
+ for (const command of tool.postSetupCmds ?? []) {
1253
+ runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
1254
+ }
1133
1255
  }
1134
- }
1135
- for (const { tool } of effectiveResolvedTools) {
1136
- for (const command of tool.postSetupCmds ?? []) {
1137
- runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
1256
+ const afterStartResults = await runSandboxHooks({
1257
+ hooks: capabilityPlan.hooksByPhase['after-container-start'],
1258
+ phase: 'after-container-start',
1259
+ context: { config: effectiveConfig, plan: capabilityPlan },
1260
+ runCommand: runBoundedSandboxHookCommand
1261
+ });
1262
+ const afterStartFailure = afterStartResults.find((result) => result.status === 'fatal');
1263
+ if (afterStartFailure) {
1264
+ throw new Error(afterStartFailure.message
1265
+ ?? `Sandbox hook '${afterStartFailure.hookId}' failed.`);
1138
1266
  }
1267
+ replacementLease.release();
1268
+ replacementLeaseHeld = false;
1269
+ return 'Container started';
1139
1270
  }
1140
- const afterStartResults = await runSandboxHooks({
1141
- hooks: capabilityPlan.hooksByPhase['after-container-start'],
1142
- phase: 'after-container-start',
1143
- context: { config: effectiveConfig, plan: capabilityPlan },
1144
- runCommand: runBoundedSandboxHookCommand
1145
- });
1146
- const afterStartFailure = afterStartResults.find((result) => result.status === 'fatal');
1147
- if (afterStartFailure) {
1148
- throw new Error(afterStartFailure.message
1149
- ?? `Sandbox hook '${afterStartFailure.hookId}' failed.`);
1271
+ catch (error) {
1272
+ if (replacementLeaseHeld) {
1273
+ try {
1274
+ replacementLease.release();
1275
+ }
1276
+ catch {
1277
+ // Preserve the original cutover failure and its evidence.
1278
+ }
1279
+ }
1280
+ throw error;
1150
1281
  }
1151
- return 'Container started';
1152
1282
  }
1153
1283
  }
1154
1284
  ]);