@fitlab-ai/agent-infra 0.11.10 → 0.11.11

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 (132) hide show
  1. package/dist/lib/agent-clients/adapter.js +20 -0
  2. package/dist/lib/agent-clients/adapters/codex-sandbox.js +13 -0
  3. package/dist/lib/agent-clients/adapters/codex.js +4 -0
  4. package/dist/lib/internal/manual-validation.js +2 -2
  5. package/dist/lib/internal/platform-pr.js +4 -5
  6. package/dist/lib/internal/task-artifact.js +1 -1
  7. package/dist/lib/internal/task-create.js +15 -1
  8. package/dist/lib/internal/task-finalization.js +1 -3
  9. package/dist/lib/internal/task-operation-registry.js +4 -138
  10. package/dist/lib/platform/pr-summary.js +45 -8
  11. package/dist/lib/platform/pull-requests.js +79 -14
  12. package/dist/lib/platform/verification-sync.js +17 -14
  13. package/dist/lib/sandbox/commands/create.js +18 -2
  14. package/dist/lib/sandbox/commands/enter.js +47 -33
  15. package/dist/lib/sandbox/control/client.js +46 -12
  16. package/dist/lib/sandbox/control/executor.js +4 -11
  17. package/dist/lib/sandbox/control/host-recovery.js +218 -0
  18. package/dist/lib/sandbox/control/protocol.js +2 -3
  19. package/dist/lib/sandbox/control/server.js +42 -2
  20. package/dist/lib/sandbox/environment.js +150 -0
  21. package/dist/lib/sandbox/recovery.js +8 -0
  22. package/dist/lib/sandbox/workspace-identity.js +0 -14
  23. package/dist/lib/sandbox/workspace-view.js +33 -6
  24. package/dist/lib/task/artifact-command.js +7 -2
  25. package/dist/lib/task/artifact-lifecycle.js +2 -1
  26. package/dist/lib/task/capabilities.js +4 -1
  27. package/dist/lib/task/control-authority.js +0 -2
  28. package/dist/lib/task/events.js +1 -1
  29. package/dist/lib/task/finalization.js +37 -54
  30. package/dist/lib/task/frontmatter.js +24 -1
  31. package/dist/lib/task/ledger-intents.js +2 -1
  32. package/dist/lib/task/platform-operation-journal.js +12 -0
  33. package/dist/lib/task/platform-operation-recovery.js +14 -7
  34. package/dist/lib/task/resolve-ref.js +8 -3
  35. package/dist/lifecycle-build-manifest.json +3 -3
  36. package/lib/agent-clients/adapter.ts +22 -0
  37. package/lib/agent-clients/adapters/codex-sandbox.ts +13 -0
  38. package/lib/agent-clients/adapters/codex.ts +4 -0
  39. package/lib/internal/manual-validation.ts +2 -2
  40. package/lib/internal/platform-pr.ts +3 -4
  41. package/lib/internal/task-artifact.ts +1 -1
  42. package/lib/internal/task-create.ts +15 -2
  43. package/lib/internal/task-finalization.ts +1 -3
  44. package/lib/internal/task-operation-registry.ts +4 -150
  45. package/lib/platform/pr-summary.ts +43 -7
  46. package/lib/platform/pull-requests.ts +74 -13
  47. package/lib/platform/verification-sync.ts +18 -14
  48. package/lib/sandbox/commands/create.ts +24 -2
  49. package/lib/sandbox/commands/enter.ts +42 -39
  50. package/lib/sandbox/control/client.ts +52 -15
  51. package/lib/sandbox/control/executor.ts +5 -12
  52. package/lib/sandbox/control/host-recovery.ts +195 -0
  53. package/lib/sandbox/control/protocol.ts +4 -5
  54. package/lib/sandbox/control/server.ts +42 -1
  55. package/lib/sandbox/environment.ts +162 -0
  56. package/lib/sandbox/recovery.ts +10 -0
  57. package/lib/sandbox/tool-types.ts +1 -0
  58. package/lib/sandbox/workspace-identity.ts +0 -26
  59. package/lib/sandbox/workspace-view.ts +33 -8
  60. package/lib/task/artifact-command.ts +9 -2
  61. package/lib/task/artifact-lifecycle.ts +2 -1
  62. package/lib/task/capabilities.ts +5 -2
  63. package/lib/task/control-authority.ts +0 -4
  64. package/lib/task/events.ts +1 -1
  65. package/lib/task/finalization.ts +34 -51
  66. package/lib/task/frontmatter.ts +25 -1
  67. package/lib/task/ledger-intents.ts +2 -1
  68. package/lib/task/platform-operation-journal.ts +29 -2
  69. package/lib/task/platform-operation-recovery.ts +12 -6
  70. package/lib/task/resolve-ref.ts +8 -3
  71. package/package.json +1 -1
  72. package/templates/.agents/rules/README.en.md +1 -1
  73. package/templates/.agents/rules/README.zh-CN.md +1 -1
  74. package/templates/.agents/rules/issue-sync.en.md +1 -1
  75. package/templates/.agents/rules/issue-sync.zh-CN.md +1 -1
  76. package/templates/.agents/rules/no-mid-flow-questions.en.md +19 -0
  77. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +19 -0
  78. package/templates/.agents/rules/review-handshake.en.md +3 -0
  79. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -0
  80. package/templates/.agents/rules/task-management.en.md +1 -1
  81. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  82. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  83. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -1
  84. package/templates/.agents/skills/block-task/SKILL.en.md +1 -1
  85. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
  86. package/templates/.agents/skills/cancel-task/SKILL.en.md +1 -1
  87. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +1 -1
  88. package/templates/.agents/skills/close-codescan/SKILL.en.md +3 -0
  89. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +3 -0
  90. package/templates/.agents/skills/close-dependabot/SKILL.en.md +3 -0
  91. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +3 -0
  92. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  93. package/templates/.agents/skills/code-task/reference/branch-management.en.md +1 -1
  94. package/templates/.agents/skills/code-task/reference/branch-management.zh-CN.md +1 -1
  95. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +2 -0
  96. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +2 -0
  97. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +1 -1
  98. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +1 -1
  99. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  100. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  101. package/templates/.agents/skills/create-pr/SKILL.en.md +1 -1
  102. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  103. package/templates/.agents/skills/create-pr/reference/branch-strategy.en.md +2 -2
  104. package/templates/.agents/skills/create-pr/reference/branch-strategy.zh-CN.md +2 -2
  105. package/templates/.agents/skills/create-task/SKILL.en.md +3 -5
  106. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +3 -4
  107. package/templates/.agents/skills/import-codescan/SKILL.en.md +1 -1
  108. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  109. package/templates/.agents/skills/import-dependabot/SKILL.en.md +1 -1
  110. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  111. package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
  112. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  113. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
  114. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -1
  115. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  116. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  117. package/templates/.agents/skills/review-pr/SKILL.en.md +1 -1
  118. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +1 -1
  119. package/templates/.agents/skills/test/SKILL.en.md +4 -1
  120. package/templates/.agents/skills/test/SKILL.zh-CN.md +4 -1
  121. package/templates/.agents/skills/test-integration/SKILL.en.md +5 -2
  122. package/templates/.agents/skills/test-integration/SKILL.zh-CN.md +5 -2
  123. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +4 -1
  124. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +4 -1
  125. package/templates/.agents/skills/upgrade-dependency/SKILL.en.md +5 -2
  126. package/templates/.agents/skills/upgrade-dependency/SKILL.zh-CN.md +5 -2
  127. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -2
  128. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +2 -2
  129. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
  130. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
  131. package/dist/lib/task/finalization-handoff.js +0 -93
  132. package/lib/task/finalization-handoff.ts +0 -105
@@ -457,7 +457,7 @@ function checkTaskCommentContent(context, remoteData, shared) {
457
457
  return shared.failResult(CHECK_TYPE, buildCommentContentMismatchMessage("task", context.issueNumber, expectedBody, commentBody), "check_failed");
458
458
  }
459
459
  function checkPrTypeLabel(context, remoteData, shared) {
460
- if (!context.audit.enabled || !context.hasTriage || !context.prNumber || !remoteData.prLabels) {
460
+ if (!context.audit.enabled || !context.hasTriage || !context.prIdentity || remoteData.prLabels === undefined || remoteData.prLabels === null) {
461
461
  return infoResult('PR type-label audit is not applicable because the required PR metadata or capability is unavailable');
462
462
  }
463
463
  const expectedLabel = mapTaskTypeToLabel(context.task.metadata.type);
@@ -465,12 +465,12 @@ function checkPrTypeLabel(context, remoteData, shared) {
465
465
  return infoResult('PR type-label audit is not applicable because the task type has no label mapping');
466
466
  }
467
467
  if (remoteData.prLabels.includes(expectedLabel)) {
468
- return shared.passResult(CHECK_TYPE, `PR #${context.prNumber} has expected type label '${expectedLabel}'`);
468
+ return shared.passResult(CHECK_TYPE, `${pullRequestLabel(context)} has expected type label '${expectedLabel}'`);
469
469
  }
470
- return shared.failResult(CHECK_TYPE, `Expected type label '${expectedLabel}' not found on PR #${context.prNumber}`, "check_failed");
470
+ return shared.failResult(CHECK_TYPE, `Expected type label '${expectedLabel}' not found on ${pullRequestLabel(context)}`, "check_failed");
471
471
  }
472
472
  function checkInLabelsMatchPr(context, remoteData, shared) {
473
- if (!context.audit.enabled || !context.hasTriage || !context.prNumber || !remoteData.prLabels) {
473
+ if (!context.audit.enabled || !context.hasTriage || !context.prIdentity || remoteData.prLabels === undefined || remoteData.prLabels === null) {
474
474
  return infoResult('PR in: label audit is not applicable because the required PR metadata or capability is unavailable');
475
475
  }
476
476
  const issueInLabels = extractLabelNames(remoteData.issue.labels)
@@ -480,9 +480,12 @@ function checkInLabelsMatchPr(context, remoteData, shared) {
480
480
  .filter((label) => label.startsWith("in:"))
481
481
  .sort();
482
482
  if (arraysEqual(issueInLabels, prInLabels)) {
483
- return shared.passResult(CHECK_TYPE, `PR #${context.prNumber} in: labels match Issue #${context.issueNumber}`);
483
+ return shared.passResult(CHECK_TYPE, `${pullRequestLabel(context)} in: labels match Issue #${context.issueNumber}`);
484
484
  }
485
- return shared.failResult(CHECK_TYPE, `in: labels mismatch — PR #${context.prNumber} has [${formatLabelList(prInLabels)}], Issue #${context.issueNumber} has [${formatLabelList(issueInLabels)}]`, "check_failed");
485
+ return shared.failResult(CHECK_TYPE, `in: labels mismatch — ${pullRequestLabel(context)} has [${formatLabelList(prInLabels)}], Issue #${context.issueNumber} has [${formatLabelList(issueInLabels)}]`, "check_failed");
486
+ }
487
+ function pullRequestLabel(context) {
488
+ return context.prNumber ? `PR #${context.prNumber}` : 'Pull request';
486
489
  }
487
490
  function checkInLabelsComputed(context, remoteData, shared) {
488
491
  if (!context.audit.enabled || !context.hasTriage) {
@@ -574,13 +577,13 @@ function checkIssueFields(context, remoteData, shared) {
574
577
  return shared.passResult(CHECK_TYPE, `Issue #${context.issueNumber} fields match task metadata`);
575
578
  }
576
579
  function checkPrAssignee(context, remoteData, shared) {
577
- if (!context.audit.enabled || !context.hasPush || !context.prNumber) {
580
+ if (!context.audit.enabled || !context.hasPush || !context.prIdentity || remoteData.prAssignees === undefined || remoteData.prAssignees === null) {
578
581
  return infoResult('PR assignee audit is not applicable because it is disabled or required PR metadata/capability is unavailable');
579
582
  }
580
- if (!remoteData.prAssignees || remoteData.prAssignees.length === 0) {
581
- return shared.failResult(CHECK_TYPE, `PR #${context.prNumber} has no assignee`, "check_failed");
583
+ if (remoteData.prAssignees.length === 0) {
584
+ return shared.failResult(CHECK_TYPE, `${pullRequestLabel(context)} has no assignee`, "check_failed");
582
585
  }
583
- return shared.passResult(CHECK_TYPE, `PR #${context.prNumber} has an assignee`);
586
+ return shared.passResult(CHECK_TYPE, `${pullRequestLabel(context)} has an assignee`);
584
587
  }
585
588
  function checkMilestone(context, remoteData, shared) {
586
589
  if (!context.hasTriage) {
@@ -589,16 +592,16 @@ function checkMilestone(context, remoteData, shared) {
589
592
  if (!remoteData.issue?.milestone?.title) {
590
593
  return shared.failResult(CHECK_TYPE, `Issue #${context.issueNumber} has no milestone set`, "check_failed");
591
594
  }
592
- if (context.prNumber && remoteData.prMilestone !== undefined && !remoteData.prMilestone?.title) {
593
- return shared.failResult(CHECK_TYPE, `PR #${context.prNumber} has no milestone set`, "check_failed");
595
+ if (context.prIdentity && remoteData.prMilestone !== undefined && !remoteData.prMilestone?.title) {
596
+ return shared.failResult(CHECK_TYPE, `${pullRequestLabel(context)} has no milestone set`, "check_failed");
594
597
  }
595
598
  if (context.audit.requireSpecificMilestone) {
596
599
  const issueTitle = remoteData.issue.milestone.title;
597
600
  if (VERSION_LINE_REGEX.test(issueTitle)) {
598
601
  return shared.failResult(CHECK_TYPE, `Issue #${context.issueNumber} milestone '${issueTitle}' is a release line; narrow to a specific version (e.g. ${issueTitle.replace(/\.x$/, ".N")}) before continuing`, "check_failed");
599
602
  }
600
- if (context.prNumber && remoteData.prMilestone?.title && VERSION_LINE_REGEX.test(remoteData.prMilestone.title)) {
601
- return shared.failResult(CHECK_TYPE, `PR #${context.prNumber} milestone '${remoteData.prMilestone.title}' is a release line; narrow to a specific version before continuing`, "check_failed");
603
+ if (context.prIdentity && remoteData.prMilestone?.title && VERSION_LINE_REGEX.test(remoteData.prMilestone.title)) {
604
+ return shared.failResult(CHECK_TYPE, `${pullRequestLabel(context)} milestone '${remoteData.prMilestone.title}' is a release line; narrow to a specific version before continuing`, "check_failed");
602
605
  }
603
606
  }
604
607
  return shared.passResult(CHECK_TYPE, `Issue #${context.issueNumber} milestone satisfies the configured policy`);
@@ -672,7 +672,7 @@ function readProjectInitCommandDigest(controlRoot) {
672
672
  function writeProjectInitCommandDigest(controlRoot, commandSha256) {
673
673
  atomicWriteJson(path.join(controlRoot, 'project-init.json'), { version: 1, commandSha256 });
674
674
  }
675
- export async function create(args, { runProjectInitCommand = true } = {}) {
675
+ export async function create(args, { runProjectInitCommand = true, workspace: workspaceOverride } = {}) {
676
676
  const { values, positionals } = parseArgs({
677
677
  args,
678
678
  allowPositionals: true,
@@ -696,7 +696,13 @@ export async function create(args, { runProjectInitCommand = true } = {}) {
696
696
  validateSelinuxDisableEnv();
697
697
  const config = loadConfig();
698
698
  const [branchOrTaskId = '', base] = positionals;
699
- const target = resolveSandboxTarget(branchOrTaskId, config.repoRoot);
699
+ const resolvedTarget = resolveSandboxTarget(branchOrTaskId, config.repoRoot);
700
+ const workspace = workspaceOverride?.mode === 'task-bound'
701
+ && resolvedTarget.workspace.mode === 'task-bound'
702
+ && workspaceOverride.taskId === resolvedTarget.workspace.taskId
703
+ ? resolvedTarget.workspace
704
+ : workspaceOverride ?? resolvedTarget.workspace;
705
+ const target = { ...resolvedTarget, workspace };
700
706
  const branch = target.branch;
701
707
  assertValidBranchName(branch);
702
708
  const effectiveConfig = {
@@ -866,6 +872,9 @@ export async function create(args, { runProjectInitCommand = true } = {}) {
866
872
  task: async () => {
867
873
  for (const { tool, dir } of effectiveResolvedTools) {
868
874
  fs.mkdirSync(dir, { recursive: true });
875
+ for (const { hostSubdir } of tool.hostStateMounts ?? []) {
876
+ fs.mkdirSync(path.join(dir, hostSubdir), { recursive: true });
877
+ }
869
878
  for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
870
879
  const destination = path.join(dir, sandboxName);
871
880
  if (fs.existsSync(hostPath) && !fs.existsSync(destination)) {
@@ -1105,6 +1114,10 @@ export async function create(args, { runProjectInitCommand = true } = {}) {
1105
1114
  '-v',
1106
1115
  volumeArg(engine, hostPath, path.posix.join(tool.containerMount, containerSubpath))
1107
1116
  ]));
1117
+ const hostStateMountVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => (tool.hostStateMounts ?? []).flatMap(({ hostSubdir, containerSubpath }) => [
1118
+ '-v',
1119
+ volumeArg(engine, path.join(dir, hostSubdir), path.posix.join(tool.containerMount, containerSubpath))
1120
+ ]));
1108
1121
  fs.mkdirSync(shareCommon, { recursive: true });
1109
1122
  fs.mkdirSync(shareBranch, { recursive: true });
1110
1123
  fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
@@ -1133,6 +1146,8 @@ export async function create(args, { runProjectInitCommand = true } = {}) {
1133
1146
  `${sandboxBranchLabel(effectiveConfig)}=${branch}`,
1134
1147
  '--label',
1135
1148
  `${sandboxWorkspaceModeLabel(effectiveConfig)}=${target.workspace.mode}`,
1149
+ '-e',
1150
+ 'AGENT_INFRA_SANDBOX=1',
1136
1151
  ...(target.workspace.mode === 'task-bound'
1137
1152
  ? [
1138
1153
  '--label',
@@ -1163,6 +1178,7 @@ export async function create(args, { runProjectInitCommand = true } = {}) {
1163
1178
  ...tmpfsArgs,
1164
1179
  ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1165
1180
  ...liveMountVolumes,
1181
+ ...hostStateMountVolumes,
1166
1182
  ...envFile.dockerArgs,
1167
1183
  ...tzFlags,
1168
1184
  '-w',
@@ -8,15 +8,16 @@ import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
8
8
  import { redactCommandError } from '../redaction.js';
9
9
  import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.js';
10
10
  import { detectHostTimezone } from '../host-timezone.js';
11
- import { fetchSandboxRows, selectSandboxContainer, } from './list-running.js';
12
- import { resolveSandboxReentryContext, resolveSandboxTarget } from '../workspace-identity.js';
11
+ import { fetchSandboxRows, selectSandboxContainer, startSandboxContainer, } from './list-running.js';
12
+ import { resolveSandboxTarget } from '../workspace-identity.js';
13
13
  const USAGE = `Usage: ai sandbox exec [--recreate] <branch | TASK-id | N> [cmd...]
14
14
 
15
15
  N references an active task short id from
16
16
  .agents/workspace/active/.short-ids.json. They resolve only via that
17
17
  registry — they do not reference a container's row position in
18
- 'ai sandbox ls' output. --recreate is a host recovery flag only before the
19
- target; the same token after the target is passed to the container command.`;
18
+ 'ai sandbox ls' output. --recreate rebuilds the matched container while
19
+ preserving its task or branch identity. The same token after the target is
20
+ passed to the container command.`;
20
21
  const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
21
22
  // Terminal-detection variables that interactive TUIs (e.g. claude-code)
22
23
  // inspect to enable progressive enhancements such as the kitty keyboard
@@ -78,41 +79,54 @@ export async function enter(args) {
78
79
  const config = loadConfig();
79
80
  const engine = detectEngine(config);
80
81
  const parsed = parseEnterArgs(args);
81
- const target = resolveSandboxTarget(parsed.target, config.repoRoot);
82
- const branch = target.branch;
83
- assertValidBranchName(branch);
82
+ const fullTaskId = /^TASK-\d{8}-\d{6}$/.test(parsed.target);
83
+ const target = fullTaskId ? null : resolveSandboxTarget(parsed.target, config.repoRoot);
84
+ const branch = target?.branch ?? '';
85
+ if (branch)
86
+ assertValidBranchName(branch);
84
87
  const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config), {
85
88
  mode: sandboxWorkspaceModeLabel(config),
86
89
  taskId: sandboxTaskIdLabel(config)
87
90
  });
88
- const found = selectSandboxContainer([...running, ...nonRunning], containerNameCandidates(config, branch));
89
- if (!found) {
90
- throw new Error(`No sandbox found for branch '${branch}'. Run 'ai sandbox create ${branch}' to create one.`);
91
+ const rows = [...running, ...nonRunning];
92
+ const found = fullTaskId
93
+ ? rows.find((row) => row.taskId === parsed.target)
94
+ : rows.find((row) => row.branch === branch)
95
+ ?? selectSandboxContainer(rows, containerNameCandidates(config, branch));
96
+ if (!found)
97
+ throw new Error(`No sandbox found for '${parsed.target}'.`);
98
+ const sandboxBranch = found.branch || branch;
99
+ if (sandboxBranch)
100
+ assertValidBranchName(sandboxBranch);
101
+ if (target?.workspace.mode === 'task-bound' && found.taskId !== target.workspace.taskId) {
102
+ throw new Error(`Sandbox task mismatch for short id '${parsed.target}'.`);
91
103
  }
92
- const containerWorkspace = found.workspaceMode === 'task-bound' && found.taskId
104
+ const workspace = found.workspaceMode === 'task-bound' && found.taskId
93
105
  ? { mode: 'task-bound', taskId: found.taskId }
94
- : found.workspaceMode === 'branch-only'
95
- ? { mode: 'branch-only' }
96
- : { mode: 'legacy-invalid' };
97
- const reentry = resolveSandboxReentryContext({
98
- target,
99
- containerWorkspace,
100
- repoRoot: config.repoRoot
101
- });
102
- const ready = await ensureSandboxReady({
103
- config,
104
- engine,
105
- branch,
106
- workspace: reentry.workspace,
107
- reentry: reentry.reentry,
108
- row: found,
109
- allowRecreate: parsed.recreate,
110
- recreate: async () => {
111
- const { create } = await import('./create.js');
112
- await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
113
- }
114
- });
115
- const container = ready.container;
106
+ : { mode: 'branch-only' };
107
+ let container = found.name;
108
+ if (parsed.recreate) {
109
+ const ready = await ensureSandboxReady({
110
+ config,
111
+ engine,
112
+ branch: sandboxBranch,
113
+ workspace,
114
+ row: found,
115
+ allowRecreate: true,
116
+ forceRecreate: true,
117
+ recreate: async () => {
118
+ const { create } = await import('./create.js');
119
+ await create([sandboxBranch, '--no-refresh'], {
120
+ runProjectInitCommand: false,
121
+ workspace
122
+ });
123
+ }
124
+ });
125
+ container = ready.container;
126
+ }
127
+ else if (!found.running) {
128
+ startSandboxContainer(engine, found.name);
129
+ }
116
130
  const cmd = parsed.command;
117
131
  const capabilityPlan = createSandboxCapabilityPlan(config);
118
132
  const enterHookResults = await runSandboxHooks({
@@ -1,5 +1,4 @@
1
1
  import fs from 'node:fs';
2
- import { hasTaskBoundMarker } from '../../internal/task-operation-registry.js';
3
2
  import path from 'node:path';
4
3
  import { randomUUID } from 'node:crypto';
5
4
  import { SANDBOX_CONTROL_ADMISSION_WINDOW_MS, SANDBOX_CONTROL_MAX_BYTES, SANDBOX_CONTROL_STATUS_STALE_MS, isSandboxControlFamily } from './protocol.js';
@@ -8,6 +7,9 @@ import { readSandboxControlPayload, readSandboxControlStatus } from './state.js'
8
7
  import { accessSandboxTaskView, taskViewFromStatus } from './task-view.js';
9
8
  import { readSandboxControlIdentitySentinel } from './identity-sentinel.js';
10
9
  import { configuredShortIdLength, resolveShortIdReadOnly } from '../../task/short-id.js';
10
+ import { hasTaskBoundMarker, isSandbox } from '../environment.js';
11
+ import { loadConfig } from '../config.js';
12
+ import { recoverSandboxControlFromHost } from './host-recovery.js';
11
13
  const SANDBOX_CONTROL_RESPONSE_SETTLE_MS = 250;
12
14
  const SANDBOX_TASK_FINALIZATION_RECOVERY_BUDGET_MS = 5 * 60_000;
13
15
  export class SandboxControlClientError extends Error {
@@ -98,7 +100,7 @@ function parseResponse(raw, id, accepted = false) {
98
100
  }
99
101
  return response;
100
102
  }
101
- function readPublishedResponse(raw, id, channelDir, accepted = false) {
103
+ function readPublishedResponse(raw, id, channelDir, accepted, generation) {
102
104
  const response = parseResponse(raw, id, accepted);
103
105
  if (response.outputState !== 'available')
104
106
  return response;
@@ -112,6 +114,7 @@ function readPublishedResponse(raw, id, channelDir, accepted = false) {
112
114
  }
113
115
  if (!response.payload || response.payload.version !== payload.version
114
116
  || response.payload.id !== payload.id || response.payload.generation !== payload.generation
117
+ || payload.generation !== generation
115
118
  || response.payload.stdoutBytes !== payload.stdoutBytes || response.payload.stderrBytes !== payload.stderrBytes
116
119
  || response.payload.stdoutSha256 !== payload.stdoutSha256 || response.payload.stderrSha256 !== payload.stderrSha256) {
117
120
  clientError('SANDBOX_CONTROL_RESPONSE_INVALID', 'broker payload reference is invalid', false, true, id);
@@ -216,7 +219,7 @@ function exchangeSandboxControl(request, params) {
216
219
  try {
217
220
  // The request-specific terminal proves the broker claimed the request; preserve recovery evidence if marker cleanup won the race.
218
221
  accepted = true;
219
- response = readPublishedResponse(raw, request.id, channelDir, accepted);
222
+ response = readPublishedResponse(raw, request.id, channelDir, accepted, request.generation);
220
223
  malformedResponseRaw = null;
221
224
  }
222
225
  catch (error) {
@@ -264,7 +267,40 @@ export function recoverSandboxControl(requestId, params = {}) {
264
267
  if (!/^[a-f0-9-]{16,64}$/u.test(requestId)) {
265
268
  clientError('SANDBOX_CONTROL_REQUEST_INVALID', 'request id is invalid', false, true);
266
269
  }
267
- const channelDir = params.channelDir ?? process.env.AGENT_INFRA_CONTROL_DIR ?? '/run/agent-infra/control';
270
+ try {
271
+ if (!isSandbox()) {
272
+ const config = loadConfig();
273
+ const response = recoverSandboxControlFromHost(requestId, {
274
+ managedRoot: path.join(config.controlBase, config.project),
275
+ timeoutMs: params.timeoutMs
276
+ });
277
+ if (response.phase === 'rejected' && response.error?.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN') {
278
+ clientError('SANDBOX_CONTROL_RESULT_UNKNOWN', 'recovery evidence is unavailable; inspect domain state before retrying', false, true, requestId);
279
+ }
280
+ return response;
281
+ }
282
+ const channelDir = process.env.AGENT_INFRA_CONTROL_DIR;
283
+ const statusDir = process.env.AGENT_INFRA_CONTROL_STATUS_DIR;
284
+ const identity = readSandboxControlIdentitySentinel(statusDir);
285
+ return recoverSandboxControlFromChannel(requestId, {
286
+ channelDir, generation: identity.generation, timeoutMs: params.timeoutMs
287
+ });
288
+ }
289
+ catch (error) {
290
+ if (error instanceof SandboxControlClientError)
291
+ throw error;
292
+ const message = error instanceof Error ? error.message : String(error);
293
+ const code = /^([A-Z][A-Z0-9_]+)/u.exec(message)?.[1] ?? 'SANDBOX_CONTROL_RECOVERY_INVALID';
294
+ clientError(code, 'sandbox control recovery evidence is invalid or unavailable', false, true, requestId);
295
+ }
296
+ }
297
+ // Low-level channel reader for control integration tests and internal callers
298
+ // that already hold a manifest-bound channel path.
299
+ export function recoverSandboxControlFromChannel(requestId, params) {
300
+ if (!/^[a-f0-9-]{16,64}$/u.test(requestId)) {
301
+ clientError('SANDBOX_CONTROL_REQUEST_INVALID', 'request id is invalid', false, true);
302
+ }
303
+ const { channelDir } = params;
268
304
  const responsePath = path.join(channelDir, 'responses', `${requestId}.json`);
269
305
  const deadline = Date.now() + (params.timeoutMs ?? 30_000);
270
306
  let accepted = false;
@@ -287,7 +323,7 @@ export function recoverSandboxControl(requestId, params = {}) {
287
323
  try {
288
324
  // The request-specific terminal proves the broker claimed the request; preserve recovery evidence if marker cleanup won the race.
289
325
  accepted = true;
290
- response = readPublishedResponse(raw, requestId, channelDir, accepted);
326
+ response = readPublishedResponse(raw, requestId, channelDir, accepted, params.generation);
291
327
  malformedResponseRaw = null;
292
328
  }
293
329
  catch (error) {
@@ -338,7 +374,7 @@ export function requestSandboxControl(params) {
338
374
  const auth = authority(params);
339
375
  const issuedAt = Date.now();
340
376
  const request = {
341
- version: 3, id: randomUUID(), ...auth, issuedAt,
377
+ version: 4, id: randomUUID(), ...auth, issuedAt,
342
378
  expiresAt: issuedAt + SANDBOX_CONTROL_ADMISSION_WINDOW_MS,
343
379
  family: params.family, args: params.args,
344
380
  controllerProcess: null,
@@ -353,9 +389,8 @@ export function requestSandboxTaskFinalization(params) {
353
389
  const auth = authority(params);
354
390
  const issuedAt = Date.now();
355
391
  const id = randomUUID();
356
- const handoffSha256 = (params.prepareHandoff ?? (() => '0'.repeat(64)))({ generation: auth.generation, requestId: id });
357
392
  const request = {
358
- version: 3,
393
+ version: 4,
359
394
  id,
360
395
  ...auth,
361
396
  issuedAt,
@@ -363,7 +398,6 @@ export function requestSandboxTaskFinalization(params) {
363
398
  family: 'task-finalization',
364
399
  operation: 'complete',
365
400
  agent,
366
- handoffSha256,
367
401
  args: [],
368
402
  controllerProcess: null,
369
403
  controllerProof: null
@@ -391,7 +425,7 @@ export function recoverAcceptedTaskFinalization(requestId, params, recover = rec
391
425
  if (timeoutMs <= 0)
392
426
  break;
393
427
  try {
394
- const response = recover(requestId, { channelDir: params.channelDir, timeoutMs });
428
+ const response = recover(requestId, { timeoutMs });
395
429
  if (response.phase === 'rejected' && response.error?.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN')
396
430
  continue;
397
431
  return response;
@@ -504,7 +538,7 @@ function requestCodexController(params) {
504
538
  const auth = authority(params);
505
539
  const issuedAt = Date.now();
506
540
  const request = {
507
- version: 3,
541
+ version: 4,
508
542
  id: randomUUID(),
509
543
  ...auth,
510
544
  issuedAt,
@@ -551,7 +585,7 @@ export function requestSandboxTaskCreate(params) {
551
585
  const auth = authority(params);
552
586
  const issuedAt = Date.now();
553
587
  const request = {
554
- version: 3, id: randomUUID(), ...auth, issuedAt,
588
+ version: 4, id: randomUUID(), ...auth, issuedAt,
555
589
  expiresAt: issuedAt + SANDBOX_CONTROL_ADMISSION_WINDOW_MS,
556
590
  family: 'task-create', candidate: params.candidate,
557
591
  controllerProcess: null,
@@ -2,7 +2,6 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { homedir } from 'node:os';
6
5
  import { getProcessStartTime } from '../../server/process-state.js';
7
6
  import { createTask, projectTaskCreateResult, taskCreateExitCode, taskCreateFailure } from '../../task/create-service.js';
8
7
  import { applyTaskFinalization } from '../../task/finalization.js';
@@ -15,10 +14,6 @@ import { createSandboxExecutorExecutionContext, dispatchTaskControlOperation, pa
15
14
  import { assertSandboxControlBrokerOwner, readSandboxControlManifest } from './lifecycle.js';
16
15
  import { computeLifecycleBuildIdentity } from '../../agent-clients/adapters/codex-lifecycle/build-identity.js';
17
16
  import { closeCodexControllerRegistration, CodexControllerRegistrationError, openCodexControllerRegistration, resolveCodexControllerBinding } from './controller-registration.js';
18
- import { shareBranchDir } from '../constants.js';
19
- function handoffDirectory(manifest) {
20
- return shareBranchDir({ shareBase: path.join(homedir(), '.agent-infra', 'share', manifest.project) }, manifest.branch);
21
- }
22
17
  function sameBrokerOwner(left, right) {
23
18
  return left.version === right.version
24
19
  && left.pid === right.pid
@@ -233,7 +228,7 @@ function finalizationResult(result) {
233
228
  async function executeRequestInner(manifest, manifestPath, request, options = {}) {
234
229
  if (request.family === 'task-create') {
235
230
  try {
236
- const result = await createTask(request.candidate, { repoRoot: manifest.repoRoot });
231
+ const result = await (options.createTask ?? createTask)(request.candidate, { repoRoot: manifest.repoRoot });
237
232
  return taskCreateExecutionResult(projectTaskCreateResult(result, {
238
233
  requestId: request.id,
239
234
  accepted: true,
@@ -304,7 +299,7 @@ async function executeRequestInner(manifest, manifestPath, request, options = {}
304
299
  const parsed = parseTaskControlOperation('task-finalization', [manifest.taskId, 'complete', '--agent', request.agent]);
305
300
  if (parsed.family !== 'task-finalization')
306
301
  throw new Error('SANDBOX_CONTROL_FINALIZATION_OPERATION_INVALID');
307
- const operation = { ...parsed, request: { ...parsed.request, handoffSha256: request.handoffSha256 } };
302
+ const operation = parsed;
308
303
  const result = dispatchTaskControlOperation(createSandboxExecutorExecutionContext({
309
304
  repoRoot: manifest.repoRoot,
310
305
  worktreeRoot: manifest.worktreeRoot,
@@ -312,8 +307,7 @@ async function executeRequestInner(manifest, manifestPath, request, options = {}
312
307
  taskId: manifest.taskId,
313
308
  generation: manifest.generation,
314
309
  manifestPath,
315
- requestId: request.id,
316
- handoffDirectory: handoffDirectory(manifest)
310
+ requestId: request.id
317
311
  }), operation);
318
312
  return finalizationResult(await result);
319
313
  }
@@ -334,8 +328,7 @@ async function executeRequestInner(manifest, manifestPath, request, options = {}
334
328
  taskId: manifest.taskId,
335
329
  generation: manifest.generation,
336
330
  manifestPath,
337
- requestId: request.id,
338
- handoffDirectory: handoffDirectory(manifest)
331
+ requestId: request.id
339
332
  });
340
333
  const format = (value) => {
341
334
  const result = value;