@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
@@ -10,11 +10,9 @@ import {
10
10
  type SandboxControlRequest,
11
11
  type SandboxControlResponse,
12
12
  type SandboxAgentClientRequest,
13
- type SandboxTaskCreateRequest,
14
- type SandboxTaskFinalizationRequest
13
+ type SandboxTaskCreateRequest
15
14
  } from './protocol.ts';
16
15
  import { isAgentClientId } from '../../agent-clients/types.ts';
17
- import { normalizeAgentToken } from '../../agent-clients/tokens.ts';
18
16
  import { readSandboxControlPayload, readSandboxControlStatus } from './state.ts';
19
17
  import type { TaskCreateCandidateV1 } from '../../task/create.ts';
20
18
  import { accessSandboxTaskView, taskViewFromStatus, type TaskViewAccessEffect } from './task-view.ts';
@@ -25,7 +23,6 @@ import { loadConfig } from '../config.ts';
25
23
  import { recoverSandboxControlFromHost } from './host-recovery.ts';
26
24
 
27
25
  const SANDBOX_CONTROL_RESPONSE_SETTLE_MS = 250;
28
- const SANDBOX_TASK_FINALIZATION_RECOVERY_BUDGET_MS = 5 * 60_000;
29
26
 
30
27
  export class SandboxControlClientError extends Error {
31
28
  readonly detail: SandboxControlError;
@@ -114,7 +111,6 @@ function preflight(
114
111
 
115
112
  function taskViewEffectForRequest(request: SandboxControlRequest): TaskViewAccessEffect | null {
116
113
  if (request.family === 'task-lifecycle') return 'progress';
117
- if (request.family === 'task-finalization') return 'recovery';
118
114
  return null;
119
115
  }
120
116
 
@@ -216,9 +212,6 @@ function exchangeSandboxControl(request: SandboxControlRequest, params: Readonly
216
212
  || (requestTask.kind === 'task' && requestTask.taskId !== identity.taskId))) {
217
213
  clientError('SANDBOX_CONTROL_IDENTITY_TOPOLOGY_MISMATCH', 'request task does not match the sandbox identity', false);
218
214
  }
219
- if (identity.mode === 'branch-only' && request.family === 'task-finalization') {
220
- clientError('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot finalize tasks', false);
221
- }
222
215
  }
223
216
  const encoded = `${JSON.stringify(request)}\n`;
224
217
  if (Buffer.byteLength(encoded, 'utf8') > SANDBOX_CONTROL_MAX_BYTES) {
@@ -421,9 +414,12 @@ export function requestSandboxControl(params: Readonly<{
421
414
  token?: string; generation?: string; timeoutMs?: number;
422
415
  }>): SandboxControlResponse {
423
416
  if (!isSandboxControlFamily(params.family)) clientError('SANDBOX_CONTROL_COMMAND_DENIED', `'${params.family}' is not allowed`, false);
424
- if (params.family === 'task-create' || params.family === 'agent-client' || params.family === 'task-finalization') {
417
+ if (params.family === 'task-create' || params.family === 'agent-client') {
425
418
  clientError('SANDBOX_CONTROL_COMMAND_DENIED', `'${params.family}' requires a typed request`, false);
426
419
  }
420
+ if (params.args[1] === 'complete') {
421
+ clientError('SANDBOX_CONTROL_COMMAND_DENIED', 'complete must use task-finalization', false);
422
+ }
427
423
  const auth = authority(params);
428
424
  const issuedAt = Date.now();
429
425
  const request: SandboxControlRequest = {
@@ -463,75 +459,6 @@ export function requestSandboxAgentOperation(params: Readonly<{
463
459
  return exchangeSandboxControl(request, params);
464
460
  }
465
461
 
466
- export function requestSandboxTaskFinalization(params: Readonly<{
467
- agent: string;
468
- channelDir?: string;
469
- statusDir?: string;
470
- token?: string;
471
- generation?: string;
472
- timeoutMs?: number;
473
- recoveryBudgetMs?: number;
474
- }>): SandboxControlResponse {
475
- const agent = normalizeAgentToken(params.agent);
476
- if (!agent) clientError('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization agent is invalid', false);
477
- const auth = authority(params);
478
- const issuedAt = Date.now();
479
- const id = randomUUID();
480
- const request: SandboxTaskFinalizationRequest = {
481
- version: 4,
482
- id,
483
- ...auth,
484
- issuedAt,
485
- expiresAt: issuedAt + SANDBOX_CONTROL_ADMISSION_WINDOW_MS,
486
- family: 'task-finalization',
487
- operation: 'complete',
488
- agent,
489
- args: []
490
- };
491
- let response: SandboxControlResponse;
492
- try {
493
- response = exchangeSandboxControl(request, params);
494
- } catch (error) {
495
- if (!(error instanceof SandboxControlClientError)
496
- || error.detail.code !== 'SANDBOX_CONTROL_RESULT_UNKNOWN'
497
- || !error.accepted || !error.requestId) throw error;
498
- return recoverAcceptedTaskFinalization(error.requestId, params);
499
- }
500
- if (response.phase === 'rejected' && response.error?.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN') {
501
- return recoverAcceptedTaskFinalization(response.id, params);
502
- }
503
- return response;
504
- }
505
-
506
- export function recoverAcceptedTaskFinalization(
507
- requestId: string,
508
- params: Readonly<{ recoveryBudgetMs?: number }>,
509
- recover: typeof recoverSandboxControl = recoverSandboxControl
510
- ): SandboxControlResponse {
511
- const deadline = Date.now() + (params.recoveryBudgetMs ?? SANDBOX_TASK_FINALIZATION_RECOVERY_BUDGET_MS);
512
- while (Date.now() < deadline) {
513
- const timeoutMs = Math.min(30_000, deadline - Date.now());
514
- if (timeoutMs <= 0) break;
515
- try {
516
- const response = recover(requestId, { timeoutMs });
517
- if (response.phase === 'rejected' && response.error?.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN') continue;
518
- return response;
519
- } catch (error) {
520
- if (error instanceof SandboxControlClientError
521
- && error.detail.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN'
522
- && error.requestId === requestId) continue;
523
- throw error;
524
- }
525
- }
526
- clientError(
527
- 'SANDBOX_CONTROL_RESULT_UNKNOWN',
528
- 'accepted finalization request remained unknown after the automatic recovery budget; inspect the receipt and resume by request id',
529
- false,
530
- true,
531
- requestId
532
- );
533
- }
534
-
535
462
  export function requestSandboxTaskCreate(params: Readonly<{
536
463
  candidate: TaskCreateCandidateV1; channelDir?: string; statusDir?: string;
537
464
  token?: string; generation?: string; timeoutMs?: number;
@@ -10,8 +10,6 @@ import {
10
10
  taskCreateFailure,
11
11
  type TaskCreateResult
12
12
  } from '../../task/create-service.ts';
13
- import { applyTaskFinalization } from '../../task/finalization.ts';
14
- import { serializeTaskFinalizationEnvelope } from '../../task/finalization-envelope.ts';
15
13
  import {
16
14
  bindSandboxControlTask,
17
15
  validateSandboxControlRequest,
@@ -256,22 +254,6 @@ function taskCreateExecutionFailure(requestId: string, code: string, message: st
256
254
  }));
257
255
  }
258
256
 
259
- function finalizationResult(result: Awaited<ReturnType<typeof applyTaskFinalization>>): SandboxControlExecutionResult {
260
- const payload = {
261
- version: 1,
262
- status: result.status,
263
- changed: result.changed,
264
- accepted: true,
265
- result,
266
- error: result.error
267
- };
268
- return {
269
- exitCode: result.status === 'completed' ? 0 : result.status === 'blocked' ? 2 : 1,
270
- stdout: `${JSON.stringify(payload)}\n`,
271
- stderr: ''
272
- };
273
- }
274
-
275
257
  type ExecuteRequestOptions = Readonly<{
276
258
  createTask?: typeof createTask;
277
259
  agentClientOperation?: AgentClientSandboxControlOperation;
@@ -301,26 +283,6 @@ async function executeRequestInner(
301
283
  validateAgentClientOperation(request, manifest);
302
284
  return executeAgentClientOperation(request, manifest, manifestPath, options.agentClientOperation);
303
285
  }
304
- if (request.family === 'task-finalization') {
305
- const parsed = parseTaskControlOperation(
306
- 'task-finalization', [manifest.taskId!, 'complete', '--agent', request.agent]
307
- );
308
- if (parsed.family !== 'task-finalization') throw new Error('SANDBOX_CONTROL_FINALIZATION_OPERATION_INVALID');
309
- const operation = parsed;
310
- const result = dispatchTaskControlOperation(
311
- createSandboxExecutorExecutionContext({
312
- repoRoot: manifest.repoRoot,
313
- worktreeRoot: manifest.worktreeRoot,
314
- runtimeDir: manifest.runtimeDir,
315
- taskId: manifest.taskId!,
316
- generation: manifest.generation,
317
- manifestPath,
318
- requestId: request.id
319
- }),
320
- operation
321
- );
322
- return finalizationResult(await result);
323
- }
324
286
  const boundArgs = bindSandboxControlTask(request, manifest.taskId!);
325
287
  let operation: TaskControlOperation;
326
288
  try {
@@ -438,7 +400,7 @@ export async function runSandboxControlExecutor(requestPath: string, nonce: stri
438
400
  family: request.family,
439
401
  operation: operation && typeof operation === 'object' && 'family' in operation && operation.family === 'task-lifecycle'
440
402
  ? operation.request.intent
441
- : request.family === 'task-finalization' || request.family === 'agent-client' ? request.operation : null,
403
+ : request.family === 'agent-client' ? request.operation : null,
442
404
  phase: 'started-committed',
443
405
  outcome: 'in-progress'
444
406
  });
@@ -454,20 +416,7 @@ export async function runSandboxControlExecutor(requestPath: string, nonce: stri
454
416
  });
455
417
  const detail = error instanceof Error ? error.message : String(error);
456
418
  const code = /^([A-Z][A-Z0-9_]+)/u.exec(detail)?.[1] ?? 'SANDBOX_CONTROL_EXECUTOR_FAILED';
457
- result = request.family === 'task-finalization'
458
- ? {
459
- exitCode: 1,
460
- stdout: serializeTaskFinalizationEnvelope({
461
- status: code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? 'unknown' : 'failed',
462
- changed: code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? null : false,
463
- accepted: true,
464
- requestId: request.id,
465
- result: null,
466
- error: { code, message: detail, retryable: false }
467
- }),
468
- stderr: ''
469
- }
470
- : request.family === 'task-create'
419
+ result = request.family === 'task-create'
471
420
  ? taskCreateExecutionFailure(request.id, code, detail)
472
421
  : lifecycleFailure(code, detail);
473
422
  }
@@ -23,6 +23,7 @@ import {
23
23
  type SandboxResourceLock
24
24
  } from './native-file-lock.ts';
25
25
  import { isSandboxAuthorityEvidence } from '../engines/authority.ts';
26
+ import { validateSandboxControlIdentity } from './identity-sentinel.ts';
26
27
  import {
27
28
  parseSandboxControlStatus,
28
29
  readExecution,
@@ -639,6 +640,51 @@ function markSandboxControlRootQuiescing(root: string): void {
639
640
  }
640
641
  }
641
642
 
643
+ function readTaskCompletionManifest(
644
+ root: string,
645
+ binding: Readonly<{ taskId: string; generation: string; controlRootId: string; statusDir: string }>
646
+ ): SandboxControlManifest {
647
+ const manifest = readSandboxControlManifest(path.join(root, 'manifest.json'));
648
+ const identity = validateSandboxControlIdentity({
649
+ publicStatusDir: binding.statusDir,
650
+ root,
651
+ mode: manifest.mode,
652
+ taskId: manifest.taskId,
653
+ generation: manifest.generation,
654
+ controlRootId: manifest.controlRootId
655
+ });
656
+ if (identity.state !== 'valid' || manifest.mode !== 'task-bound'
657
+ || manifest.taskId !== binding.taskId || manifest.generation !== binding.generation
658
+ || manifest.controlRootId !== binding.controlRootId) {
659
+ throw new Error('SANDBOX_CONTROL_IDENTITY_MISMATCH');
660
+ }
661
+ return manifest;
662
+ }
663
+
664
+ export async function quiesceSandboxControlForTaskCompletion(
665
+ root: string,
666
+ binding: Readonly<{ taskId: string; generation: string; controlRootId: string; statusDir: string }>,
667
+ timeoutMs = DEFAULT_QUIESCE_TIMEOUT_MS
668
+ ): Promise<void> {
669
+ const resolvedRoot = path.resolve(root);
670
+ const manifest = readTaskCompletionManifest(resolvedRoot, binding);
671
+ markSandboxControlRootQuiescing(resolvedRoot);
672
+ const deadline = Date.now() + timeoutMs;
673
+ const startupPath = path.join(resolvedRoot, BROKER_STARTING_FILE);
674
+ const brokerPath = path.join(resolvedRoot, 'broker.json');
675
+ while (true) {
676
+ const startup = readStartupOwner(startupPath);
677
+ if (fs.existsSync(startupPath) && !startup) throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
678
+ const broker = readBrokerOwner(brokerPath);
679
+ if (broker && (broker.token !== manifest.token || broker.generation !== manifest.generation)) {
680
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
681
+ }
682
+ if (!startup && !broker) return;
683
+ if (Date.now() >= deadline) throw new Error('SANDBOX_CONTROL_BROKER_DRAIN_TIMEOUT');
684
+ await new Promise((resolve) => setTimeout(resolve, 25));
685
+ }
686
+ }
687
+
642
688
  function recordSandboxRemovalPending(
643
689
  root: string,
644
690
  manifest: SandboxControlManifest,
@@ -1108,12 +1154,29 @@ function readSandboxControlManifestValue(manifestPath: string): SandboxControlMa
1108
1154
  || (candidate.mode === 'branch-only' && candidate.taskId !== null)
1109
1155
  || !isSandboxAuthorityEvidence(candidate.authorityEvidence)) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
1110
1156
  const root = path.dirname(path.resolve(manifestPath));
1111
- if (path.resolve(candidate.channelDir) !== path.join(root, 'channel')
1112
- || path.resolve(candidate.publicStatusDir) !== path.join(root, 'public')
1113
- || path.resolve(candidate.processingDir) !== path.join(root, 'processing')) {
1157
+ const canonicalRoot = fs.realpathSync.native(root);
1158
+ const matchesControlChildPath = (candidatePath: string, name: string): boolean => {
1159
+ const resolved = path.resolve(candidatePath);
1160
+ if (path.basename(resolved) !== name) return false;
1161
+ const expected = path.join(canonicalRoot, name);
1162
+ try {
1163
+ return fs.realpathSync.native(resolved) === expected;
1164
+ } catch (error) {
1165
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1166
+ try {
1167
+ return fs.realpathSync.native(path.dirname(resolved)) === canonicalRoot;
1168
+ } catch (parentError) {
1169
+ if ((parentError as NodeJS.ErrnoException).code !== 'ENOENT') throw parentError;
1170
+ return false;
1171
+ }
1172
+ }
1173
+ };
1174
+ if (!matchesControlChildPath(candidate.channelDir, 'channel')
1175
+ || !matchesControlChildPath(candidate.publicStatusDir, 'public')
1176
+ || !matchesControlChildPath(candidate.processingDir, 'processing')) {
1114
1177
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
1115
1178
  }
1116
- if (path.resolve(candidate.runtimeDir) !== path.join(root, 'runtime')) {
1179
+ if (!matchesControlChildPath(candidate.runtimeDir, 'runtime')) {
1117
1180
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
1118
1181
  }
1119
1182
  const normalized: SandboxControlManifest = {
@@ -1,6 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { validateTaskCreateCandidate, type TaskCreateCandidateV1 } from '../../task/create.ts';
3
- import { normalizeAgentToken } from '../../agent-clients/tokens.ts';
4
3
  import { isAgentClientId } from '../../agent-clients/types.ts';
5
4
  import type { ProcessIdentity } from '../../server/process-state.ts';
6
5
  import type { SandboxAuthorityEvidenceV1 } from '../engines/authority.ts';
@@ -15,7 +14,7 @@ export const SANDBOX_CONTROL_ADMISSION_WINDOW_MS = 2_000;
15
14
  export const SANDBOX_CONTROL_STATUS_INTERVAL_MS = 250;
16
15
  export const SANDBOX_CONTROL_STATUS_STALE_MS = 1_500;
17
16
  export const SANDBOX_CONTROL_FUTURE_SKEW_MS = 1_000;
18
- export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-finalization', 'task-create', 'agent-client'] as const;
17
+ export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-create', 'agent-client'] as const;
19
18
  export type SandboxControlTimingPolicy = Readonly<{
20
19
  controlTickMs: number;
21
20
  parkedBindingInitialMs: number;
@@ -62,9 +61,6 @@ type RequestBase = Readonly<{
62
61
  export type SandboxTaskCommandRequest = RequestBase & Readonly<{
63
62
  family: 'task-lifecycle'; args: string[];
64
63
  }>;
65
- export type SandboxTaskFinalizationRequest = RequestBase & Readonly<{
66
- family: 'task-finalization'; operation: 'complete'; agent: string; args: [];
67
- }>;
68
64
  export type SandboxTaskCreateRequest = RequestBase & Readonly<{
69
65
  family: 'task-create'; candidate: TaskCreateCandidateV1;
70
66
  }>;
@@ -74,7 +70,7 @@ export type SandboxAgentClientRequest = RequestBase & Readonly<{
74
70
  operation: string;
75
71
  payload: Record<string, unknown>;
76
72
  }>;
77
- export type SandboxControlRequest = SandboxTaskCommandRequest | SandboxTaskFinalizationRequest | SandboxTaskCreateRequest | SandboxAgentClientRequest;
73
+ export type SandboxControlRequest = SandboxTaskCommandRequest | SandboxTaskCreateRequest | SandboxAgentClientRequest;
78
74
  export type SandboxControlError = Readonly<{ code: string; message: string; retryable: boolean }>;
79
75
  export type SandboxControlResultEvidence = Readonly<{
80
76
  version: 1;
@@ -270,24 +266,14 @@ export function validateSandboxControlRequest(
270
266
  }
271
267
  return request as SandboxAgentClientRequest;
272
268
  }
273
- if (request.family === 'task-finalization') {
274
- const expected = ['agent', 'args', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'operation', 'token', 'version'];
275
- if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
276
- || request.operation !== 'complete'
277
- || !Array.isArray(request.args) || request.args.length !== 0
278
- || typeof request.agent !== 'string' || normalizeAgentToken(request.agent) !== request.agent) {
279
- fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization request schema or authorization is invalid');
280
- }
281
- if (manifest.mode !== 'task-bound' || !manifest.taskId) {
282
- fail('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot finalize tasks');
283
- }
284
- return request as SandboxTaskFinalizationRequest;
285
- }
286
269
  const expected = ['args', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
287
270
  if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
288
271
  || !Array.isArray(request.args) || !request.args.every((arg) => typeof arg === 'string')) {
289
272
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
290
273
  }
274
+ if (request.args[1] === 'complete') {
275
+ fail('SANDBOX_CONTROL_COMMAND_DENIED', 'complete must use task-finalization');
276
+ }
291
277
  if (manifest.mode !== 'task-bound' || !manifest.taskId) {
292
278
  fail(
293
279
  'SANDBOX_CONTROL_BRANCH_ONLY',
@@ -298,7 +284,7 @@ export function validateSandboxControlRequest(
298
284
  }
299
285
 
300
286
  export function bindSandboxControlTask(request: SandboxControlRequest, taskId: string): string[] {
301
- if (request.family === 'task-create' || request.family === 'agent-client' || request.family === 'task-finalization') {
287
+ if (request.family === 'task-create' || request.family === 'agent-client') {
302
288
  fail('SANDBOX_CONTROL_REQUEST_INVALID', `${request.family} requests do not bind a current task`);
303
289
  }
304
290
  if (request.args.length === 0) fail('SANDBOX_CONTROL_REQUEST_INVALID', 'command arguments are required');
@@ -76,7 +76,6 @@ import { recoverAgentClientOperation, validateAgentClientOperation } from './age
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,7 +99,6 @@ 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
103
  if (request.family === 'agent-client') return request.operation;
134
104
  if (request.family !== 'task-lifecycle') return null;
@@ -327,9 +297,7 @@ async function readRecoveryDomain(
327
297
  payloadOutput: string | null
328
298
  ): Promise<RecoveryDomainEvidence> {
329
299
  if (!operation) return { domain: null };
330
- const taskRef = request.family === 'task-finalization'
331
- ? manifest.taskId
332
- : 'args' in request ? request.args[0] ?? null : manifest.taskId;
300
+ const taskRef = 'args' in request ? request.args[0] ?? null : manifest.taskId;
333
301
  const output = parseControlOutput(payloadOutput);
334
302
  if (!taskRef && operation.family !== 'task-create' && operation.family !== 'agent-client') {
335
303
  return { domain: null };
@@ -842,6 +810,7 @@ export async function serveSandboxControl(
842
810
  let bindingBackoffMs: number = timing.parkedBindingInitialMs;
843
811
  try {
844
812
  if (!brokerOwns()) return;
813
+ if (isSandboxControlRootQuiescing(root)) return;
845
814
  if (taskView.state === 'current' && taskView.observedSource === 'completed') {
846
815
  // Recheck durable re-entry evidence before publishing a trusted startup view.
847
816
  const unverified = { ...taskView, state: 'unknown' as const,
@@ -932,11 +901,6 @@ export async function serveSandboxControl(
932
901
  settledExecution = active;
933
902
  active = null;
934
903
  }
935
- const finalizationView = settledExecution
936
- ? await taskViewForFinalizationResult(manifest, settledExecution.request, settledExecution.result)
937
- : null;
938
- if (finalizationView) taskView = finalizationView;
939
-
940
904
  let reasonCode: string | null = null;
941
905
  try {
942
906
  if (!brokerOwns()) break;
@@ -1005,7 +969,9 @@ export async function serveSandboxControl(
1005
969
  }
1006
970
 
1007
971
  let retiring = false;
1008
- 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()) {
1009
975
  if (!/^[a-f0-9-]{16,64}\.json$/.test(name)) continue;
1010
976
  const id = name.slice(0, -5);
1011
977
  const source = path.join(requestsDir, name);
@@ -1013,6 +979,10 @@ export async function serveSandboxControl(
1013
979
  let validatedRequest: SandboxControlRequest | null = null;
1014
980
  let prepared: PreparedSandboxControlExecution | null = null;
1015
981
  try {
982
+ if (isSandboxControlRootQuiescing(root)) {
983
+ retiring = true;
984
+ break;
985
+ }
1016
986
  if (!brokerOwns()) {
1017
987
  retiring = true;
1018
988
  break;
@@ -1230,11 +1200,6 @@ export async function serveSandboxControl(
1230
1200
  }
1231
1201
  }
1232
1202
  if (owned && active.result && active.resultEvidenceWritten) {
1233
- const finalizationView = await taskViewForFinalizationResult(manifest, active.request, active.result);
1234
- if (finalizationView) {
1235
- taskView = finalizationView;
1236
- writeSandboxControlStatus(manifest, broker, 'healthy', null, null, Date.now(), taskView);
1237
- }
1238
1203
  if (publishExecutionResult(manifest, active.request, active.result, brokerOwns)) {
1239
1204
  if (brokerOwns()) {
1240
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[] {
@@ -77,6 +78,13 @@ export function sandboxCoreBindMounts(
77
78
  containerPath: taskMountPath,
78
79
  readOnly: false
79
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
+ }
80
88
  }
81
89
  mounts.push(
82
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';
@@ -673,31 +674,43 @@ export function startCompletedSandboxContainer(params: {
673
674
  const activeRoot = path.join(params.config.repoRoot, '.agents', 'workspace', 'active');
674
675
  const historicalSource = path.join(activeRoot, params.taskId);
675
676
  fs.mkdirSync(activeRoot, { recursive: true });
677
+ let replacedEmptyRoot = false;
678
+ let emptyRootMode = 0o700;
676
679
  try {
677
- fs.lstatSync(historicalSource);
678
- throw new Error(
679
- `SANDBOX_COMPLETED_SOURCE_CONFLICT: historical task source already exists at ${historicalSource}`
680
- );
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;
681
689
  } catch (error) {
682
690
  if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
683
691
  }
684
692
 
685
- fs.symlinkSync(
686
- completedSource,
687
- historicalSource,
688
- process.platform === 'win32' ? 'junction' : 'dir'
689
- );
693
+ let temporaryLink = false;
690
694
  try {
695
+ fs.symlinkSync(
696
+ completedSource,
697
+ historicalSource,
698
+ process.platform === 'win32' ? 'junction' : 'dir'
699
+ );
700
+ temporaryLink = true;
691
701
  params.start(params.engine, params.container);
692
702
  } finally {
693
- const temporarySource = fs.lstatSync(historicalSource);
694
- const resolvedSource = fs.realpathSync.native(historicalSource);
695
- if (!temporarySource.isSymbolicLink() || resolvedSource !== completedSource) {
696
- throw new Error(
697
- `SANDBOX_COMPLETED_SOURCE_CHANGED: refusing to remove unexpected path at ${historicalSource}`
698
- );
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);
699
712
  }
700
- fs.unlinkSync(historicalSource);
713
+ if (replacedEmptyRoot) fs.mkdirSync(historicalSource, { mode: emptyRootMode });
701
714
  }
702
715
  }
703
716
 
@@ -749,6 +762,7 @@ function expectedMounts(params: {
749
762
  ? recoveryTaskSources(config.repoRoot, params.workspace.taskId)
750
763
  : null;
751
764
  const taskId = params.workspace.mode === 'task-bound' ? params.workspace.taskId : null;
765
+ const completedTaskSource = taskId ? sandboxCompletedTaskMountSource(config.repoRoot, taskId) : null;
752
766
  const worktrees = worktreeDirCandidates(config, branch).filter((candidate) =>
753
767
  fs.existsSync(path.join(candidate, '.git'))
754
768
  );
@@ -770,6 +784,7 @@ function expectedMounts(params: {
770
784
  : {
771
785
  runtimeDir: control.runtimeDir,
772
786
  taskSources: taskSources!.accessiblePaths,
787
+ completedTaskSource: completedTaskSource!,
773
788
  taskId: taskId!
774
789
  })
775
790
  }).map((mount) => ({
@@ -780,6 +795,10 @@ function expectedMounts(params: {
780
795
  && mount.containerPath === `/workspace/.agents/workspace/active/${taskId}`
781
796
  ? { sourceAccessiblePaths: taskSources!.accessiblePaths }
782
797
  : {}),
798
+ ...(completedTaskSource !== null && taskId !== null
799
+ && mount.containerPath === `/workspace/.agents/workspace/completed/${taskId}`
800
+ ? { sourceAccessiblePaths: [completedTaskSource] }
801
+ : {}),
783
802
  expectedRW: !mount.readOnly
784
803
  }));
785
804
  const live = tools.flatMap((tool) =>