@fitlab-ai/agent-infra 0.9.6 → 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 (127) hide show
  1. package/dist/bin/internal-cli.js +5 -0
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +45 -4
  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/sandbox-controller.js +370 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +21 -5
  7. package/dist/lib/defaults.json +10 -2
  8. package/dist/lib/git/workflow.js +30 -3
  9. package/dist/lib/internal/codex-lifecycle.js +69 -1
  10. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  11. package/dist/lib/internal/task-orchestration.js +37 -5
  12. package/dist/lib/platform/context.js +6 -4
  13. package/dist/lib/platform/github-client.js +55 -2
  14. package/dist/lib/process-data/checkpoint.js +319 -0
  15. package/dist/lib/process-data/index.js +128 -60
  16. package/dist/lib/process-data/materialize.js +84 -0
  17. package/dist/lib/process-data/normalize.js +34 -1
  18. package/dist/lib/process-data/reconcile.js +55 -1
  19. package/dist/lib/process-data/sources.js +422 -1
  20. package/dist/lib/process-data/store.js +341 -11
  21. package/dist/lib/sandbox/commands/create.js +103 -51
  22. package/dist/lib/sandbox/commands/rm.js +64 -15
  23. package/dist/lib/sandbox/commands/start.js +4 -3
  24. package/dist/lib/sandbox/control/container-identity.js +64 -0
  25. package/dist/lib/sandbox/control/executor.js +13 -7
  26. package/dist/lib/sandbox/control/lifecycle.js +238 -29
  27. package/dist/lib/sandbox/control/protocol.js +7 -0
  28. package/dist/lib/sandbox/control/server.js +202 -26
  29. package/dist/lib/sandbox/control/state.js +25 -15
  30. package/dist/lib/sandbox/control/timing.js +8 -0
  31. package/dist/lib/sandbox/mounts.js +5 -13
  32. package/dist/lib/sandbox/recovery.js +59 -42
  33. package/dist/lib/sandbox/workspace-view.js +57 -40
  34. package/dist/lib/server/daemon.js +1 -1
  35. package/dist/lib/server/process-control.js +11 -1
  36. package/dist/lib/server/process-state.js +57 -20
  37. package/dist/lib/task/artifact-lifecycle.js +82 -11
  38. package/dist/lib/task/artifact-receipts.js +128 -0
  39. package/dist/lib/task/codex-orchestration.js +81 -6
  40. package/dist/lib/task/commands/validate.js +1 -1
  41. package/dist/lib/task/create.js +10 -2
  42. package/dist/lib/task/delegation-receipts.js +84 -2
  43. package/dist/lib/task/events.js +139 -2
  44. package/dist/lib/task/ledger.js +13 -1
  45. package/dist/lib/task/orchestration.js +235 -14
  46. package/dist/lib/task/sections.js +5 -1
  47. package/dist/lib/task/task-execution-lock.js +5 -4
  48. package/dist/lib/task/verification-engine.js +40 -8
  49. package/dist/lifecycle-build-manifest.json +6 -0
  50. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +60 -3
  51. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  52. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +321 -0
  53. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  54. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +453 -0
  55. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +33 -4
  56. package/lib/defaults.json +10 -2
  57. package/lib/git/workflow.ts +25 -2
  58. package/lib/internal/codex-lifecycle.ts +68 -1
  59. package/lib/internal/codex-sandbox-controller.ts +109 -0
  60. package/lib/internal/task-orchestration.ts +31 -4
  61. package/lib/platform/context.ts +8 -5
  62. package/lib/platform/github-client.ts +61 -3
  63. package/lib/process-data/checkpoint.ts +335 -0
  64. package/lib/process-data/index.ts +112 -60
  65. package/lib/process-data/materialize.ts +83 -0
  66. package/lib/process-data/normalize.ts +29 -1
  67. package/lib/process-data/reconcile.ts +64 -2
  68. package/lib/process-data/sources.ts +449 -2
  69. package/lib/process-data/store.ts +304 -11
  70. package/lib/process-data/types.ts +124 -2
  71. package/lib/sandbox/commands/create.ts +56 -7
  72. package/lib/sandbox/commands/rm.ts +64 -14
  73. package/lib/sandbox/commands/start.ts +4 -3
  74. package/lib/sandbox/control/container-identity.ts +93 -0
  75. package/lib/sandbox/control/executor.ts +14 -10
  76. package/lib/sandbox/control/lifecycle.ts +264 -33
  77. package/lib/sandbox/control/protocol.ts +31 -5
  78. package/lib/sandbox/control/server.ts +184 -24
  79. package/lib/sandbox/control/state.ts +34 -17
  80. package/lib/sandbox/control/timing.ts +12 -0
  81. package/lib/sandbox/mounts.ts +5 -15
  82. package/lib/sandbox/recovery.ts +71 -42
  83. package/lib/sandbox/workspace-view.ts +61 -40
  84. package/lib/server/daemon.ts +1 -1
  85. package/lib/server/process-control.ts +13 -3
  86. package/lib/server/process-state.ts +82 -27
  87. package/lib/task/artifact-lifecycle.ts +73 -10
  88. package/lib/task/artifact-receipts.ts +161 -0
  89. package/lib/task/codex-orchestration.ts +91 -5
  90. package/lib/task/commands/validate.ts +1 -1
  91. package/lib/task/create.ts +11 -2
  92. package/lib/task/delegation-receipts.ts +160 -8
  93. package/lib/task/events.ts +122 -1
  94. package/lib/task/ledger.ts +16 -1
  95. package/lib/task/orchestration.ts +356 -17
  96. package/lib/task/sections.ts +6 -2
  97. package/lib/task/task-execution-lock.ts +11 -9
  98. package/lib/task/verification-engine.ts +51 -16
  99. package/package.json +1 -1
  100. package/templates/.agents/README.en.md +7 -0
  101. package/templates/.agents/README.zh-CN.md +5 -0
  102. package/templates/.agents/hooks/lifecycle-delegation.js +25 -1
  103. package/templates/.agents/rules/lifecycle-orchestration.en.md +8 -2
  104. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +8 -2
  105. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  106. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  107. package/templates/.agents/rules/review-handshake.en.md +3 -1
  108. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  109. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  110. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  111. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  112. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  113. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +6 -6
  114. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +6 -6
  115. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -3
  116. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -3
  117. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  118. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  119. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +25 -0
  120. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +25 -0
  121. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  122. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  123. package/templates/.agents/skills/run-task/reference/host-validation.en.md +2 -0
  124. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +2 -0
  125. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  126. package/templates/.agents/workspace/README.en.md +0 -26
  127. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -35,7 +35,7 @@ import {
35
35
  type SandboxWorkspaceIdentity
36
36
  } from './workspace-identity.ts';
37
37
  import { inspectWorktree, type WorktreeSnapshot } from './worktree-safety.ts';
38
- import { runEngine, runOkEngine, runVerboseEngine } from './shell.ts';
38
+ import { commandForEngine, runEngine, runOkEngine, runProbe, runVerboseEngine } from './shell.ts';
39
39
  import {
40
40
  declaredTmpfsSeedEntries,
41
41
  resolveTools,
@@ -52,12 +52,16 @@ import {
52
52
  import { getProcessStartTime, processIdentityMatches, removePidFileIfMatches } from '../server/process-state.ts';
53
53
  import {
54
54
  acquireSandboxControlBrokerStartup,
55
- isSandboxControlRootQuiescing
55
+ garbageCollectSandboxControlRoot,
56
+ isSandboxControlRootQuiescing,
57
+ readSandboxControlManifest,
58
+ type BrokerOwner
56
59
  } from './control/lifecycle.ts';
57
60
  import {
58
61
  appendSandboxControlAudit,
59
62
  readSandboxControlStatus
60
63
  } from './control/state.ts';
64
+ import { inspectSandboxControlContainer } from './control/container-identity.ts';
61
65
  import {
62
66
  SANDBOX_CONTROL_FUTURE_SKEW_MS,
63
67
  SANDBOX_CONTROL_STATUS_STALE_MS,
@@ -142,8 +146,27 @@ type RecoveryCommandDeps = {
142
146
  start?: typeof startSandboxContainer;
143
147
  fetchRows?: typeof fetchSandboxRows;
144
148
  ensureControlBroker?: () => void | Promise<void>;
149
+ probe?: typeof runProbe;
145
150
  };
146
151
 
152
+ export function worktreeProbeForEngine(
153
+ engine: string,
154
+ probe: typeof runProbe = runProbe
155
+ ): typeof runProbe {
156
+ return (cmd, args, opts = {}) => {
157
+ const engineArgs = [...args];
158
+ if (engine === 'wsl2' && cmd === 'git') {
159
+ for (let index = 0; index < engineArgs.length - 1; index += 1) {
160
+ if (engineArgs[index] === '-C') {
161
+ engineArgs[index + 1] = toEnginePath(engine, engineArgs[index + 1]!);
162
+ }
163
+ }
164
+ }
165
+ const command = commandForEngine(engine, cmd, engineArgs);
166
+ return probe(command.cmd, command.args, opts);
167
+ };
168
+ }
169
+
147
170
  type EnsureSandboxReadyParams = {
148
171
  config: SandboxConfig;
149
172
  engine: string;
@@ -151,6 +174,7 @@ type EnsureSandboxReadyParams = {
151
174
  workspace?: SandboxWorkspaceIdentity;
152
175
  row: SandboxRow;
153
176
  allowRecreate?: boolean;
177
+ forceRecreate?: boolean;
154
178
  recreate?: (branch: string) => Promise<void>;
155
179
  writeWarning?: (message: string) => void;
156
180
  deps?: RecoveryCommandDeps;
@@ -168,7 +192,7 @@ async function waitForSandboxControlBrokerStatus(params: {
168
192
  statusDir: string;
169
193
  generation: string;
170
194
  pid: number;
171
- startTime?: string;
195
+ startTime?: number;
172
196
  }, timeoutMs = 5_000): Promise<void> {
173
197
  const deadline = Date.now() + timeoutMs;
174
198
  while (Date.now() < deadline) {
@@ -191,7 +215,7 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
191
215
  const directory = path.dirname(fileURLToPath(import.meta.url));
192
216
  const extension = path.extname(fileURLToPath(import.meta.url));
193
217
  const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
194
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as SandboxControlManifest;
218
+ const manifest = readSandboxControlManifest(manifestPath);
195
219
  const root = path.dirname(manifestPath);
196
220
  if (isSandboxControlRootQuiescing(root)) throw new Error('SANDBOX_CONTROL_QUIESCING');
197
221
  const brokerPath = path.join(root, 'broker.json');
@@ -207,6 +231,7 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
207
231
  version?: unknown;
208
232
  pid?: unknown;
209
233
  startTime?: unknown;
234
+ brokerId?: unknown;
210
235
  token?: unknown;
211
236
  generation?: unknown;
212
237
  };
@@ -216,15 +241,22 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
216
241
  } catch {
217
242
  // Malformed owner records are stale and replaced below.
218
243
  }
219
- const owner = broker !== null && typeof broker.pid === 'number' && typeof broker.startTime === 'string'
220
- ? { version: 1 as const, pid: broker.pid, startTime: broker.startTime }
244
+ const owner = broker !== null && broker.version === 3 && typeof broker.pid === 'number'
245
+ && Number.isSafeInteger(broker.pid) && broker.pid > 0 && typeof broker.startTime === 'number'
246
+ && Number.isSafeInteger(broker.startTime) && typeof broker.brokerId === 'string'
247
+ && broker.brokerId.length > 0 && typeof broker.token === 'string'
248
+ && typeof broker.generation === 'string'
249
+ ? broker as BrokerOwner
221
250
  : null;
222
- const live = owner !== null && processIdentityMatches(owner);
251
+ if (broker === null || broker.version !== 3 || owner === null) {
252
+ throw new Error('SANDBOX_CONTROL_BROKER_VERSION_INVALID: rebuild the sandbox container before starting a new broker');
253
+ }
254
+ const live = owner !== null && processIdentityMatches({ pid: owner.pid, startTime: owner.startTime });
223
255
  if (
224
256
  live
225
257
  && owner !== null
226
258
  && broker !== null
227
- && broker.version === 2
259
+ && broker.version === 3
228
260
  && broker.token === manifest.token
229
261
  && broker.generation === manifest.generation
230
262
  ) {
@@ -237,27 +269,6 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
237
269
  });
238
270
  return;
239
271
  }
240
- if (live && owner) {
241
- const deadline = Date.now() + 5_000;
242
- while (Date.now() < deadline) {
243
- await new Promise((resolve) => setTimeout(resolve, 25));
244
- try {
245
- if (fs.readFileSync(brokerPath, 'utf8') !== brokerSnapshot) {
246
- return startSandboxControlBroker(repoRoot, manifestPath);
247
- }
248
- } catch (error) {
249
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
250
- return startSandboxControlBroker(repoRoot, manifestPath);
251
- }
252
- throw error;
253
- }
254
- if (!processIdentityMatches(owner)) {
255
- removePidFileIfMatches(brokerPath, brokerSnapshot);
256
- return startSandboxControlBroker(repoRoot, manifestPath);
257
- }
258
- }
259
- throw new Error('SANDBOX_CONTROL_BROKER_OWNER_ACTIVE');
260
- }
261
272
  }
262
273
  const startupStartTime = getProcessStartTime(process.pid);
263
274
  if (!startupStartTime) throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
@@ -317,9 +328,16 @@ async function ensureSandboxControlBroker(params: {
317
328
  identity: params.workspace
318
329
  });
319
330
  if (!fs.existsSync(control.manifestPath)) return;
320
- const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as SandboxControlManifest;
321
- if (manifest.version !== 3) {
322
- throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 3; container-only recreation is required');
331
+ const validatedManifest = readSandboxControlManifest(control.manifestPath);
332
+ const containerObservation = await inspectSandboxControlContainer(validatedManifest);
333
+ if (containerObservation.state === 'unknown') {
334
+ throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${containerObservation.reason}`);
335
+ }
336
+ if (containerObservation.state === 'absent') {
337
+ await garbageCollectSandboxControlRoot(control.root, {
338
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(validatedManifest, { timeoutMs })
339
+ });
340
+ throw new Error('SANDBOX_CONTROL_CONTAINER_ABSENT: recreate the sandbox container before broker recovery');
323
341
  }
324
342
  const brokerPath = path.join(control.root, 'broker.json');
325
343
  try {
@@ -327,20 +345,25 @@ async function ensureSandboxControlBroker(params: {
327
345
  version?: unknown;
328
346
  pid?: unknown;
329
347
  startTime?: unknown;
348
+ brokerId?: unknown;
330
349
  token?: unknown;
331
350
  generation?: unknown;
332
351
  };
333
352
  if (
334
- broker.version === 2
353
+ broker.version === 3
335
354
  && typeof broker.pid === 'number'
336
- && typeof broker.startTime === 'string'
337
- && broker.token === manifest.token
338
- && broker.generation === manifest.generation
339
- && processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime })
355
+ && Number.isSafeInteger(broker.pid)
356
+ && typeof broker.startTime === 'number'
357
+ && Number.isSafeInteger(broker.startTime)
358
+ && typeof broker.brokerId === 'string'
359
+ && broker.brokerId.length > 0
360
+ && broker.token === validatedManifest.token
361
+ && broker.generation === validatedManifest.generation
362
+ && processIdentityMatches({ pid: broker.pid, startTime: broker.startTime })
340
363
  ) {
341
364
  try {
342
365
  const status = readSandboxControlStatus(control.statusDir);
343
- if (status.generation === manifest.generation && status.broker.pid === broker.pid
366
+ if (status.generation === validatedManifest.generation && status.broker.pid === broker.pid
344
367
  && status.broker.startTime === broker.startTime
345
368
  && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
346
369
  && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS) return;
@@ -350,7 +373,7 @@ async function ensureSandboxControlBroker(params: {
350
373
  await waitForSandboxControlBrokerStatus({
351
374
  root: control.root,
352
375
  statusDir: control.statusDir,
353
- generation: manifest.generation,
376
+ generation: validatedManifest.generation,
354
377
  pid: broker.pid,
355
378
  startTime: broker.startTime
356
379
  });
@@ -1048,6 +1071,9 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1048
1071
  workspace: params.workspace ?? { mode: 'branch-only' }
1049
1072
  });
1050
1073
  }
1074
+ if (params.forceRecreate) {
1075
+ throw new Error('Explicit container recreation requested.');
1076
+ }
1051
1077
  if (!params.row.running) {
1052
1078
  startFn(params.engine, params.row.name);
1053
1079
  const initial = assess({
@@ -1192,7 +1218,8 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1192
1218
  `SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected exactly one existing worktree for '${params.branch}', found ${existingWorktrees.length}. ${remediation}`
1193
1219
  );
1194
1220
  }
1195
- const beforeInspection = inspectWorktree(existingWorktrees[0]!);
1221
+ const worktreeProbe = worktreeProbeForEngine(params.engine, deps?.probe);
1222
+ const beforeInspection = inspectWorktree(existingWorktrees[0]!, { probe: worktreeProbe });
1196
1223
  if (beforeInspection.status === 'failed') {
1197
1224
  throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: ${beforeInspection.message}`);
1198
1225
  }
@@ -1204,7 +1231,9 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1204
1231
  );
1205
1232
  }
1206
1233
 
1207
- const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
1234
+ const warning = params.forceRecreate
1235
+ ? `Explicit sandbox recreation requested. Replacing only the container. ${dataBoundary}`
1236
+ : `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
1208
1237
  warnings.push(warning);
1209
1238
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
1210
1239
  const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
@@ -1245,7 +1274,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1245
1274
  replacementFailure = error;
1246
1275
  }
1247
1276
 
1248
- const afterInspection = inspectWorktree(before.worktree);
1277
+ const afterInspection = inspectWorktree(before.worktree, { probe: worktreeProbe });
1249
1278
  if (afterInspection.status === 'failed' || afterInspection.snapshot.identity !== before.identity) {
1250
1279
  const detail = afterInspection.status === 'failed'
1251
1280
  ? afterInspection.message
@@ -47,6 +47,7 @@ export function sandboxWorkspaceViewPaths(params: Readonly<{
47
47
  }
48
48
 
49
49
  export type SandboxControlSetup = Readonly<{
50
+ root: string;
50
51
  channelDir: string;
51
52
  statusDir: string;
52
53
  manifestPath: string;
@@ -84,44 +85,6 @@ function assertSafeDirectory(directory: string, expectedBase: string): void {
84
85
  }
85
86
  }
86
87
 
87
- function assertSafeMountTargetPath(target: string, worktreeRoot: string, canonicalWorktreeRoot: string): void {
88
- const relative = path.relative(worktreeRoot, target);
89
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
90
- throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
91
- }
92
- let current = worktreeRoot;
93
- for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
94
- current = segment ? path.join(current, segment) : current;
95
- try {
96
- if (fs.lstatSync(current).isSymbolicLink()) {
97
- throw new Error(`Sandbox workspace mount target must not contain a symbolic link: ${current}`);
98
- }
99
- const canonicalRelative = path.relative(canonicalWorktreeRoot, fs.realpathSync.native(current));
100
- if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
101
- throw new Error(`Sandbox workspace mount target escapes its real worktree: ${current}`);
102
- }
103
- } catch (error) {
104
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
105
- }
106
- }
107
- }
108
-
109
- export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void {
110
- const resolvedWorktreeRoot = path.resolve(worktreeRoot);
111
- const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
112
- const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
113
- const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
114
- const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
115
- for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
116
- assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
117
- }
118
- fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
119
- for (const { hostPath } of statePaths) {
120
- fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
121
- }
122
- fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
123
- }
124
-
125
88
  export function assertSandboxTaskSource(repoRoot: string, taskId: string): string {
126
89
  if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
127
90
  const activeRoot = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace', 'active'));
@@ -137,6 +100,43 @@ export function assertSandboxTaskSource(repoRoot: string, taskId: string): strin
137
100
  return canonical;
138
101
  }
139
102
 
103
+ export function prepareSandboxWorkspaceMountTarget(worktreeRoot: string): string {
104
+ const worktree = path.resolve(worktreeRoot);
105
+ const canonicalWorktree = fs.realpathSync.native(worktree);
106
+ const agentsRoot = path.join(worktree, '.agents');
107
+ if (fs.existsSync(agentsRoot)) {
108
+ const agentsStat = fs.lstatSync(agentsRoot);
109
+ if (!agentsStat.isDirectory() || agentsStat.isSymbolicLink()) {
110
+ throw new Error(`Sandbox workspace mount ancestor must be a real directory: ${agentsRoot}`);
111
+ }
112
+ } else {
113
+ fs.mkdirSync(agentsRoot, { mode: 0o700 });
114
+ }
115
+ const canonicalAgents = fs.realpathSync.native(agentsRoot);
116
+ if (path.dirname(canonicalAgents) !== canonicalWorktree) {
117
+ throw new Error(`Sandbox workspace mount ancestor escapes the worktree: ${agentsRoot}`);
118
+ }
119
+
120
+ const target = path.join(agentsRoot, 'workspace');
121
+ if (fs.existsSync(target)) {
122
+ const stat = fs.lstatSync(target);
123
+ if (stat.isSymbolicLink()) {
124
+ throw new Error(`Sandbox workspace mount target must not be a symbolic link: ${target}`);
125
+ }
126
+ if (!stat.isDirectory()) {
127
+ throw new Error(`Sandbox workspace mount target must be a directory: ${target}`);
128
+ }
129
+ if (path.dirname(fs.realpathSync.native(target)) !== canonicalAgents) {
130
+ throw new Error(`Sandbox workspace mount target escapes the worktree: ${target}`);
131
+ }
132
+ } else {
133
+ fs.mkdirSync(target, { mode: 0o700 });
134
+ }
135
+ fs.chmodSync(target, 0o700);
136
+ fs.accessSync(target, fs.constants.W_OK);
137
+ return target;
138
+ }
139
+
140
140
  export function materializeSandboxWorkspaceView(params: Readonly<{
141
141
  base: string;
142
142
  project: string;
@@ -180,6 +180,7 @@ export function materializeSandboxControl(params: Readonly<{
180
180
  container: string;
181
181
  branch: string;
182
182
  identity: SandboxWorkspaceIdentity;
183
+ engine?: string;
183
184
  }>): SandboxControlSetup {
184
185
  const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
185
186
  assertSafeDirectory(root, path.resolve(params.base));
@@ -201,11 +202,13 @@ export function materializeSandboxControl(params: Readonly<{
201
202
  const generation = randomBytes(16).toString('hex');
202
203
  const repoRoot = fs.realpathSync.native(params.repoRoot);
203
204
  const manifest: SandboxControlManifest = {
204
- version: 3,
205
+ version: 4,
206
+ engine: params.engine ?? 'docker',
205
207
  repoRoot,
206
208
  worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
207
209
  project: params.project,
208
210
  container: params.container,
211
+ containerIdentity: { id: '', labels: {} },
209
212
  branch: params.branch,
210
213
  mode: params.identity.mode,
211
214
  taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
@@ -216,5 +219,23 @@ export function materializeSandboxControl(params: Readonly<{
216
219
  processingDir
217
220
  };
218
221
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
219
- return { channelDir, statusDir, manifestPath, token, generation };
222
+ return { root, channelDir, statusDir, manifestPath, token, generation };
223
+ }
224
+
225
+ export function finalizeSandboxControlManifest(
226
+ setup: SandboxControlSetup,
227
+ identity: Readonly<{ engine: string; id: string; labels: Readonly<Record<string, string>> }>
228
+ ): void {
229
+ if (!identity.engine || !identity.id) throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
230
+ const manifest = JSON.parse(fs.readFileSync(setup.manifestPath, 'utf8')) as Record<string, unknown>;
231
+ manifest.version = 4;
232
+ manifest.engine = identity.engine;
233
+ manifest.containerIdentity = { id: identity.id, labels: { ...identity.labels } };
234
+ const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
235
+ try {
236
+ fs.writeFileSync(temporary, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' });
237
+ fs.renameSync(temporary, setup.manifestPath);
238
+ } finally {
239
+ fs.rmSync(temporary, { force: true });
240
+ }
220
241
  }
@@ -139,7 +139,7 @@ export async function runDaemon(): Promise<void> {
139
139
  logger.close();
140
140
  if (ownStartTime !== null) {
141
141
  removePidRecordIfMatches(config.pidFile, {
142
- version: 1,
142
+ version: 2,
143
143
  pid: process.pid,
144
144
  startTime: ownStartTime
145
145
  });
@@ -11,7 +11,7 @@ import {
11
11
  removePidFileIfMatches,
12
12
  writePidRecord
13
13
  } from './process-state.ts';
14
- import type { PidRecord } from './process-state.ts';
14
+ import type { PidRecord, ProcessIdentity, ProcessState } from './process-state.ts';
15
15
 
16
16
  export { isProcessAlive } from './process-state.ts';
17
17
 
@@ -51,13 +51,13 @@ async function waitForProcessIdentity(pid: number, timeoutMs = 2000): Promise<Pi
51
51
  const deadline = Date.now() + timeoutMs;
52
52
  while (Date.now() < deadline) {
53
53
  const startTime = getProcessStartTime(pid);
54
- if (startTime !== null) return { version: 1, pid, startTime };
54
+ if (startTime !== null) return { version: 2, pid, startTime };
55
55
  await delay(25);
56
56
  }
57
57
  return null;
58
58
  }
59
59
 
60
- function terminateIfMatching(record: PidRecord): void {
60
+ function terminateIfMatching(record: ProcessIdentity): void {
61
61
  if (!processIdentityMatches(record)) return;
62
62
  const command = buildStopCommand(record.pid, process.platform);
63
63
  try {
@@ -83,6 +83,9 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
83
83
  process.stdout.write(`server already running (pid ${existing.record.pid})\n`);
84
84
  return;
85
85
  }
86
+ if (existing.kind === 'legacy-json' || existing.kind === 'legacy-pid-only' || existing.kind === 'legacy-unknown') {
87
+ throw new Error('server: legacy pid record requires the matching previous version to stop it before starting');
88
+ }
86
89
  if (existing.kind !== 'missing') {
87
90
  removePidFileIfMatches(pidPath, existing.snapshot);
88
91
  }
@@ -135,6 +138,9 @@ export async function stop(): Promise<void> {
135
138
  return;
136
139
  }
137
140
  if (state.kind !== 'running') {
141
+ if (state.kind === 'legacy-json' || state.kind === 'legacy-pid-only' || state.kind === 'legacy-unknown') {
142
+ throw new Error('server: legacy pid record requires the matching previous version to stop it');
143
+ }
138
144
  removePidFileIfMatches(config.pidFile, state.snapshot);
139
145
  process.stdout.write('server is not running (removed stale pid file)\n');
140
146
  return;
@@ -170,6 +176,10 @@ export function status(): void {
170
176
  const state = readProcessState(config.pidFile);
171
177
 
172
178
  if (state.kind !== 'running') {
179
+ if (state.kind === 'legacy-json' || state.kind === 'legacy-pid-only' || state.kind === 'legacy-unknown') {
180
+ process.stdout.write('server: legacy pid record requires the matching previous version to stop it\n');
181
+ return;
182
+ }
173
183
  if (state.kind !== 'missing') removePidFileIfMatches(config.pidFile, state.snapshot);
174
184
  process.stdout.write('server: stopped\n');
175
185
  return;
@@ -2,22 +2,34 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
 
5
- export type PidRecord = {
6
- version: 1;
5
+ export type ProcessStartTime = number;
6
+
7
+ export type ProcessIdentity = Readonly<{
7
8
  pid: number;
8
- startTime: string;
9
+ startTime: ProcessStartTime;
10
+ }>;
11
+
12
+ export type PidRecord = ProcessIdentity & {
13
+ version: 2;
9
14
  };
10
15
 
11
16
  export type ProcessStartTimeQuery = { command: string; args: string[] };
12
17
 
13
18
  export type ProcessState =
14
19
  | { kind: 'missing'; snapshot: null }
15
- | { kind: 'invalid'; snapshot: string }
16
- | { kind: 'legacy'; snapshot: string; pid: number }
20
+ | { kind: 'legacy-json'; snapshot: string; legacy: LegacyProcessSnapshot }
21
+ | { kind: 'legacy-pid-only'; snapshot: string; legacy: LegacyProcessSnapshot }
22
+ | { kind: 'legacy-unknown'; snapshot: string; legacy: LegacyProcessSnapshot; reason: string }
17
23
  | { kind: 'stale'; snapshot: string; record: PidRecord }
18
24
  | { kind: 'running'; snapshot: string; record: PidRecord };
19
25
 
20
- export type LinuxProcessStat = { state: string; startTime: string };
26
+ export type LegacyProcessSnapshot = Readonly<{
27
+ pid?: number;
28
+ raw: string;
29
+ format: 'json-v1' | 'pid-only' | 'malformed';
30
+ }>;
31
+
32
+ export type LinuxProcessStat = { state: string; startTime: number };
21
33
 
22
34
  export function parseLinuxProcessStat(raw: string): LinuxProcessStat | null {
23
35
  const closeParen = raw.lastIndexOf(')');
@@ -28,7 +40,9 @@ export function parseLinuxProcessStat(raw: string): LinuxProcessStat | null {
28
40
  if (typeof state !== 'string' || state.length !== 1 || !/^\d+$/.test(startTime ?? '')) {
29
41
  return null;
30
42
  }
31
- return { state, startTime: startTime! };
43
+ const numericStartTime = Number(startTime);
44
+ if (!Number.isSafeInteger(numericStartTime)) return null;
45
+ return { state, startTime: numericStartTime };
32
46
  }
33
47
 
34
48
  export function buildProcessStartTimeQuery(
@@ -68,10 +82,22 @@ export function isProcessAlive(pid: number, platform: NodeJS.Platform = process.
68
82
  }
69
83
  }
70
84
 
85
+ function parseDarwinStartTime(output: string): number | null {
86
+ const match = output.trim().match(/^(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) ([A-Z][a-z]{2}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/);
87
+ if (!match) return null;
88
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
89
+ const month = months.indexOf(match[1]!);
90
+ if (month < 0) return null;
91
+ const timestamp = Date.UTC(
92
+ Number(match[6]), month, Number(match[2]), Number(match[3]), Number(match[4]), Number(match[5])
93
+ );
94
+ return Number.isNaN(timestamp) ? null : timestamp;
95
+ }
96
+
71
97
  export function getProcessStartTime(
72
98
  pid: number,
73
99
  platform: NodeJS.Platform = process.platform
74
- ): string | null {
100
+ ): ProcessStartTime | null {
75
101
  if (!Number.isInteger(pid) || pid <= 0) return null;
76
102
  if (platform === 'linux') {
77
103
  try {
@@ -85,43 +111,71 @@ export function getProcessStartTime(
85
111
  const query = buildProcessStartTimeQuery(pid, platform);
86
112
  if (query === null) return null;
87
113
  try {
88
- const output = execFileSync(query.command, query.args, { encoding: 'utf8' }).trim();
114
+ const output = execFileSync(query.command, query.args, {
115
+ encoding: 'utf8',
116
+ env: platform === 'darwin' ? { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } : process.env
117
+ }).trim();
89
118
  if (output.length === 0) return null;
90
119
  if (platform === 'win32') {
91
120
  const timestamp = new Date(output);
92
- return Number.isNaN(timestamp.getTime()) ? null : timestamp.toISOString();
121
+ return Number.isNaN(timestamp.getTime()) ? null : timestamp.getTime();
93
122
  }
94
- return output;
123
+ if (platform === 'darwin') return parseDarwinStartTime(output);
124
+ return null;
95
125
  } catch {
96
126
  return null;
97
127
  }
98
128
  }
99
129
 
100
- function parsePidRecord(raw: string): { kind: 'invalid' } | { kind: 'legacy'; pid: number } | { kind: 'record'; record: PidRecord } {
130
+ function parsePidRecord(
131
+ raw: string
132
+ ):
133
+ | { kind: 'legacy-json'; legacy: LegacyProcessSnapshot }
134
+ | { kind: 'legacy-pid-only'; legacy: LegacyProcessSnapshot }
135
+ | { kind: 'legacy-unknown'; legacy: LegacyProcessSnapshot; reason: string }
136
+ | { kind: 'record'; record: PidRecord } {
101
137
  const trimmed = raw.trim();
102
138
  if (/^[1-9]\d*$/.test(trimmed)) {
103
139
  const pid = Number(trimmed);
104
- return Number.isSafeInteger(pid) ? { kind: 'legacy', pid } : { kind: 'invalid' };
140
+ return Number.isSafeInteger(pid)
141
+ ? { kind: 'legacy-pid-only', legacy: { pid, raw, format: 'pid-only' } }
142
+ : { kind: 'legacy-unknown', legacy: { raw, format: 'malformed' }, reason: 'pid is not a safe integer' };
105
143
  }
106
144
  try {
107
- const value = JSON.parse(trimmed) as Partial<PidRecord> | null;
145
+ const value = JSON.parse(trimmed) as { version?: unknown; pid?: unknown; startTime?: unknown } | null;
146
+ const pidValue: unknown = value?.pid;
147
+ const startTimeValue: unknown = value?.startTime;
108
148
  if (
109
149
  value !== null &&
110
- value.version === 1 &&
111
- Number.isSafeInteger(value.pid) &&
112
- (value.pid ?? 0) > 0 &&
113
- typeof value.startTime === 'string' &&
114
- value.startTime.length > 0
150
+ value.version === 2 &&
151
+ typeof pidValue === 'number' &&
152
+ Number.isSafeInteger(pidValue) &&
153
+ pidValue > 0 &&
154
+ typeof startTimeValue === 'number' &&
155
+ Number.isSafeInteger(startTimeValue) &&
156
+ startTimeValue >= 0
115
157
  ) {
158
+ const startTime = startTimeValue as number;
116
159
  return {
117
160
  kind: 'record',
118
- record: { version: 1, pid: value.pid!, startTime: value.startTime }
161
+ record: { version: 2, pid: pidValue, startTime }
119
162
  };
120
163
  }
164
+ if (
165
+ value !== null &&
166
+ value.version === 1 &&
167
+ typeof pidValue === 'number' &&
168
+ Number.isSafeInteger(pidValue) &&
169
+ pidValue > 0 &&
170
+ typeof value.startTime === 'string' &&
171
+ value.startTime.length > 0
172
+ ) {
173
+ return { kind: 'legacy-json', legacy: { pid: pidValue, raw, format: 'json-v1' } };
174
+ }
175
+ return { kind: 'legacy-unknown', legacy: { raw, format: 'malformed' }, reason: 'unsupported PID record schema' };
121
176
  } catch {
122
- // Invalid JSON is an invalid PID record.
177
+ return { kind: 'legacy-unknown', legacy: { raw, format: 'malformed' }, reason: 'invalid PID record JSON' };
123
178
  }
124
- return { kind: 'invalid' };
125
179
  }
126
180
 
127
181
  export function readProcessState(pidFile: string): ProcessState {
@@ -130,12 +184,13 @@ export function readProcessState(pidFile: string): ProcessState {
130
184
  snapshot = fs.readFileSync(pidFile, 'utf8');
131
185
  } catch (error) {
132
186
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing', snapshot: null };
133
- return { kind: 'invalid', snapshot: '' };
187
+ return { kind: 'legacy-unknown', snapshot: '', legacy: { raw: '', format: 'malformed' }, reason: 'PID record cannot be read' };
134
188
  }
135
189
 
136
190
  const parsed = parsePidRecord(snapshot);
137
- if (parsed.kind === 'invalid') return { kind: 'invalid', snapshot };
138
- if (parsed.kind === 'legacy') return { kind: 'legacy', snapshot, pid: parsed.pid };
191
+ if (parsed.kind === 'legacy-json') return { kind: parsed.kind, snapshot, legacy: parsed.legacy };
192
+ if (parsed.kind === 'legacy-pid-only') return { kind: parsed.kind, snapshot, legacy: parsed.legacy };
193
+ if (parsed.kind === 'legacy-unknown') return { kind: parsed.kind, snapshot, legacy: parsed.legacy, reason: parsed.reason };
139
194
 
140
195
  const currentStartTime = getProcessStartTime(parsed.record.pid);
141
196
  if (currentStartTime === null || currentStartTime !== parsed.record.startTime) {
@@ -178,6 +233,6 @@ export function removePidRecordIfMatches(pidFile: string, expected: PidRecord):
178
233
  return removePidFileIfMatches(pidFile, state.snapshot);
179
234
  }
180
235
 
181
- export function processIdentityMatches(record: PidRecord): boolean {
182
- return getProcessStartTime(record.pid) === record.startTime;
236
+ export function processIdentityMatches(identity: ProcessIdentity): boolean {
237
+ return getProcessStartTime(identity.pid) === identity.startTime;
183
238
  }