@fitlab-ai/agent-infra 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/bin/internal-cli.js +8 -3
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +171 -20
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +244 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +370 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +68 -8
  8. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  9. package/dist/lib/defaults.json +10 -2
  10. package/dist/lib/git/workflow.js +30 -4
  11. package/dist/lib/internal/codex-lifecycle.js +169 -3
  12. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  13. package/dist/lib/internal/release-workflow.js +108 -11
  14. package/dist/lib/internal/sandbox-control.js +43 -6
  15. package/dist/lib/internal/task-create.js +12 -4
  16. package/dist/lib/internal/task-orchestration.js +40 -7
  17. package/dist/lib/platform/context.js +6 -4
  18. package/dist/lib/platform/github-client.js +55 -2
  19. package/dist/lib/platform/issue-comments.js +2 -1
  20. package/dist/lib/process-data/checkpoint.js +319 -0
  21. package/dist/lib/process-data/index.js +128 -60
  22. package/dist/lib/process-data/materialize.js +84 -0
  23. package/dist/lib/process-data/normalize.js +34 -1
  24. package/dist/lib/process-data/reconcile.js +55 -1
  25. package/dist/lib/process-data/sources.js +422 -1
  26. package/dist/lib/process-data/store.js +341 -11
  27. package/dist/lib/release/workflow.js +29 -21
  28. package/dist/lib/sandbox/commands/create.js +111 -60
  29. package/dist/lib/sandbox/commands/prune.js +40 -16
  30. package/dist/lib/sandbox/commands/rm.js +372 -94
  31. package/dist/lib/sandbox/commands/start.js +4 -3
  32. package/dist/lib/sandbox/control/client.js +138 -22
  33. package/dist/lib/sandbox/control/container-identity.js +64 -0
  34. package/dist/lib/sandbox/control/executor.js +142 -0
  35. package/dist/lib/sandbox/control/lifecycle.js +557 -0
  36. package/dist/lib/sandbox/control/protocol.js +69 -26
  37. package/dist/lib/sandbox/control/server.js +395 -99
  38. package/dist/lib/sandbox/control/state.js +191 -0
  39. package/dist/lib/sandbox/control/timing.js +8 -0
  40. package/dist/lib/sandbox/index.js +2 -2
  41. package/dist/lib/sandbox/managed-fs.js +14 -2
  42. package/dist/lib/sandbox/mounts.js +22 -19
  43. package/dist/lib/sandbox/recovery.js +284 -50
  44. package/dist/lib/sandbox/workspace-view.js +90 -10
  45. package/dist/lib/sandbox/worktree-safety.js +196 -0
  46. package/dist/lib/server/daemon.js +1 -1
  47. package/dist/lib/server/process-control.js +17 -1
  48. package/dist/lib/server/process-state.js +57 -20
  49. package/dist/lib/task/artifact-lifecycle.js +87 -15
  50. package/dist/lib/task/artifact-receipts.js +128 -0
  51. package/dist/lib/task/codex-orchestration.js +236 -0
  52. package/dist/lib/task/commands/status.js +3 -0
  53. package/dist/lib/task/commands/validate.js +289 -0
  54. package/dist/lib/task/commit-finalization.js +36 -9
  55. package/dist/lib/task/create.js +10 -2
  56. package/dist/lib/task/delegation-receipts.js +109 -2
  57. package/dist/lib/task/events.js +149 -6
  58. package/dist/lib/task/finalization-artifacts.js +2 -1
  59. package/dist/lib/task/implementation-inputs.js +1 -7
  60. package/dist/lib/task/index.js +9 -0
  61. package/dist/lib/task/ledger.js +13 -1
  62. package/dist/lib/task/orchestration.js +365 -15
  63. package/dist/lib/task/sections.js +5 -1
  64. package/dist/lib/task/task-execution-lock.js +5 -4
  65. package/dist/lib/task/verification-engine.js +78 -14
  66. package/dist/lib/task/verification.js +1 -0
  67. package/dist/lifecycle-build-manifest.json +6 -0
  68. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +202 -18
  69. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  70. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +321 -0
  71. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
  72. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  73. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +453 -0
  74. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +83 -6
  75. package/lib/agent-clients/adapters/codex.ts +1 -1
  76. package/lib/defaults.json +10 -2
  77. package/lib/git/workflow.ts +25 -3
  78. package/lib/internal/codex-lifecycle.ts +175 -4
  79. package/lib/internal/codex-sandbox-controller.ts +109 -0
  80. package/lib/internal/release-workflow.ts +107 -11
  81. package/lib/internal/sandbox-control.ts +38 -6
  82. package/lib/internal/task-create.ts +16 -4
  83. package/lib/internal/task-orchestration.ts +34 -7
  84. package/lib/platform/context.ts +8 -5
  85. package/lib/platform/github-client.ts +61 -3
  86. package/lib/platform/issue-comments.ts +2 -1
  87. package/lib/process-data/checkpoint.ts +335 -0
  88. package/lib/process-data/index.ts +112 -60
  89. package/lib/process-data/materialize.ts +83 -0
  90. package/lib/process-data/normalize.ts +29 -1
  91. package/lib/process-data/reconcile.ts +64 -2
  92. package/lib/process-data/sources.ts +449 -2
  93. package/lib/process-data/store.ts +304 -11
  94. package/lib/process-data/types.ts +124 -2
  95. package/lib/release/workflow.ts +69 -20
  96. package/lib/sandbox/commands/create.ts +70 -25
  97. package/lib/sandbox/commands/prune.ts +53 -15
  98. package/lib/sandbox/commands/rm.ts +423 -108
  99. package/lib/sandbox/commands/start.ts +4 -3
  100. package/lib/sandbox/control/client.ts +152 -28
  101. package/lib/sandbox/control/container-identity.ts +93 -0
  102. package/lib/sandbox/control/executor.ts +167 -0
  103. package/lib/sandbox/control/lifecycle.ts +573 -0
  104. package/lib/sandbox/control/protocol.ts +121 -68
  105. package/lib/sandbox/control/server.ts +398 -109
  106. package/lib/sandbox/control/state.ts +213 -0
  107. package/lib/sandbox/control/timing.ts +12 -0
  108. package/lib/sandbox/index.ts +2 -2
  109. package/lib/sandbox/managed-fs.ts +17 -2
  110. package/lib/sandbox/mounts.ts +16 -8
  111. package/lib/sandbox/recovery.ts +335 -65
  112. package/lib/sandbox/workspace-view.ts +102 -11
  113. package/lib/sandbox/worktree-safety.ts +234 -0
  114. package/lib/server/daemon.ts +1 -1
  115. package/lib/server/process-control.ts +24 -3
  116. package/lib/server/process-state.ts +82 -27
  117. package/lib/task/artifact-lifecycle.ts +78 -14
  118. package/lib/task/artifact-receipts.ts +161 -0
  119. package/lib/task/codex-orchestration.ts +319 -0
  120. package/lib/task/commands/status.ts +2 -0
  121. package/lib/task/commands/validate.ts +282 -0
  122. package/lib/task/commit-finalization.ts +52 -9
  123. package/lib/task/create.ts +11 -2
  124. package/lib/task/delegation-receipts.ts +208 -7
  125. package/lib/task/events.ts +131 -5
  126. package/lib/task/finalization-artifacts.ts +2 -1
  127. package/lib/task/implementation-inputs.ts +1 -5
  128. package/lib/task/index.ts +8 -0
  129. package/lib/task/ledger.ts +16 -1
  130. package/lib/task/orchestration.ts +552 -18
  131. package/lib/task/sections.ts +6 -2
  132. package/lib/task/task-execution-lock.ts +11 -9
  133. package/lib/task/verification-engine.ts +93 -24
  134. package/lib/task/verification.ts +2 -1
  135. package/package.json +1 -1
  136. package/templates/.agents/README.en.md +7 -0
  137. package/templates/.agents/README.zh-CN.md +5 -0
  138. package/templates/.agents/hooks/lifecycle-delegation.js +72 -12
  139. package/templates/.agents/rules/lifecycle-orchestration.en.md +8 -2
  140. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +8 -2
  141. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  142. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  143. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  144. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  145. package/templates/.agents/rules/review-handshake.en.md +3 -1
  146. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  147. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  148. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  149. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  150. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  151. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  152. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  153. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  154. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  155. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +25 -0
  156. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +25 -0
  157. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  158. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  159. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +69 -0
  160. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +69 -0
  161. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  162. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  163. package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -3
  164. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -3
  165. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  166. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  167. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  168. package/templates/.codex/hooks.json +8 -8
  169. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  170. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
  171. package/templates/.agents/workspace/README.en.md +0 -26
  172. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -2,18 +2,158 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { containerNameCandidates, sandboxBranchLabel, sandboxLabel, sandboxRuntimeCapabilityLabel, sandboxTaskIdLabel, sandboxWorkspaceModeLabel } from './constants.js';
5
+ import { nodeEntryArgs } from './control/executor.js';
6
+ import { containerNameCandidates, sandboxBranchLabel, sandboxLabel, sandboxRuntimeCapabilityLabel, sandboxTaskIdLabel, sandboxWorkspaceModeLabel, worktreeDirCandidates } from './constants.js';
6
7
  import { isAgentClientId } from '../agent-clients/types.js';
7
8
  import { createSandboxCapabilityPlan } from './agent-client-reconciler.js';
8
9
  import { toEnginePath } from './engines/wsl2-paths.js';
9
10
  import { sandboxCoreBindMounts } from './mounts.js';
10
11
  import { assertSandboxTaskSource, sandboxControlPaths, sandboxWorkspaceViewPaths } from './workspace-view.js';
11
12
  import { parseSandboxWorkspaceIdentity, sameSandboxWorkspaceIdentity } from './workspace-identity.js';
12
- import { runEngine, runOkEngine, runVerboseEngine } from './shell.js';
13
+ import { inspectWorktree } from './worktree-safety.js';
14
+ import { commandForEngine, runEngine, runOkEngine, runProbe, runVerboseEngine } from './shell.js';
13
15
  import { declaredTmpfsSeedEntries, resolveTools, toolConfigDirCandidates } from './tools.js';
14
16
  import { fetchSandboxRows, selectSandboxContainer, startSandboxContainer } from './commands/list-running.js';
15
- import { processIdentityMatches } from '../server/process-state.js';
16
- function ensureSandboxControlBroker(params) {
17
+ import { getProcessStartTime, processIdentityMatches, removePidFileIfMatches } from '../server/process-state.js';
18
+ import { acquireSandboxControlBrokerStartup, garbageCollectSandboxControlRoot, isSandboxControlRootQuiescing, readSandboxControlManifest } from './control/lifecycle.js';
19
+ import { appendSandboxControlAudit, readSandboxControlStatus } from './control/state.js';
20
+ import { inspectSandboxControlContainer } from './control/container-identity.js';
21
+ import { SANDBOX_CONTROL_FUTURE_SKEW_MS, SANDBOX_CONTROL_STATUS_STALE_MS } from './control/protocol.js';
22
+ export function worktreeProbeForEngine(engine, probe = runProbe) {
23
+ return (cmd, args, opts = {}) => {
24
+ const engineArgs = [...args];
25
+ if (engine === 'wsl2' && cmd === 'git') {
26
+ for (let index = 0; index < engineArgs.length - 1; index += 1) {
27
+ if (engineArgs[index] === '-C') {
28
+ engineArgs[index + 1] = toEnginePath(engine, engineArgs[index + 1]);
29
+ }
30
+ }
31
+ }
32
+ const command = commandForEngine(engine, cmd, engineArgs);
33
+ return probe(command.cmd, command.args, opts);
34
+ };
35
+ }
36
+ async function waitForSandboxControlBrokerStatus(params, timeoutMs = 5_000) {
37
+ const deadline = Date.now() + timeoutMs;
38
+ while (Date.now() < deadline) {
39
+ if (isSandboxControlRootQuiescing(params.root))
40
+ throw new Error('SANDBOX_CONTROL_QUIESCING');
41
+ try {
42
+ const status = readSandboxControlStatus(params.statusDir);
43
+ if (status.generation === params.generation && status.broker.pid === params.pid
44
+ && (!params.startTime || status.broker.startTime === params.startTime)
45
+ && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
46
+ && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS)
47
+ return;
48
+ }
49
+ catch {
50
+ // The broker may still be publishing its initial atomic status.
51
+ }
52
+ await new Promise((resolve) => setTimeout(resolve, 25));
53
+ }
54
+ throw new Error('SANDBOX_CONTROL_BROKER_START_TIMEOUT');
55
+ }
56
+ export async function startSandboxControlBroker(repoRoot, manifestPath) {
57
+ const directory = path.dirname(fileURLToPath(import.meta.url));
58
+ const extension = path.extname(fileURLToPath(import.meta.url));
59
+ const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
60
+ const manifest = readSandboxControlManifest(manifestPath);
61
+ const root = path.dirname(manifestPath);
62
+ if (isSandboxControlRootQuiescing(root))
63
+ throw new Error('SANDBOX_CONTROL_QUIESCING');
64
+ const brokerPath = path.join(root, 'broker.json');
65
+ let brokerSnapshot = null;
66
+ let replacedBrokerRecord = false;
67
+ try {
68
+ brokerSnapshot = fs.readFileSync(brokerPath, 'utf8');
69
+ }
70
+ catch (error) {
71
+ if (error.code !== 'ENOENT')
72
+ throw error;
73
+ }
74
+ if (brokerSnapshot !== null) {
75
+ let broker = null;
76
+ try {
77
+ broker = JSON.parse(brokerSnapshot);
78
+ }
79
+ catch {
80
+ // Malformed owner records are stale and replaced below.
81
+ }
82
+ const owner = broker !== null && broker.version === 3 && typeof broker.pid === 'number'
83
+ && Number.isSafeInteger(broker.pid) && broker.pid > 0 && typeof broker.startTime === 'number'
84
+ && Number.isSafeInteger(broker.startTime) && typeof broker.brokerId === 'string'
85
+ && broker.brokerId.length > 0 && typeof broker.token === 'string'
86
+ && typeof broker.generation === 'string'
87
+ ? broker
88
+ : null;
89
+ if (broker === null || broker.version !== 3 || owner === null) {
90
+ throw new Error('SANDBOX_CONTROL_BROKER_VERSION_INVALID: rebuild the sandbox container before starting a new broker');
91
+ }
92
+ const live = owner !== null && processIdentityMatches({ pid: owner.pid, startTime: owner.startTime });
93
+ if (live
94
+ && owner !== null
95
+ && broker !== null
96
+ && broker.version === 3
97
+ && broker.token === manifest.token
98
+ && broker.generation === manifest.generation) {
99
+ await waitForSandboxControlBrokerStatus({
100
+ root,
101
+ statusDir: manifest.publicStatusDir,
102
+ generation: manifest.generation,
103
+ pid: owner.pid,
104
+ startTime: owner.startTime
105
+ });
106
+ return;
107
+ }
108
+ }
109
+ const startupStartTime = getProcessStartTime(process.pid);
110
+ if (!startupStartTime)
111
+ throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
112
+ const releaseStartup = await acquireSandboxControlBrokerStartup(root, {
113
+ pid: process.pid,
114
+ startTime: startupStartTime
115
+ });
116
+ let child;
117
+ try {
118
+ const currentBroker = fs.existsSync(brokerPath) ? fs.readFileSync(brokerPath, 'utf8') : null;
119
+ if (currentBroker !== brokerSnapshot)
120
+ return startSandboxControlBroker(repoRoot, manifestPath);
121
+ if (brokerSnapshot !== null) {
122
+ replacedBrokerRecord = removePidFileIfMatches(brokerPath, brokerSnapshot);
123
+ if (!replacedBrokerRecord && fs.existsSync(brokerPath)) {
124
+ throw new Error('SANDBOX_CONTROL_BROKER_OWNER_TRANSITION');
125
+ }
126
+ }
127
+ if (replacedBrokerRecord) {
128
+ appendSandboxControlAudit(manifest, 'broker-observed-crash');
129
+ appendSandboxControlAudit(manifest, 'broker-restart');
130
+ }
131
+ child = spawn(process.execPath, nodeEntryArgs(internalCli, ['sandbox-control', 'serve', '--manifest', manifestPath]), { cwd: repoRoot, detached: true, stdio: 'ignore' });
132
+ }
133
+ finally {
134
+ releaseStartup();
135
+ }
136
+ let spawnError = null;
137
+ child.once('error', (error) => { spawnError = error; });
138
+ child.unref();
139
+ if (!child.pid)
140
+ throw new Error('SANDBOX_CONTROL_BROKER_START_FAILED');
141
+ try {
142
+ await waitForSandboxControlBrokerStatus({
143
+ root,
144
+ statusDir: manifest.publicStatusDir,
145
+ generation: manifest.generation,
146
+ pid: child.pid
147
+ });
148
+ }
149
+ catch (error) {
150
+ child.kill('SIGTERM');
151
+ if (spawnError)
152
+ throw spawnError;
153
+ throw error;
154
+ }
155
+ }
156
+ async function ensureSandboxControlBroker(params) {
17
157
  const control = sandboxControlPaths({
18
158
  base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
19
159
  project: params.config.project,
@@ -22,31 +162,55 @@ function ensureSandboxControlBroker(params) {
22
162
  });
23
163
  if (!fs.existsSync(control.manifestPath))
24
164
  return;
25
- const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8'));
165
+ const validatedManifest = readSandboxControlManifest(control.manifestPath);
166
+ const containerObservation = await inspectSandboxControlContainer(validatedManifest);
167
+ if (containerObservation.state === 'unknown') {
168
+ throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${containerObservation.reason}`);
169
+ }
170
+ if (containerObservation.state === 'absent') {
171
+ await garbageCollectSandboxControlRoot(control.root, {
172
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(validatedManifest, { timeoutMs })
173
+ });
174
+ throw new Error('SANDBOX_CONTROL_CONTAINER_ABSENT: recreate the sandbox container before broker recovery');
175
+ }
26
176
  const brokerPath = path.join(control.root, 'broker.json');
27
177
  try {
28
178
  const broker = JSON.parse(fs.readFileSync(brokerPath, 'utf8'));
29
- if (broker.version === 1
179
+ if (broker.version === 3
30
180
  && typeof broker.pid === 'number'
31
- && typeof broker.startTime === 'string'
32
- && broker.token === manifest.token
33
- && processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime }))
181
+ && Number.isSafeInteger(broker.pid)
182
+ && typeof broker.startTime === 'number'
183
+ && Number.isSafeInteger(broker.startTime)
184
+ && typeof broker.brokerId === 'string'
185
+ && broker.brokerId.length > 0
186
+ && broker.token === validatedManifest.token
187
+ && broker.generation === validatedManifest.generation
188
+ && processIdentityMatches({ pid: broker.pid, startTime: broker.startTime })) {
189
+ try {
190
+ const status = readSandboxControlStatus(control.statusDir);
191
+ if (status.generation === validatedManifest.generation && status.broker.pid === broker.pid
192
+ && status.broker.startTime === broker.startTime
193
+ && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
194
+ && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS)
195
+ return;
196
+ }
197
+ catch {
198
+ // A verified live owner may still be publishing its initial status.
199
+ }
200
+ await waitForSandboxControlBrokerStatus({
201
+ root: control.root,
202
+ statusDir: control.statusDir,
203
+ generation: validatedManifest.generation,
204
+ pid: broker.pid,
205
+ startTime: broker.startTime
206
+ });
34
207
  return;
208
+ }
35
209
  }
36
210
  catch {
37
211
  // A missing, stale, or malformed broker record is replaced below.
38
212
  }
39
- fs.rmSync(brokerPath, { force: true });
40
- const directory = path.dirname(fileURLToPath(import.meta.url));
41
- const extension = path.extname(fileURLToPath(import.meta.url));
42
- const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
43
- const child = spawn(process.execPath, [internalCli, 'sandbox-control', 'serve', '--manifest', control.manifestPath], { cwd: params.config.repoRoot, detached: true, stdio: 'ignore' });
44
- child.once('error', () => {
45
- // Readiness remains fail-closed; a later control request reports the
46
- // unavailable broker without turning this detached spawn into an
47
- // unhandled process error.
48
- });
49
- child.unref();
213
+ await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
50
214
  }
51
215
  function findingKey(finding) {
52
216
  return `${finding.repairKind}:${finding.path ?? finding.seed?.targetPath ?? finding.message}`;
@@ -54,9 +218,22 @@ function findingKey(finding) {
54
218
  export function classifySandboxRecovery(snapshot) {
55
219
  const findings = [];
56
220
  if (!snapshot.identityOk) {
221
+ const branchOnly = snapshot.containerIdValid
222
+ && snapshot.actualBranch === snapshot.expectedBranch
223
+ && snapshot.expectedWorkspace.mode === 'task-bound'
224
+ && snapshot.actualWorkspace.mode === 'branch-only';
225
+ const expectedIdentity = snapshot.expectedWorkspace.mode === 'task-bound'
226
+ ? `task-bound:${snapshot.expectedWorkspace.taskId}`
227
+ : snapshot.expectedWorkspace.mode;
228
+ const actualIdentity = snapshot.actualWorkspace.mode === 'task-bound'
229
+ ? `task-bound:${snapshot.actualWorkspace.taskId}`
230
+ : snapshot.actualWorkspace.mode;
57
231
  findings.push({
58
232
  repairKind: 'hard-failure',
59
- message: 'Container identity does not match the requested sandbox branch.'
233
+ code: branchOnly ? 'SANDBOX_CONTROL_BRANCH_ONLY' : 'SANDBOX_WORKSPACE_IDENTITY_CONFLICT',
234
+ message: branchOnly
235
+ ? `Container is branch-only but the requested sandbox is ${expectedIdentity}.`
236
+ : `Container identity ${actualIdentity} on branch ${snapshot.actualBranch ?? 'unknown'} does not match requested ${expectedIdentity} on branch ${snapshot.expectedBranch}.`
60
237
  });
61
238
  }
62
239
  if (snapshot.runtimeCapabilityOk === false) {
@@ -211,6 +388,7 @@ function expectedMounts(params) {
211
388
  const core = sandboxCoreBindMounts(config, branch, {
212
389
  workspaceViewRoot: view.root,
213
390
  controlDir: control.channelDir,
391
+ controlStatusDir: control.statusDir,
214
392
  ...(params.workspace.mode === 'task-bound'
215
393
  ? {
216
394
  taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
@@ -379,8 +557,12 @@ export function collectSandboxRecoverySnapshot(params) {
379
557
  };
380
558
  });
381
559
  return {
382
- identityOk: typeof inspection.Id === 'string'
383
- && inspection.Id.length > 0
560
+ containerIdValid: typeof inspection.Id === 'string' && inspection.Id.length > 0,
561
+ expectedBranch: params.branch,
562
+ actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
563
+ expectedWorkspace: workspace,
564
+ actualWorkspace: containerWorkspace,
565
+ identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
384
566
  && branchLabel === params.branch
385
567
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
386
568
  runtimeCapabilityOk: enforceCanonicalPlan
@@ -487,7 +669,7 @@ function repairFindings(params) {
487
669
  }
488
670
  }
489
671
  function describeFindings(findings) {
490
- return findings.map((finding) => finding.message).join(' ');
672
+ return findings.map((finding) => finding.code ? `${finding.code}: ${finding.message}` : finding.message).join(' ');
491
673
  }
492
674
  function assess(params) {
493
675
  const snapshot = collectSandboxRecoverySnapshot(params);
@@ -547,12 +729,20 @@ export async function ensureSandboxReady(params) {
547
729
  const startFn = deps?.start ?? startSandboxContainer;
548
730
  const warnings = [];
549
731
  let failure = null;
550
- ensureSandboxControlBroker({
551
- config: params.config,
552
- container: params.row.name,
553
- workspace: params.workspace ?? { mode: 'branch-only' }
554
- });
555
732
  try {
733
+ if (deps?.ensureControlBroker) {
734
+ await deps.ensureControlBroker();
735
+ }
736
+ else {
737
+ await ensureSandboxControlBroker({
738
+ config: params.config,
739
+ container: params.row.name,
740
+ workspace: params.workspace ?? { mode: 'branch-only' }
741
+ });
742
+ }
743
+ if (params.forceRecreate) {
744
+ throw new Error('Explicit container recreation requested.');
745
+ }
556
746
  if (!params.row.running) {
557
747
  startFn(params.engine, params.row.name);
558
748
  const initial = assess({
@@ -668,34 +858,78 @@ export async function ensureSandboxReady(params) {
668
858
  failure = error instanceof Error ? error : new Error(String(error));
669
859
  }
670
860
  const dataBoundary = 'The existing container writable layer, ordinary /tmp data, processes, tmux sessions, and RAM state may be lost by replacement; worktree and host-managed sandbox data are preserved.';
861
+ const recoveryTarget = params.workspace?.mode === 'task-bound'
862
+ ? params.workspace.taskId
863
+ : params.branch;
671
864
  if (!params.allowRecreate || !params.recreate) {
672
- throw new Error(`Sandbox recovery failed: ${failure.message} ${dataBoundary} Re-run with --recreate to authorize container-only replacement.`);
865
+ throw new Error([
866
+ `Sandbox recovery failed. Run 'ai sandbox start --recreate ${recoveryTarget}' on the host to authorize container-only replacement.`,
867
+ dataBoundary,
868
+ `Details: ${failure.message}`
869
+ ].join('\n'));
870
+ }
871
+ const existingWorktrees = worktreeDirCandidates(params.config, params.branch).filter((candidate) => fs.existsSync(candidate));
872
+ if (existingWorktrees.length !== 1) {
873
+ const remediation = existingWorktrees.length === 0
874
+ ? `Restore the worktree for '${params.branch}' before recovering the container.`
875
+ : `Found ${existingWorktrees.map((candidate) => JSON.stringify(candidate)).join(', ')}; `
876
+ + 'keep the worktree in use, remove the stale directory, then retry.';
877
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected exactly one existing worktree for '${params.branch}', found ${existingWorktrees.length}. ${remediation}`);
673
878
  }
674
- const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
879
+ const worktreeProbe = worktreeProbeForEngine(params.engine, deps?.probe);
880
+ const beforeInspection = inspectWorktree(existingWorktrees[0], { probe: worktreeProbe });
881
+ if (beforeInspection.status === 'failed') {
882
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: ${beforeInspection.message}`);
883
+ }
884
+ const before = beforeInspection.snapshot;
885
+ if (before.branch !== params.branch) {
886
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected branch '${params.branch}', found '${before.branch}'. `
887
+ + `Run 'git -C ${JSON.stringify(before.worktree)} checkout ${params.branch}' to restore the expected branch, then retry.`);
888
+ }
889
+ const warning = params.forceRecreate
890
+ ? `Explicit sandbox recreation requested. Replacing only the container. ${dataBoundary}`
891
+ : `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
675
892
  warnings.push(warning);
676
893
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
677
894
  const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
678
- if (params.row.running) {
679
- runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
895
+ let replacementResult = null;
896
+ let replacementFailure = null;
897
+ try {
898
+ if (params.row.running) {
899
+ runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
900
+ }
901
+ runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
902
+ await params.recreate(params.branch);
903
+ const rows = (deps?.fetchRows ?? fetchSandboxRows)(params.engine, sandboxLabel(params.config), sandboxBranchLabel(params.config));
904
+ const replacement = selectSandboxContainer([...rows.running, ...rows.nonRunning], containerNameCandidates(params.config, params.branch));
905
+ if (!replacement?.running) {
906
+ throw new Error('Replacement sandbox container was not found in a running state.');
907
+ }
908
+ const final = assess({
909
+ config: params.config,
910
+ engine: params.engine,
911
+ branch: params.branch,
912
+ workspace: params.workspace,
913
+ container: replacement.name,
914
+ deps
915
+ });
916
+ if (final.findings.length > 0) {
917
+ throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
918
+ }
919
+ replacementResult = { container: replacement.name, path: 'recreated', warnings };
680
920
  }
681
- runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
682
- await params.recreate(params.branch);
683
- const rows = (deps?.fetchRows ?? fetchSandboxRows)(params.engine, sandboxLabel(params.config), sandboxBranchLabel(params.config));
684
- const replacement = selectSandboxContainer([...rows.running, ...rows.nonRunning], containerNameCandidates(params.config, params.branch));
685
- if (!replacement?.running) {
686
- throw new Error('Replacement sandbox container was not found in a running state.');
921
+ catch (error) {
922
+ replacementFailure = error;
687
923
  }
688
- const final = assess({
689
- config: params.config,
690
- engine: params.engine,
691
- branch: params.branch,
692
- workspace: params.workspace,
693
- container: replacement.name,
694
- deps
695
- });
696
- if (final.findings.length > 0) {
697
- throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
924
+ const afterInspection = inspectWorktree(before.worktree, { probe: worktreeProbe });
925
+ if (afterInspection.status === 'failed' || afterInspection.snapshot.identity !== before.identity) {
926
+ const detail = afterInspection.status === 'failed'
927
+ ? afterInspection.message
928
+ : `snapshot changed from ${before.identity} to ${afterInspection.snapshot.identity}`;
929
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_CHANGED: ${detail}`, replacementFailure === null ? undefined : { cause: replacementFailure });
698
930
  }
699
- return { container: replacement.name, path: 'recreated', warnings };
931
+ if (replacementFailure !== null)
932
+ throw replacementFailure;
933
+ return replacementResult;
700
934
  }
701
935
  //# sourceMappingURL=recovery.js.map
@@ -1,6 +1,18 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ export const SANDBOX_WORKSPACE_VIEW_STATES = Object.freeze([
5
+ 'active',
6
+ 'completed',
7
+ 'blocked',
8
+ 'archive'
9
+ ]);
10
+ export function sandboxWorkspaceViewStatePaths(root) {
11
+ return SANDBOX_WORKSPACE_VIEW_STATES.map((state) => ({
12
+ state,
13
+ hostPath: path.join(root, state)
14
+ }));
15
+ }
4
16
  export function sandboxWorkspaceViewPaths(params) {
5
17
  const identityKey = params.identity.mode === 'task-bound'
6
18
  ? `task-bound:${params.identity.taskId}`
@@ -21,7 +33,13 @@ export function sandboxControlPaths(params) {
21
33
  : 'branch-only';
22
34
  const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
23
35
  const root = path.resolve(params.base, params.project, params.container, digest);
24
- return { root, channelDir: path.join(root, 'channel'), manifestPath: path.join(root, 'manifest.json') };
36
+ return {
37
+ root,
38
+ channelDir: path.join(root, 'channel'),
39
+ statusDir: path.join(root, 'public'),
40
+ processingDir: path.join(root, 'processing'),
41
+ manifestPath: path.join(root, 'manifest.json')
42
+ };
25
43
  }
26
44
  function assertSafeDirectory(directory, expectedBase) {
27
45
  const relative = path.relative(expectedBase, directory);
@@ -47,17 +65,53 @@ export function assertSandboxTaskSource(repoRoot, taskId) {
47
65
  }
48
66
  return canonical;
49
67
  }
68
+ export function prepareSandboxWorkspaceMountTarget(worktreeRoot) {
69
+ const worktree = path.resolve(worktreeRoot);
70
+ const canonicalWorktree = fs.realpathSync.native(worktree);
71
+ const agentsRoot = path.join(worktree, '.agents');
72
+ if (fs.existsSync(agentsRoot)) {
73
+ const agentsStat = fs.lstatSync(agentsRoot);
74
+ if (!agentsStat.isDirectory() || agentsStat.isSymbolicLink()) {
75
+ throw new Error(`Sandbox workspace mount ancestor must be a real directory: ${agentsRoot}`);
76
+ }
77
+ }
78
+ else {
79
+ fs.mkdirSync(agentsRoot, { mode: 0o700 });
80
+ }
81
+ const canonicalAgents = fs.realpathSync.native(agentsRoot);
82
+ if (path.dirname(canonicalAgents) !== canonicalWorktree) {
83
+ throw new Error(`Sandbox workspace mount ancestor escapes the worktree: ${agentsRoot}`);
84
+ }
85
+ const target = path.join(agentsRoot, 'workspace');
86
+ if (fs.existsSync(target)) {
87
+ const stat = fs.lstatSync(target);
88
+ if (stat.isSymbolicLink()) {
89
+ throw new Error(`Sandbox workspace mount target must not be a symbolic link: ${target}`);
90
+ }
91
+ if (!stat.isDirectory()) {
92
+ throw new Error(`Sandbox workspace mount target must be a directory: ${target}`);
93
+ }
94
+ if (path.dirname(fs.realpathSync.native(target)) !== canonicalAgents) {
95
+ throw new Error(`Sandbox workspace mount target escapes the worktree: ${target}`);
96
+ }
97
+ }
98
+ else {
99
+ fs.mkdirSync(target, { mode: 0o700 });
100
+ }
101
+ fs.chmodSync(target, 0o700);
102
+ fs.accessSync(target, fs.constants.W_OK);
103
+ return target;
104
+ }
50
105
  export function materializeSandboxWorkspaceView(params) {
51
106
  const projectRoot = path.resolve(params.base, params.project);
52
107
  const { root, taskMountPath } = sandboxWorkspaceViewPaths(params);
53
108
  assertSafeDirectory(projectRoot, path.resolve(params.base));
54
109
  assertSafeDirectory(root, projectRoot);
55
110
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
56
- for (const state of ['active', 'completed', 'blocked', 'archive']) {
57
- const stateDir = path.join(root, state);
58
- assertSafeDirectory(stateDir, root);
59
- fs.rmSync(stateDir, { recursive: true, force: true });
60
- fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
111
+ for (const { hostPath } of sandboxWorkspaceViewStatePaths(root)) {
112
+ assertSafeDirectory(hostPath, root);
113
+ fs.rmSync(hostPath, { recursive: true, force: true });
114
+ fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
61
115
  }
62
116
  const active = path.join(root, 'active');
63
117
  const registry = {
@@ -77,12 +131,16 @@ export function materializeSandboxWorkspaceView(params) {
77
131
  return { root, taskMountPath };
78
132
  }
79
133
  export function materializeSandboxControl(params) {
80
- const { root, channelDir, manifestPath } = sandboxControlPaths(params);
134
+ const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
81
135
  assertSafeDirectory(root, path.resolve(params.base));
82
136
  const consumedDir = path.join(root, 'consumed');
83
137
  assertSafeDirectory(consumedDir, root);
84
138
  fs.rmSync(consumedDir, { recursive: true, force: true });
85
139
  fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
140
+ for (const directory of [statusDir, processingDir]) {
141
+ assertSafeDirectory(directory, root);
142
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
143
+ }
86
144
  for (const queue of ['requests', 'responses']) {
87
145
  const directory = path.join(channelDir, queue);
88
146
  assertSafeDirectory(directory, root);
@@ -90,20 +148,42 @@ export function materializeSandboxControl(params) {
90
148
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
91
149
  }
92
150
  const token = randomBytes(32).toString('hex');
151
+ const generation = randomBytes(16).toString('hex');
93
152
  const repoRoot = fs.realpathSync.native(params.repoRoot);
94
153
  const manifest = {
95
- version: 2,
154
+ version: 4,
155
+ engine: params.engine ?? 'docker',
96
156
  repoRoot,
97
157
  worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
98
158
  project: params.project,
99
159
  container: params.container,
160
+ containerIdentity: { id: '', labels: {} },
100
161
  branch: params.branch,
101
162
  mode: params.identity.mode,
102
163
  taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
103
164
  token,
104
- channelDir
165
+ generation,
166
+ channelDir,
167
+ publicStatusDir: statusDir,
168
+ processingDir
105
169
  };
106
170
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
107
- return { channelDir, manifestPath, token };
171
+ return { root, channelDir, statusDir, manifestPath, token, generation };
172
+ }
173
+ export function finalizeSandboxControlManifest(setup, identity) {
174
+ if (!identity.engine || !identity.id)
175
+ throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
176
+ const manifest = JSON.parse(fs.readFileSync(setup.manifestPath, 'utf8'));
177
+ manifest.version = 4;
178
+ manifest.engine = identity.engine;
179
+ manifest.containerIdentity = { id: identity.id, labels: { ...identity.labels } };
180
+ const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
181
+ try {
182
+ fs.writeFileSync(temporary, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' });
183
+ fs.renameSync(temporary, setup.manifestPath);
184
+ }
185
+ finally {
186
+ fs.rmSync(temporary, { force: true });
187
+ }
108
188
  }
109
189
  //# sourceMappingURL=workspace-view.js.map