@fitlab-ai/agent-infra 0.11.11 → 0.11.13

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 (154) hide show
  1. package/dist/bin/internal-cli.js +2 -2
  2. package/dist/lib/agent-clients/adapter.js +7 -1
  3. package/dist/lib/agent-clients/adapters/claude-code.js +5 -1
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +78 -63
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/binding.js +114 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/cli.js +447 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-client.js +130 -0
  8. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-operation.js +131 -0
  9. package/dist/lib/{sandbox/control → agent-clients/adapters/codex-lifecycle}/controller-registration.js +2 -2
  10. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +48 -1
  11. package/dist/lib/agent-clients/adapters/codex-lifecycle/recovery.js +124 -62
  12. package/dist/lib/{internal/codex-sandbox-controller.js → agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.js} +3 -3
  13. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +3 -5
  14. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +211 -213
  15. package/dist/lib/agent-clients/adapters/codex-orchestration.js +468 -0
  16. package/dist/lib/agent-clients/adapters/codex.js +40 -1
  17. package/dist/lib/agent-clients/adapters/index.js +2 -10
  18. package/dist/lib/agent-clients/tokens.js +3 -2
  19. package/dist/lib/internal/agent-client.js +132 -72
  20. package/dist/lib/internal/cli-route-inventory.js +2 -2
  21. package/dist/lib/internal/sandbox-control.js +1 -81
  22. package/dist/lib/internal/task-finalization.js +26 -19
  23. package/dist/lib/internal/task-lifecycle.js +1 -1
  24. package/dist/lib/internal/task-operation-registry.js +1 -1
  25. package/dist/lib/internal/task-validate.js +1 -1
  26. package/dist/lib/platform/github-client.js +33 -3
  27. package/dist/lib/platform/issues.js +18 -6
  28. package/dist/lib/runtime/agent-runtime.js +6 -29
  29. package/dist/lib/sandbox/commands/control-status.js +1 -0
  30. package/dist/lib/sandbox/commands/create.js +7 -2
  31. package/dist/lib/sandbox/commands/ls.js +4 -5
  32. package/dist/lib/sandbox/commands/rm.js +1 -1
  33. package/dist/lib/sandbox/control/agent-operation.js +34 -0
  34. package/dist/lib/sandbox/control/client.js +14 -201
  35. package/dist/lib/sandbox/control/executor.js +12 -125
  36. package/dist/lib/sandbox/control/lifecycle.js +66 -4
  37. package/dist/lib/sandbox/control/protocol.js +15 -54
  38. package/dist/lib/sandbox/control/server.js +216 -281
  39. package/dist/lib/sandbox/control/task-view.js +0 -13
  40. package/dist/lib/sandbox/mounts.js +9 -1
  41. package/dist/lib/sandbox/recovery.js +31 -10
  42. package/dist/lib/sandbox/removal.js +28 -5
  43. package/dist/lib/sandbox/workspace-identity.js +18 -6
  44. package/dist/lib/sandbox/workspace-view.js +56 -19
  45. package/dist/lib/task/commands/ls.js +3 -3
  46. package/dist/lib/task/control-authority.js +56 -264
  47. package/dist/lib/task/control-recovery.js +2 -3
  48. package/dist/lib/task/create.js +2 -2
  49. package/dist/lib/task/delegation-receipts.js +74 -161
  50. package/dist/lib/task/events.js +88 -44
  51. package/dist/lib/task/finalization.js +35 -6
  52. package/dist/lib/task/lifecycle.js +274 -115
  53. package/dist/lib/task/orchestration.js +201 -194
  54. package/dist/lib/task/runtime-paths.js +10 -0
  55. package/dist/lib/task/short-id.js +37 -27
  56. package/dist/lib/task/verification-engine.js +32 -26
  57. package/dist/lib/task/workspace-snapshot.js +1 -1
  58. package/dist/lifecycle-build-manifest.json +3 -3
  59. package/lib/agent-clients/adapter.ts +39 -0
  60. package/lib/agent-clients/adapters/claude-code.ts +5 -1
  61. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +105 -80
  62. package/lib/agent-clients/adapters/codex-lifecycle/binding.ts +147 -0
  63. package/lib/agent-clients/adapters/codex-lifecycle/cli.ts +510 -0
  64. package/lib/agent-clients/adapters/codex-lifecycle/controller-client.ts +158 -0
  65. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +1 -1
  66. package/lib/agent-clients/adapters/codex-lifecycle/controller-operation.ts +151 -0
  67. package/lib/{sandbox/control → agent-clients/adapters/codex-lifecycle}/controller-registration.ts +4 -4
  68. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +43 -0
  69. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +8 -5
  70. package/lib/agent-clients/adapters/codex-lifecycle/recovery.ts +190 -65
  71. package/lib/{internal/codex-sandbox-controller.ts → agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.ts} +2 -2
  72. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +3 -5
  73. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +270 -224
  74. package/lib/agent-clients/adapters/codex-orchestration.ts +632 -0
  75. package/lib/agent-clients/adapters/codex.ts +44 -1
  76. package/lib/agent-clients/adapters/index.ts +7 -20
  77. package/lib/agent-clients/tokens.ts +4 -3
  78. package/lib/internal/agent-client.ts +142 -73
  79. package/lib/internal/cli-route-inventory.ts +2 -2
  80. package/lib/internal/sandbox-control.ts +0 -97
  81. package/lib/internal/task-finalization.ts +30 -18
  82. package/lib/internal/task-lifecycle.ts +1 -1
  83. package/lib/internal/task-operation-registry.ts +1 -1
  84. package/lib/internal/task-validate.ts +1 -1
  85. package/lib/platform/github-client.ts +34 -3
  86. package/lib/platform/issues.ts +18 -6
  87. package/lib/runtime/agent-runtime.ts +6 -41
  88. package/lib/sandbox/commands/control-status.ts +1 -0
  89. package/lib/sandbox/commands/create.ts +7 -1
  90. package/lib/sandbox/commands/ls.ts +4 -5
  91. package/lib/sandbox/commands/rm.ts +1 -1
  92. package/lib/sandbox/control/agent-operation.ts +49 -0
  93. package/lib/sandbox/control/client.ts +20 -254
  94. package/lib/sandbox/control/executor.ts +13 -136
  95. package/lib/sandbox/control/lifecycle.ts +67 -4
  96. package/lib/sandbox/control/protocol.ts +22 -66
  97. package/lib/sandbox/control/server.ts +21 -95
  98. package/lib/sandbox/control/task-view.ts +0 -19
  99. package/lib/sandbox/mounts.ts +10 -1
  100. package/lib/sandbox/recovery.ts +38 -17
  101. package/lib/sandbox/removal.ts +33 -7
  102. package/lib/sandbox/workspace-identity.ts +16 -6
  103. package/lib/sandbox/workspace-view.ts +50 -18
  104. package/lib/task/commands/ls.ts +3 -3
  105. package/lib/task/control-authority.ts +66 -378
  106. package/lib/task/control-recovery.ts +3 -4
  107. package/lib/task/create.ts +3 -3
  108. package/lib/task/delegation-receipts.ts +87 -231
  109. package/lib/task/events.ts +100 -42
  110. package/lib/task/finalization.ts +37 -7
  111. package/lib/task/lifecycle.ts +257 -109
  112. package/lib/task/orchestration.ts +260 -237
  113. package/lib/task/runtime-paths.ts +12 -0
  114. package/lib/task/short-id.ts +33 -40
  115. package/lib/task/verification-engine.ts +30 -25
  116. package/lib/task/workspace-snapshot.ts +1 -1
  117. package/package.json +1 -1
  118. package/templates/.agents/hooks/lifecycle-delegation.js +1 -25
  119. package/templates/.agents/rules/README.en.md +2 -1
  120. package/templates/.agents/rules/README.zh-CN.md +2 -1
  121. package/templates/.agents/rules/next-step-output.en.md +6 -19
  122. package/templates/.agents/rules/next-step-output.zh-CN.md +6 -19
  123. package/templates/.agents/rules/strategy-pattern.en.md +25 -0
  124. package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
  125. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  126. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -1
  127. package/templates/.agents/skills/check-task/SKILL.en.md +1 -1
  128. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +1 -1
  129. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  130. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  131. package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
  132. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
  133. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +0 -1
  134. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +0 -1
  135. package/templates/.agents/skills/complete-task/SKILL.en.md +5 -29
  136. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -26
  137. package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
  138. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
  139. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  140. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  141. package/templates/.agents/skills/review-code/SKILL.en.md +1 -1
  142. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +1 -1
  143. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  144. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  145. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  146. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  147. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  148. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  149. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +0 -546
  150. package/dist/lib/internal/codex-lifecycle.js +0 -385
  151. package/dist/lib/task/codex-orchestration.js +0 -206
  152. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +0 -776
  153. package/lib/internal/codex-lifecycle.ts +0 -390
  154. package/lib/task/codex-orchestration.ts +0 -276
@@ -72,11 +72,10 @@ import {
72
72
  import type { BrokerOwner } from './lifecycle.ts';
73
73
  import { nextSandboxControlBackoff } from './timing.ts';
74
74
  import { readTaskFinalizationReceipt } from '../../task/finalization.ts';
75
- import { readCodexControllerRegistration } from './controller-registration.ts';
75
+ import { recoverAgentClientOperation, validateAgentClientOperation } from './agent-operation.ts';
76
76
  import { validateSandboxControlIdentity } from './identity-sentinel.ts';
77
77
  import {
78
78
  mergeSandboxTaskView,
79
- taskViewAfterFinalization,
80
79
  taskViewForManifest,
81
80
  type SandboxTaskView
82
81
  } from './task-view.ts';
@@ -91,34 +90,6 @@ type ActiveExecution = {
91
90
  settled: boolean;
92
91
  };
93
92
 
94
- async function taskViewForFinalizationResult(
95
- manifest: SandboxControlManifest,
96
- request: SandboxControlRequest,
97
- result: SandboxControlExecutionResult | null
98
- ): Promise<SandboxTaskView | null> {
99
- if (request.family !== 'task-finalization' || result?.exitCode !== 0 || !manifest.taskId) return null;
100
- let receipt: unknown = null;
101
- for (let attempt = 0; attempt < 100 && !receipt; attempt += 1) {
102
- try {
103
- receipt = readTaskFinalizationReceipt(manifest.repoRoot, manifest.taskId);
104
- } catch {
105
- // A missing or temporarily moving task directory is retried before publishing completion.
106
- }
107
- if (!receipt) await new Promise((resolve) => setTimeout(resolve, 10));
108
- }
109
- const finalized = taskViewAfterFinalization({
110
- taskId: manifest.taskId,
111
- generation: manifest.generation,
112
- requestId: request.id,
113
- receipt
114
- });
115
- if (finalized.state === 'unknown') return finalized;
116
- const canonical = taskViewForManifest({ ...manifest, receipt });
117
- return canonical.state === 'current' && canonical.observedSource === 'completed'
118
- ? canonical
119
- : finalized;
120
- }
121
-
122
93
  function appendBrokerAudit(
123
94
  manifest: SandboxControlManifest,
124
95
  event: string,
@@ -128,9 +99,8 @@ function appendBrokerAudit(
128
99
  }
129
100
 
130
101
  function operationKey(request: SandboxControlRequest): string | null {
131
- if (request.family === 'task-finalization') return request.operation;
132
102
  if (request.family === 'task-create') return 'create';
133
- if (request.family === 'codex-controller') return request.command;
103
+ if (request.family === 'agent-client') return request.operation;
134
104
  if (request.family !== 'task-lifecycle') return null;
135
105
  try {
136
106
  const operation = parseTaskControlOperation(request.family, request.args);
@@ -318,46 +288,6 @@ function taskCreateDomainEvidence(
318
288
  }
319
289
  }
320
290
 
321
- function controllerDomainEvidence(
322
- manifest: SandboxControlManifest,
323
- manifestPath: string,
324
- request: SandboxControlRequest,
325
- output: Record<string, unknown> | null
326
- ): Readonly<Record<string, unknown>> {
327
- if (!output || output.error !== null || typeof output.status !== 'string') return { consistent: false };
328
- try {
329
- const registration = readCodexControllerRegistration(manifestPath);
330
- if (request.family !== 'codex-controller') return { consistent: false };
331
- if (request.command === 'open') {
332
- const lease = output.lease && typeof output.lease === 'object' && !Array.isArray(output.lease)
333
- ? output.lease as Record<string, unknown> : null;
334
- return {
335
- consistent: output.status === 'opened' && output.changed === true
336
- && lease?.taskId === registration.taskId
337
- && lease.controlGeneration === registration.controlGeneration
338
- && lease.controllerInstanceDigest === registration.controllerInstanceDigest
339
- && registration.taskId === manifest.taskId
340
- && registration.controlGeneration === manifest.generation
341
- };
342
- }
343
- if (request.command === 'verify') {
344
- const binding = output.binding && typeof output.binding === 'object' && !Array.isArray(output.binding)
345
- ? output.binding as Record<string, unknown> : null;
346
- return {
347
- consistent: output.status === 'verified' && output.changed === false
348
- && binding?.taskId === registration.taskId
349
- && binding.controlGeneration === registration.controlGeneration
350
- && binding.controllerInstanceDigest === registration.controllerInstanceDigest
351
- };
352
- }
353
- return { consistent: false };
354
- } catch (error) {
355
- return { consistent: request.family === 'codex-controller' && request.command === 'close'
356
- && (error as { code?: string }).code === 'CODEX_SANDBOX_CONTROLLER_REGISTRATION_MISSING'
357
- && output?.status === 'closed' && typeof output.changed === 'boolean' };
358
- }
359
- }
360
-
361
291
  async function readRecoveryDomain(
362
292
  manifest: SandboxControlManifest,
363
293
  manifestPath: string,
@@ -367,11 +297,9 @@ async function readRecoveryDomain(
367
297
  payloadOutput: string | null
368
298
  ): Promise<RecoveryDomainEvidence> {
369
299
  if (!operation) return { domain: null };
370
- const taskRef = request.family === 'task-finalization'
371
- ? manifest.taskId
372
- : 'args' in request ? request.args[0] ?? null : manifest.taskId;
300
+ const taskRef = 'args' in request ? request.args[0] ?? null : manifest.taskId;
373
301
  const output = parseControlOutput(payloadOutput);
374
- if (!taskRef && operation.family !== 'task-create' && operation.family !== 'codex-controller') {
302
+ if (!taskRef && operation.family !== 'task-create' && operation.family !== 'agent-client') {
375
303
  return { domain: null };
376
304
  }
377
305
 
@@ -399,8 +327,13 @@ async function readRecoveryDomain(
399
327
  if (operation.family === 'task-create') {
400
328
  return { domain: taskCreateDomainEvidence(manifest, output) };
401
329
  }
402
- if (operation.family === 'codex-controller') {
403
- return { domain: controllerDomainEvidence(manifest, manifestPath, request, output) };
330
+ if (operation.family === 'agent-client' && request.family === 'agent-client') {
331
+ return { domain: recoverAgentClientOperation({
332
+ request,
333
+ manifest,
334
+ manifestPath,
335
+ stdout: payloadOutput
336
+ }) };
404
337
  }
405
338
  return { domain: null };
406
339
  }
@@ -638,6 +571,7 @@ async function recoverProcessing(manifest: SandboxControlManifest, manifestPath:
638
571
  request = validateSandboxControlRequest(rawRequest, manifest, {
639
572
  now: typeof rawRequest.issuedAt === 'number' ? rawRequest.issuedAt : undefined
640
573
  });
574
+ if (request.family === 'agent-client') validateAgentClientOperation(request, manifest);
641
575
  } catch {
642
576
  // Missing or malformed request evidence remains fail-closed below.
643
577
  }
@@ -876,6 +810,7 @@ export async function serveSandboxControl(
876
810
  let bindingBackoffMs: number = timing.parkedBindingInitialMs;
877
811
  try {
878
812
  if (!brokerOwns()) return;
813
+ if (isSandboxControlRootQuiescing(root)) return;
879
814
  if (taskView.state === 'current' && taskView.observedSource === 'completed') {
880
815
  // Recheck durable re-entry evidence before publishing a trusted startup view.
881
816
  const unverified = { ...taskView, state: 'unknown' as const,
@@ -966,11 +901,6 @@ export async function serveSandboxControl(
966
901
  settledExecution = active;
967
902
  active = null;
968
903
  }
969
- const finalizationView = settledExecution
970
- ? await taskViewForFinalizationResult(manifest, settledExecution.request, settledExecution.result)
971
- : null;
972
- if (finalizationView) taskView = finalizationView;
973
-
974
904
  let reasonCode: string | null = null;
975
905
  try {
976
906
  if (!brokerOwns()) break;
@@ -1039,7 +969,9 @@ export async function serveSandboxControl(
1039
969
  }
1040
970
 
1041
971
  let retiring = false;
1042
- for (const name of fs.readdirSync(requestsDir).sort()) {
972
+ const quiescing = isSandboxControlRootQuiescing(root);
973
+ if (quiescing && !active) break;
974
+ if (!quiescing) for (const name of fs.readdirSync(requestsDir).sort()) {
1043
975
  if (!/^[a-f0-9-]{16,64}\.json$/.test(name)) continue;
1044
976
  const id = name.slice(0, -5);
1045
977
  const source = path.join(requestsDir, name);
@@ -1047,6 +979,10 @@ export async function serveSandboxControl(
1047
979
  let validatedRequest: SandboxControlRequest | null = null;
1048
980
  let prepared: PreparedSandboxControlExecution | null = null;
1049
981
  try {
982
+ if (isSandboxControlRootQuiescing(root)) {
983
+ retiring = true;
984
+ break;
985
+ }
1050
986
  if (!brokerOwns()) {
1051
987
  retiring = true;
1052
988
  break;
@@ -1070,6 +1006,7 @@ export async function serveSandboxControl(
1070
1006
  if (reasonCode) throw new Error(reasonCode);
1071
1007
  if (active) throw new Error('SANDBOX_CONTROL_BUSY');
1072
1008
  const request = validateSandboxControlRequest(JSON.parse(fs.readFileSync(claimed, 'utf8')), manifest);
1009
+ if (request.family === 'agent-client') validateAgentClientOperation(request, manifest);
1073
1010
  validatedRequest = request;
1074
1011
  criticalRequestPhase(manifest, request, 'validated', 'in-progress');
1075
1012
  appendBrokerAudit(manifest, 'request-validated', {
@@ -1162,12 +1099,6 @@ export async function serveSandboxControl(
1162
1099
  retiring = true;
1163
1100
  continue;
1164
1101
  }
1165
- if (!brokerOwns()) {
1166
- prepared.terminate(false);
1167
- active = null;
1168
- retiring = true;
1169
- continue;
1170
- }
1171
1102
  criticalRequestPhase(manifest, request, 'accepted-authorized', 'in-progress');
1172
1103
  writeAcceptedResponse(manifest, {
1173
1104
  version: 2, id, phase: 'accepted', exitCode: null, stdout: '', stderr: '', error: null
@@ -1269,11 +1200,6 @@ export async function serveSandboxControl(
1269
1200
  }
1270
1201
  }
1271
1202
  if (owned && active.result && active.resultEvidenceWritten) {
1272
- const finalizationView = await taskViewForFinalizationResult(manifest, active.request, active.result);
1273
- if (finalizationView) {
1274
- taskView = finalizationView;
1275
- writeSandboxControlStatus(manifest, broker, 'healthy', null, null, Date.now(), taskView);
1276
- }
1277
1203
  if (publishExecutionResult(manifest, active.request, active.result, brokerOwns)) {
1278
1204
  if (brokerOwns()) {
1279
1205
  removeAcceptedResponse(manifest, active.request.id);
@@ -180,25 +180,6 @@ export function projectSandboxTaskView(input: TaskViewProjectionInput): SandboxT
180
180
  };
181
181
  }
182
182
 
183
- export function taskViewAfterFinalization(params: Readonly<{
184
- taskId: string;
185
- generation: string;
186
- requestId: string;
187
- receipt: unknown;
188
- }>): SandboxTaskView {
189
- const receipt = receiptIdentity(params.receipt, params.generation, params.requestId);
190
- if (!receipt) {
191
- return {
192
- state: 'unknown', taskId: params.taskId, observedSource: 'unknown', receipt: null,
193
- reasonCode: 'SANDBOX_TASK_VIEW_RECEIPT_INVALID'
194
- };
195
- }
196
- return {
197
- state: 'finalized-stale', taskId: params.taskId, observedSource: 'active', receipt,
198
- reasonCode: 'SANDBOX_TASK_VIEW_FINALIZED'
199
- };
200
- }
201
-
202
183
  export function mergeSandboxTaskView(
203
184
  canonical: SandboxTaskView,
204
185
  previous: SandboxTaskView
@@ -31,6 +31,7 @@ export function sandboxCoreBindMounts(
31
31
  worktreeGitFile?: string;
32
32
  runtimeDir?: string;
33
33
  taskSources?: string[];
34
+ completedTaskSource?: string;
34
35
  taskId?: string;
35
36
  }
36
37
  ): SandboxBindMountDeclaration[] {
@@ -71,11 +72,19 @@ export function sandboxCoreBindMounts(
71
72
  ...workspaceMounts,
72
73
  ];
73
74
  if (taskBound) {
75
+ const taskMountPath = path.posix.join('/workspace/.agents/workspace/active', overrides.taskId!);
74
76
  mounts.push({
75
77
  hostPaths: taskSources,
76
- containerPath: path.posix.join('/workspace/.agents/workspace/active', overrides.taskId!),
78
+ containerPath: taskMountPath,
77
79
  readOnly: false
78
80
  });
81
+ if (overrides.completedTaskSource) {
82
+ mounts.push({
83
+ hostPaths: [overrides.completedTaskSource],
84
+ containerPath: path.posix.join('/workspace/.agents/workspace/completed', overrides.taskId!),
85
+ readOnly: false
86
+ });
87
+ }
79
88
  }
80
89
  mounts.push(
81
90
  {
@@ -25,6 +25,7 @@ import { sandboxCoreBindMounts } from './mounts.ts';
25
25
  import { resolveSandboxGitMetadata } from './git-metadata.ts';
26
26
  import {
27
27
  assertSandboxTaskSource,
28
+ sandboxCompletedTaskMountSource,
28
29
  sandboxControlPaths,
29
30
  sandboxWorkspaceViewPaths
30
31
  } from './workspace-view.ts';
@@ -341,6 +342,7 @@ async function ensureSandboxControlBroker(params: {
341
342
  }): Promise<void> {
342
343
  const control = sandboxControlPaths({
343
344
  base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
345
+ repoRoot: params.config.repoRoot,
344
346
  project: params.config.project,
345
347
  container: params.container,
346
348
  identity: params.workspace
@@ -672,31 +674,43 @@ export function startCompletedSandboxContainer(params: {
672
674
  const activeRoot = path.join(params.config.repoRoot, '.agents', 'workspace', 'active');
673
675
  const historicalSource = path.join(activeRoot, params.taskId);
674
676
  fs.mkdirSync(activeRoot, { recursive: true });
677
+ let replacedEmptyRoot = false;
678
+ let emptyRootMode = 0o700;
675
679
  try {
676
- fs.lstatSync(historicalSource);
677
- throw new Error(
678
- `SANDBOX_COMPLETED_SOURCE_CONFLICT: historical task source already exists at ${historicalSource}`
679
- );
680
+ const existing = fs.lstatSync(historicalSource);
681
+ if (existing.isSymbolicLink() || !existing.isDirectory() || fs.readdirSync(historicalSource).length > 0) {
682
+ throw new Error(
683
+ `SANDBOX_COMPLETED_SOURCE_CONFLICT: historical task source already exists at ${historicalSource}`
684
+ );
685
+ }
686
+ emptyRootMode = existing.mode & 0o777;
687
+ fs.rmdirSync(historicalSource);
688
+ replacedEmptyRoot = true;
680
689
  } catch (error) {
681
690
  if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
682
691
  }
683
692
 
684
- fs.symlinkSync(
685
- completedSource,
686
- historicalSource,
687
- process.platform === 'win32' ? 'junction' : 'dir'
688
- );
693
+ let temporaryLink = false;
689
694
  try {
695
+ fs.symlinkSync(
696
+ completedSource,
697
+ historicalSource,
698
+ process.platform === 'win32' ? 'junction' : 'dir'
699
+ );
700
+ temporaryLink = true;
690
701
  params.start(params.engine, params.container);
691
702
  } finally {
692
- const temporarySource = fs.lstatSync(historicalSource);
693
- const resolvedSource = fs.realpathSync.native(historicalSource);
694
- if (!temporarySource.isSymbolicLink() || resolvedSource !== completedSource) {
695
- throw new Error(
696
- `SANDBOX_COMPLETED_SOURCE_CHANGED: refusing to remove unexpected path at ${historicalSource}`
697
- );
703
+ if (temporaryLink) {
704
+ const temporarySource = fs.lstatSync(historicalSource);
705
+ const resolvedSource = fs.realpathSync.native(historicalSource);
706
+ if (!temporarySource.isSymbolicLink() || resolvedSource !== completedSource) {
707
+ throw new Error(
708
+ `SANDBOX_COMPLETED_SOURCE_CHANGED: refusing to remove unexpected path at ${historicalSource}`
709
+ );
710
+ }
711
+ fs.unlinkSync(historicalSource);
698
712
  }
699
- fs.unlinkSync(historicalSource);
713
+ if (replacedEmptyRoot) fs.mkdirSync(historicalSource, { mode: emptyRootMode });
700
714
  }
701
715
  }
702
716
 
@@ -739,6 +753,7 @@ function expectedMounts(params: {
739
753
  });
740
754
  const control = sandboxControlPaths({
741
755
  base: config.controlBase ?? path.join(config.home, '.agent-infra', 'sandbox-control'),
756
+ repoRoot: config.repoRoot,
742
757
  project: config.project,
743
758
  container: params.container,
744
759
  identity: params.workspace
@@ -747,6 +762,7 @@ function expectedMounts(params: {
747
762
  ? recoveryTaskSources(config.repoRoot, params.workspace.taskId)
748
763
  : null;
749
764
  const taskId = params.workspace.mode === 'task-bound' ? params.workspace.taskId : null;
765
+ const completedTaskSource = taskId ? sandboxCompletedTaskMountSource(config.repoRoot, taskId) : null;
750
766
  const worktrees = worktreeDirCandidates(config, branch).filter((candidate) =>
751
767
  fs.existsSync(path.join(candidate, '.git'))
752
768
  );
@@ -768,6 +784,7 @@ function expectedMounts(params: {
768
784
  : {
769
785
  runtimeDir: control.runtimeDir,
770
786
  taskSources: taskSources!.accessiblePaths,
787
+ completedTaskSource: completedTaskSource!,
771
788
  taskId: taskId!
772
789
  })
773
790
  }).map((mount) => ({
@@ -778,6 +795,10 @@ function expectedMounts(params: {
778
795
  && mount.containerPath === `/workspace/.agents/workspace/active/${taskId}`
779
796
  ? { sourceAccessiblePaths: taskSources!.accessiblePaths }
780
797
  : {}),
798
+ ...(completedTaskSource !== null && taskId !== null
799
+ && mount.containerPath === `/workspace/.agents/workspace/completed/${taskId}`
800
+ ? { sourceAccessiblePaths: [completedTaskSource] }
801
+ : {}),
781
802
  expectedRW: !mount.readOnly
782
803
  }));
783
804
  const live = tools.flatMap((tool) =>
@@ -1275,7 +1296,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1275
1296
  let completedReentry: { manifest: SandboxControlManifest; evidence: Awaited<ReturnType<typeof prepareCompletedReentry>> } | null = null;
1276
1297
  if (params.reentry === 'completed' && params.workspace?.mode === 'task-bound') {
1277
1298
  const control = sandboxControlPaths({
1278
- base: params.config.controlBase, project: params.config.project,
1299
+ base: params.config.controlBase, repoRoot: params.config.repoRoot, project: params.config.project,
1279
1300
  container: params.row.name, identity: params.workspace
1280
1301
  });
1281
1302
  const manifest = readSandboxControlManifest(control.manifestPath);
@@ -829,7 +829,7 @@ function resolveRmTarget(
829
829
  ? matchedContainers
830
830
  : [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
831
831
  const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
832
- base: config.controlBase, project: config.project, container, identity
832
+ base: config.controlBase, repoRoot: config.repoRoot, project: config.project, container, identity
833
833
  }).root));
834
834
  const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
835
835
  base: config.workspaceViewBase, project: config.project, container, identity
@@ -1007,7 +1007,9 @@ async function removeUncheckedSandbox(
1007
1007
  }
1008
1008
  for (const root of controlRoots) {
1009
1009
  fs.rmSync(root, { recursive: true, force: true });
1010
- removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
1010
+ if (target.workspace.mode !== 'task-bound') {
1011
+ removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
1012
+ }
1011
1013
  }
1012
1014
  for (const root of workspaceViewRoots) {
1013
1015
  fs.rmSync(root, { recursive: true, force: true });
@@ -1027,14 +1029,19 @@ async function removeUncheckedSandbox(
1027
1029
  assertBranchRemovalIdentity(config, effectiveBranch, worktreePermits);
1028
1030
  runSafe('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch]);
1029
1031
  }
1030
- if (target.workspace.mode === 'task-bound') {
1031
- releaseStaleShortIdRegistry(config.repoRoot, target.workspace.taskId);
1032
- }
1032
+ releaseTaskBoundSandboxIdentity(config.repoRoot, target.workspace);
1033
1033
 
1034
1034
  if (!options.quiet) p.outro('Sandbox removed');
1035
1035
  return null;
1036
1036
  }
1037
1037
 
1038
+ function releaseTaskBoundSandboxIdentity(
1039
+ repoRoot: string,
1040
+ workspace: SandboxWorkspaceKey
1041
+ ): void {
1042
+ if (workspace.mode === 'task-bound') releaseStaleShortIdRegistry(repoRoot, workspace.taskId);
1043
+ }
1044
+
1038
1045
  async function rmOneCore(
1039
1046
  config: SandboxConfig,
1040
1047
  tools: SandboxTool[],
@@ -1066,11 +1073,25 @@ async function rmPurgeCore(
1066
1073
  const confirm = prompt.confirm ?? p.confirm;
1067
1074
  const isCancel = prompt.isCancel ?? p.isCancel;
1068
1075
  p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
1069
-
1076
+ const listed = fetchSandboxRows(
1077
+ engine,
1078
+ sandboxLabel(config),
1079
+ sandboxBranchLabel(config),
1080
+ { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) }
1081
+ );
1082
+ const rows = [...listed.running, ...listed.nonRunning];
1070
1083
  const containers = runEngine(engine, 'docker', [
1071
1084
  'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
1072
1085
  ]).split('\n').filter(Boolean);
1086
+ const completedTaskIds = [...new Set(rows
1087
+ .filter((row) => row.workspaceMode === 'task-bound' && row.taskId)
1088
+ .map((row) => row.taskId!))];
1073
1089
  for (const name of containers) runSafeEngine(engine, 'docker', ['rm', '-f', name]);
1090
+ if (!sandboxContainersRemoved(engine, containers)) {
1091
+ throw new Error(`SANDBOX_REMOVAL_CONTAINER_STILL_PRESENT: ${containers.join(', ')}`);
1092
+ }
1093
+
1094
+ let cleanupCompleted = true;
1074
1095
 
1075
1096
  const worktrees = fs.existsSync(config.worktreeBase)
1076
1097
  ? fs.readdirSync(config.worktreeBase).map((entry) => path.join(config.worktreeBase, entry))
@@ -1080,21 +1101,26 @@ async function rmPurgeCore(
1080
1101
  if (!isCancel(selected) && selected) {
1081
1102
  for (const worktree of worktrees) fs.rmSync(worktree, { recursive: true, force: true });
1082
1103
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
1083
- }
1104
+ } else cleanupCompleted = false;
1084
1105
  }
1085
1106
 
1086
1107
  for (const dir of projectToolDirs(config, tools)) fs.rmSync(dir, { recursive: true, force: true });
1087
1108
  if (fs.existsSync(config.shellConfigBase)) {
1088
1109
  const selected = await confirm({ message: `Remove all shell config dirs in ${config.shellConfigBase}?`, initialValue: true });
1089
1110
  if (!isCancel(selected) && selected) fs.rmSync(config.shellConfigBase, { recursive: true, force: true });
1111
+ else cleanupCompleted = false;
1090
1112
  }
1091
1113
  if (fs.existsSync(config.shareBase)) {
1092
1114
  const selected = await confirm({ message: `Remove all share dirs for project (${config.shareBase})?`, initialValue: true });
1093
1115
  if (!isCancel(selected) && selected) fs.rmSync(config.shareBase, { recursive: true, force: true });
1116
+ else cleanupCompleted = false;
1094
1117
  }
1095
1118
  for (const base of [config.workspaceViewBase, config.controlBase]) {
1096
1119
  fs.rmSync(path.join(base, config.project), { recursive: true, force: true });
1097
1120
  }
1121
+ if (cleanupCompleted) {
1122
+ for (const taskId of completedTaskIds) releaseStaleShortIdRegistry(config.repoRoot, taskId);
1123
+ }
1098
1124
  const removeImage = await confirm({ message: `Remove image ${config.imageName}?`, initialValue: false });
1099
1125
  if (!isCancel(removeImage) && removeImage) runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
1100
1126
  p.outro(pc.green('All project sandboxes removed'));
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRemovedHashShortIdInput } from '../task/short-id.ts';
4
+ import { resolveTaskRef } from '../task/resolve-ref.ts';
4
5
  import {
5
6
  resolveTaskWorkspace,
6
7
  type TaskWorkspaceState
@@ -76,6 +77,14 @@ function taskIdentity(repoRoot: string, taskId: string, registry: Record<string,
76
77
  return { mode: 'task-bound', taskId, shortId: shortIds[0]! };
77
78
  }
78
79
 
80
+ function cleanupTaskId(requestedRef: string, repoRoot: string): string | null {
81
+ if (TASK_ID_RE.test(requestedRef)) return requestedRef;
82
+ if (!SHORT_ID_RE.test(requestedRef)) return null;
83
+ const task = resolveTaskRef(requestedRef, { repoRoot });
84
+ if (!task.ok) throw new Error(task.message);
85
+ return task.taskId;
86
+ }
87
+
79
88
  export function resolveSandboxTarget(requestedRef: string, repoRoot: string): SandboxTarget {
80
89
  if (isRemovedHashShortIdInput(requestedRef)) {
81
90
  throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
@@ -130,23 +139,24 @@ export function resolveSandboxCleanupTarget(
130
139
  throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
131
140
  }
132
141
 
133
- if (TASK_ID_RE.test(requestedRef)) {
142
+ const taskId = cleanupTaskId(requestedRef, repoRoot);
143
+ if (taskId) {
134
144
  let task: ReturnType<typeof resolveTaskWorkspace>;
135
145
  try {
136
- task = resolveTaskWorkspace(requestedRef, repoRoot);
146
+ task = resolveTaskWorkspace(taskId, repoRoot);
137
147
  } catch (error) {
138
- if (!(error instanceof Error && error.message === `Task not found: ${requestedRef}`)) throw error;
139
- const branch = options.resolveMissingTask?.(requestedRef);
148
+ if (!(error instanceof Error && error.message === `Task not found: ${taskId}`)) throw error;
149
+ const branch = options.resolveMissingTask?.(taskId);
140
150
  if (!branch) throw error;
141
151
  return {
142
152
  requestedRef,
143
153
  branch,
144
- workspace: { mode: 'task-bound', taskId: requestedRef },
154
+ workspace: { mode: 'task-bound', taskId },
145
155
  taskState: 'unknown'
146
156
  };
147
157
  }
148
158
  if (!options.allowProtected && (task.state === 'blocked' || task.state === 'archive')) {
149
- throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${requestedRef} is ${task.state}`);
159
+ throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${taskId} is ${task.state}`);
150
160
  }
151
161
  return {
152
162
  requestedRef,
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import type { SandboxWorkspaceIdentity, SandboxWorkspaceKey } from './workspace-identity.ts';
5
5
  import { resolveTaskWorkspace } from './task-resolver.ts';
6
6
  import type { SandboxControlManifest } from './control/protocol.ts';
7
+ import { resolveTaskRuntimeRoot } from '../task/runtime-paths.ts';
7
8
  import {
8
9
  createSandboxControlIdentitySentinel,
9
10
  writeSandboxControlIdentitySentinel
@@ -63,15 +64,17 @@ export type SandboxControlManifestDraft = Readonly<Omit<SandboxControlManifest,
63
64
 
64
65
  export function sandboxControlPaths(params: Readonly<{
65
66
  base: string;
67
+ repoRoot?: string;
66
68
  project: string;
67
69
  container: string;
68
70
  identity: SandboxWorkspaceIdentity | SandboxWorkspaceKey;
69
71
  }>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; runtimeDir: string; manifestPath: string }> {
70
- const identityKey = params.identity.mode === 'task-bound'
71
- ? `task-bound:${params.identity.taskId}`
72
- : 'branch-only';
73
- const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
74
- const root = path.resolve(params.base, params.project, params.container, digest);
72
+ const root = params.identity.mode === 'task-bound'
73
+ ? path.join(resolveTaskRuntimeRoot(params.identity.taskId, { repoRoot: params.repoRoot }), 'sandbox-control')
74
+ : (() => {
75
+ const digest = createHash('sha256').update('branch-only').digest('hex').slice(0, 16);
76
+ return path.resolve(params.base, params.project, params.container, digest);
77
+ })();
75
78
  return {
76
79
  root,
77
80
  channelDir: path.join(root, 'channel'),
@@ -154,13 +157,15 @@ export function assertSandboxTaskSource(repoRoot: string, taskId: string): strin
154
157
  return canonical;
155
158
  }
156
159
 
157
- export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void {
160
+ export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string, taskId?: string): void {
158
161
  const resolvedWorktreeRoot = path.resolve(worktreeRoot);
159
162
  const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
160
163
  const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
161
164
  const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
162
165
  const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
163
- for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
166
+ const completedTaskTarget = taskId ? path.join(workspaceRoot, 'completed', taskId) : null;
167
+ if (taskId && !/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
168
+ for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget, ...(completedTaskTarget ? [completedTaskTarget] : [])]) {
164
169
  assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
165
170
  }
166
171
  fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
@@ -168,10 +173,47 @@ export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void
168
173
  fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
169
174
  fs.chmodSync(hostPath, 0o700);
170
175
  }
176
+ if (completedTaskTarget) fs.mkdirSync(completedTaskTarget, { recursive: true, mode: 0o700 });
171
177
  fs.chmodSync(workspaceRoot, 0o700);
172
178
  fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
173
179
  }
174
180
 
181
+ export function prepareSandboxCompletedTaskMountSource(repoRoot: string, taskId: string): string {
182
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
183
+ const workspaceRoot = path.resolve(repoRoot, '.agents', 'workspace');
184
+ fs.mkdirSync(path.join(workspaceRoot, 'completed'), { recursive: true, mode: 0o700 });
185
+ const completedRoot = path.join(workspaceRoot, 'completed');
186
+ const completedStat = fs.lstatSync(completedRoot);
187
+ if (!completedStat.isDirectory() || completedStat.isSymbolicLink()) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
188
+ const target = path.join(completedRoot, taskId);
189
+ try {
190
+ const stat = fs.lstatSync(target);
191
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(target).length > 0) {
192
+ throw new Error(`SANDBOX_COMPLETED_MOUNT_CONFLICT: ${target}`);
193
+ }
194
+ } catch (error) {
195
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
196
+ fs.mkdirSync(target, { mode: 0o700 });
197
+ }
198
+ return sandboxCompletedTaskMountSource(repoRoot, taskId);
199
+ }
200
+
201
+ export function sandboxCompletedTaskMountSource(repoRoot: string, taskId: string): string {
202
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
203
+ const source = path.resolve(repoRoot, '.agents', 'workspace', 'completed', taskId);
204
+ const stat = fs.lstatSync(source);
205
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
206
+ const canonicalWorkspace = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace'));
207
+ const canonicalSource = fs.realpathSync.native(source);
208
+ const canonicalRepoRoot = fs.realpathSync.native(repoRoot);
209
+ const workspaceRelative = path.relative(canonicalRepoRoot, canonicalWorkspace);
210
+ const relative = path.relative(canonicalWorkspace, canonicalSource);
211
+ if (workspaceRelative.startsWith('..') || path.isAbsolute(workspaceRelative)
212
+ || path.dirname(canonicalSource) !== fs.realpathSync.native(path.dirname(source))
213
+ || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
214
+ return canonicalSource;
215
+ }
216
+
175
217
  export function materializeSandboxWorkspaceView(params: Readonly<{
176
218
  base: string;
177
219
  project: string;
@@ -221,7 +263,7 @@ export function materializeSandboxControl(params: Readonly<{
221
263
  }>;
222
264
  }>): SandboxControlSetup {
223
265
  const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
224
- assertSafeDirectory(root, path.resolve(params.base));
266
+ assertSafeDirectory(root, params.identity.mode === 'task-bound' ? path.dirname(root) : path.resolve(params.base));
225
267
  if (fs.existsSync(root)) {
226
268
  if (!params.replacementLease || path.resolve(params.replacementLease.root) !== root) {
227
269
  throw new Error('SANDBOX_CONTROL_REPLACEMENT_REQUIRED');
@@ -234,17 +276,7 @@ export function materializeSandboxControl(params: Readonly<{
234
276
  fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
235
277
  for (const directory of [statusDir, processingDir, runtimeDir]) {
236
278
  assertSafeDirectory(directory, root);
237
- if (directory === runtimeDir) fs.rmSync(directory, { recursive: true, force: true });
238
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
239
- }
240
- for (const directory of [
241
- path.join(runtimeDir, 'clients'),
242
- path.join(runtimeDir, 'clients', 'codex', 'capabilities'),
243
- path.join(runtimeDir, 'clients', 'codex', 'lifecycle')
244
- ]) {
245
- assertSafeDirectory(directory, runtimeDir);
246
279
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
247
- fs.chmodSync(directory, 0o700);
248
280
  }
249
281
  for (const queue of ['requests', 'responses']) {
250
282
  const directory = path.join(channelDir, queue);