@fitlab-ai/agent-infra 0.11.13 → 0.11.14

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 (89) hide show
  1. package/dist/bin/internal-cli.js +2 -7
  2. package/dist/lib/agent-clients/adapter.js +0 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +5 -9
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/cli.js +1 -5
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/recovery.js +1 -5
  6. package/dist/lib/agent-clients/adapters/codex-orchestration.js +1 -3
  7. package/dist/lib/agent-clients/adapters/codex.js +0 -2
  8. package/dist/lib/internal/cli-route-inventory.js +0 -1
  9. package/dist/lib/internal/task-finalization.js +6 -4
  10. package/dist/lib/internal/task-lifecycle.js +3 -1
  11. package/dist/lib/internal/task-operation-registry.js +1 -3
  12. package/dist/lib/internal/task-validate.js +14 -124
  13. package/dist/lib/platform/release-notes.js +2 -2
  14. package/dist/lib/sandbox/commands/control-status.js +0 -1
  15. package/dist/lib/sandbox/commands/create.js +5 -4
  16. package/dist/lib/sandbox/config.js +12 -0
  17. package/dist/lib/sandbox/control/audit.js +1 -2
  18. package/dist/lib/sandbox/control/client.js +12 -47
  19. package/dist/lib/sandbox/control/executor.js +6 -10
  20. package/dist/lib/sandbox/control/host-recovery.js +89 -27
  21. package/dist/lib/sandbox/control/lifecycle.js +20 -16
  22. package/dist/lib/sandbox/control/protocol.js +8 -36
  23. package/dist/lib/sandbox/control/server.js +2 -21
  24. package/dist/lib/sandbox/environment.js +7 -8
  25. package/dist/lib/sandbox/mounts.js +71 -59
  26. package/dist/lib/sandbox/recovery.js +2 -3
  27. package/dist/lib/sandbox/removal.js +19 -4
  28. package/dist/lib/sandbox/workspace-view.js +41 -4
  29. package/dist/lib/task/control-authority.js +39 -17
  30. package/dist/lib/task/control-recovery.js +1 -2
  31. package/dist/lib/task/finalization.js +13 -2
  32. package/dist/lib/task/lifecycle.js +92 -33
  33. package/dist/lifecycle-build-manifest.json +3 -3
  34. package/lib/agent-clients/adapter.ts +0 -27
  35. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +6 -12
  36. package/lib/agent-clients/adapters/codex-lifecycle/cli.ts +1 -5
  37. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +0 -7
  38. package/lib/agent-clients/adapters/codex-lifecycle/recovery.ts +1 -5
  39. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +1 -4
  40. package/lib/agent-clients/adapters/codex-orchestration.ts +1 -3
  41. package/lib/agent-clients/adapters/codex.ts +0 -2
  42. package/lib/internal/cli-route-inventory.ts +0 -1
  43. package/lib/internal/task-finalization.ts +6 -4
  44. package/lib/internal/task-lifecycle.ts +3 -1
  45. package/lib/internal/task-operation-registry.ts +1 -3
  46. package/lib/internal/task-validate.ts +13 -127
  47. package/lib/platform/release-notes.ts +2 -2
  48. package/lib/sandbox/commands/control-status.ts +0 -1
  49. package/lib/sandbox/commands/create.ts +5 -4
  50. package/lib/sandbox/config.ts +13 -0
  51. package/lib/sandbox/control/audit.ts +1 -2
  52. package/lib/sandbox/control/client.ts +13 -61
  53. package/lib/sandbox/control/executor.ts +7 -13
  54. package/lib/sandbox/control/host-recovery.ts +94 -21
  55. package/lib/sandbox/control/lifecycle.ts +18 -16
  56. package/lib/sandbox/control/protocol.ts +9 -45
  57. package/lib/sandbox/control/server.ts +2 -17
  58. package/lib/sandbox/environment.ts +7 -8
  59. package/lib/sandbox/mounts.ts +92 -75
  60. package/lib/sandbox/recovery.ts +2 -3
  61. package/lib/sandbox/removal.ts +16 -4
  62. package/lib/sandbox/workspace-view.ts +40 -5
  63. package/lib/task/control-authority.ts +40 -26
  64. package/lib/task/control-recovery.ts +2 -3
  65. package/lib/task/finalization.ts +15 -2
  66. package/lib/task/lifecycle.ts +103 -31
  67. package/package.json +1 -1
  68. package/templates/.agents/rules/lifecycle-orchestration.en.md +3 -4
  69. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +3 -4
  70. package/templates/.agents/rules/task-short-id.en.md +18 -12
  71. package/templates/.agents/rules/task-short-id.zh-CN.md +12 -7
  72. package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
  73. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
  74. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  75. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  76. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-client.js +0 -130
  77. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +0 -127
  78. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-operation.js +0 -131
  79. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-registration.js +0 -275
  80. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.js +0 -106
  81. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +0 -345
  82. package/dist/lib/sandbox/control/agent-operation.js +0 -34
  83. package/lib/agent-clients/adapters/codex-lifecycle/controller-client.ts +0 -158
  84. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +0 -163
  85. package/lib/agent-clients/adapters/codex-lifecycle/controller-operation.ts +0 -151
  86. package/lib/agent-clients/adapters/codex-lifecycle/controller-registration.ts +0 -352
  87. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.ts +0 -109
  88. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +0 -437
  89. package/lib/sandbox/control/agent-operation.ts +0 -49
@@ -2,10 +2,13 @@ import path from 'node:path';
2
2
  import { shareBranchDir, shareCommonDir, shellConfigDirCandidates, worktreeDirCandidates } from './constants.js';
3
3
  import { sandboxWorkspaceViewStatePaths } from './workspace-view.js';
4
4
  import { SANDBOX_GIT_COMMON_DIR } from './git-metadata.js';
5
- export function sandboxCoreBindMounts(config, branch, overrides) {
6
- const taskSources = overrides.taskSources ?? [];
7
- const taskBound = Boolean(taskSources.length > 0 && overrides.taskId);
8
- const workspaceMounts = sandboxWorkspaceViewStatePaths(overrides.workspaceViewRoot).map(({ state, hostPath }) => taskBound && state === 'active'
5
+ function workspaceStateMounts(workspaceViewRoot, taskBound) {
6
+ return sandboxWorkspaceViewStatePaths(workspaceViewRoot)
7
+ // The writable completed-task mount below is nested under this path. Keep
8
+ // the worktree target visible so Docker can mount it without creating a
9
+ // mountpoint inside a read-only parent bind mount.
10
+ .filter(({ state }) => !(taskBound && state === 'completed'))
11
+ .map(({ state, hostPath }) => taskBound && state === 'active'
9
12
  ? {
10
13
  hostPaths: [path.join(hostPath, '.short-ids.json')],
11
14
  containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
@@ -16,17 +19,69 @@ export function sandboxCoreBindMounts(config, branch, overrides) {
16
19
  containerPath: path.posix.join('/workspace/.agents/workspace', state),
17
20
  readOnly: true
18
21
  });
22
+ }
23
+ function assertGitMetadataPair(overrides) {
19
24
  if (Boolean(overrides.gitCommonDir) !== Boolean(overrides.worktreeGitFile)) {
20
25
  throw new Error('SANDBOX_GIT_METADATA_INCOMPLETE');
21
26
  }
22
- const gitCommonMount = overrides.gitCommonDir
23
- ? { hostPaths: [overrides.gitCommonDir], containerPath: SANDBOX_GIT_COMMON_DIR, readOnly: false }
24
- : null;
25
- const worktreeGitFileMount = overrides.worktreeGitFile
26
- ? { hostPaths: [overrides.worktreeGitFile], containerPath: '/workspace/.git', readOnly: true }
27
- : null;
27
+ }
28
+ function gitCommonMount(gitCommonDir) {
29
+ return gitCommonDir ? [{ hostPaths: [gitCommonDir], containerPath: SANDBOX_GIT_COMMON_DIR, readOnly: false }] : [];
30
+ }
31
+ function worktreeGitFileMount(worktreeGitFile) {
32
+ return worktreeGitFile ? [{ hostPaths: [worktreeGitFile], containerPath: '/workspace/.git', readOnly: true }] : [];
33
+ }
34
+ function taskWorkspaceMounts(taskSources, taskBound, overrides) {
35
+ if (!taskBound)
36
+ return [];
37
+ const taskId = overrides.taskId;
38
+ const mounts = [{
39
+ hostPaths: taskSources,
40
+ containerPath: path.posix.join('/workspace/.agents/workspace/active', taskId),
41
+ readOnly: false
42
+ }];
43
+ if (overrides.completedTaskSource)
44
+ mounts.push({
45
+ hostPaths: [overrides.completedTaskSource],
46
+ containerPath: path.posix.join('/workspace/.agents/workspace/completed', taskId),
47
+ readOnly: false
48
+ });
49
+ return mounts;
50
+ }
51
+ function sharedMounts(config, branch, shellConfigHostDir) {
52
+ return [{
53
+ hostPaths: [shareCommonDir(config)], containerPath: '/share/common', readOnly: false
54
+ }, {
55
+ hostPaths: [shareBranchDir(config, branch)], containerPath: '/share/branch', readOnly: false
56
+ }, {
57
+ hostPaths: shellConfigHostDir ? [shellConfigHostDir] : shellConfigDirCandidates(config, branch),
58
+ containerPath: '/home/devuser/.host-shell-config', readOnly: true
59
+ }];
60
+ }
61
+ function controlMounts(overrides, taskBound) {
62
+ const mounts = [{
63
+ hostPaths: [overrides.controlStatusDir], containerPath: '/run/agent-infra/control-status', readOnly: true
64
+ }, {
65
+ hostPaths: [overrides.controlDir], containerPath: '/run/agent-infra/control', readOnly: false
66
+ }];
67
+ if (taskBound && overrides.runtimeDir)
68
+ mounts.push({
69
+ hostPaths: [overrides.runtimeDir], containerPath: '/run/agent-infra/runtime', readOnly: false
70
+ });
71
+ if (taskBound && overrides.controlRootDir)
72
+ mounts.push({
73
+ hostPaths: [overrides.controlRootDir], containerPath: '/run/agent-infra/control-root', readOnly: false
74
+ });
75
+ return mounts;
76
+ }
77
+ export function sandboxCoreBindMounts(config, branch, overrides) {
78
+ const taskSources = overrides.taskSources ?? [];
79
+ const taskBound = Boolean(taskSources.length > 0 && overrides.taskId);
80
+ assertGitMetadataPair(overrides);
81
+ if (taskBound && !overrides.controlRootDir)
82
+ throw new Error('SANDBOX_CONTROL_ROOT_MISSING');
28
83
  const mounts = [
29
- ...(gitCommonMount ? [gitCommonMount] : []),
84
+ ...gitCommonMount(overrides.gitCommonDir),
30
85
  {
31
86
  hostPaths: overrides.worktree
32
87
  ? [overrides.worktree]
@@ -34,55 +89,12 @@ export function sandboxCoreBindMounts(config, branch, overrides) {
34
89
  containerPath: '/workspace',
35
90
  readOnly: false
36
91
  },
37
- ...(worktreeGitFileMount ? [worktreeGitFileMount] : []),
38
- ...workspaceMounts,
92
+ ...worktreeGitFileMount(overrides.worktreeGitFile),
93
+ ...workspaceStateMounts(overrides.workspaceViewRoot, taskBound),
94
+ ...taskWorkspaceMounts(taskSources, taskBound, overrides),
95
+ ...sharedMounts(config, branch, overrides.shellConfigHostDir),
96
+ ...controlMounts(overrides, taskBound)
39
97
  ];
40
- if (taskBound) {
41
- const taskMountPath = path.posix.join('/workspace/.agents/workspace/active', overrides.taskId);
42
- mounts.push({
43
- hostPaths: taskSources,
44
- containerPath: taskMountPath,
45
- readOnly: false
46
- });
47
- if (overrides.completedTaskSource) {
48
- mounts.push({
49
- hostPaths: [overrides.completedTaskSource],
50
- containerPath: path.posix.join('/workspace/.agents/workspace/completed', overrides.taskId),
51
- readOnly: false
52
- });
53
- }
54
- }
55
- mounts.push({
56
- hostPaths: [shareCommonDir(config)],
57
- containerPath: '/share/common',
58
- readOnly: false
59
- }, {
60
- hostPaths: [shareBranchDir(config, branch)],
61
- containerPath: '/share/branch',
62
- readOnly: false
63
- }, {
64
- hostPaths: overrides.shellConfigHostDir
65
- ? [overrides.shellConfigHostDir]
66
- : shellConfigDirCandidates(config, branch),
67
- containerPath: '/home/devuser/.host-shell-config',
68
- readOnly: true
69
- });
70
- mounts.push({
71
- hostPaths: [overrides.controlStatusDir],
72
- containerPath: '/run/agent-infra/control-status',
73
- readOnly: true
74
- }, {
75
- hostPaths: [overrides.controlDir],
76
- containerPath: '/run/agent-infra/control',
77
- readOnly: false
78
- });
79
- if (taskBound && overrides.runtimeDir) {
80
- mounts.push({
81
- hostPaths: [overrides.runtimeDir],
82
- containerPath: '/run/agent-infra/runtime',
83
- readOnly: false
84
- });
85
- }
86
98
  return mounts;
87
99
  }
88
100
  //# sourceMappingURL=mounts.js.map
@@ -174,7 +174,6 @@ export async function startSandboxControlBroker(repoRoot, manifestPath) {
174
174
  async function ensureSandboxControlBroker(params) {
175
175
  const control = sandboxControlPaths({
176
176
  base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
177
- repoRoot: params.config.repoRoot,
178
177
  project: params.config.project,
179
178
  container: params.container,
180
179
  identity: params.workspace
@@ -523,7 +522,6 @@ function expectedMounts(params) {
523
522
  });
524
523
  const control = sandboxControlPaths({
525
524
  base: config.controlBase ?? path.join(config.home, '.agent-infra', 'sandbox-control'),
526
- repoRoot: config.repoRoot,
527
525
  project: config.project,
528
526
  container: params.container,
529
527
  identity: params.workspace
@@ -546,6 +544,7 @@ function expectedMounts(params) {
546
544
  }),
547
545
  workspaceViewRoot: view.root,
548
546
  controlDir: control.channelDir,
547
+ ...(params.workspace.mode === 'task-bound' ? { controlRootDir: control.root } : {}),
549
548
  controlStatusDir: control.statusDir,
550
549
  ...(taskSources === null
551
550
  ? {}
@@ -926,7 +925,7 @@ export async function ensureSandboxReady(params) {
926
925
  let completedReentry = null;
927
926
  if (params.reentry === 'completed' && params.workspace?.mode === 'task-bound') {
928
927
  const control = sandboxControlPaths({
929
- base: params.config.controlBase, repoRoot: params.config.repoRoot, project: params.config.project,
928
+ base: params.config.controlBase, project: params.config.project,
930
929
  container: params.row.name, identity: params.workspace
931
930
  });
932
931
  const manifest = readSandboxControlManifest(control.manifestPath);
@@ -568,7 +568,7 @@ function resolveRmTarget(config, tools, cleanupTarget, options = {}) {
568
568
  ? matchedContainers
569
569
  : [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
570
570
  const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
571
- base: config.controlBase, repoRoot: config.repoRoot, project: config.project, container, identity
571
+ base: config.controlBase, project: config.project, container, identity
572
572
  }).root));
573
573
  const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
574
574
  base: config.workspaceViewBase, project: config.project, container, identity
@@ -763,6 +763,21 @@ async function rmPurgeCore(config, tools, prompt = {}) {
763
763
  const containers = runEngine(engine, 'docker', [
764
764
  'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
765
765
  ]).split('\n').filter(Boolean);
766
+ const controlRoots = rows.flatMap((row) => {
767
+ if (!row.branch)
768
+ return [];
769
+ const identity = row.workspaceMode === 'task-bound' && row.taskId
770
+ ? { mode: 'task-bound', taskId: row.taskId }
771
+ : row.workspaceMode === 'branch-only'
772
+ ? { mode: 'branch-only' }
773
+ : null;
774
+ if (!identity)
775
+ return [];
776
+ return [sandboxControlPaths({
777
+ base: config.controlBase, project: config.project,
778
+ container: row.name, identity
779
+ }).root];
780
+ });
766
781
  const completedTaskIds = [...new Set(rows
767
782
  .filter((row) => row.workspaceMode === 'task-bound' && row.taskId)
768
783
  .map((row) => row.taskId))];
@@ -801,9 +816,9 @@ async function rmPurgeCore(config, tools, prompt = {}) {
801
816
  else
802
817
  cleanupCompleted = false;
803
818
  }
804
- for (const base of [config.workspaceViewBase, config.controlBase]) {
805
- fs.rmSync(path.join(base, config.project), { recursive: true, force: true });
806
- }
819
+ fs.rmSync(path.join(config.workspaceViewBase, config.project), { recursive: true, force: true });
820
+ for (const root of new Set(controlRoots))
821
+ fs.rmSync(root, { recursive: true, force: true });
807
822
  if (cleanupCompleted) {
808
823
  for (const taskId of completedTaskIds)
809
824
  releaseStaleShortIdRegistry(config.repoRoot, taskId);
@@ -2,7 +2,6 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { resolveTaskWorkspace } from './task-resolver.js';
5
- import { resolveTaskRuntimeRoot } from '../task/runtime-paths.js';
6
5
  import { createSandboxControlIdentitySentinel, writeSandboxControlIdentitySentinel } from './control/identity-sentinel.js';
7
6
  export const SANDBOX_WORKSPACE_VIEW_STATES = Object.freeze([
8
7
  'active',
@@ -27,7 +26,17 @@ export function sandboxWorkspaceViewPaths(params) {
27
26
  }
28
27
  export function sandboxControlPaths(params) {
29
28
  const root = params.identity.mode === 'task-bound'
30
- ? path.join(resolveTaskRuntimeRoot(params.identity.taskId, { repoRoot: params.repoRoot }), 'sandbox-control')
29
+ ? (() => {
30
+ if (!/^TASK-\d{8}-\d{6}$/.test(params.identity.taskId)
31
+ || !params.project || path.basename(params.project) !== params.project || params.project === '.' || params.project === '..') {
32
+ throw new Error('SANDBOX_CONTROL_ROOT_INVALID');
33
+ }
34
+ const candidate = path.resolve(params.base, params.project, 'tasks', params.identity.taskId);
35
+ const relative = path.relative(path.resolve(params.base), candidate);
36
+ if (relative.startsWith('..') || path.isAbsolute(relative))
37
+ throw new Error('SANDBOX_CONTROL_ROOT_INVALID');
38
+ return candidate;
39
+ })()
31
40
  : (() => {
32
41
  const digest = createHash('sha256').update('branch-only').digest('hex').slice(0, 16);
33
42
  return path.resolve(params.base, params.project, params.container, digest);
@@ -50,6 +59,22 @@ function assertSafeDirectory(directory, expectedBase) {
50
59
  throw new Error(`Sandbox workspace view must not be a symbolic link: ${directory}`);
51
60
  }
52
61
  }
62
+ function assertSafeTaskBoundControlRoot(basePath, root) {
63
+ const relative = path.relative(basePath, root);
64
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
65
+ throw new Error(`Sandbox control root escapes its configured base: ${root}`);
66
+ }
67
+ if (fs.existsSync(basePath) && fs.lstatSync(basePath).isSymbolicLink()) {
68
+ throw new Error(`Sandbox control base must not be a symbolic link: ${basePath}`);
69
+ }
70
+ let current = basePath;
71
+ for (const segment of relative.split(path.sep)) {
72
+ current = path.join(current, segment);
73
+ if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
74
+ throw new Error(`Sandbox control root must not contain a symbolic link: ${current}`);
75
+ }
76
+ }
77
+ }
53
78
  function assertSafeMountTargetPath(target, worktreeRoot, canonicalWorktreeRoot) {
54
79
  const relative = path.relative(worktreeRoot, target);
55
80
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
@@ -203,7 +228,12 @@ export function materializeSandboxWorkspaceView(params) {
203
228
  }
204
229
  export function materializeSandboxControl(params) {
205
230
  const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
206
- assertSafeDirectory(root, params.identity.mode === 'task-bound' ? path.dirname(root) : path.resolve(params.base));
231
+ if (params.identity.mode === 'task-bound') {
232
+ assertSafeTaskBoundControlRoot(path.resolve(params.base), root);
233
+ }
234
+ else {
235
+ assertSafeDirectory(root, path.resolve(params.base));
236
+ }
207
237
  if (fs.existsSync(root)) {
208
238
  if (!params.replacementLease || path.resolve(params.replacementLease.root) !== root) {
209
239
  throw new Error('SANDBOX_CONTROL_REPLACEMENT_REQUIRED');
@@ -261,9 +291,16 @@ export function finalizeSandboxControlManifest(setup, identity) {
261
291
  containerIdentity: { id: identity.id, labels: { ...identity.labels } },
262
292
  authorityEvidence: identity.authorityEvidence
263
293
  };
294
+ const persistedManifest = {
295
+ ...manifest,
296
+ channelDir: 'channel',
297
+ publicStatusDir: 'public',
298
+ processingDir: 'processing',
299
+ runtimeDir: 'runtime'
300
+ };
264
301
  const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
265
302
  try {
266
- fs.writeFileSync(temporary, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' });
303
+ fs.writeFileSync(temporary, `${JSON.stringify(persistedManifest)}\n`, { mode: 0o600, flag: 'wx' });
267
304
  fs.renameSync(temporary, setup.manifestPath);
268
305
  }
269
306
  finally {
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { normalizeAgentToken } from '../agent-clients/tokens.js';
3
3
  import { isAgentClientId } from '../agent-clients/types.js';
4
+ import { sandboxControlPaths } from '../sandbox/workspace-view.js';
4
5
  import { getAgentClientAdapter } from '../agent-clients/registry.js';
5
6
  import { activateMatchingOrchestrationDelegation, activateOrchestrationDelegation, advanceOrchestration, awaitOrchestrationDelegationActivation, beginOrResumeOrchestration, dispatchOrchestrationDelegation, OrchestrationStateError, pauseOrchestration, prepareOrchestrationDelegation, recoverPreparedOrchestrationDelegation, routeOrchestration, sealMatchingOrchestrationDelegation, sealOrchestrationDelegation, statusOrchestration } from './orchestration.js';
6
7
  import { applyTaskFinalization } from './finalization.js';
@@ -25,21 +26,14 @@ function absolute(name, value) {
25
26
  invalidContext(`${name} must be absolute`);
26
27
  return path.resolve(value);
27
28
  }
28
- function binding(value) {
29
- if (value === null || value === undefined)
30
- return value;
31
- if (!/^[a-f0-9]{64}$/u.test(value.instanceDigest) || !value.controlGeneration) {
32
- invalidContext('controller binding is invalid');
33
- }
34
- return value;
35
- }
36
29
  export function createDirectHostExecutionContext(params) {
37
30
  return {
38
31
  source: 'direct-host',
39
32
  mode: 'direct-host',
40
33
  repoRoot: absolute('repoRoot', params.repoRoot),
41
- ...(params.runtimeDir === undefined ? {} : { runtimeDir: absolute('runtimeDir', params.runtimeDir) }),
42
- ...(params.controllerBinding === undefined ? {} : { controllerBinding: binding(params.controllerBinding) }),
34
+ ...(params.controlBase === undefined ? {} : { controlBase: absolute('controlBase', params.controlBase) }),
35
+ ...(params.project === undefined ? {} : { project: params.project }),
36
+ ...(params.runtimeDir === undefined ? {} : { runtimeDir: absolute('runtimeDir', params.runtimeDir) })
43
37
  };
44
38
  }
45
39
  export function createSandboxExecutorExecutionContext(params) {
@@ -59,8 +53,7 @@ export function createSandboxExecutorExecutionContext(params) {
59
53
  taskId: params.taskId,
60
54
  generation: params.generation,
61
55
  manifestPath: absolute('manifestPath', params.manifestPath),
62
- requestId: params.requestId,
63
- ...(params.controllerBinding === undefined ? {} : { controllerBinding: binding(params.controllerBinding) }),
56
+ requestId: params.requestId
64
57
  };
65
58
  }
66
59
  export function assertTaskControlExecutionContext(context) {
@@ -70,7 +63,10 @@ export function assertTaskControlExecutionContext(context) {
70
63
  absolute('repoRoot', context.repoRoot);
71
64
  if (context.runtimeDir !== undefined)
72
65
  absolute('runtimeDir', context.runtimeDir);
73
- binding(context.controllerBinding);
66
+ if (Boolean(context.controlBase) !== Boolean(context.project))
67
+ invalidContext('control configuration is incomplete');
68
+ if (context.controlBase !== undefined)
69
+ absolute('controlBase', context.controlBase);
74
70
  return;
75
71
  }
76
72
  if (context.mode !== 'task-bound-sandbox' || !/^TASK-\d{8}-\d{6}$/u.test(context.taskId)
@@ -84,7 +80,6 @@ export function assertTaskControlExecutionContext(context) {
84
80
  if (path.resolve(context.runtimeDir) !== path.join(path.dirname(path.resolve(context.manifestPath)), 'runtime')) {
85
81
  invalidContext('runtimeDir is not bound to the manifest control root');
86
82
  }
87
- binding(context.controllerBinding);
88
83
  }
89
84
  function boundTaskRef(context, taskRef) {
90
85
  return context.source === 'sandbox-executor' ? context.taskId : taskRef;
@@ -115,6 +110,8 @@ function lifecycleTaskId(request, repoRoot) {
115
110
  }
116
111
  }
117
112
  async function applyLifecycleWithAuthority(context, request) {
113
+ const taskId = lifecycleTaskId(request, context.repoRoot);
114
+ const controlRoot = taskId ? controlRootForTask(context, taskId) : undefined;
118
115
  const execute = async (underTaskLock = false) => {
119
116
  if (request.intent === 'recover-started') {
120
117
  const adapter = isAgentClientId(request.agent)
@@ -130,10 +127,17 @@ async function applyLifecycleWithAuthority(context, request) {
130
127
  return adapter.recoverStarted(request, { repoRoot: context.repoRoot });
131
128
  }
132
129
  return underTaskLock
133
- ? applyTaskLifecycleWithinTaskLock(request, { repoRoot: context.repoRoot }, taskId)
134
- : applyTaskLifecycle(request, { repoRoot: context.repoRoot });
130
+ ? applyTaskLifecycleWithinTaskLock(request, {
131
+ repoRoot: context.repoRoot,
132
+ ...(controlRoot ? { controlRoot } : {}),
133
+ ...(context.source === 'direct-host' && context.project ? { controlProject: context.project } : {})
134
+ }, taskId)
135
+ : applyTaskLifecycle(request, {
136
+ repoRoot: context.repoRoot,
137
+ ...(controlRoot ? { controlRoot } : {}),
138
+ ...(context.source === 'direct-host' && context.project ? { controlProject: context.project } : {})
139
+ });
135
140
  };
136
- const taskId = lifecycleTaskId(request, context.repoRoot);
137
141
  if (!taskId)
138
142
  return await execute();
139
143
  try {
@@ -153,6 +157,18 @@ async function applyLifecycleWithAuthority(context, request) {
153
157
  return taskLifecycleFailure(request, { code: error.code, message: error.message }, taskId);
154
158
  }
155
159
  }
160
+ function controlRootForTask(context, taskId) {
161
+ if (context.source === 'sandbox-executor')
162
+ return path.dirname(context.manifestPath);
163
+ if (!context.controlBase || !context.project)
164
+ return undefined;
165
+ return sandboxControlPaths({
166
+ base: context.controlBase,
167
+ project: context.project,
168
+ container: '',
169
+ identity: { mode: 'task-bound', taskId }
170
+ }).root;
171
+ }
156
172
  function orchestration(context, operation) {
157
173
  const taskRef = boundTaskRef(context, operation.taskRef);
158
174
  const input = operation.input;
@@ -219,6 +235,10 @@ export function dispatchTaskControlOperation(context, operation) {
219
235
  });
220
236
  }
221
237
  if (operation.family === 'task-finalization') {
238
+ const resolved = resolveTaskRef(boundTaskRef(context, operation.request.taskRef), { repoRoot: context.repoRoot });
239
+ const controlRoot = context.source === 'sandbox-executor'
240
+ ? path.dirname(context.manifestPath)
241
+ : resolved.ok ? controlRootForTask(context, resolved.taskId) : undefined;
222
242
  return applyTaskFinalization({
223
243
  ...operation.request,
224
244
  taskRef: boundTaskRef(context, operation.request.taskRef)
@@ -228,6 +248,8 @@ export function dispatchTaskControlOperation(context, operation) {
228
248
  controlBinding: { generation: context.generation, requestId: context.requestId },
229
249
  manifestPath: context.manifestPath
230
250
  } : {}),
251
+ ...(controlRoot ? { controlRoot } : {}),
252
+ ...(context.source === 'direct-host' && context.project ? { controlProject: context.project } : {}),
231
253
  preflight: (request, options) => verifyTaskEvent({ ...request, event: 'complete-task.hard-preflight' }, options)
232
254
  });
233
255
  }
@@ -19,8 +19,7 @@ export const SANDBOX_CONTROL_REQUIRED_COMPLETION_PHASES = Object.freeze([
19
19
  const LIFECYCLE_INTENTS = ['block', 'activate', 'cancel', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'];
20
20
  export const SANDBOX_CONTROL_RECOVERY_OPERATIONS = Object.freeze([
21
21
  ...LIFECYCLE_INTENTS.map((intent) => ({ family: 'task-lifecycle', intent, class: 'lifecycle-mutation' })),
22
- { family: 'task-create', intent: 'create', class: 'task-create' },
23
- ...['controller.close', 'controller.verify'].map((intent) => ({ family: 'agent-client', intent, class: 'agent-client-operation' }))
22
+ { family: 'task-create', intent: 'create', class: 'task-create' }
24
23
  ]);
25
24
  export function digestControlRecoveryIntent(family, intent) {
26
25
  return createHash('sha256').update(`${family}\0${intent}`, 'utf8').digest('hex');
@@ -894,7 +894,13 @@ async function prepareUnderLock(request, taskId, options) {
894
894
  receipt = updateReceipt(repoRoot, receipt, { lifecycle: 'done', lastError: null });
895
895
  }
896
896
  else {
897
- const result = lifecycle({ taskRef: taskId, intent: 'complete', agent: request.agent }, { repoRoot, prepareOnly: true, ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {}) });
897
+ const result = lifecycle({ taskRef: taskId, intent: 'complete', agent: request.agent }, {
898
+ repoRoot,
899
+ prepareOnly: true,
900
+ ...(options.controlRoot ? { controlRoot: options.controlRoot } : {}),
901
+ ...(options.controlProject ? { controlProject: options.controlProject } : {}),
902
+ ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {})
903
+ });
898
904
  lifecycleResult = lifecycleStep(result);
899
905
  if (result.status !== 'applied' && result.status !== 'no-op') {
900
906
  receipt = updateReceipt(repoRoot, receipt, { lifecycle: 'pending', lastError: lifecycleResult.error });
@@ -1037,7 +1043,12 @@ async function commitPreparedTaskFinalization(request, options) {
1037
1043
  });
1038
1044
  receipt = updateReceipt(repoRoot, receipt, { verification: 'pending' });
1039
1045
  const lifecycleRequest = { taskRef: resolved.taskId, intent: 'complete', agent: request.agent };
1040
- const lifecycleOptions = { repoRoot, ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {}) };
1046
+ const lifecycleOptions = {
1047
+ repoRoot,
1048
+ ...(options.controlRoot ? { controlRoot: options.controlRoot } : {}),
1049
+ ...(options.controlProject ? { controlProject: options.controlProject } : {}),
1050
+ ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {})
1051
+ };
1041
1052
  const result = options.lifecycle
1042
1053
  ? options.lifecycle(lifecycleRequest, lifecycleOptions)
1043
1054
  : applyTaskLifecycleWithinTaskLock(lifecycleRequest, lifecycleOptions, resolved.taskId);
@@ -215,6 +215,96 @@ function transferTaskContents(sourcePath, targetPath) {
215
215
  throw new Error('completed target does not match task source after copy');
216
216
  }
217
217
  }
218
+ function pathExists(candidate) {
219
+ try {
220
+ fs.lstatSync(candidate);
221
+ return true;
222
+ }
223
+ catch (error) {
224
+ if (error.code === 'ENOENT')
225
+ return false;
226
+ throw error;
227
+ }
228
+ }
229
+ function assertTaskControlRootPath(controlRoot, taskId, expectedProject) {
230
+ if (path.basename(controlRoot) !== taskId || path.basename(path.dirname(controlRoot)) !== 'tasks'
231
+ || (expectedProject !== undefined && path.basename(path.dirname(path.dirname(controlRoot))) !== expectedProject)) {
232
+ throw new Error('control root path does not match the task/project layout');
233
+ }
234
+ const managedBase = path.dirname(path.dirname(path.dirname(controlRoot)));
235
+ let current = managedBase;
236
+ for (const segment of path.relative(managedBase, controlRoot).split(path.sep).filter(Boolean)) {
237
+ const stat = fs.lstatSync(current);
238
+ if (stat.isSymbolicLink())
239
+ throw new Error('control root ancestor is a symbolic link');
240
+ current = path.join(current, segment);
241
+ }
242
+ const stat = fs.lstatSync(controlRoot);
243
+ if (!stat.isDirectory() || stat.isSymbolicLink())
244
+ throw new Error('control root is not a real directory');
245
+ }
246
+ function assertTaskControlManifestIdentity(controlRoot, taskId, repoRoot, expectedProject) {
247
+ const controlManifest = path.join(controlRoot, 'manifest.json');
248
+ if (!fs.existsSync(controlManifest))
249
+ throw new Error('control manifest is missing');
250
+ const manifestStat = fs.lstatSync(controlManifest);
251
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink())
252
+ throw new Error('control manifest is not a real file');
253
+ const manifest = JSON.parse(fs.readFileSync(controlManifest, 'utf8'));
254
+ const project = expectedProject ?? path.basename(path.dirname(path.dirname(controlRoot)));
255
+ if (manifest.mode !== 'task-bound' || manifest.taskId !== taskId || manifest.project !== project
256
+ || typeof manifest.repoRoot !== 'string' || fs.realpathSync.native(manifest.repoRoot) !== fs.realpathSync.native(repoRoot)) {
257
+ throw new Error('control manifest identity does not match the task');
258
+ }
259
+ }
260
+ function taskBoundControlMoveSafety(request, controlRoot, options, taskId, repoRoot, validateManifestIdentity) {
261
+ if (!controlRoot)
262
+ return null;
263
+ const controlManifest = path.join(controlRoot, 'manifest.json');
264
+ const controlLease = path.join(controlRoot, 'lease.json');
265
+ const stateExists = pathExists(controlRoot) || pathExists(controlManifest) || pathExists(controlLease);
266
+ if (stateExists) {
267
+ try {
268
+ assertTaskControlRootPath(controlRoot, taskId, options.controlProject);
269
+ if (validateManifestIdentity) {
270
+ assertTaskControlManifestIdentity(controlRoot, taskId, repoRoot, options.controlProject);
271
+ }
272
+ else {
273
+ const manifestStat = fs.lstatSync(controlManifest);
274
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink())
275
+ throw new Error('control manifest is not a real file');
276
+ }
277
+ }
278
+ catch {
279
+ return { code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE', message: 'task-bound sandbox control root is missing, malformed, or belongs to another task' };
280
+ }
281
+ }
282
+ if (request.intent !== 'complete' && (pathExists(controlManifest) || pathExists(controlLease))) {
283
+ return { code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE', message: 'task-bound sandbox control state exists; stop and remove the verified sandbox before moving the task directory' };
284
+ }
285
+ return null;
286
+ }
287
+ function orchestrationMoveSafety(sourcePath) {
288
+ const orchestrationPath = path.join(sourcePath, '.runtime', 'orchestration.json');
289
+ if (!pathExists(orchestrationPath))
290
+ return null;
291
+ try {
292
+ const run = JSON.parse(fs.readFileSync(orchestrationPath, 'utf8'));
293
+ if (run.status === 'running' || run.pendingDelegation !== null && run.pendingDelegation !== undefined) {
294
+ return { code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE', message: 'task orchestration has an active or pending delegation; settle it before moving the task directory' };
295
+ }
296
+ }
297
+ catch {
298
+ return { code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE', message: 'task orchestration state is unreadable; refusing to move the task directory' };
299
+ }
300
+ return null;
301
+ }
302
+ function runtimeMoveSafety(request, movingTaskDirectory, options, taskId, repoRoot, sourcePath, validateControlManifestIdentity) {
303
+ if (!movingTaskDirectory)
304
+ return null;
305
+ const controlSafety = taskBoundControlMoveSafety(request, options.controlRoot, options, taskId, repoRoot, validateControlManifestIdentity);
306
+ return controlSafety ?? orchestrationMoveSafety(sourcePath);
307
+ }
218
308
  function targetContainsTask(targetPath, taskId, request) {
219
309
  try {
220
310
  const stat = fs.lstatSync(targetPath);
@@ -546,37 +636,6 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
546
636
  const targetPath = path.join(repoRoot, '.agents', 'workspace', spec.target, taskId);
547
637
  const requiredSteps = stepsForIntent(request.intent);
548
638
  const movingTaskDirectory = sourcePath !== targetPath;
549
- const assertRuntimeMoveSafe = () => {
550
- if (!movingTaskDirectory)
551
- return null;
552
- const controlManifest = path.join(sourcePath, '.runtime', 'sandbox-control', 'manifest.json');
553
- const controlLease = path.join(sourcePath, '.runtime', 'sandbox-control', 'lease.json');
554
- if (request.intent !== 'complete' && (fs.existsSync(controlManifest) || fs.existsSync(controlLease))) {
555
- return {
556
- code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
557
- message: 'task-bound sandbox control state exists; stop and remove the verified sandbox before moving the task directory'
558
- };
559
- }
560
- const orchestrationPath = path.join(sourcePath, '.runtime', 'orchestration.json');
561
- if (fs.existsSync(orchestrationPath)) {
562
- try {
563
- const run = JSON.parse(fs.readFileSync(orchestrationPath, 'utf8'));
564
- if (run.status === 'running' || run.pendingDelegation !== null && run.pendingDelegation !== undefined) {
565
- return {
566
- code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
567
- message: 'task orchestration has an active or pending delegation; settle it before moving the task directory'
568
- };
569
- }
570
- }
571
- catch {
572
- return {
573
- code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
574
- message: 'task orchestration state is unreadable; refusing to move the task directory'
575
- };
576
- }
577
- }
578
- return null;
579
- };
580
639
  const currentTaskFile = path.join(sourcePath, 'task.md');
581
640
  const existingJournal = fs.existsSync(path.join(sourcePath, '.task-lifecycle.json'));
582
641
  if (sourceState === spec.target && !existingJournal) {
@@ -599,7 +658,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
599
658
  && !(request.intent === 'complete' && targetContainsTask(targetPath, taskId, request))) {
600
659
  return failed(request, { code: 'LIFECYCLE_TARGET_CONFLICT', message: `target directory already exists: ${targetPath}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
601
660
  }
602
- const moveSafety = assertRuntimeMoveSafe();
661
+ const moveSafety = runtimeMoveSafety(request, movingTaskDirectory, options, taskId, repoRoot, sourcePath, true);
603
662
  if (moveSafety)
604
663
  return failed(request, moveSafety, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
605
664
  const content = io.readFileSync(currentTaskFile);
@@ -757,7 +816,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
757
816
  if (fs.existsSync(targetPath) && !fs.existsSync(path.join(sourcePath, 'task.md')))
758
817
  completed.add('directory-moved');
759
818
  if (!completed.has('directory-moved')) {
760
- const moveSafety = assertRuntimeMoveSafe();
819
+ const moveSafety = runtimeMoveSafety(request, movingTaskDirectory, options, taskId, repoRoot, sourcePath, false);
761
820
  if (moveSafety)
762
821
  return failed(request, moveSafety, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, completedSteps: [...completed], changed: completed.size > 0, timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
763
822
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "protocolVersion": 3,
3
- "packageVersion": "0.11.13",
4
- "sourceInputHash": "70e89f362895c1f8f976011715a86b47e06c105ca4285eacbb3fa51d0459a282",
5
- "internalExecutableBuildHash": "f183e6d39ba985dbb2962eb951b4019af9594361ca3c46b88bf65fff325d7ad4"
3
+ "packageVersion": "0.11.14",
4
+ "sourceInputHash": "24a7d69b687f9ea8fdee5f47f7e59d1b53e13452a7f4f8edd9ca5b494cb5a0d4",
5
+ "internalExecutableBuildHash": "9825f6d7ca5d82e4c5f774b94f0d5e94e1b312583406eeb89a74d717ad757c4a"
6
6
  }