@fitlab-ai/agent-infra 0.11.12 → 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 (68) hide show
  1. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +11 -25
  2. package/dist/lib/internal/sandbox-control.js +1 -81
  3. package/dist/lib/internal/task-finalization.js +26 -19
  4. package/dist/lib/internal/task-lifecycle.js +1 -1
  5. package/dist/lib/platform/github-client.js +33 -3
  6. package/dist/lib/sandbox/commands/create.js +6 -2
  7. package/dist/lib/sandbox/commands/ls.js +4 -5
  8. package/dist/lib/sandbox/commands/rm.js +1 -1
  9. package/dist/lib/sandbox/control/client.js +4 -64
  10. package/dist/lib/sandbox/control/executor.js +4 -50
  11. package/dist/lib/sandbox/control/lifecycle.js +66 -4
  12. package/dist/lib/sandbox/control/protocol.js +5 -16
  13. package/dist/lib/sandbox/control/server.js +203 -234
  14. package/dist/lib/sandbox/control/task-view.js +0 -13
  15. package/dist/lib/sandbox/mounts.js +7 -0
  16. package/dist/lib/sandbox/recovery.js +28 -9
  17. package/dist/lib/sandbox/removal.js +19 -0
  18. package/dist/lib/sandbox/workspace-identity.js +18 -6
  19. package/dist/lib/sandbox/workspace-view.js +48 -2
  20. package/dist/lib/task/commands/ls.js +3 -3
  21. package/dist/lib/task/control-authority.js +2 -0
  22. package/dist/lib/task/control-recovery.js +1 -2
  23. package/dist/lib/task/finalization.js +14 -0
  24. package/dist/lib/task/lifecycle.js +203 -31
  25. package/dist/lib/task/orchestration.js +26 -72
  26. package/dist/lib/task/short-id.js +37 -27
  27. package/dist/lib/task/verification-engine.js +4 -11
  28. package/dist/lifecycle-build-manifest.json +3 -3
  29. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +20 -23
  30. package/lib/internal/sandbox-control.ts +0 -97
  31. package/lib/internal/task-finalization.ts +30 -18
  32. package/lib/internal/task-lifecycle.ts +1 -1
  33. package/lib/platform/github-client.ts +34 -3
  34. package/lib/sandbox/commands/create.ts +6 -1
  35. package/lib/sandbox/commands/ls.ts +4 -5
  36. package/lib/sandbox/commands/rm.ts +1 -1
  37. package/lib/sandbox/control/client.ts +5 -78
  38. package/lib/sandbox/control/executor.ts +2 -53
  39. package/lib/sandbox/control/lifecycle.ts +67 -4
  40. package/lib/sandbox/control/protocol.ts +6 -20
  41. package/lib/sandbox/control/server.ts +9 -44
  42. package/lib/sandbox/control/task-view.ts +0 -19
  43. package/lib/sandbox/mounts.ts +8 -0
  44. package/lib/sandbox/recovery.ts +35 -16
  45. package/lib/sandbox/removal.ts +21 -2
  46. package/lib/sandbox/workspace-identity.ts +16 -6
  47. package/lib/sandbox/workspace-view.ts +41 -2
  48. package/lib/task/commands/ls.ts +3 -3
  49. package/lib/task/control-authority.ts +1 -0
  50. package/lib/task/control-recovery.ts +2 -3
  51. package/lib/task/finalization.ts +14 -0
  52. package/lib/task/lifecycle.ts +180 -27
  53. package/lib/task/orchestration.ts +30 -68
  54. package/lib/task/short-id.ts +33 -40
  55. package/lib/task/verification-engine.ts +3 -9
  56. package/package.json +1 -1
  57. package/templates/.agents/rules/README.en.md +1 -0
  58. package/templates/.agents/rules/README.zh-CN.md +1 -0
  59. package/templates/.agents/rules/strategy-pattern.en.md +25 -0
  60. package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
  61. package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
  62. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
  63. package/templates/.agents/skills/complete-task/SKILL.en.md +5 -26
  64. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -23
  65. package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
  66. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
  67. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  68. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
@@ -215,7 +215,7 @@ function createCodexLifecycleStore(options) {
215
215
  throw new Error(`Codex lifecycle child '${childThreadId}' was not found uniquely`);
216
216
  return matches[0];
217
217
  }
218
- function consume(childThreadId, consumer, expectedHookDefinitionHash, expectedTaskBinding) {
218
+ function consumeWithLockMode(childThreadId, consumer, expectedHookDefinitionHash, expectedTaskBinding, taskLockHeld) {
219
219
  if (!consumer.trim())
220
220
  throw new Error('Codex lifecycle consumer is required');
221
221
  const current = read(childThreadId);
@@ -223,8 +223,11 @@ function createCodexLifecycleStore(options) {
223
223
  if (!binding)
224
224
  throw new Error('Codex lifecycle task receipt binding is missing');
225
225
  const spawn = current.state.spawn;
226
- if (!spawn)
227
- throw new Error('Codex lifecycle spawn evidence is missing');
226
+ if (!spawn) {
227
+ throw new Error(taskLockHeld
228
+ ? 'Codex lifecycle task receipt binding is missing'
229
+ : 'Codex lifecycle spawn evidence is missing');
230
+ }
228
231
  const key = recordKey(spawn.sessionId, spawn.turnId, spawn.toolUseId);
229
232
  return update(binding, key, (latest) => {
230
233
  if (!latest || expectedTaskBinding && JSON.stringify(latest.taskBinding) !== JSON.stringify(expectedTaskBinding)) {
@@ -241,30 +244,13 @@ function createCodexLifecycleStore(options) {
241
244
  if (latest.consumer)
242
245
  return latest;
243
246
  return Object.freeze({ ...latest, revision: latest.revision + 1, consumer, consumedAt: now(), updatedAt: now() });
244
- });
247
+ }, taskLockHeld);
248
+ }
249
+ function consume(childThreadId, consumer, expectedHookDefinitionHash, expectedTaskBinding) {
250
+ return consumeWithLockMode(childThreadId, consumer, expectedHookDefinitionHash, expectedTaskBinding, false);
245
251
  }
246
252
  function consumeWithinTaskLock(childThreadId, consumer, expectedHookDefinitionHash, expectedTaskBinding) {
247
- if (!consumer.trim())
248
- throw new Error('Codex lifecycle consumer is required');
249
- const current = read(childThreadId);
250
- const binding = expectedTaskBinding ?? current.taskBinding;
251
- if (!binding || !current.state.spawn)
252
- throw new Error('Codex lifecycle task receipt binding is missing');
253
- const key = recordKey(current.state.spawn.sessionId, current.state.spawn.turnId, current.state.spawn.toolUseId);
254
- return update(binding, key, (latest) => {
255
- if (!latest || expectedTaskBinding && JSON.stringify(latest.taskBinding) !== JSON.stringify(expectedTaskBinding)) {
256
- throw new Error('Codex lifecycle task binding does not match the expected receipt');
257
- }
258
- if (latest.consumer && latest.consumer !== consumer) {
259
- throw new Error(`Codex lifecycle evidence was already consumed by '${latest.consumer}'`);
260
- }
261
- if (latest.state.status !== 'stop-ready')
262
- throw new Error('Codex lifecycle evidence is not stop-ready');
263
- if (expectedHookDefinitionHash && latest.state.startEvidence?.hookDefinitionHash !== expectedHookDefinitionHash) {
264
- throw new Error('Codex lifecycle hook definition hash is stale');
265
- }
266
- return latest.consumer ? latest : Object.freeze({ ...latest, revision: latest.revision + 1, consumer, consumedAt: now(), updatedAt: now() });
267
- }, true);
253
+ return consumeWithLockMode(childThreadId, consumer, expectedHookDefinitionHash, expectedTaskBinding, true);
268
254
  }
269
255
  function expireBefore(cutoff) {
270
256
  let changed = 0;
@@ -1,18 +1,8 @@
1
- import { recoverSandboxControl, requestSandboxControl, requestSandboxTaskFinalization, SandboxControlClientError } from '../sandbox/control/client.js';
2
- import { normalizeAgentToken, AGENT_USAGE_HINT } from '../agent-clients/tokens.js';
1
+ import { recoverSandboxControl, requestSandboxControl, SandboxControlClientError } from '../sandbox/control/client.js';
3
2
  import { serveSandboxControl } from '../sandbox/control/server.js';
4
3
  import { runSandboxControlExecutor } from '../sandbox/control/executor.js';
5
4
  import { ensureInternalHandlerRoute, internalHandlerRoute } from './cli-route-inventory.js';
6
5
  import { parseTaskCreateResult, taskCreateExitCode } from '../task/create-service.js';
7
- import { serializeTaskFinalizationEnvelope } from '../task/finalization-envelope.js';
8
- function writeFinalizationEnvelope(status, accepted, error, changed = false, result = null, requestId = null) {
9
- process.stdout.write(serializeTaskFinalizationEnvelope({ status, changed, accepted, requestId, result, error }));
10
- }
11
- function finalizationErrorStatus(error) {
12
- if (error.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN')
13
- return 'unknown';
14
- return error.retryable ? 'blocked' : 'failed';
15
- }
16
6
  function writeClientError(error) {
17
7
  process.stderr.write(`${error.detail.message}\n`);
18
8
  if (error.requestId)
@@ -31,72 +21,6 @@ function recoveredExitCode(response) {
31
21
  }
32
22
  return response.exitCode ?? 1;
33
23
  }
34
- function finalizationResponseOutput(response) {
35
- try {
36
- const value = JSON.parse(response.stdout);
37
- if (value.version === 2
38
- && value.status
39
- && ['completed', 'failed', 'blocked', 'unknown'].includes(value.status)
40
- && (typeof value.changed === 'boolean' || value.changed === null)) {
41
- return serializeTaskFinalizationEnvelope({
42
- status: value.status,
43
- changed: value.changed,
44
- accepted: true,
45
- requestId: response.id,
46
- result: value.result ?? null,
47
- error: value.error ?? null
48
- });
49
- }
50
- }
51
- catch {
52
- // Preserve non-envelope output for diagnostics from the executor.
53
- }
54
- return response.stdout;
55
- }
56
- function sandboxFinalizationClient(args) {
57
- if (args.length !== 4 || !args[0] || args[1] !== 'complete' || args[2] !== '--agent' || !args[3]) {
58
- const error = { code: 'TASK_FINALIZATION_PAYLOAD_INVALID', message: 'task ref, complete intent, and --agent are required', retryable: false };
59
- writeFinalizationEnvelope('failed', false, error);
60
- process.stderr.write('Usage: agent-infra-internal task-finalization <N | TASK-id> complete --agent <agent>\n');
61
- process.exitCode = 1;
62
- return;
63
- }
64
- const agent = normalizeAgentToken(args[3]);
65
- if (!agent) {
66
- const error = { code: 'TASK_FINALIZATION_PAYLOAD_INVALID', message: `invalid --agent: ${AGENT_USAGE_HINT}`, retryable: false };
67
- writeFinalizationEnvelope('failed', false, error);
68
- process.exitCode = 1;
69
- return;
70
- }
71
- let response;
72
- try {
73
- response = requestSandboxTaskFinalization({ agent });
74
- }
75
- catch (error) {
76
- if (!(error instanceof SandboxControlClientError))
77
- throw error;
78
- const status = finalizationErrorStatus(error.detail);
79
- writeFinalizationEnvelope(status, error.accepted, error.detail, status === 'unknown' ? null : false, null, error.requestId);
80
- writeClientError(error);
81
- process.exitCode = error.detail.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? 1 : error.detail.retryable ? 2 : 1;
82
- return;
83
- }
84
- if (response.phase === 'rejected') {
85
- const error = response.error ?? {
86
- code: 'SANDBOX_CONTROL_REJECTED',
87
- message: 'sandbox control rejected the finalization request',
88
- retryable: false
89
- };
90
- const status = finalizationErrorStatus(error);
91
- writeFinalizationEnvelope(status, error.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN', error, status === 'unknown' ? null : false, null, response.id);
92
- process.stderr.write(`${error.message}\n`);
93
- process.exitCode = error.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? 1 : error.retryable ? 2 : 1;
94
- return;
95
- }
96
- process.stdout.write(finalizationResponseOutput(response));
97
- process.stderr.write(response.stderr);
98
- process.exitCode = response.exitCode ?? 1;
99
- }
100
24
  async function sandboxControl(args) {
101
25
  if (!ensureInternalHandlerRoute('sandbox-control', args))
102
26
  return;
@@ -152,10 +76,6 @@ async function sandboxControl(args) {
152
76
  }
153
77
  if (internalHandlerRoute('sandbox-control', 'client', operation ?? '')) {
154
78
  const [family = '', ...commandArgs] = rest;
155
- if (family === 'task-finalization') {
156
- sandboxFinalizationClient(commandArgs);
157
- return;
158
- }
159
79
  let response;
160
80
  try {
161
81
  response = requestSandboxControl({ family, args: commandArgs });
@@ -1,9 +1,12 @@
1
+ import path from 'node:path';
1
2
  import { createDirectHostExecutionContext, dispatchTaskControlOperation, parseTaskControlOperation } from '../task/control-authority.js';
2
3
  import { applyTaskFinalization } from '../task/finalization.js';
3
4
  import { detectRepoRoot, resolveTaskRef } from '../task/resolve-ref.js';
4
5
  import { resolveSandboxControlTransport } from './task-operation-registry.js';
5
- import { requestSandboxTaskFinalization, SandboxControlClientError } from '../sandbox/control/client.js';
6
+ import { quiesceSandboxControlForTaskCompletion } from '../sandbox/control/lifecycle.js';
7
+ import { resolveTaskRuntimeRoot } from '../task/runtime-paths.js';
6
8
  import { serializeTaskFinalizationEnvelope } from '../task/finalization-envelope.js';
9
+ import { verifyTaskEvent } from '../task/verification.js';
7
10
  import { ensureInternalHandlerRoute, internalHandlerRoute } from './cli-route-inventory.js';
8
11
  const USAGE = 'Usage: agent-infra-internal task-finalization <N | TASK-id> complete --agent <agent>\n';
9
12
  function envelope(status, changed, accepted, result, error, requestId = null) {
@@ -64,26 +67,30 @@ async function taskFinalization(args = []) {
64
67
  };
65
68
  const transport = resolveSandboxControlTransport(process.env, { localWorkflow: true });
66
69
  if (transport.kind === 'sandbox-local') {
67
- try {
68
- const response = requestSandboxTaskFinalization({ agent: boundOperation.request.agent });
69
- process.stdout.write(response.stdout);
70
- process.stderr.write(response.stderr);
71
- process.exitCode = response.exitCode ?? 1;
72
- return;
73
- }
74
- catch (error) {
75
- const clientError = error instanceof SandboxControlClientError ? error : null;
76
- const detail = clientError
77
- ? clientError.detail
78
- : { code: 'SANDBOX_CONTROL_CLIENT_FAILED', message: error instanceof Error ? error.message : String(error), retryable: false };
79
- const status = detail.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN'
80
- ? 'unknown'
81
- : detail.retryable ? 'blocked' : 'failed';
82
- const changed = status === 'unknown' ? null : false;
83
- process.stdout.write(envelope(status, changed, clientError?.accepted ?? false, null, detail, clientError?.requestId ?? null));
84
- process.exitCode = exitCode(status);
70
+ const statusDir = process.env.AGENT_INFRA_CONTROL_STATUS_DIR;
71
+ const taskId = process.env.AGENT_INFRA_TASK_ID;
72
+ const generation = process.env.AGENT_INFRA_CONTROL_GENERATION;
73
+ const controlRootId = process.env.AGENT_INFRA_CONTROL_ROOT_ID;
74
+ if (!statusDir || !taskId || !generation || !controlRootId || taskId !== resolved.taskId) {
75
+ const detail = { code: 'TASK_CONTROL_TRANSPORT_INVALID', message: 'sandbox task control identity is incomplete', retryable: false };
76
+ process.stdout.write(envelope('failed', false, true, null, detail));
77
+ process.exitCode = 1;
85
78
  return;
86
79
  }
80
+ const result = await applyTaskFinalization(boundOperation.request, {
81
+ repoRoot,
82
+ beforeLifecycle: async (currentTaskId) => {
83
+ if (currentTaskId !== taskId)
84
+ throw new Error('SANDBOX_CONTROL_IDENTITY_MISMATCH');
85
+ await quiesceSandboxControlForTaskCompletion(path.join(resolveTaskRuntimeRoot(taskId, { repoRoot }), 'sandbox-control'), { taskId, generation, controlRootId, statusDir });
86
+ },
87
+ preflight: (request, options) => verifyTaskEvent({ ...request, event: 'complete-task.hard-preflight' }, options)
88
+ });
89
+ const status = result.status === 'prepared' ? 'failed' : result.status;
90
+ process.stdout.write(envelope(status, result.changed, true, result, result.error
91
+ ?? (result.status === 'prepared' ? { code: 'TASK_FINALIZATION_PREPARATION_INCOMPLETE', message: 'finalization did not commit', retryable: true } : null)));
92
+ process.exitCode = exitCode(status);
93
+ return;
87
94
  }
88
95
  const result = await dispatchTaskControlOperation(createDirectHostExecutionContext({ repoRoot }), boundOperation);
89
96
  if (result.status === 'prepared') {
@@ -2,7 +2,7 @@ import { createDirectHostExecutionContext, dispatchTaskControlOperation, parseTa
2
2
  import { lifecycleIntentCatalog } from '../task/lifecycle.js';
3
3
  import { detectRepoRoot, resolveTaskRef } from '../task/resolve-ref.js';
4
4
  import { ensureInternalHandlerRoute, internalHandlerRoute } from './cli-route-inventory.js';
5
- const USAGE = `Usage: agent-infra-internal task-lifecycle <N | TASK-id> <intent> --agent <agent> [intent flags] [--dry-run]\n\nIntents: ${lifecycleIntentCatalog.join(', ')}\nrecover-started: --auto\n`;
5
+ const USAGE = `Usage: agent-infra-internal task-lifecycle <N | TASK-id> <intent> --agent <agent> [intent flags] [--dry-run]\n\nIntents: ${lifecycleIntentCatalog.filter((intent) => intent !== 'complete').join(', ')}\nrecover-started: --auto\n`;
6
6
  function usageFailure(message) {
7
7
  process.stdout.write(`${JSON.stringify({ status: 'failed', changed: false, error: { code: 'LIFECYCLE_PAYLOAD_INVALID', message } })}\n`);
8
8
  process.stderr.write(USAGE);
@@ -18,6 +18,32 @@ function boundedFailureDetail(result) {
18
18
  ? diagnostic
19
19
  : `${diagnostic.slice(0, ERROR_DETAIL_LIMIT)}… [truncated]`;
20
20
  }
21
+ function requestDiagnostics(args, request, attempt, errorMessage) {
22
+ if (process.env.AGENT_INFRA_GH_DIAGNOSTICS !== '1' && !/rate.?limit/iu.test(errorMessage))
23
+ return '';
24
+ const apiIndex = args.indexOf('api');
25
+ const endpoint = apiIndex < 0
26
+ ? args.slice(0, 2).filter((arg) => !arg.startsWith('-')).join(' ')
27
+ : args.slice(apiIndex + 1).find((arg) => !arg.startsWith('-') && !arg.includes('=')) || 'unknown';
28
+ const safeEndpoint = endpoint.startsWith('http')
29
+ ? (() => {
30
+ try {
31
+ const url = new URL(endpoint);
32
+ return `${url.host}${url.pathname}`;
33
+ }
34
+ catch {
35
+ return 'unknown';
36
+ }
37
+ })()
38
+ : endpoint.split('?')[0] || 'unknown';
39
+ const methodIndex = args.findIndex((arg) => arg === '-X' || arg === '--method');
40
+ const methodFromArgs = methodIndex >= 0 ? args[methodIndex + 1] : undefined;
41
+ const method = methodFromArgs || request.method || (safeEndpoint === 'graphql' ? 'POST' : 'GET');
42
+ const tokenEnvironment = ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']
43
+ .filter((name) => Boolean(process.env[name]));
44
+ const configDirOverride = Boolean(process.env.GH_CONFIG_DIR);
45
+ return ` [gh-diagnostic endpoint=${safeEndpoint} method=${method} attempt=${attempt} tokenEnv=${tokenEnvironment.join(',') || 'none'} ghConfigDirOverride=${configDirOverride}]`;
46
+ }
21
47
  function defaultRunner(args, options) {
22
48
  const command = process.env.AGENT_INFRA_GH_BIN || 'gh';
23
49
  let prefix = [];
@@ -135,14 +161,18 @@ function createGitHubClient(options = {}) {
135
161
  if (result.status === 0)
136
162
  return { ok: true, value: result.stdout };
137
163
  const error = classifyGitHubFailure(result);
164
+ const requestDetail = requestDiagnostics(args, request, attempt + 1, error.message);
165
+ const contextualError = requestDetail
166
+ ? { ...error, message: `${error.message}${requestDetail}` }
167
+ : error;
138
168
  if (!retryableMethod || !error.requestRetryable)
139
- return { ok: false, error: clientError(error) };
169
+ return { ok: false, error: clientError(contextualError) };
140
170
  if (attempt >= delays.length) {
141
171
  return {
142
172
  ok: false,
143
173
  error: error.code === 'NETWORK_TRANSIENT'
144
- ? { ...clientError(error), code: 'NETWORK_RETRY_EXHAUSTED', message: `${error.message} (retry exhausted)` }
145
- : clientError(error)
174
+ ? { ...clientError(contextualError), code: 'NETWORK_RETRY_EXHAUSTED', message: `${contextualError.message} (retry exhausted)` }
175
+ : clientError(contextualError)
146
176
  };
147
177
  }
148
178
  sleep(delays[attempt]);
@@ -23,7 +23,7 @@ import { inspectSandboxControlContainer } from '../control/container-identity.js
23
23
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
24
24
  import { sandboxCoreBindMounts } from '../mounts.js';
25
25
  import { materializeSandboxGitMetadata } from '../git-metadata.js';
26
- import { assertSandboxTaskSource, finalizeSandboxControlManifest, materializeSandboxControl, materializeSandboxWorkspaceView, prepareSandboxWorkspaceMountTargets, sandboxControlPaths, } from '../workspace-view.js';
26
+ import { assertSandboxTaskSource, finalizeSandboxControlManifest, materializeSandboxControl, materializeSandboxWorkspaceView, prepareSandboxWorkspaceMountTargets, prepareSandboxCompletedTaskMountSource, sandboxControlPaths, } from '../workspace-view.js';
27
27
  import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.js';
28
28
  import { validateSelinuxDisableEnv } from '../engines/selinux.js';
29
29
  import { captureSandboxAuthority, commandForSandboxAuthority, verifySandboxAuthority } from '../engines/authority.js';
@@ -1038,7 +1038,10 @@ export async function create(args, { runProjectInitCommand = true, workspace: wo
1038
1038
  const taskSource = target.workspace.mode === 'task-bound'
1039
1039
  ? assertSandboxTaskSource(effectiveConfig.repoRoot, target.workspace.taskId)
1040
1040
  : null;
1041
- prepareSandboxWorkspaceMountTargets(worktree);
1041
+ const completedTaskSource = target.workspace.mode === 'task-bound'
1042
+ ? prepareSandboxCompletedTaskMountSource(effectiveConfig.repoRoot, target.workspace.taskId)
1043
+ : null;
1044
+ prepareSandboxWorkspaceMountTargets(worktree, target.workspace.mode === 'task-bound' ? target.workspace.taskId : undefined);
1042
1045
  if (previousCutoverSnapshot) {
1043
1046
  assertSandboxControlCutoverSnapshot(previousCutoverSnapshot);
1044
1047
  if (previousManifest) {
@@ -1081,6 +1084,7 @@ export async function create(args, { runProjectInitCommand = true, workspace: wo
1081
1084
  ...(target.workspace.mode === 'task-bound'
1082
1085
  ? {
1083
1086
  taskSources: [taskSource],
1087
+ completedTaskSource: completedTaskSource,
1084
1088
  taskId: target.workspace.taskId
1085
1089
  }
1086
1090
  : {})
@@ -11,11 +11,10 @@ export { containerListFormat, parseLabels } from './list-running.js';
11
11
  const USAGE = `Usage: ai sandbox ls
12
12
 
13
13
  Lists all containers for the current project. The '#' column is a
14
- display-only row number; the 'SHORT' column shows the active task short
15
- id bound to each container's branch (via
16
- .agents/workspace/active/.short-ids.json), or '-' if no active task is
17
- bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
18
- A SHORT value of '-' means this container has no active task short id and may
14
+ display-only row number; the 'SHORT' column shows the task short id bound to
15
+ each task-bound container, or '-' if no short id is registered. Pass the
16
+ SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
17
+ A SHORT value of '-' means this container has no task short id and may
19
18
  be eligible for "ai sandbox rm --unbound" cleanup. Use a full TASK-id to
20
19
  remove a specific task-bound sandbox.
21
20
 
@@ -7,7 +7,7 @@ import { sandboxBranchLabel, sandboxLabel, sandboxTaskIdLabel, sandboxWorkspaceM
7
7
  import { fetchSandboxRows } from './list-running.js';
8
8
  import { rmOne, rmPurge, rmUnbound } from '../removal.js';
9
9
  const USAGE = `Usage:
10
- ai sandbox rm <branch | TASK-id | short id> Remove one sandbox; use a full TASK-id for a task-bound sandbox and a branch for branch-only sandboxes
10
+ ai sandbox rm <branch | TASK-id | short id> Remove one sandbox; use a retained short id or full TASK-id for a task-bound sandbox and a branch for branch-only sandboxes
11
11
  ai sandbox rm --unbound [--dry-run] [--yes] Remove sandboxes not bound to an active task short id
12
12
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
13
13
  function resolveMissingTaskSandboxBranch(taskId, config) {
@@ -3,7 +3,6 @@ import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { SANDBOX_CONTROL_ADMISSION_WINDOW_MS, SANDBOX_CONTROL_MAX_BYTES, SANDBOX_CONTROL_STATUS_STALE_MS, isSandboxControlFamily } from './protocol.js';
5
5
  import { isAgentClientId } from '../../agent-clients/types.js';
6
- import { normalizeAgentToken } from '../../agent-clients/tokens.js';
7
6
  import { readSandboxControlPayload, readSandboxControlStatus } from './state.js';
8
7
  import { accessSandboxTaskView, taskViewFromStatus } from './task-view.js';
9
8
  import { readSandboxControlIdentitySentinel } from './identity-sentinel.js';
@@ -12,7 +11,6 @@ import { hasTaskBoundMarker, isSandbox } from '../environment.js';
12
11
  import { loadConfig } from '../config.js';
13
12
  import { recoverSandboxControlFromHost } from './host-recovery.js';
14
13
  const SANDBOX_CONTROL_RESPONSE_SETTLE_MS = 250;
15
- const SANDBOX_TASK_FINALIZATION_RECOVERY_BUDGET_MS = 5 * 60_000;
16
14
  export class SandboxControlClientError extends Error {
17
15
  detail;
18
16
  accepted;
@@ -85,8 +83,6 @@ function preflight(statusDir, generation, taskViewEffect = null, now = Date.now(
85
83
  function taskViewEffectForRequest(request) {
86
84
  if (request.family === 'task-lifecycle')
87
85
  return 'progress';
88
- if (request.family === 'task-finalization')
89
- return 'recovery';
90
86
  return null;
91
87
  }
92
88
  function parseResponse(raw, id, accepted = false) {
@@ -180,9 +176,6 @@ function exchangeSandboxControl(request, params) {
180
176
  || (requestTask.kind === 'task' && requestTask.taskId !== identity.taskId))) {
181
177
  clientError('SANDBOX_CONTROL_IDENTITY_TOPOLOGY_MISMATCH', 'request task does not match the sandbox identity', false);
182
178
  }
183
- if (identity.mode === 'branch-only' && request.family === 'task-finalization') {
184
- clientError('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot finalize tasks', false);
185
- }
186
179
  }
187
180
  const encoded = `${JSON.stringify(request)}\n`;
188
181
  if (Buffer.byteLength(encoded, 'utf8') > SANDBOX_CONTROL_MAX_BYTES) {
@@ -369,9 +362,12 @@ function authority(params) {
369
362
  export function requestSandboxControl(params) {
370
363
  if (!isSandboxControlFamily(params.family))
371
364
  clientError('SANDBOX_CONTROL_COMMAND_DENIED', `'${params.family}' is not allowed`, false);
372
- if (params.family === 'task-create' || params.family === 'agent-client' || params.family === 'task-finalization') {
365
+ if (params.family === 'task-create' || params.family === 'agent-client') {
373
366
  clientError('SANDBOX_CONTROL_COMMAND_DENIED', `'${params.family}' requires a typed request`, false);
374
367
  }
368
+ if (params.args[1] === 'complete') {
369
+ clientError('SANDBOX_CONTROL_COMMAND_DENIED', 'complete must use task-finalization', false);
370
+ }
375
371
  const auth = authority(params);
376
372
  const issuedAt = Date.now();
377
373
  const request = {
@@ -400,62 +396,6 @@ export function requestSandboxAgentOperation(params) {
400
396
  };
401
397
  return exchangeSandboxControl(request, params);
402
398
  }
403
- export function requestSandboxTaskFinalization(params) {
404
- const agent = normalizeAgentToken(params.agent);
405
- if (!agent)
406
- clientError('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization agent is invalid', false);
407
- const auth = authority(params);
408
- const issuedAt = Date.now();
409
- const id = randomUUID();
410
- const request = {
411
- version: 4,
412
- id,
413
- ...auth,
414
- issuedAt,
415
- expiresAt: issuedAt + SANDBOX_CONTROL_ADMISSION_WINDOW_MS,
416
- family: 'task-finalization',
417
- operation: 'complete',
418
- agent,
419
- args: []
420
- };
421
- let response;
422
- try {
423
- response = exchangeSandboxControl(request, params);
424
- }
425
- catch (error) {
426
- if (!(error instanceof SandboxControlClientError)
427
- || error.detail.code !== 'SANDBOX_CONTROL_RESULT_UNKNOWN'
428
- || !error.accepted || !error.requestId)
429
- throw error;
430
- return recoverAcceptedTaskFinalization(error.requestId, params);
431
- }
432
- if (response.phase === 'rejected' && response.error?.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN') {
433
- return recoverAcceptedTaskFinalization(response.id, params);
434
- }
435
- return response;
436
- }
437
- export function recoverAcceptedTaskFinalization(requestId, params, recover = recoverSandboxControl) {
438
- const deadline = Date.now() + (params.recoveryBudgetMs ?? SANDBOX_TASK_FINALIZATION_RECOVERY_BUDGET_MS);
439
- while (Date.now() < deadline) {
440
- const timeoutMs = Math.min(30_000, deadline - Date.now());
441
- if (timeoutMs <= 0)
442
- break;
443
- try {
444
- const response = recover(requestId, { timeoutMs });
445
- if (response.phase === 'rejected' && response.error?.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN')
446
- continue;
447
- return response;
448
- }
449
- catch (error) {
450
- if (error instanceof SandboxControlClientError
451
- && error.detail.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN'
452
- && error.requestId === requestId)
453
- continue;
454
- throw error;
455
- }
456
- }
457
- clientError('SANDBOX_CONTROL_RESULT_UNKNOWN', 'accepted finalization request remained unknown after the automatic recovery budget; inspect the receipt and resume by request id', false, true, requestId);
458
- }
459
399
  export function requestSandboxTaskCreate(params) {
460
400
  const auth = authority(params);
461
401
  const issuedAt = Date.now();
@@ -4,8 +4,6 @@ import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { getProcessStartTime } from '../../server/process-state.js';
6
6
  import { createTask, projectTaskCreateResult, taskCreateExitCode, taskCreateFailure } from '../../task/create-service.js';
7
- import { applyTaskFinalization } from '../../task/finalization.js';
8
- import { serializeTaskFinalizationEnvelope } from '../../task/finalization-envelope.js';
9
7
  import { bindSandboxControlTask, validateSandboxControlRequest } from './protocol.js';
10
8
  import { atomicWriteJson, executionPath, readActiveLease, terminateSandboxControlExecution } from './state.js';
11
9
  import { requestAuditFields, resultAuditFields, appendDiagnosticAudit, createSandboxControlAuditContext, appendCriticalAudit, writeSandboxControlTransition } from './audit.js';
@@ -192,21 +190,6 @@ function taskCreateExecutionFailure(requestId, code, message) {
192
190
  recovery: 'inspect-domain-state'
193
191
  }));
194
192
  }
195
- function finalizationResult(result) {
196
- const payload = {
197
- version: 1,
198
- status: result.status,
199
- changed: result.changed,
200
- accepted: true,
201
- result,
202
- error: result.error
203
- };
204
- return {
205
- exitCode: result.status === 'completed' ? 0 : result.status === 'blocked' ? 2 : 1,
206
- stdout: `${JSON.stringify(payload)}\n`,
207
- stderr: ''
208
- };
209
- }
210
193
  async function executeRequestInner(manifest, manifestPath, request, options = {}) {
211
194
  if (request.family === 'task-create') {
212
195
  try {
@@ -227,22 +210,6 @@ async function executeRequestInner(manifest, manifestPath, request, options = {}
227
210
  validateAgentClientOperation(request, manifest);
228
211
  return executeAgentClientOperation(request, manifest, manifestPath, options.agentClientOperation);
229
212
  }
230
- if (request.family === 'task-finalization') {
231
- const parsed = parseTaskControlOperation('task-finalization', [manifest.taskId, 'complete', '--agent', request.agent]);
232
- if (parsed.family !== 'task-finalization')
233
- throw new Error('SANDBOX_CONTROL_FINALIZATION_OPERATION_INVALID');
234
- const operation = parsed;
235
- const result = dispatchTaskControlOperation(createSandboxExecutorExecutionContext({
236
- repoRoot: manifest.repoRoot,
237
- worktreeRoot: manifest.worktreeRoot,
238
- runtimeDir: manifest.runtimeDir,
239
- taskId: manifest.taskId,
240
- generation: manifest.generation,
241
- manifestPath,
242
- requestId: request.id
243
- }), operation);
244
- return finalizationResult(await result);
245
- }
246
213
  const boundArgs = bindSandboxControlTask(request, manifest.taskId);
247
214
  let operation;
248
215
  try {
@@ -363,7 +330,7 @@ export async function runSandboxControlExecutor(requestPath, nonce) {
363
330
  family: request.family,
364
331
  operation: operation && typeof operation === 'object' && 'family' in operation && operation.family === 'task-lifecycle'
365
332
  ? operation.request.intent
366
- : request.family === 'task-finalization' || request.family === 'agent-client' ? request.operation : null,
333
+ : request.family === 'agent-client' ? request.operation : null,
367
334
  phase: 'started-committed',
368
335
  outcome: 'in-progress'
369
336
  });
@@ -380,22 +347,9 @@ export async function runSandboxControlExecutor(requestPath, nonce) {
380
347
  });
381
348
  const detail = error instanceof Error ? error.message : String(error);
382
349
  const code = /^([A-Z][A-Z0-9_]+)/u.exec(detail)?.[1] ?? 'SANDBOX_CONTROL_EXECUTOR_FAILED';
383
- result = request.family === 'task-finalization'
384
- ? {
385
- exitCode: 1,
386
- stdout: serializeTaskFinalizationEnvelope({
387
- status: code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? 'unknown' : 'failed',
388
- changed: code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? null : false,
389
- accepted: true,
390
- requestId: request.id,
391
- result: null,
392
- error: { code, message: detail, retryable: false }
393
- }),
394
- stderr: ''
395
- }
396
- : request.family === 'task-create'
397
- ? taskCreateExecutionFailure(request.id, code, detail)
398
- : lifecycleFailure(code, detail);
350
+ result = request.family === 'task-create'
351
+ ? taskCreateExecutionFailure(request.id, code, detail)
352
+ : lifecycleFailure(code, detail);
399
353
  }
400
354
  process.stdout.write(result.stdout);
401
355
  process.stderr.write(result.stderr);
@@ -9,6 +9,7 @@ import { DEFAULT_SANDBOX_CONTROL_TIMING } from './protocol.js';
9
9
  import { inspectSandboxControlContainer } from './container-identity.js';
10
10
  import { acquireSandboxResourceLock, resolveSandboxLockNamespace } from './native-file-lock.js';
11
11
  import { isSandboxAuthorityEvidence } from '../engines/authority.js';
12
+ import { validateSandboxControlIdentity } from './identity-sentinel.js';
12
13
  import { parseSandboxControlStatus, readExecution, terminateSandboxControlExecution } from './state.js';
13
14
  const DEFAULT_QUIESCE_TIMEOUT_MS = DEFAULT_SANDBOX_CONTROL_TIMING.quiesceDeadlineMs;
14
15
  const QUIESCING_FILE = 'quiescing.json';
@@ -500,6 +501,45 @@ function markSandboxControlRootQuiescing(root) {
500
501
  throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
501
502
  }
502
503
  }
504
+ function readTaskCompletionManifest(root, binding) {
505
+ const manifest = readSandboxControlManifest(path.join(root, 'manifest.json'));
506
+ const identity = validateSandboxControlIdentity({
507
+ publicStatusDir: binding.statusDir,
508
+ root,
509
+ mode: manifest.mode,
510
+ taskId: manifest.taskId,
511
+ generation: manifest.generation,
512
+ controlRootId: manifest.controlRootId
513
+ });
514
+ if (identity.state !== 'valid' || manifest.mode !== 'task-bound'
515
+ || manifest.taskId !== binding.taskId || manifest.generation !== binding.generation
516
+ || manifest.controlRootId !== binding.controlRootId) {
517
+ throw new Error('SANDBOX_CONTROL_IDENTITY_MISMATCH');
518
+ }
519
+ return manifest;
520
+ }
521
+ export async function quiesceSandboxControlForTaskCompletion(root, binding, timeoutMs = DEFAULT_QUIESCE_TIMEOUT_MS) {
522
+ const resolvedRoot = path.resolve(root);
523
+ const manifest = readTaskCompletionManifest(resolvedRoot, binding);
524
+ markSandboxControlRootQuiescing(resolvedRoot);
525
+ const deadline = Date.now() + timeoutMs;
526
+ const startupPath = path.join(resolvedRoot, BROKER_STARTING_FILE);
527
+ const brokerPath = path.join(resolvedRoot, 'broker.json');
528
+ while (true) {
529
+ const startup = readStartupOwner(startupPath);
530
+ if (fs.existsSync(startupPath) && !startup)
531
+ throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
532
+ const broker = readBrokerOwner(brokerPath);
533
+ if (broker && (broker.token !== manifest.token || broker.generation !== manifest.generation)) {
534
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
535
+ }
536
+ if (!startup && !broker)
537
+ return;
538
+ if (Date.now() >= deadline)
539
+ throw new Error('SANDBOX_CONTROL_BROKER_DRAIN_TIMEOUT');
540
+ await new Promise((resolve) => setTimeout(resolve, 25));
541
+ }
542
+ }
503
543
  function recordSandboxRemovalPending(root, manifest, phase, journal) {
504
544
  const pendingPath = path.join(root, 'removal-pending.json');
505
545
  const temporary = `${pendingPath}.${process.pid}.${randomUUID()}.tmp`;
@@ -934,12 +974,34 @@ function readSandboxControlManifestValue(manifestPath) {
934
974
  || !isSandboxAuthorityEvidence(candidate.authorityEvidence))
935
975
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
936
976
  const root = path.dirname(path.resolve(manifestPath));
937
- if (path.resolve(candidate.channelDir) !== path.join(root, 'channel')
938
- || path.resolve(candidate.publicStatusDir) !== path.join(root, 'public')
939
- || path.resolve(candidate.processingDir) !== path.join(root, 'processing')) {
977
+ const canonicalRoot = fs.realpathSync.native(root);
978
+ const matchesControlChildPath = (candidatePath, name) => {
979
+ const resolved = path.resolve(candidatePath);
980
+ if (path.basename(resolved) !== name)
981
+ return false;
982
+ const expected = path.join(canonicalRoot, name);
983
+ try {
984
+ return fs.realpathSync.native(resolved) === expected;
985
+ }
986
+ catch (error) {
987
+ if (error.code !== 'ENOENT')
988
+ throw error;
989
+ try {
990
+ return fs.realpathSync.native(path.dirname(resolved)) === canonicalRoot;
991
+ }
992
+ catch (parentError) {
993
+ if (parentError.code !== 'ENOENT')
994
+ throw parentError;
995
+ return false;
996
+ }
997
+ }
998
+ };
999
+ if (!matchesControlChildPath(candidate.channelDir, 'channel')
1000
+ || !matchesControlChildPath(candidate.publicStatusDir, 'public')
1001
+ || !matchesControlChildPath(candidate.processingDir, 'processing')) {
940
1002
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
941
1003
  }
942
- if (path.resolve(candidate.runtimeDir) !== path.join(root, 'runtime')) {
1004
+ if (!matchesControlChildPath(candidate.runtimeDir, 'runtime')) {
943
1005
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
944
1006
  }
945
1007
  const normalized = {