@fitlab-ai/agent-infra 0.9.7 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/bin/internal-cli.js +5 -0
  2. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +2 -1
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +2 -1
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +30 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +2 -1
  7. package/dist/lib/decide.js +68 -14
  8. package/dist/lib/init.js +1 -1
  9. package/dist/lib/internal/release-workflow.js +1 -3
  10. package/dist/lib/internal/task-activity.js +57 -7
  11. package/dist/lib/internal/task-event.js +57 -4
  12. package/dist/lib/internal/task-ledger.js +59 -5
  13. package/dist/lib/internal/task-lifecycle.js +59 -8
  14. package/dist/lib/internal/task-orchestration.js +11 -3
  15. package/dist/lib/internal/task-override.js +131 -0
  16. package/dist/lib/internal/task-review.js +64 -6
  17. package/dist/lib/internal/task-verify.js +65 -3
  18. package/dist/lib/internal/task-warning.js +57 -4
  19. package/dist/lib/platform/pr-summary.js +5 -3
  20. package/dist/lib/runtime/agent-runtime.js +56 -0
  21. package/dist/lib/sandbox/commands/create.js +389 -311
  22. package/dist/lib/sandbox/control/container-identity.js +1 -1
  23. package/dist/lib/sandbox/control/executor.js +10 -2
  24. package/dist/lib/sandbox/control/lifecycle.js +392 -72
  25. package/dist/lib/sandbox/control/state.js +27 -11
  26. package/dist/lib/sandbox/engine.js +14 -7
  27. package/dist/lib/sandbox/mounts.js +20 -5
  28. package/dist/lib/sandbox/recovery.js +78 -10
  29. package/dist/lib/sandbox/workspace-view.js +62 -40
  30. package/dist/lib/server/process-state.js +51 -1
  31. package/dist/lib/task/activity-intent.js +4 -1
  32. package/dist/lib/task/decision-intents.js +4 -2
  33. package/dist/lib/task/delegation-receipts.js +37 -16
  34. package/dist/lib/task/events.js +9 -5
  35. package/dist/lib/task/guard-override.js +31 -0
  36. package/dist/lib/task/human-override.js +1240 -0
  37. package/dist/lib/task/ledger-intents.js +6 -3
  38. package/dist/lib/task/lifecycle.js +69 -14
  39. package/dist/lib/task/orchestration.js +60 -6
  40. package/dist/lib/task/review-finalization.js +3 -2
  41. package/dist/lib/task/verification-engine.js +40 -14
  42. package/dist/lib/task/verification.js +13 -1
  43. package/dist/lib/task/workflow-warning-intents.js +4 -2
  44. package/dist/lib/task/write.js +2 -1
  45. package/dist/lifecycle-build-manifest.json +3 -3
  46. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  47. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +2 -1
  48. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +2 -1
  49. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +29 -2
  50. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +2 -1
  51. package/lib/decide.ts +59 -14
  52. package/lib/init.ts +1 -1
  53. package/lib/internal/release-workflow.ts +1 -3
  54. package/lib/internal/task-activity.ts +46 -6
  55. package/lib/internal/task-event.ts +53 -4
  56. package/lib/internal/task-ledger.ts +48 -4
  57. package/lib/internal/task-lifecycle.ts +56 -12
  58. package/lib/internal/task-orchestration.ts +12 -4
  59. package/lib/internal/task-override.ts +138 -0
  60. package/lib/internal/task-review.ts +51 -6
  61. package/lib/internal/task-verify.ts +52 -3
  62. package/lib/internal/task-warning.ts +44 -3
  63. package/lib/platform/pr-summary.ts +10 -3
  64. package/lib/runtime/agent-runtime.ts +76 -0
  65. package/lib/sandbox/commands/create.ts +88 -4
  66. package/lib/sandbox/config.ts +2 -1
  67. package/lib/sandbox/control/container-identity.ts +3 -3
  68. package/lib/sandbox/control/executor.ts +10 -2
  69. package/lib/sandbox/control/lifecycle.ts +463 -76
  70. package/lib/sandbox/control/protocol.ts +5 -2
  71. package/lib/sandbox/control/state.ts +35 -18
  72. package/lib/sandbox/engine.ts +20 -9
  73. package/lib/sandbox/mounts.ts +23 -5
  74. package/lib/sandbox/recovery.ts +90 -9
  75. package/lib/sandbox/workspace-view.ts +70 -40
  76. package/lib/server/process-state.ts +48 -1
  77. package/lib/task/activity-intent.ts +4 -2
  78. package/lib/task/decision-intents.ts +4 -2
  79. package/lib/task/delegation-receipts.ts +46 -18
  80. package/lib/task/events.ts +10 -5
  81. package/lib/task/guard-override.ts +50 -0
  82. package/lib/task/human-override.ts +1503 -0
  83. package/lib/task/ledger-intents.ts +6 -3
  84. package/lib/task/lifecycle.ts +71 -14
  85. package/lib/task/orchestration.ts +89 -7
  86. package/lib/task/review-finalization.ts +6 -2
  87. package/lib/task/verification-engine.ts +42 -16
  88. package/lib/task/verification.ts +15 -1
  89. package/lib/task/workflow-warning-intents.ts +4 -2
  90. package/lib/task/write.ts +4 -1
  91. package/package.json +1 -1
  92. package/templates/.agents/hooks/lifecycle-delegation.js +8 -3
  93. package/templates/.agents/rules/lifecycle-orchestration.en.md +3 -2
  94. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +3 -2
  95. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -1
  96. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -1
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { buildProcessTreeStopCommand } from '../../server/process-control.js';
6
- import { processIdentityMatches } from '../../server/process-state.js';
6
+ import { getProcessIdentityState } from '../../server/process-state.js';
7
7
  export const SANDBOX_CONTROL_AUDIT_MAX_BYTES = 1024 * 1024;
8
8
  export const SANDBOX_CONTROL_EXECUTION_STOP_MS = 2_000;
9
9
  function assertRegularFile(filePath) {
@@ -72,7 +72,7 @@ export function readActiveLease(manifest, now = Date.now()) {
72
72
  return null;
73
73
  return lease;
74
74
  }
75
- export function cleanupStaleSandboxControlLease(manifest, now = Date.now()) {
75
+ export function cleanupStaleSandboxControlLease(manifest, now = Date.now(), options = {}) {
76
76
  const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
77
77
  if (!fs.existsSync(filePath))
78
78
  return false;
@@ -87,10 +87,13 @@ export function cleanupStaleSandboxControlLease(manifest, now = Date.now()) {
87
87
  || typeof lease.branch !== 'string' || typeof lease.reason !== 'string') {
88
88
  throw new Error('SANDBOX_CONTROL_LEASE_INVALID');
89
89
  }
90
- const stale = lease.generation !== manifest.generation || lease.taskId !== manifest.taskId
91
- || lease.branch !== manifest.branch || lease.expiresAt <= now || !processIdentityMatches({
90
+ const ownerState = (options.identityProbe ?? getProcessIdentityState)({
92
91
  pid: lease.owner.pid, startTime: lease.owner.startTime
93
92
  });
93
+ if (ownerState === 'unknown')
94
+ throw new Error('SANDBOX_CONTROL_LEASE_OWNER_UNAVAILABLE');
95
+ const stale = lease.generation !== manifest.generation || lease.taskId !== manifest.taskId
96
+ || lease.branch !== manifest.branch || lease.expiresAt <= now || ownerState === 'dead';
94
97
  if (!stale)
95
98
  return false;
96
99
  if (fs.readFileSync(filePath, 'utf8') !== raw)
@@ -125,29 +128,42 @@ export function readExecution(filePath) {
125
128
  export function terminateSandboxControlExecution(execution, options = {}) {
126
129
  const platform = options.platform ?? process.platform;
127
130
  const processGroupId = execution.child.processGroupId;
128
- const processGroupAlive = (groupId) => {
131
+ const processGroupState = (groupId) => {
129
132
  try {
130
133
  const rows = execFileSync('ps', ['-axo', 'pgid=,stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
131
134
  return rows.split('\n').some((row) => {
132
135
  const match = row.trim().match(/^(\d+)\s+(\S+)/);
133
136
  return Number(match?.[1]) === groupId && !match?.[2]?.startsWith('Z');
134
- });
137
+ }) ? 'alive' : 'dead';
135
138
  }
136
139
  catch {
137
140
  try {
138
141
  process.kill(-groupId, 0);
139
- return true;
142
+ return 'alive';
140
143
  }
141
144
  catch (error) {
142
- return error.code === 'EPERM';
145
+ const code = error.code;
146
+ if (code === 'ESRCH')
147
+ return 'dead';
148
+ if (code === 'EPERM')
149
+ return 'alive';
150
+ return 'unknown';
143
151
  }
144
152
  }
145
153
  };
146
- const treeAlive = () => {
154
+ const treeState = () => {
147
155
  if (platform === 'win32' || !processGroupId) {
148
- return processIdentityMatches({ pid: execution.child.pid, startTime: execution.child.startTime });
156
+ return (options.identityProbe ?? getProcessIdentityState)({
157
+ pid: execution.child.pid, startTime: execution.child.startTime
158
+ });
149
159
  }
150
- return processGroupAlive(processGroupId);
160
+ return processGroupState(processGroupId);
161
+ };
162
+ const treeAlive = () => {
163
+ const state = treeState();
164
+ if (state === 'unknown')
165
+ throw new Error('SANDBOX_CONTROL_EXECUTION_OWNER_UNAVAILABLE');
166
+ return state === 'alive';
151
167
  };
152
168
  if (!treeAlive())
153
169
  return true;
@@ -14,6 +14,12 @@ const PLATFORM_DEFAULTS = Object.freeze({
14
14
  darwin: ENGINES.COLIMA,
15
15
  win32: ENGINES.WSL2
16
16
  });
17
+ function isPerPlatformEngine(engine) {
18
+ return typeof engine === 'object' && engine !== null;
19
+ }
20
+ function resolveEngineForPlatform(engine, os) {
21
+ return isPerPlatformEngine(engine) ? engine[os] : engine;
22
+ }
17
23
  function runFns({ runFn = run, runOkFn = runOk, runSafeFn = runSafe, runVerboseFn = runVerbose } = {}) {
18
24
  return {
19
25
  run: runFn,
@@ -56,23 +62,24 @@ function ensureBuildKit(engine, runOkEngineFn) {
56
62
  }
57
63
  }
58
64
  export function validateSandboxEngine(engine, { platformFn = platform } = {}) {
59
- if (engine === null || engine === undefined) {
65
+ const os = platformFn();
66
+ const resolved = resolveEngineForPlatform(engine, os);
67
+ if (resolved === null || resolved === undefined) {
60
68
  return null;
61
69
  }
62
- const os = platformFn();
63
- if (!(engine in ADAPTERS)) {
70
+ if (!(resolved in ADAPTERS)) {
64
71
  const known = Object.keys(ADAPTERS).join(', ');
65
- throw new Error(`sandbox: invalid "sandbox.engine" value "${engine}" (unknown sandbox engine). `
72
+ throw new Error(`sandbox: invalid "sandbox.engine" value "${resolved}" (unknown sandbox engine). `
66
73
  + `Valid engines: ${known}.`);
67
74
  }
68
- const adapter = ADAPTERS[engine];
75
+ const adapter = ADAPTERS[resolved];
69
76
  if (!adapter.supportedPlatforms.includes(os)) {
70
77
  const supported = enginesForPlatform(os);
71
78
  const supportedList = supported.length > 0 ? supported.join(', ') : 'none';
72
- throw new Error(`sandbox: "sandbox.engine" value "${engine}" is not supported on ${os}. `
79
+ throw new Error(`sandbox: "sandbox.engine" value "${resolved}" is not supported on ${os}. `
73
80
  + `Supported engines on ${os}: ${supportedList}.`);
74
81
  }
75
- return engine;
82
+ return resolved;
76
83
  }
77
84
  export function detectEngine(config = {}, { platformFn = platform } = {}) {
78
85
  const configured = validateSandboxEngine(config.engine, { platformFn });
@@ -1,7 +1,19 @@
1
1
  import path from 'node:path';
2
2
  import { shareBranchDir, shareCommonDir, shellConfigDirCandidates, worktreeDirCandidates } from './constants.js';
3
+ import { sandboxWorkspaceViewStatePaths } from './workspace-view.js';
3
4
  export function sandboxCoreBindMounts(config, branch, overrides) {
4
5
  const taskBound = Boolean(overrides.taskSource && overrides.taskId);
6
+ const workspaceMounts = sandboxWorkspaceViewStatePaths(overrides.workspaceViewRoot).map(({ state, hostPath }) => taskBound && state === 'active'
7
+ ? {
8
+ hostPaths: [path.join(hostPath, '.short-ids.json')],
9
+ containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
10
+ readOnly: true
11
+ }
12
+ : {
13
+ hostPaths: [hostPath],
14
+ containerPath: path.posix.join('/workspace/.agents/workspace', state),
15
+ readOnly: true
16
+ });
5
17
  const mounts = [
6
18
  {
7
19
  hostPaths: overrides.worktree
@@ -10,11 +22,7 @@ export function sandboxCoreBindMounts(config, branch, overrides) {
10
22
  containerPath: '/workspace',
11
23
  readOnly: false
12
24
  },
13
- {
14
- hostPaths: [overrides.workspaceViewRoot],
15
- containerPath: '/workspace/.agents/workspace',
16
- readOnly: true
17
- },
25
+ ...workspaceMounts,
18
26
  ];
19
27
  if (taskBound) {
20
28
  mounts.push({
@@ -47,6 +55,13 @@ export function sandboxCoreBindMounts(config, branch, overrides) {
47
55
  containerPath: '/run/agent-infra/control',
48
56
  readOnly: false
49
57
  });
58
+ if (taskBound && overrides.runtimeDir) {
59
+ mounts.push({
60
+ hostPaths: [overrides.runtimeDir],
61
+ containerPath: '/run/agent-infra/runtime',
62
+ readOnly: false
63
+ });
64
+ }
50
65
  return mounts;
51
66
  }
52
67
  //# sourceMappingURL=mounts.js.map
@@ -14,7 +14,7 @@ import { inspectWorktree } from './worktree-safety.js';
14
14
  import { commandForEngine, runEngine, runOkEngine, runProbe, runVerboseEngine } from './shell.js';
15
15
  import { declaredTmpfsSeedEntries, resolveTools, toolConfigDirCandidates } from './tools.js';
16
16
  import { fetchSandboxRows, selectSandboxContainer, startSandboxContainer } from './commands/list-running.js';
17
- import { getProcessStartTime, processIdentityMatches, removePidFileIfMatches } from '../server/process-state.js';
17
+ import { getProcessIdentityState, getProcessStartTime, removePidFileIfMatches } from '../server/process-state.js';
18
18
  import { acquireSandboxControlBrokerStartup, garbageCollectSandboxControlRoot, isSandboxControlRootQuiescing, readSandboxControlManifest } from './control/lifecycle.js';
19
19
  import { appendSandboxControlAudit, readSandboxControlStatus } from './control/state.js';
20
20
  import { inspectSandboxControlContainer } from './control/container-identity.js';
@@ -89,7 +89,10 @@ export async function startSandboxControlBroker(repoRoot, manifestPath) {
89
89
  if (broker === null || broker.version !== 3 || owner === null) {
90
90
  throw new Error('SANDBOX_CONTROL_BROKER_VERSION_INVALID: rebuild the sandbox container before starting a new broker');
91
91
  }
92
- const live = owner !== null && processIdentityMatches({ pid: owner.pid, startTime: owner.startTime });
92
+ const ownerState = getProcessIdentityState({ pid: owner.pid, startTime: owner.startTime });
93
+ if (ownerState === 'unknown')
94
+ throw new Error('SANDBOX_CONTROL_BROKER_OWNER_UNAVAILABLE');
95
+ const live = ownerState === 'alive';
93
96
  if (live
94
97
  && owner !== null
95
98
  && broker !== null
@@ -176,6 +179,15 @@ async function ensureSandboxControlBroker(params) {
176
179
  const brokerPath = path.join(control.root, 'broker.json');
177
180
  try {
178
181
  const broker = JSON.parse(fs.readFileSync(brokerPath, 'utf8'));
182
+ const validIdentity = typeof broker.pid === 'number'
183
+ && Number.isSafeInteger(broker.pid)
184
+ && typeof broker.startTime === 'number'
185
+ && Number.isSafeInteger(broker.startTime);
186
+ const ownerState = validIdentity
187
+ ? getProcessIdentityState({ pid: broker.pid, startTime: broker.startTime })
188
+ : 'dead';
189
+ if (ownerState === 'unknown')
190
+ throw new Error('SANDBOX_CONTROL_BROKER_OWNER_UNAVAILABLE');
179
191
  if (broker.version === 3
180
192
  && typeof broker.pid === 'number'
181
193
  && Number.isSafeInteger(broker.pid)
@@ -185,7 +197,7 @@ async function ensureSandboxControlBroker(params) {
185
197
  && broker.brokerId.length > 0
186
198
  && broker.token === validatedManifest.token
187
199
  && broker.generation === validatedManifest.generation
188
- && processIdentityMatches({ pid: broker.pid, startTime: broker.startTime })) {
200
+ && ownerState === 'alive') {
189
201
  try {
190
202
  const status = readSandboxControlStatus(control.statusDir);
191
203
  if (status.generation === validatedManifest.generation && status.broker.pid === broker.pid
@@ -207,7 +219,9 @@ async function ensureSandboxControlBroker(params) {
207
219
  return;
208
220
  }
209
221
  }
210
- catch {
222
+ catch (error) {
223
+ if (error instanceof Error && error.message === 'SANDBOX_CONTROL_BROKER_OWNER_UNAVAILABLE')
224
+ throw error;
211
225
  // A missing, stale, or malformed broker record is replaced below.
212
226
  }
213
227
  await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
@@ -242,6 +256,30 @@ export function classifySandboxRecovery(snapshot) {
242
256
  message: 'Container runtime capability signature does not match the current sandbox plan.'
243
257
  });
244
258
  }
259
+ if (snapshot.workspaceTopology === 'legacy-parent') {
260
+ findings.push({
261
+ repairKind: 'hard-failure',
262
+ code: 'SANDBOX_WORKSPACE_TOPOLOGY_MISMATCH',
263
+ message: 'Sandbox uses the legacy workspace parent-mount topology; recreate the container explicitly to migrate it.',
264
+ path: '/workspace/.agents/workspace'
265
+ });
266
+ }
267
+ if (snapshot.taskView && !snapshot.taskView.readable) {
268
+ findings.push({
269
+ repairKind: 'hard-failure',
270
+ code: 'SANDBOX_TASK_VIEW_UNREADABLE',
271
+ message: `Bound task view is not readable at ${snapshot.taskView.path}.`,
272
+ path: snapshot.taskView.path
273
+ });
274
+ }
275
+ if (snapshot.runtimeStoreOk === false) {
276
+ findings.push({
277
+ repairKind: 'hard-failure',
278
+ code: 'SANDBOX_RUNTIME_STORE_UNAVAILABLE',
279
+ message: 'Task-bound runtime store is not writable and readable by devuser.',
280
+ path: '/run/agent-infra/runtime'
281
+ });
282
+ }
245
283
  for (const mountPath of snapshot.unexpectedCapabilityMounts ?? []) {
246
284
  findings.push({
247
285
  repairKind: 'hard-failure',
@@ -250,6 +288,9 @@ export function classifySandboxRecovery(snapshot) {
250
288
  });
251
289
  }
252
290
  for (const mount of snapshot.mounts) {
291
+ if (snapshot.workspaceTopology === 'legacy-parent'
292
+ && mount.path.startsWith('/workspace/.agents/workspace'))
293
+ continue;
253
294
  if (mount.actualType !== mount.expectedType
254
295
  || !mount.sourceMatches
255
296
  || mount.actualRW !== mount.expectedRW
@@ -331,6 +372,20 @@ function inspectContainer(engine, container, runFn) {
331
372
  }
332
373
  return parsed[0];
333
374
  }
375
+ function workspaceTopology(mountsByDestination) {
376
+ if (mountsByDestination.has('/workspace/.agents/workspace'))
377
+ return 'legacy-parent';
378
+ const perState = [
379
+ '/workspace/.agents/workspace/active',
380
+ '/workspace/.agents/workspace/active/.short-ids.json',
381
+ '/workspace/.agents/workspace/completed',
382
+ '/workspace/.agents/workspace/blocked',
383
+ '/workspace/.agents/workspace/archive'
384
+ ];
385
+ return perState.some((destination) => mountsByDestination.has(destination))
386
+ ? 'per-state'
387
+ : 'unknown';
388
+ }
334
389
  function probe(engine, container, script, args, runOkFn, user = 'devuser') {
335
390
  return runOkFn(engine, 'docker', [
336
391
  'exec', '--user', user, container, 'sh', '-c', script, 'agent-infra-recovery', ...args
@@ -391,6 +446,7 @@ function expectedMounts(params) {
391
446
  controlStatusDir: control.statusDir,
392
447
  ...(params.workspace.mode === 'task-bound'
393
448
  ? {
449
+ runtimeDir: control.runtimeDir,
394
450
  taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
395
451
  taskId: params.workspace.taskId
396
452
  }
@@ -489,6 +545,10 @@ export function collectSandboxRecoverySnapshot(params) {
489
545
  const capabilityPlan = createSandboxCapabilityPlan(params.config);
490
546
  const workspace = params.workspace ?? { mode: 'branch-only' };
491
547
  const tools = [...capabilityPlan.tools];
548
+ const topology = workspaceTopology(mountsByDestination);
549
+ const taskViewPath = workspace.mode === 'task-bound'
550
+ ? `/workspace/.agents/workspace/active/${workspace.taskId}/task.md`
551
+ : null;
492
552
  const seeds = declaredTmpfsSeedEntries(tools).map((seed) => {
493
553
  const mounted = mountsByDestination.get(seed.stagingPath)?.Type === 'bind';
494
554
  return {
@@ -507,6 +567,9 @@ export function collectSandboxRecoverySnapshot(params) {
507
567
  permissionsOk: probe(params.engine, params.container, 'test "$(stat -c %u:%g:%a -- "$1")" = "$(id -u devuser):$(id -g devuser):700"', [tool.containerMount], runOkFn),
508
568
  writable: probe(params.engine, params.container, 'probe="$1/.agent-infra-ready-$$"; trap \'rm -f -- "$probe"\' EXIT; : > "$probe"', [tool.containerMount], runOkFn)
509
569
  }));
570
+ const runtimeStoreOk = workspace.mode === 'task-bound'
571
+ ? probe(params.engine, params.container, 'probe="$1/.agent-infra-runtime-$$"; trap \'rm -f -- "$probe"\' EXIT; printf runtime-probe > "$probe" && test "$(cat -- "$probe")" = runtime-probe', ['/run/agent-infra/runtime'], runOkFn)
572
+ : undefined;
510
573
  const labels = inspection.Config?.Labels ?? {};
511
574
  const branchLabel = labels[sandboxBranchLabel(params.config)];
512
575
  const containerWorkspace = parseSandboxWorkspaceIdentity(labels, {
@@ -562,6 +625,14 @@ export function collectSandboxRecoverySnapshot(params) {
562
625
  actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
563
626
  expectedWorkspace: workspace,
564
627
  actualWorkspace: containerWorkspace,
628
+ workspaceTopology: topology,
629
+ ...(taskViewPath === null ? {} : {
630
+ taskView: {
631
+ path: taskViewPath,
632
+ readable: probe(params.engine, params.container, 'test -r "$1"', [taskViewPath], runOkFn)
633
+ }
634
+ }),
635
+ ...(runtimeStoreOk === undefined ? {} : { runtimeStoreOk }),
565
636
  identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
566
637
  && branchLabel === params.branch
567
638
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
@@ -677,10 +748,12 @@ function assess(params) {
677
748
  }
678
749
  function canRetryFreshReadiness(findings) {
679
750
  return findings.length > 0 && findings.every((finding) => finding.repairKind === 'hard-failure'
751
+ && finding.code !== 'SANDBOX_WORKSPACE_TOPOLOGY_MISMATCH'
680
752
  && finding.path !== undefined
681
753
  && (finding.path.startsWith('/workspace/.agents/workspace')
682
754
  || finding.path === '/home/devuser/.host-shell-config'
683
- || finding.path === '/home/devuser/.bash_aliases'));
755
+ || finding.path === '/home/devuser/.bash_aliases'
756
+ || finding.path === '/run/agent-infra/runtime'));
684
757
  }
685
758
  export async function assertFreshSandboxReady(params) {
686
759
  validateTmpfsSeedEntries({
@@ -891,14 +964,9 @@ export async function ensureSandboxReady(params) {
891
964
  : `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
892
965
  warnings.push(warning);
893
966
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
894
- const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
895
967
  let replacementResult = null;
896
968
  let replacementFailure = null;
897
969
  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
970
  await params.recreate(params.branch);
903
971
  const rows = (deps?.fetchRows ?? fetchSandboxRows)(params.engine, sandboxLabel(params.config), sandboxBranchLabel(params.config));
904
972
  const replacement = selectSandboxContainer([...rows.running, ...rows.nonRunning], containerNameCandidates(params.config, params.branch));
@@ -38,6 +38,7 @@ export function sandboxControlPaths(params) {
38
38
  channelDir: path.join(root, 'channel'),
39
39
  statusDir: path.join(root, 'public'),
40
40
  processingDir: path.join(root, 'processing'),
41
+ runtimeDir: path.join(root, 'runtime'),
41
42
  manifestPath: path.join(root, 'manifest.json')
42
43
  };
43
44
  }
@@ -50,6 +51,29 @@ function assertSafeDirectory(directory, expectedBase) {
50
51
  throw new Error(`Sandbox workspace view must not be a symbolic link: ${directory}`);
51
52
  }
52
53
  }
54
+ function assertSafeMountTargetPath(target, worktreeRoot, canonicalWorktreeRoot) {
55
+ const relative = path.relative(worktreeRoot, target);
56
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
57
+ throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
58
+ }
59
+ let current = worktreeRoot;
60
+ for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
61
+ current = segment ? path.join(current, segment) : current;
62
+ try {
63
+ if (fs.lstatSync(current).isSymbolicLink()) {
64
+ throw new Error(`Sandbox workspace mount target must not contain a symbolic link: ${current}`);
65
+ }
66
+ const canonicalRelative = path.relative(canonicalWorktreeRoot, fs.realpathSync.native(current));
67
+ if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
68
+ throw new Error(`Sandbox workspace mount target escapes its real worktree: ${current}`);
69
+ }
70
+ }
71
+ catch (error) {
72
+ if (error.code !== 'ENOENT')
73
+ throw error;
74
+ }
75
+ }
76
+ }
53
77
  export function assertSandboxTaskSource(repoRoot, taskId) {
54
78
  if (!/^TASK-\d{8}-\d{6}$/.test(taskId))
55
79
  throw new Error('SANDBOX_TASK_SOURCE_INVALID');
@@ -65,42 +89,22 @@ export function assertSandboxTaskSource(repoRoot, taskId) {
65
89
  }
66
90
  return canonical;
67
91
  }
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}`);
92
+ export function prepareSandboxWorkspaceMountTargets(worktreeRoot) {
93
+ const resolvedWorktreeRoot = path.resolve(worktreeRoot);
94
+ const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
95
+ const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
96
+ const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
97
+ const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
98
+ for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
99
+ assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
84
100
  }
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 });
101
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
102
+ for (const { hostPath } of statePaths) {
103
+ fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
104
+ fs.chmodSync(hostPath, 0o700);
100
105
  }
101
- fs.chmodSync(target, 0o700);
102
- fs.accessSync(target, fs.constants.W_OK);
103
- return target;
106
+ fs.chmodSync(workspaceRoot, 0o700);
107
+ fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
104
108
  }
105
109
  export function materializeSandboxWorkspaceView(params) {
106
110
  const projectRoot = path.resolve(params.base, params.project);
@@ -131,15 +135,32 @@ export function materializeSandboxWorkspaceView(params) {
131
135
  return { root, taskMountPath };
132
136
  }
133
137
  export function materializeSandboxControl(params) {
134
- const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
138
+ const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
135
139
  assertSafeDirectory(root, path.resolve(params.base));
140
+ if (fs.existsSync(root)) {
141
+ if (!params.replacementLease || path.resolve(params.replacementLease.root) !== root) {
142
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_REQUIRED');
143
+ }
144
+ params.replacementLease.assertOwned();
145
+ }
136
146
  const consumedDir = path.join(root, 'consumed');
137
147
  assertSafeDirectory(consumedDir, root);
138
148
  fs.rmSync(consumedDir, { recursive: true, force: true });
139
149
  fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
140
- for (const directory of [statusDir, processingDir]) {
150
+ for (const directory of [statusDir, processingDir, runtimeDir]) {
141
151
  assertSafeDirectory(directory, root);
152
+ if (directory === runtimeDir)
153
+ fs.rmSync(directory, { recursive: true, force: true });
154
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
155
+ }
156
+ for (const directory of [
157
+ path.join(runtimeDir, 'clients'),
158
+ path.join(runtimeDir, 'clients', 'codex', 'capabilities'),
159
+ path.join(runtimeDir, 'clients', 'codex', 'lifecycle')
160
+ ]) {
161
+ assertSafeDirectory(directory, runtimeDir);
142
162
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
163
+ fs.chmodSync(directory, 0o700);
143
164
  }
144
165
  for (const queue of ['requests', 'responses']) {
145
166
  const directory = path.join(channelDir, queue);
@@ -151,7 +172,7 @@ export function materializeSandboxControl(params) {
151
172
  const generation = randomBytes(16).toString('hex');
152
173
  const repoRoot = fs.realpathSync.native(params.repoRoot);
153
174
  const manifest = {
154
- version: 4,
175
+ version: 5,
155
176
  engine: params.engine ?? 'docker',
156
177
  repoRoot,
157
178
  worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
@@ -165,16 +186,17 @@ export function materializeSandboxControl(params) {
165
186
  generation,
166
187
  channelDir,
167
188
  publicStatusDir: statusDir,
168
- processingDir
189
+ processingDir,
190
+ runtimeDir
169
191
  };
170
192
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
171
- return { root, channelDir, statusDir, manifestPath, token, generation };
193
+ return { root, channelDir, statusDir, runtimeDir, manifestPath, token, generation };
172
194
  }
173
195
  export function finalizeSandboxControlManifest(setup, identity) {
174
196
  if (!identity.engine || !identity.id)
175
197
  throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
176
198
  const manifest = JSON.parse(fs.readFileSync(setup.manifestPath, 'utf8'));
177
- manifest.version = 4;
199
+ manifest.version = 5;
178
200
  manifest.engine = identity.engine;
179
201
  manifest.containerIdentity = { id: identity.id, labels: { ...identity.labels } };
180
202
  const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
@@ -97,6 +97,56 @@ export function getProcessStartTime(pid, platform = process.platform) {
97
97
  return null;
98
98
  }
99
99
  }
100
+ export function getProcessIdentityState(identity, platform = process.platform) {
101
+ if (!Number.isInteger(identity.pid) || identity.pid <= 0 || !Number.isSafeInteger(identity.startTime)) {
102
+ return 'dead';
103
+ }
104
+ if (platform === 'linux') {
105
+ try {
106
+ const stat = parseLinuxProcessStat(fs.readFileSync(`/proc/${identity.pid}/stat`, 'utf8'));
107
+ if (stat === null)
108
+ return 'unknown';
109
+ if (stat.state === 'Z')
110
+ return 'dead';
111
+ return stat.startTime === identity.startTime ? 'alive' : 'dead';
112
+ }
113
+ catch (error) {
114
+ return error.code === 'ENOENT' ? 'dead' : 'unknown';
115
+ }
116
+ }
117
+ try {
118
+ process.kill(identity.pid, 0);
119
+ }
120
+ catch (error) {
121
+ const code = error.code;
122
+ if (code === 'ESRCH')
123
+ return 'dead';
124
+ if (code !== 'EPERM')
125
+ return 'unknown';
126
+ }
127
+ const query = buildProcessStartTimeQuery(identity.pid, platform);
128
+ if (query === null)
129
+ return 'unknown';
130
+ try {
131
+ const output = execFileSync(query.command, query.args, {
132
+ encoding: 'utf8',
133
+ env: platform === 'darwin' ? { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } : process.env
134
+ }).trim();
135
+ if (output.length === 0)
136
+ return 'unknown';
137
+ const startTime = platform === 'win32'
138
+ ? new Date(output).getTime()
139
+ : platform === 'darwin'
140
+ ? parseDarwinStartTime(output)
141
+ : null;
142
+ if (startTime === null || Number.isNaN(startTime))
143
+ return 'unknown';
144
+ return startTime === identity.startTime ? 'alive' : 'dead';
145
+ }
146
+ catch {
147
+ return 'unknown';
148
+ }
149
+ }
100
150
  function parsePidRecord(raw) {
101
151
  const trimmed = raw.trim();
102
152
  if (/^[1-9]\d*$/.test(trimmed)) {
@@ -200,6 +250,6 @@ export function removePidRecordIfMatches(pidFile, expected) {
200
250
  return removePidFileIfMatches(pidFile, state.snapshot);
201
251
  }
202
252
  export function processIdentityMatches(identity) {
203
- return getProcessStartTime(identity.pid) === identity.startTime;
253
+ return getProcessIdentityState(identity) === 'alive';
204
254
  }
205
255
  //# sourceMappingURL=process-state.js.map
@@ -5,6 +5,7 @@ import { locateActivityLog, appendActivityEntry } from './activity-log.js';
5
5
  import { assertWritableInventory, buildArtifactLinkSection, inspectTaskArtifacts, parseArtifactName } from './artifact-lifecycle.js';
6
6
  import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.js';
7
7
  import { captureTaskWriteMetadata, writeTask } from './write.js';
8
+ import { allowsManualOverride } from './guard-override.js';
8
9
  const HEAD_RE = /^[0-9a-f]{40}$/;
9
10
  const REVIEW_ACTION_RE = /^Review PR \(Round ([1-9]\d*)\)( \[started\])?$/;
10
11
  const VERDICTS = new Set(['approved', 'changes-requested', 'commented']);
@@ -292,9 +293,11 @@ export function applyPrReviewActivityIntent(intent, options = {}) {
292
293
  const resolved = resolveTaskRef(intent.taskRef, { repoRoot: options.repoRoot });
293
294
  if (!resolved.ok)
294
295
  return failed(intent, resolved.code, resolved.message, resolved.taskId);
295
- if (resolved.state !== 'active') {
296
+ if (resolved.state !== 'active' && !allowsManualOverride(options.manualOverride, 'activity-intent', 'TASK_STATE_MISMATCH')) {
296
297
  return failed(intent, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
297
298
  }
299
+ if (options.lockAlreadyHeld)
300
+ return applyLocked(intent, validated.agent, { ...options, repoRoot: resolved.repoRoot });
298
301
  try {
299
302
  return withTaskExecutionLock(resolved.repoRoot, resolved.taskId, `task-activity:${intent.kind}`, () => applyLocked(intent, validated.agent, { ...options, repoRoot: resolved.repoRoot }), options.lockOptions);
300
303
  }
@@ -6,6 +6,7 @@ import { LEDGER_COLUMNS, LEDGER_HEADINGS, parseLedger } from './ledger.js';
6
6
  import { resolveTaskContext } from './resolve-ref.js';
7
7
  import { extractSection, extractSubSection, findSectionHeading } from './sections.js';
8
8
  import { captureTaskWriteMetadata, writeTask } from './write.js';
9
+ import { allowsManualOverride } from './guard-override.js';
9
10
  const DECISION_ALIASES = ['人工裁决', 'Human Rulings', 'Human Decisions', 'Human Decision'];
10
11
  const ACTIVITY_ALIASES = ['活动日志', 'Activity Log'];
11
12
  function failed(code, message, taskId = null, ledgerId = null) {
@@ -56,7 +57,8 @@ function applyHumanDecision(request, options = {}) {
56
57
  const resolved = resolveTaskContext(request.taskRef, { repoRoot: options.repoRoot });
57
58
  if (!resolved.ok)
58
59
  return failed(resolved.code, resolved.message, resolved.taskId);
59
- if (resolved.state !== 'active')
60
+ const stateOverride = allowsManualOverride(options.manualOverride, 'decision-intent', 'TASK_STATE_MISMATCH');
61
+ if (resolved.state !== 'active' && !stateOverride)
60
62
  return failed('TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
61
63
  let content;
62
64
  let rows;
@@ -155,7 +157,7 @@ function applyHumanDecision(request, options = {}) {
155
157
  kind: 'section', aliases: ACTIVITY_ALIASES, heading: activity.heading,
156
158
  body: appendActivityEntry(activity, { time: metadata.timestamp, step: 'Human Decision', agent: 'human', note: `${row.id} decided → ${recordId}` })
157
159
  });
158
- const writeResult = writeTask({ taskRef: resolved.taskId, expectedState: 'active', mutations, dryRun: request.dryRun }, {
160
+ const writeResult = writeTask({ taskRef: resolved.taskId, expectedState: stateOverride ? resolved.state : 'active', mutations, dryRun: request.dryRun }, {
159
161
  ...options,
160
162
  taskLocation: { repoRoot: resolved.repoRoot, taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, state: resolved.state },
161
163
  metadataProvider: () => metadata