@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
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { parseTypedTaskFrontmatter } from './frontmatter.js';
3
4
  const REGISTRY_NAME = '.short-ids.json';
4
5
  const LOCK_NAME = '.short-ids.json.lock';
5
6
  function normalizeShortIdInput(input, opts) {
@@ -90,20 +91,33 @@ function resolveShortIdReadOnly(input, repoRoot, opts) {
90
91
  const taskId = ids[key];
91
92
  if (!taskId) {
92
93
  const message = Object.keys(ids).length === 0
93
- ? `short id '${key}' not found; active task registry is empty.`
94
- : `short id '${key}' not found in active task registry (it may have been cleaned up after archival; check 'task-short-id.js list').`;
94
+ ? `short id '${key}' not found; task short-id registry is empty.`
95
+ : `short id '${key}' not found in task short-id registry.`;
95
96
  return shortIdFailure('SHORT_ID_NOT_FOUND', message);
96
97
  }
97
- const taskMdPath = path.join(repoRoot, '.agents', 'workspace', 'active', taskId, 'task.md');
98
- if (!fs.existsSync(taskMdPath)) {
99
- const remainingActiveEntries = Object.values(ids).filter((candidateTaskId) => fs.existsSync(path.join(repoRoot, '.agents', 'workspace', 'active', candidateTaskId, 'task.md')));
100
- const message = remainingActiveEntries.length === 0
101
- ? `short id '${key}' not found; active task registry is empty.`
102
- : `short id '${key}' not found in active task registry (it may have been cleaned up after archival; check 'task-short-id.js list').`;
103
- return shortIdFailure('SHORT_ID_STALE', message, taskId);
98
+ const activeTask = path.join(repoRoot, '.agents', 'workspace', 'active', taskId, 'task.md');
99
+ const completedTask = path.join(repoRoot, '.agents', 'workspace', 'completed', taskId, 'task.md');
100
+ let isValidCompletedTask = false;
101
+ try {
102
+ const task = parseTypedTaskFrontmatter(fs.readFileSync(completedTask, 'utf8'));
103
+ isValidCompletedTask = task.id === taskId && task.status === 'completed';
104
+ }
105
+ catch { /* a mapping is valid only while its task content is present */ }
106
+ if (!fs.existsSync(activeTask) && !isValidCompletedTask) {
107
+ return shortIdFailure('SHORT_ID_STALE', `short id '${key}' points to no active or completed task content.`, taskId);
104
108
  }
105
109
  return { ok: true, taskId };
106
110
  }
111
+ function hasRetainedCompletedTask(activeDir, taskId) {
112
+ try {
113
+ const taskPath = path.join(path.dirname(activeDir), 'completed', taskId, 'task.md');
114
+ const task = parseTypedTaskFrontmatter(fs.readFileSync(taskPath, 'utf8'));
115
+ return task.id === taskId && task.status === 'completed';
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
107
121
  function configuredShortIdLength(repoRoot) {
108
122
  try {
109
123
  const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
@@ -206,7 +220,8 @@ function mutateShortIdRegistryAt(activeDir, width, taskId, requested, onlyReleas
206
220
  const original = JSON.stringify(registry.ids);
207
221
  if (pruneOrphans) {
208
222
  for (const [key, candidate] of Object.entries(registry.ids)) {
209
- if (candidate !== taskId && !fs.existsSync(path.join(activeDir, candidate, 'task.md')))
223
+ if (candidate !== taskId && !fs.existsSync(path.join(activeDir, candidate, 'task.md'))
224
+ && !hasRetainedCompletedTask(activeDir, candidate))
210
225
  delete registry.ids[key];
211
226
  }
212
227
  }
@@ -277,34 +292,29 @@ function executeShortIdCommand(request) {
277
292
  if (normalized.kind !== 'shortId') {
278
293
  throw Object.assign(new Error(`invalid short id format '${request.argument}'`), { code: 'SHORT_ID_FORMAT_INVALID' });
279
294
  }
280
- return withRegistryLock(request.activeDir, () => {
281
- const registry = readRegistryStrict(registryPath, request.shortIdLength);
282
- let changed = false;
283
- for (const [key, taskId] of Object.entries(registry.ids)) {
284
- if (!fs.existsSync(path.join(request.activeDir, taskId, 'task.md'))) {
285
- delete registry.ids[key];
286
- changed = true;
287
- }
288
- }
289
- if (changed)
290
- writeRegistry(registryPath, registry);
291
- const taskId = registry.ids[normalized.value];
292
- if (!taskId)
293
- throw Object.assign(new Error(`short id '${normalized.value}' not found in active task registry`), { code: 'SHORT_ID_NOT_FOUND' });
294
- return { status: changed ? 'applied' : 'no-op', changed, output: taskId, error: null };
295
- });
295
+ const registry = readRegistryStrict(registryPath, request.shortIdLength);
296
+ const taskId = registry.ids[normalized.value];
297
+ if (!taskId)
298
+ throw Object.assign(new Error(`short id '${normalized.value}' not found in task short-id registry`), { code: 'SHORT_ID_NOT_FOUND' });
299
+ if (!fs.existsSync(path.join(request.activeDir, taskId, 'task.md'))
300
+ && !hasRetainedCompletedTask(request.activeDir, taskId)) {
301
+ throw Object.assign(new Error(`short id '${normalized.value}' points to a task without active or completed task content`), { code: 'SHORT_ID_STALE' });
302
+ }
303
+ return { status: 'no-op', changed: false, output: taskId, error: null };
296
304
  }
297
305
  const registry = readRegistryStrict(registryPath, request.shortIdLength);
298
306
  if (request.verify) {
299
307
  const active = fs.existsSync(request.activeDir)
300
308
  ? fs.readdirSync(request.activeDir).filter((entry) => /^TASK-\d{8}-\d{6}$/.test(entry) && fs.existsSync(path.join(request.activeDir, entry, 'task.md')))
301
309
  : [];
310
+ const retained = Object.values(registry.ids).filter((taskId) => hasRetainedCompletedTask(request.activeDir, taskId));
311
+ const validTasks = new Set([...active, ...retained]);
302
312
  const registered = new Map();
303
313
  for (const [key, taskId] of Object.entries(registry.ids))
304
314
  registered.set(taskId, [...(registered.get(taskId) ?? []), key]);
305
315
  const diff = {
306
316
  missing_in_registry: active.filter((taskId) => !registered.has(taskId)).map((taskId) => ({ taskId })),
307
- orphans_in_registry: Object.entries(registry.ids).filter(([, taskId]) => !active.includes(taskId)).map(([key, taskId]) => ({ key, taskId })),
317
+ orphans_in_registry: Object.entries(registry.ids).filter(([, taskId]) => !validTasks.has(taskId)).map(([key, taskId]) => ({ key, taskId })),
308
318
  duplicate_registry_keys: [...registered.entries()].filter(([, keys]) => keys.length > 1).map(([taskId, keys]) => ({ taskId, keys }))
309
319
  };
310
320
  const clean = Object.values(diff).every((items) => items.length === 0);
@@ -147,14 +147,9 @@ function checkOrchestrationState({ taskDir }) {
147
147
  return failResult('orchestration-state', 'Paused run requires a stable pause code and message');
148
148
  }
149
149
  if (run.status === 'completed') {
150
- if (run.completionEvidence != null) {
151
- const evidenceError = validateCleanCompletionEvidence(run);
152
- if (evidenceError)
153
- return failResult('orchestration-state', evidenceError);
154
- }
155
- else if (run.pendingDelegation !== null || !run.commitAuthorization?.consumedAt) {
156
- return failResult('orchestration-state', 'Completed run must consume commit authorization and clear pending delegation');
157
- }
150
+ const evidenceError = validateCleanCompletionEvidence(run);
151
+ if (evidenceError)
152
+ return failResult('orchestration-state', evidenceError);
158
153
  }
159
154
  else if (run.completionEvidence != null) {
160
155
  return failResult('orchestration-state', 'Clean completion evidence requires a completed run');
@@ -186,10 +181,8 @@ function validateCleanCompletionEvidence(run) {
186
181
  }
187
182
  if (run.status !== 'completed'
188
183
  || run.pendingDelegation !== null
189
- || run.commitAuthorization?.issuedAt !== null
190
- || run.commitAuthorization?.consumedAt !== null
191
184
  || run.receipts.some((receipt) => receipt.stage === 'commit')) {
192
- return 'Clean completion evidence conflicts with commit delegation state';
185
+ return 'Clean completion evidence conflicts with pending delegation or commit receipt state';
193
186
  }
194
187
  return null;
195
188
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "protocolVersion": 3,
3
- "packageVersion": "0.11.12",
4
- "sourceInputHash": "9d9be72538afb23d15312d9d5eeac8e0a9fb79b4b9b4f8715f0f0218c3f7cfdd",
5
- "internalExecutableBuildHash": "6f21c8843c158d538c95d789a9e048df47bb25080e7f935d00cf60bcb85163db"
3
+ "packageVersion": "0.11.13",
4
+ "sourceInputHash": "70e89f362895c1f8f976011715a86b47e06c105ca4285eacbb3fa51d0459a282",
5
+ "internalExecutableBuildHash": "f183e6d39ba985dbb2962eb951b4019af9594361ca3c46b88bf65fff325d7ad4"
6
6
  }
@@ -307,18 +307,23 @@ function createCodexLifecycleStore(options: CodexLifecycleStoreOptions) {
307
307
  return matches[0]!;
308
308
  }
309
309
 
310
- function consume(
310
+ function consumeWithLockMode(
311
311
  childThreadId: string,
312
312
  consumer: string,
313
- expectedHookDefinitionHash?: string,
314
- expectedTaskBinding?: CodexLifecycleTaskBinding
313
+ expectedHookDefinitionHash: string | undefined,
314
+ expectedTaskBinding: CodexLifecycleTaskBinding | undefined,
315
+ taskLockHeld: boolean
315
316
  ): StoredCodexLifecycle {
316
317
  if (!consumer.trim()) throw new Error('Codex lifecycle consumer is required');
317
318
  const current = read(childThreadId);
318
319
  const binding = expectedTaskBinding ?? current.taskBinding;
319
320
  if (!binding) throw new Error('Codex lifecycle task receipt binding is missing');
320
321
  const spawn = current.state.spawn;
321
- if (!spawn) throw new Error('Codex lifecycle spawn evidence is missing');
322
+ if (!spawn) {
323
+ throw new Error(taskLockHeld
324
+ ? 'Codex lifecycle task receipt binding is missing'
325
+ : 'Codex lifecycle spawn evidence is missing');
326
+ }
322
327
  const key = recordKey(spawn.sessionId, spawn.turnId, spawn.toolUseId);
323
328
  return update(binding, key, (latest) => {
324
329
  if (!latest || expectedTaskBinding && JSON.stringify(latest.taskBinding) !== JSON.stringify(expectedTaskBinding)) {
@@ -333,7 +338,16 @@ function createCodexLifecycleStore(options: CodexLifecycleStoreOptions) {
333
338
  }
334
339
  if (latest.consumer) return latest;
335
340
  return Object.freeze({ ...latest, revision: latest.revision + 1, consumer, consumedAt: now(), updatedAt: now() });
336
- });
341
+ }, taskLockHeld);
342
+ }
343
+
344
+ function consume(
345
+ childThreadId: string,
346
+ consumer: string,
347
+ expectedHookDefinitionHash?: string,
348
+ expectedTaskBinding?: CodexLifecycleTaskBinding
349
+ ): StoredCodexLifecycle {
350
+ return consumeWithLockMode(childThreadId, consumer, expectedHookDefinitionHash, expectedTaskBinding, false);
337
351
  }
338
352
 
339
353
  function consumeWithinTaskLock(
@@ -342,24 +356,7 @@ function createCodexLifecycleStore(options: CodexLifecycleStoreOptions) {
342
356
  expectedHookDefinitionHash?: string,
343
357
  expectedTaskBinding?: CodexLifecycleTaskBinding
344
358
  ): StoredCodexLifecycle {
345
- if (!consumer.trim()) throw new Error('Codex lifecycle consumer is required');
346
- const current = read(childThreadId);
347
- const binding = expectedTaskBinding ?? current.taskBinding;
348
- if (!binding || !current.state.spawn) throw new Error('Codex lifecycle task receipt binding is missing');
349
- const key = recordKey(current.state.spawn.sessionId, current.state.spawn.turnId, current.state.spawn.toolUseId);
350
- return update(binding, key, (latest) => {
351
- if (!latest || expectedTaskBinding && JSON.stringify(latest.taskBinding) !== JSON.stringify(expectedTaskBinding)) {
352
- throw new Error('Codex lifecycle task binding does not match the expected receipt');
353
- }
354
- if (latest.consumer && latest.consumer !== consumer) {
355
- throw new Error(`Codex lifecycle evidence was already consumed by '${latest.consumer}'`);
356
- }
357
- if (latest.state.status !== 'stop-ready') throw new Error('Codex lifecycle evidence is not stop-ready');
358
- if (expectedHookDefinitionHash && latest.state.startEvidence?.hookDefinitionHash !== expectedHookDefinitionHash) {
359
- throw new Error('Codex lifecycle hook definition hash is stale');
360
- }
361
- return latest.consumer ? latest : Object.freeze({ ...latest, revision: latest.revision + 1, consumer, consumedAt: now(), updatedAt: now() });
362
- }, true);
359
+ return consumeWithLockMode(childThreadId, consumer, expectedHookDefinitionHash, expectedTaskBinding, true);
363
360
  }
364
361
 
365
362
  function expireBefore(cutoff: string): number {
@@ -1,33 +1,12 @@
1
1
  import {
2
2
  recoverSandboxControl,
3
3
  requestSandboxControl,
4
- requestSandboxTaskFinalization,
5
4
  SandboxControlClientError
6
5
  } from '../sandbox/control/client.ts';
7
- import { normalizeAgentToken, AGENT_USAGE_HINT } from '../agent-clients/tokens.ts';
8
6
  import { serveSandboxControl } from '../sandbox/control/server.ts';
9
7
  import { runSandboxControlExecutor } from '../sandbox/control/executor.ts';
10
8
  import { ensureInternalHandlerRoute, internalHandlerRoute } from './cli-route-inventory.ts';
11
9
  import { parseTaskCreateResult, taskCreateExitCode } from '../task/create-service.ts';
12
- import { serializeTaskFinalizationEnvelope, type TaskFinalizationEnvelope } from '../task/finalization-envelope.ts';
13
-
14
- type FinalizationStatus = 'completed' | 'failed' | 'blocked' | 'unknown';
15
-
16
- function writeFinalizationEnvelope(
17
- status: FinalizationStatus,
18
- accepted: boolean,
19
- error: { code: string; message: string; retryable: boolean },
20
- changed: boolean | null = false,
21
- result: unknown = null,
22
- requestId: string | null = null
23
- ): void {
24
- process.stdout.write(serializeTaskFinalizationEnvelope({ status, changed, accepted, requestId, result, error }));
25
- }
26
-
27
- function finalizationErrorStatus(error: { retryable: boolean; code: string }): FinalizationStatus {
28
- if (error.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN') return 'unknown';
29
- return error.retryable ? 'blocked' : 'failed';
30
- }
31
10
 
32
11
  function writeClientError(error: SandboxControlClientError): void {
33
12
  process.stderr.write(`${error.detail.message}\n`);
@@ -45,78 +24,6 @@ function recoveredExitCode(response: Awaited<ReturnType<typeof recoverSandboxCon
45
24
  return response.exitCode ?? 1;
46
25
  }
47
26
 
48
- function finalizationResponseOutput(response: Awaited<ReturnType<typeof requestSandboxTaskFinalization>>): string {
49
- try {
50
- const value = JSON.parse(response.stdout) as Partial<TaskFinalizationEnvelope>;
51
- if (value.version === 2
52
- && value.status
53
- && ['completed', 'failed', 'blocked', 'unknown'].includes(value.status)
54
- && (typeof value.changed === 'boolean' || value.changed === null)) {
55
- return serializeTaskFinalizationEnvelope({
56
- status: value.status,
57
- changed: value.changed,
58
- accepted: true,
59
- requestId: response.id,
60
- result: value.result ?? null,
61
- error: value.error ?? null
62
- });
63
- }
64
- } catch {
65
- // Preserve non-envelope output for diagnostics from the executor.
66
- }
67
- return response.stdout;
68
- }
69
-
70
- function sandboxFinalizationClient(args: string[]): void {
71
- if (args.length !== 4 || !args[0] || args[1] !== 'complete' || args[2] !== '--agent' || !args[3]) {
72
- const error = { code: 'TASK_FINALIZATION_PAYLOAD_INVALID', message: 'task ref, complete intent, and --agent are required', retryable: false };
73
- writeFinalizationEnvelope('failed', false, error);
74
- process.stderr.write('Usage: agent-infra-internal task-finalization <N | TASK-id> complete --agent <agent>\n');
75
- process.exitCode = 1;
76
- return;
77
- }
78
- const agent = normalizeAgentToken(args[3]);
79
- if (!agent) {
80
- const error = { code: 'TASK_FINALIZATION_PAYLOAD_INVALID', message: `invalid --agent: ${AGENT_USAGE_HINT}`, retryable: false };
81
- writeFinalizationEnvelope('failed', false, error);
82
- process.exitCode = 1;
83
- return;
84
- }
85
- let response;
86
- try {
87
- response = requestSandboxTaskFinalization({ agent });
88
- } catch (error) {
89
- if (!(error instanceof SandboxControlClientError)) throw error;
90
- const status = finalizationErrorStatus(error.detail);
91
- writeFinalizationEnvelope(status, error.accepted, error.detail, status === 'unknown' ? null : false, null, error.requestId);
92
- writeClientError(error);
93
- process.exitCode = error.detail.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? 1 : error.detail.retryable ? 2 : 1;
94
- return;
95
- }
96
- if (response.phase === 'rejected') {
97
- const error = response.error ?? {
98
- code: 'SANDBOX_CONTROL_REJECTED',
99
- message: 'sandbox control rejected the finalization request',
100
- retryable: false
101
- };
102
- const status = finalizationErrorStatus(error);
103
- writeFinalizationEnvelope(
104
- status,
105
- error.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN',
106
- error,
107
- status === 'unknown' ? null : false,
108
- null,
109
- response.id
110
- );
111
- process.stderr.write(`${error.message}\n`);
112
- process.exitCode = error.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? 1 : error.retryable ? 2 : 1;
113
- return;
114
- }
115
- process.stdout.write(finalizationResponseOutput(response));
116
- process.stderr.write(response.stderr);
117
- process.exitCode = response.exitCode ?? 1;
118
- }
119
-
120
27
  async function sandboxControl(args: string[]): Promise<void> {
121
28
  if (!ensureInternalHandlerRoute('sandbox-control', args)) return;
122
29
  const [operation, ...rest] = args;
@@ -165,10 +72,6 @@ async function sandboxControl(args: string[]): Promise<void> {
165
72
  }
166
73
  if (internalHandlerRoute('sandbox-control', 'client', operation ?? '')) {
167
74
  const [family = '', ...commandArgs] = rest;
168
- if (family === 'task-finalization') {
169
- sandboxFinalizationClient(commandArgs);
170
- return;
171
- }
172
75
  let response;
173
76
  try {
174
77
  response = requestSandboxControl({ family, args: commandArgs });
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import {
2
3
  createDirectHostExecutionContext,
3
4
  dispatchTaskControlOperation,
@@ -6,8 +7,10 @@ import {
6
7
  import { applyTaskFinalization } from '../task/finalization.ts';
7
8
  import { detectRepoRoot, resolveTaskRef } from '../task/resolve-ref.ts';
8
9
  import { resolveSandboxControlTransport } from './task-operation-registry.ts';
9
- import { requestSandboxTaskFinalization, SandboxControlClientError } from '../sandbox/control/client.ts';
10
+ import { quiesceSandboxControlForTaskCompletion } from '../sandbox/control/lifecycle.ts';
11
+ import { resolveTaskRuntimeRoot } from '../task/runtime-paths.ts';
10
12
  import { serializeTaskFinalizationEnvelope } from '../task/finalization-envelope.ts';
13
+ import { verifyTaskEvent } from '../task/verification.ts';
11
14
  import { ensureInternalHandlerRoute, internalHandlerRoute } from './cli-route-inventory.ts';
12
15
 
13
16
  const USAGE = 'Usage: agent-infra-internal task-finalization <N | TASK-id> complete --agent <agent>\n';
@@ -79,25 +82,34 @@ async function taskFinalization(args: string[] = []): Promise<void> {
79
82
  };
80
83
  const transport = resolveSandboxControlTransport(process.env, { localWorkflow: true });
81
84
  if (transport.kind === 'sandbox-local') {
82
- try {
83
- const response = requestSandboxTaskFinalization({ agent: boundOperation.request.agent });
84
- process.stdout.write(response.stdout);
85
- process.stderr.write(response.stderr);
86
- process.exitCode = response.exitCode ?? 1;
87
- return;
88
- } catch (error) {
89
- const clientError = error instanceof SandboxControlClientError ? error : null;
90
- const detail = clientError
91
- ? clientError.detail
92
- : { code: 'SANDBOX_CONTROL_CLIENT_FAILED', message: error instanceof Error ? error.message : String(error), retryable: false };
93
- const status = detail.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN'
94
- ? 'unknown'
95
- : detail.retryable ? 'blocked' : 'failed';
96
- const changed = status === 'unknown' ? null : false;
97
- process.stdout.write(envelope(status, changed, clientError?.accepted ?? false, null, detail, clientError?.requestId ?? null));
98
- process.exitCode = exitCode(status);
85
+ const statusDir = process.env.AGENT_INFRA_CONTROL_STATUS_DIR;
86
+ const taskId = process.env.AGENT_INFRA_TASK_ID;
87
+ const generation = process.env.AGENT_INFRA_CONTROL_GENERATION;
88
+ const controlRootId = process.env.AGENT_INFRA_CONTROL_ROOT_ID;
89
+ if (!statusDir || !taskId || !generation || !controlRootId || taskId !== resolved.taskId) {
90
+ const detail = { code: 'TASK_CONTROL_TRANSPORT_INVALID', message: 'sandbox task control identity is incomplete', retryable: false };
91
+ process.stdout.write(envelope('failed', false, true, null, detail));
92
+ process.exitCode = 1;
99
93
  return;
100
94
  }
95
+ const result = await applyTaskFinalization(boundOperation.request, {
96
+ repoRoot,
97
+ beforeLifecycle: async (currentTaskId) => {
98
+ if (currentTaskId !== taskId) throw new Error('SANDBOX_CONTROL_IDENTITY_MISMATCH');
99
+ await quiesceSandboxControlForTaskCompletion(
100
+ path.join(resolveTaskRuntimeRoot(taskId, { repoRoot }), 'sandbox-control'),
101
+ { taskId, generation, controlRootId, statusDir }
102
+ );
103
+ },
104
+ preflight: (request, options) => verifyTaskEvent(
105
+ { ...request, event: 'complete-task.hard-preflight' }, options
106
+ )
107
+ });
108
+ const status = result.status === 'prepared' ? 'failed' : result.status;
109
+ process.stdout.write(envelope(status, result.changed, true, result, result.error
110
+ ?? (result.status === 'prepared' ? { code: 'TASK_FINALIZATION_PREPARATION_INCOMPLETE', message: 'finalization did not commit', retryable: true } : null)));
111
+ process.exitCode = exitCode(status);
112
+ return;
101
113
  }
102
114
  const result = await dispatchTaskControlOperation(
103
115
  createDirectHostExecutionContext({ repoRoot }),
@@ -10,7 +10,7 @@ import type { TaskLifecycleResult } from '../task/lifecycle.ts';
10
10
  import type { AgentClientLifecycleRecoveryResult as LifecycleRecoveryResult } from '../agent-clients/adapter.ts';
11
11
  import { ensureInternalHandlerRoute, internalHandlerRoute } from './cli-route-inventory.ts';
12
12
 
13
- 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`;
13
+ 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`;
14
14
 
15
15
  function usageFailure(message: string): void {
16
16
  process.stdout.write(`${JSON.stringify({ status: 'failed', changed: false, error: { code: 'LIFECYCLE_PAYLOAD_INVALID', message } })}\n`);
@@ -51,6 +51,33 @@ function boundedFailureDetail(result: RunResult): string {
51
51
  : `${diagnostic.slice(0, ERROR_DETAIL_LIMIT)}… [truncated]`;
52
52
  }
53
53
 
54
+ function requestDiagnostics(args: string[], request: RequestOptions, attempt: number, errorMessage: string): string {
55
+ if (process.env.AGENT_INFRA_GH_DIAGNOSTICS !== '1' && !/rate.?limit/iu.test(errorMessage)) return '';
56
+
57
+ const apiIndex = args.indexOf('api');
58
+ const endpoint = apiIndex < 0
59
+ ? args.slice(0, 2).filter((arg) => !arg.startsWith('-')).join(' ')
60
+ : args.slice(apiIndex + 1).find((arg) => !arg.startsWith('-') && !arg.includes('=')) || 'unknown';
61
+ const safeEndpoint = endpoint.startsWith('http')
62
+ ? (() => {
63
+ try {
64
+ const url = new URL(endpoint);
65
+ return `${url.host}${url.pathname}`;
66
+ } catch {
67
+ return 'unknown';
68
+ }
69
+ })()
70
+ : endpoint.split('?')[0] || 'unknown';
71
+ const methodIndex = args.findIndex((arg) => arg === '-X' || arg === '--method');
72
+ const methodFromArgs = methodIndex >= 0 ? args[methodIndex + 1] : undefined;
73
+ const method = methodFromArgs || request.method || (safeEndpoint === 'graphql' ? 'POST' : 'GET');
74
+ const tokenEnvironment = ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']
75
+ .filter((name) => Boolean(process.env[name]));
76
+ const configDirOverride = Boolean(process.env.GH_CONFIG_DIR);
77
+
78
+ return ` [gh-diagnostic endpoint=${safeEndpoint} method=${method} attempt=${attempt} tokenEnv=${tokenEnvironment.join(',') || 'none'} ghConfigDirOverride=${configDirOverride}]`;
79
+ }
80
+
54
81
  function defaultRunner(args: string[], options: RunOptions): RunResult {
55
82
  const command = process.env.AGENT_INFRA_GH_BIN || 'gh';
56
83
  let prefix: string[] = [];
@@ -168,13 +195,17 @@ function createGitHubClient(options: ClientOptions = {}): GitHubClient {
168
195
  const result = runner(args, request);
169
196
  if (result.status === 0) return { ok: true, value: result.stdout };
170
197
  const error = classifyGitHubFailure(result);
171
- if (!retryableMethod || !error.requestRetryable) return { ok: false, error: clientError(error) };
198
+ const requestDetail = requestDiagnostics(args, request, attempt + 1, error.message);
199
+ const contextualError = requestDetail
200
+ ? { ...error, message: `${error.message}${requestDetail}` }
201
+ : error;
202
+ if (!retryableMethod || !error.requestRetryable) return { ok: false, error: clientError(contextualError) };
172
203
  if (attempt >= delays.length) {
173
204
  return {
174
205
  ok: false,
175
206
  error: error.code === 'NETWORK_TRANSIENT'
176
- ? { ...clientError(error), code: 'NETWORK_RETRY_EXHAUSTED', message: `${error.message} (retry exhausted)` }
177
- : clientError(error)
207
+ ? { ...clientError(contextualError), code: 'NETWORK_RETRY_EXHAUSTED', message: `${contextualError.message} (retry exhausted)` }
208
+ : clientError(contextualError)
178
209
  };
179
210
  }
180
211
  sleep(delays[attempt]!);
@@ -91,6 +91,7 @@ import {
91
91
  materializeSandboxControl,
92
92
  materializeSandboxWorkspaceView,
93
93
  prepareSandboxWorkspaceMountTargets,
94
+ prepareSandboxCompletedTaskMountSource,
94
95
  sandboxControlPaths,
95
96
  } from '../workspace-view.ts';
96
97
  import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.ts';
@@ -1451,7 +1452,10 @@ export async function create(
1451
1452
  const taskSource = target.workspace.mode === 'task-bound'
1452
1453
  ? assertSandboxTaskSource(effectiveConfig.repoRoot, target.workspace.taskId)
1453
1454
  : null;
1454
- prepareSandboxWorkspaceMountTargets(worktree);
1455
+ const completedTaskSource = target.workspace.mode === 'task-bound'
1456
+ ? prepareSandboxCompletedTaskMountSource(effectiveConfig.repoRoot, target.workspace.taskId)
1457
+ : null;
1458
+ prepareSandboxWorkspaceMountTargets(worktree, target.workspace.mode === 'task-bound' ? target.workspace.taskId : undefined);
1455
1459
  if (previousCutoverSnapshot) {
1456
1460
  assertSandboxControlCutoverSnapshot(previousCutoverSnapshot);
1457
1461
  if (previousManifest) {
@@ -1494,6 +1498,7 @@ export async function create(
1494
1498
  ...(target.workspace.mode === 'task-bound'
1495
1499
  ? {
1496
1500
  taskSources: [taskSource!],
1501
+ completedTaskSource: completedTaskSource!,
1497
1502
  taskId: target.workspace.taskId
1498
1503
  }
1499
1504
  : {})
@@ -18,11 +18,10 @@ export { containerListFormat, parseLabels } from './list-running.ts';
18
18
  const USAGE = `Usage: ai sandbox ls
19
19
 
20
20
  Lists all containers for the current project. The '#' column is a
21
- display-only row number; the 'SHORT' column shows the active task short
22
- id bound to each container's branch (via
23
- .agents/workspace/active/.short-ids.json), or '-' if no active task is
24
- bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
25
- A SHORT value of '-' means this container has no active task short id and may
21
+ display-only row number; the 'SHORT' column shows the task short id bound to
22
+ each task-bound container, or '-' if no short id is registered. Pass the
23
+ SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
24
+ A SHORT value of '-' means this container has no task short id and may
26
25
  be eligible for "ai sandbox rm --unbound" cleanup. Use a full TASK-id to
27
26
  remove a specific task-bound sandbox.
28
27
 
@@ -13,7 +13,7 @@ import { fetchSandboxRows } from './list-running.ts';
13
13
  import { rmOne, rmPurge, rmUnbound } from '../removal.ts';
14
14
 
15
15
  const USAGE = `Usage:
16
- 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
16
+ 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
17
17
  ai sandbox rm --unbound [--dry-run] [--yes] Remove sandboxes not bound to an active task short id
18
18
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
19
19