@fitlab-ai/agent-infra 0.11.13 → 0.11.15

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 (115) 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 +7 -5
  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/pr-change-report.js +10 -3
  14. package/dist/lib/platform/release-notes.js +2 -2
  15. package/dist/lib/process-data/index.js +15 -4
  16. package/dist/lib/sandbox/commands/control-status.js +0 -1
  17. package/dist/lib/sandbox/commands/create.js +5 -4
  18. package/dist/lib/sandbox/config.js +12 -0
  19. package/dist/lib/sandbox/control/audit.js +1 -2
  20. package/dist/lib/sandbox/control/client.js +12 -47
  21. package/dist/lib/sandbox/control/executor.js +6 -10
  22. package/dist/lib/sandbox/control/host-recovery.js +89 -27
  23. package/dist/lib/sandbox/control/lifecycle.js +22 -37
  24. package/dist/lib/sandbox/control/protocol.js +8 -36
  25. package/dist/lib/sandbox/control/server.js +2 -21
  26. package/dist/lib/sandbox/environment.js +7 -8
  27. package/dist/lib/sandbox/mounts.js +71 -59
  28. package/dist/lib/sandbox/recovery.js +2 -3
  29. package/dist/lib/sandbox/removal.js +19 -4
  30. package/dist/lib/sandbox/workspace-identity.js +30 -15
  31. package/dist/lib/sandbox/workspace-view.js +41 -4
  32. package/dist/lib/task/control-authority.js +39 -17
  33. package/dist/lib/task/control-recovery.js +1 -2
  34. package/dist/lib/task/finalization.js +13 -2
  35. package/dist/lib/task/lifecycle.js +122 -35
  36. package/dist/lib/task/short-id.js +2 -2
  37. package/dist/lifecycle-build-manifest.json +3 -3
  38. package/lib/agent-clients/adapter.ts +0 -27
  39. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +6 -12
  40. package/lib/agent-clients/adapters/codex-lifecycle/cli.ts +1 -5
  41. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +0 -7
  42. package/lib/agent-clients/adapters/codex-lifecycle/recovery.ts +1 -5
  43. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +1 -4
  44. package/lib/agent-clients/adapters/codex-orchestration.ts +1 -3
  45. package/lib/agent-clients/adapters/codex.ts +0 -2
  46. package/lib/internal/cli-route-inventory.ts +0 -1
  47. package/lib/internal/task-finalization.ts +8 -6
  48. package/lib/internal/task-lifecycle.ts +3 -1
  49. package/lib/internal/task-operation-registry.ts +1 -3
  50. package/lib/internal/task-validate.ts +13 -127
  51. package/lib/platform/pr-change-report.ts +9 -3
  52. package/lib/platform/release-notes.ts +2 -2
  53. package/lib/process-data/index.ts +15 -4
  54. package/lib/sandbox/commands/control-status.ts +0 -1
  55. package/lib/sandbox/commands/create.ts +5 -4
  56. package/lib/sandbox/config.ts +13 -0
  57. package/lib/sandbox/control/audit.ts +1 -2
  58. package/lib/sandbox/control/client.ts +13 -61
  59. package/lib/sandbox/control/executor.ts +7 -13
  60. package/lib/sandbox/control/host-recovery.ts +94 -21
  61. package/lib/sandbox/control/lifecycle.ts +22 -37
  62. package/lib/sandbox/control/protocol.ts +9 -45
  63. package/lib/sandbox/control/server.ts +2 -17
  64. package/lib/sandbox/environment.ts +7 -8
  65. package/lib/sandbox/mounts.ts +92 -75
  66. package/lib/sandbox/recovery.ts +2 -3
  67. package/lib/sandbox/removal.ts +16 -4
  68. package/lib/sandbox/workspace-identity.ts +36 -13
  69. package/lib/sandbox/workspace-view.ts +40 -5
  70. package/lib/task/control-authority.ts +40 -26
  71. package/lib/task/control-recovery.ts +2 -3
  72. package/lib/task/finalization.ts +15 -2
  73. package/lib/task/lifecycle.ts +135 -33
  74. package/lib/task/short-id.ts +5 -2
  75. package/package.json +1 -1
  76. package/templates/.agents/rules/lifecycle-orchestration.en.md +3 -4
  77. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +3 -4
  78. package/templates/.agents/rules/task-short-id.en.md +18 -12
  79. package/templates/.agents/rules/task-short-id.zh-CN.md +12 -7
  80. package/templates/.agents/skills/complete-task/SKILL.en.md +10 -2
  81. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +10 -2
  82. package/templates/.agents/skills/create-pr/reference/change-report.en.md +12 -2
  83. package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +12 -2
  84. package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
  85. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
  86. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +5 -5
  87. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +5 -5
  88. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +13 -2
  89. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +13 -2
  90. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -0
  91. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -0
  92. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  93. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  94. package/templates/.agents/skills/watch-pr/SKILL.en.md +19 -2
  95. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +20 -3
  96. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +14 -0
  97. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +15 -1
  98. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-client.js +0 -130
  99. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +0 -127
  100. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-operation.js +0 -131
  101. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-registration.js +0 -275
  102. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.js +0 -106
  103. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +0 -345
  104. package/dist/lib/runtime/agent-runtime.js +0 -33
  105. package/dist/lib/sandbox/control/agent-operation.js +0 -34
  106. package/dist/lib/task/runtime-paths.js +0 -10
  107. package/lib/agent-clients/adapters/codex-lifecycle/controller-client.ts +0 -158
  108. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +0 -163
  109. package/lib/agent-clients/adapters/codex-lifecycle/controller-operation.ts +0 -151
  110. package/lib/agent-clients/adapters/codex-lifecycle/controller-registration.ts +0 -352
  111. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.ts +0 -109
  112. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +0 -437
  113. package/lib/runtime/agent-runtime.ts +0 -41
  114. package/lib/sandbox/control/agent-operation.ts +0 -49
  115. package/lib/task/runtime-paths.ts +0 -12
@@ -15,6 +15,89 @@ export type SandboxBindMountDeclaration = {
15
15
  readOnly: boolean;
16
16
  };
17
17
 
18
+ function workspaceStateMounts(workspaceViewRoot: string, taskBound: boolean): SandboxBindMountDeclaration[] {
19
+ return sandboxWorkspaceViewStatePaths(workspaceViewRoot)
20
+ // The writable completed-task mount below is nested under this path. Keep
21
+ // the worktree target visible so Docker can mount it without creating a
22
+ // mountpoint inside a read-only parent bind mount.
23
+ .filter(({ state }) => !(taskBound && state === 'completed'))
24
+ .map(({ state, hostPath }) => taskBound && state === 'active'
25
+ ? {
26
+ hostPaths: [path.join(hostPath, '.short-ids.json')],
27
+ containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
28
+ readOnly: true
29
+ }
30
+ : {
31
+ hostPaths: [hostPath],
32
+ containerPath: path.posix.join('/workspace/.agents/workspace', state),
33
+ readOnly: true
34
+ }
35
+ );
36
+ }
37
+
38
+ function assertGitMetadataPair(overrides: {
39
+ gitCommonDir?: string;
40
+ worktreeGitFile?: string;
41
+ }): void {
42
+ if (Boolean(overrides.gitCommonDir) !== Boolean(overrides.worktreeGitFile)) {
43
+ throw new Error('SANDBOX_GIT_METADATA_INCOMPLETE');
44
+ }
45
+ }
46
+
47
+ function gitCommonMount(gitCommonDir?: string): SandboxBindMountDeclaration[] {
48
+ return gitCommonDir ? [{ hostPaths: [gitCommonDir], containerPath: SANDBOX_GIT_COMMON_DIR, readOnly: false }] : [];
49
+ }
50
+
51
+ function worktreeGitFileMount(worktreeGitFile?: string): SandboxBindMountDeclaration[] {
52
+ return worktreeGitFile ? [{ hostPaths: [worktreeGitFile], containerPath: '/workspace/.git', readOnly: true }] : [];
53
+ }
54
+
55
+ function taskWorkspaceMounts(taskSources: string[], taskBound: boolean, overrides: {
56
+ taskId?: string;
57
+ completedTaskSource?: string;
58
+ }): SandboxBindMountDeclaration[] {
59
+ if (!taskBound) return [];
60
+ const taskId = overrides.taskId!;
61
+ const mounts: SandboxBindMountDeclaration[] = [{
62
+ hostPaths: taskSources,
63
+ containerPath: path.posix.join('/workspace/.agents/workspace/active', taskId),
64
+ readOnly: false
65
+ }];
66
+ if (overrides.completedTaskSource) mounts.push({
67
+ hostPaths: [overrides.completedTaskSource],
68
+ containerPath: path.posix.join('/workspace/.agents/workspace/completed', taskId),
69
+ readOnly: false
70
+ });
71
+ return mounts;
72
+ }
73
+
74
+ function sharedMounts(config: Pick<SandboxConfig, 'repoRoot' | 'worktreeBase' | 'shareBase' | 'shellConfigBase'>, branch: string,
75
+ shellConfigHostDir?: string): SandboxBindMountDeclaration[] {
76
+ return [{
77
+ hostPaths: [shareCommonDir(config)], containerPath: '/share/common', readOnly: false
78
+ }, {
79
+ hostPaths: [shareBranchDir(config, branch)], containerPath: '/share/branch', readOnly: false
80
+ }, {
81
+ hostPaths: shellConfigHostDir ? [shellConfigHostDir] : shellConfigDirCandidates(config, branch),
82
+ containerPath: '/home/devuser/.host-shell-config', readOnly: true
83
+ }];
84
+ }
85
+
86
+ function controlMounts(overrides: { controlDir: string; controlRootDir?: string; controlStatusDir: string; runtimeDir?: string }, taskBound: boolean): SandboxBindMountDeclaration[] {
87
+ const mounts: SandboxBindMountDeclaration[] = [{
88
+ hostPaths: [overrides.controlStatusDir], containerPath: '/run/agent-infra/control-status', readOnly: true
89
+ }, {
90
+ hostPaths: [overrides.controlDir], containerPath: '/run/agent-infra/control', readOnly: false
91
+ }];
92
+ if (taskBound && overrides.runtimeDir) mounts.push({
93
+ hostPaths: [overrides.runtimeDir], containerPath: '/run/agent-infra/runtime', readOnly: false
94
+ });
95
+ if (taskBound && overrides.controlRootDir) mounts.push({
96
+ hostPaths: [overrides.controlRootDir], containerPath: '/run/agent-infra/control-root', readOnly: false
97
+ });
98
+ return mounts;
99
+ }
100
+
18
101
  export function sandboxCoreBindMounts(
19
102
  config: Pick<
20
103
  SandboxConfig,
@@ -26,6 +109,7 @@ export function sandboxCoreBindMounts(
26
109
  shellConfigHostDir?: string;
27
110
  workspaceViewRoot: string;
28
111
  controlDir: string;
112
+ controlRootDir?: string;
29
113
  controlStatusDir: string;
30
114
  gitCommonDir?: string;
31
115
  worktreeGitFile?: string;
@@ -37,30 +121,10 @@ export function sandboxCoreBindMounts(
37
121
  ): SandboxBindMountDeclaration[] {
38
122
  const taskSources = overrides.taskSources ?? [];
39
123
  const taskBound = Boolean(taskSources.length > 0 && overrides.taskId);
40
- const workspaceMounts = sandboxWorkspaceViewStatePaths(overrides.workspaceViewRoot).map(
41
- ({ state, hostPath }) => taskBound && state === 'active'
42
- ? {
43
- hostPaths: [path.join(hostPath, '.short-ids.json')],
44
- containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
45
- readOnly: true
46
- }
47
- : {
48
- hostPaths: [hostPath],
49
- containerPath: path.posix.join('/workspace/.agents/workspace', state),
50
- readOnly: true
51
- }
52
- );
53
- if (Boolean(overrides.gitCommonDir) !== Boolean(overrides.worktreeGitFile)) {
54
- throw new Error('SANDBOX_GIT_METADATA_INCOMPLETE');
55
- }
56
- const gitCommonMount = overrides.gitCommonDir
57
- ? { hostPaths: [overrides.gitCommonDir], containerPath: SANDBOX_GIT_COMMON_DIR, readOnly: false }
58
- : null;
59
- const worktreeGitFileMount = overrides.worktreeGitFile
60
- ? { hostPaths: [overrides.worktreeGitFile], containerPath: '/workspace/.git', readOnly: true }
61
- : null;
124
+ assertGitMetadataPair(overrides);
125
+ if (taskBound && !overrides.controlRootDir) throw new Error('SANDBOX_CONTROL_ROOT_MISSING');
62
126
  const mounts: SandboxBindMountDeclaration[] = [
63
- ...(gitCommonMount ? [gitCommonMount] : []),
127
+ ...gitCommonMount(overrides.gitCommonDir),
64
128
  {
65
129
  hostPaths: overrides.worktree
66
130
  ? [overrides.worktree]
@@ -68,58 +132,11 @@ export function sandboxCoreBindMounts(
68
132
  containerPath: '/workspace',
69
133
  readOnly: false
70
134
  },
71
- ...(worktreeGitFileMount ? [worktreeGitFileMount] : []),
72
- ...workspaceMounts,
135
+ ...worktreeGitFileMount(overrides.worktreeGitFile),
136
+ ...workspaceStateMounts(overrides.workspaceViewRoot, taskBound),
137
+ ...taskWorkspaceMounts(taskSources, taskBound, overrides),
138
+ ...sharedMounts(config, branch, overrides.shellConfigHostDir),
139
+ ...controlMounts(overrides, taskBound)
73
140
  ];
74
- if (taskBound) {
75
- const taskMountPath = path.posix.join('/workspace/.agents/workspace/active', overrides.taskId!);
76
- mounts.push({
77
- hostPaths: taskSources,
78
- containerPath: taskMountPath,
79
- readOnly: false
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
- }
88
- }
89
- mounts.push(
90
- {
91
- hostPaths: [shareCommonDir(config)],
92
- containerPath: '/share/common',
93
- readOnly: false
94
- },
95
- {
96
- hostPaths: [shareBranchDir(config, branch)],
97
- containerPath: '/share/branch',
98
- readOnly: false
99
- },
100
- {
101
- hostPaths: overrides.shellConfigHostDir
102
- ? [overrides.shellConfigHostDir]
103
- : shellConfigDirCandidates(config, branch),
104
- containerPath: '/home/devuser/.host-shell-config',
105
- readOnly: true
106
- }
107
- );
108
- mounts.push({
109
- hostPaths: [overrides.controlStatusDir],
110
- containerPath: '/run/agent-infra/control-status',
111
- readOnly: true
112
- }, {
113
- hostPaths: [overrides.controlDir],
114
- containerPath: '/run/agent-infra/control',
115
- readOnly: false
116
- });
117
- if (taskBound && overrides.runtimeDir) {
118
- mounts.push({
119
- hostPaths: [overrides.runtimeDir],
120
- containerPath: '/run/agent-infra/runtime',
121
- readOnly: false
122
- });
123
- }
124
141
  return mounts;
125
142
  }
@@ -342,7 +342,6 @@ async function ensureSandboxControlBroker(params: {
342
342
  }): Promise<void> {
343
343
  const control = sandboxControlPaths({
344
344
  base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
345
- repoRoot: params.config.repoRoot,
346
345
  project: params.config.project,
347
346
  container: params.container,
348
347
  identity: params.workspace
@@ -753,7 +752,6 @@ function expectedMounts(params: {
753
752
  });
754
753
  const control = sandboxControlPaths({
755
754
  base: config.controlBase ?? path.join(config.home, '.agent-infra', 'sandbox-control'),
756
- repoRoot: config.repoRoot,
757
755
  project: config.project,
758
756
  container: params.container,
759
757
  identity: params.workspace
@@ -778,6 +776,7 @@ function expectedMounts(params: {
778
776
  }),
779
777
  workspaceViewRoot: view.root,
780
778
  controlDir: control.channelDir,
779
+ ...(params.workspace.mode === 'task-bound' ? { controlRootDir: control.root } : {}),
781
780
  controlStatusDir: control.statusDir,
782
781
  ...(taskSources === null
783
782
  ? {}
@@ -1296,7 +1295,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1296
1295
  let completedReentry: { manifest: SandboxControlManifest; evidence: Awaited<ReturnType<typeof prepareCompletedReentry>> } | null = null;
1297
1296
  if (params.reentry === 'completed' && params.workspace?.mode === 'task-bound') {
1298
1297
  const control = sandboxControlPaths({
1299
- base: params.config.controlBase, repoRoot: params.config.repoRoot, project: params.config.project,
1298
+ base: params.config.controlBase, project: params.config.project,
1300
1299
  container: params.row.name, identity: params.workspace
1301
1300
  });
1302
1301
  const manifest = readSandboxControlManifest(control.manifestPath);
@@ -829,7 +829,7 @@ function resolveRmTarget(
829
829
  ? matchedContainers
830
830
  : [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
831
831
  const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
832
- base: config.controlBase, repoRoot: config.repoRoot, project: config.project, container, identity
832
+ base: config.controlBase, project: config.project, container, identity
833
833
  }).root));
834
834
  const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
835
835
  base: config.workspaceViewBase, project: config.project, container, identity
@@ -1083,6 +1083,19 @@ async function rmPurgeCore(
1083
1083
  const containers = runEngine(engine, 'docker', [
1084
1084
  'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
1085
1085
  ]).split('\n').filter(Boolean);
1086
+ const controlRoots = rows.flatMap((row) => {
1087
+ if (!row.branch) return [];
1088
+ const identity = row.workspaceMode === 'task-bound' && row.taskId
1089
+ ? { mode: 'task-bound' as const, taskId: row.taskId }
1090
+ : row.workspaceMode === 'branch-only'
1091
+ ? { mode: 'branch-only' as const }
1092
+ : null;
1093
+ if (!identity) return [];
1094
+ return [sandboxControlPaths({
1095
+ base: config.controlBase, project: config.project,
1096
+ container: row.name, identity
1097
+ }).root];
1098
+ });
1086
1099
  const completedTaskIds = [...new Set(rows
1087
1100
  .filter((row) => row.workspaceMode === 'task-bound' && row.taskId)
1088
1101
  .map((row) => row.taskId!))];
@@ -1115,9 +1128,8 @@ async function rmPurgeCore(
1115
1128
  if (!isCancel(selected) && selected) fs.rmSync(config.shareBase, { recursive: true, force: true });
1116
1129
  else cleanupCompleted = false;
1117
1130
  }
1118
- for (const base of [config.workspaceViewBase, config.controlBase]) {
1119
- fs.rmSync(path.join(base, config.project), { recursive: true, force: true });
1120
- }
1131
+ fs.rmSync(path.join(config.workspaceViewBase, config.project), { recursive: true, force: true });
1132
+ for (const root of new Set(controlRoots)) fs.rmSync(root, { recursive: true, force: true });
1121
1133
  if (cleanupCompleted) {
1122
1134
  for (const taskId of completedTaskIds) releaseStaleShortIdRegistry(config.repoRoot, taskId);
1123
1135
  }
@@ -1,8 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { isRemovedHashShortIdInput } from '../task/short-id.ts';
4
- import { resolveTaskRef } from '../task/resolve-ref.ts';
5
3
  import {
4
+ configuredShortIdLength,
5
+ inspectShortIdRegistry,
6
+ isRemovedHashShortIdInput,
7
+ normalizeShortIdInput
8
+ } from '../task/short-id.ts';
9
+ import {
10
+ TASK_WORKSPACE_STATES,
11
+ listTaskWorkspaces,
6
12
  resolveTaskWorkspace,
7
13
  type TaskWorkspaceState
8
14
  } from './task-resolver.ts';
@@ -79,10 +85,29 @@ function taskIdentity(repoRoot: string, taskId: string, registry: Record<string,
79
85
 
80
86
  function cleanupTaskId(requestedRef: string, repoRoot: string): string | null {
81
87
  if (TASK_ID_RE.test(requestedRef)) return requestedRef;
82
- if (!SHORT_ID_RE.test(requestedRef)) return null;
83
- const task = resolveTaskRef(requestedRef, { repoRoot });
84
- if (!task.ok) throw new Error(task.message);
85
- return task.taskId;
88
+ const shortIdLength = configuredShortIdLength(repoRoot);
89
+ const normalized = normalizeShortIdInput(requestedRef, { shortIdLength });
90
+ if (normalized.kind === 'error') throw new Error(normalized.message);
91
+ if (normalized.kind !== 'shortId') return null;
92
+
93
+ const registry = inspectShortIdRegistry(repoRoot, shortIdLength);
94
+ if (registry.status !== 'valid') throw new Error(registry.error.message);
95
+ const match = [...registry.shortIds].find(([, shortId]) => shortId === normalized.value);
96
+ if (!match) {
97
+ throw new Error(`Short id '${normalized.value}' not found in active task registry`);
98
+ }
99
+ return match[0];
100
+ }
101
+
102
+ function cleanupTaskIdByBranch(requestedRef: string, repoRoot: string): string | null {
103
+ const taskIds = new Set(TASK_WORKSPACE_STATES
104
+ .flatMap((state) => listTaskWorkspaces(repoRoot, state))
105
+ .filter((task) => task.branch === requestedRef)
106
+ .map((task) => task.taskId));
107
+ if (taskIds.size > 1) {
108
+ throw new Error(`SANDBOX_TASK_IDENTITY_AMBIGUOUS: branch '${requestedRef}' is bound to multiple tasks`);
109
+ }
110
+ return taskIds.values().next().value ?? null;
86
111
  }
87
112
 
88
113
  export function resolveSandboxTarget(requestedRef: string, repoRoot: string): SandboxTarget {
@@ -139,7 +164,8 @@ export function resolveSandboxCleanupTarget(
139
164
  throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
140
165
  }
141
166
 
142
- const taskId = cleanupTaskId(requestedRef, repoRoot);
167
+ const taskId = cleanupTaskId(requestedRef, repoRoot)
168
+ ?? cleanupTaskIdByBranch(requestedRef, repoRoot);
143
169
  if (taskId) {
144
170
  let task: ReturnType<typeof resolveTaskWorkspace>;
145
171
  try {
@@ -166,14 +192,11 @@ export function resolveSandboxCleanupTarget(
166
192
  };
167
193
  }
168
194
 
169
- const target = resolveSandboxTarget(requestedRef, repoRoot);
170
195
  return {
171
196
  requestedRef,
172
- branch: target.branch,
173
- workspace: target.workspace.mode === 'task-bound'
174
- ? { mode: 'task-bound', taskId: target.workspace.taskId }
175
- : { mode: 'branch-only' },
176
- taskState: target.workspace.mode === 'task-bound' ? 'active' : 'branch-only'
197
+ branch: requestedRef,
198
+ workspace: { mode: 'branch-only' },
199
+ taskState: 'branch-only'
177
200
  };
178
201
  }
179
202
 
@@ -4,7 +4,6 @@ import path from 'node:path';
4
4
  import type { SandboxWorkspaceIdentity, SandboxWorkspaceKey } from './workspace-identity.ts';
5
5
  import { resolveTaskWorkspace } from './task-resolver.ts';
6
6
  import type { SandboxControlManifest } from './control/protocol.ts';
7
- import { resolveTaskRuntimeRoot } from '../task/runtime-paths.ts';
8
7
  import {
9
8
  createSandboxControlIdentitySentinel,
10
9
  writeSandboxControlIdentitySentinel
@@ -64,13 +63,21 @@ export type SandboxControlManifestDraft = Readonly<Omit<SandboxControlManifest,
64
63
 
65
64
  export function sandboxControlPaths(params: Readonly<{
66
65
  base: string;
67
- repoRoot?: string;
68
66
  project: string;
69
67
  container: string;
70
68
  identity: SandboxWorkspaceIdentity | SandboxWorkspaceKey;
71
69
  }>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; runtimeDir: string; manifestPath: string }> {
72
70
  const root = params.identity.mode === 'task-bound'
73
- ? path.join(resolveTaskRuntimeRoot(params.identity.taskId, { repoRoot: params.repoRoot }), 'sandbox-control')
71
+ ? (() => {
72
+ if (!/^TASK-\d{8}-\d{6}$/.test(params.identity.taskId)
73
+ || !params.project || path.basename(params.project) !== params.project || params.project === '.' || params.project === '..') {
74
+ throw new Error('SANDBOX_CONTROL_ROOT_INVALID');
75
+ }
76
+ const candidate = path.resolve(params.base, params.project, 'tasks', params.identity.taskId);
77
+ const relative = path.relative(path.resolve(params.base), candidate);
78
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('SANDBOX_CONTROL_ROOT_INVALID');
79
+ return candidate;
80
+ })()
74
81
  : (() => {
75
82
  const digest = createHash('sha256').update('branch-only').digest('hex').slice(0, 16);
76
83
  return path.resolve(params.base, params.project, params.container, digest);
@@ -95,6 +102,23 @@ function assertSafeDirectory(directory: string, expectedBase: string): void {
95
102
  }
96
103
  }
97
104
 
105
+ function assertSafeTaskBoundControlRoot(basePath: string, root: string): void {
106
+ const relative = path.relative(basePath, root);
107
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
108
+ throw new Error(`Sandbox control root escapes its configured base: ${root}`);
109
+ }
110
+ if (fs.existsSync(basePath) && fs.lstatSync(basePath).isSymbolicLink()) {
111
+ throw new Error(`Sandbox control base must not be a symbolic link: ${basePath}`);
112
+ }
113
+ let current = basePath;
114
+ for (const segment of relative.split(path.sep)) {
115
+ current = path.join(current, segment);
116
+ if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
117
+ throw new Error(`Sandbox control root must not contain a symbolic link: ${current}`);
118
+ }
119
+ }
120
+ }
121
+
98
122
  function assertSafeMountTargetPath(
99
123
  target: string,
100
124
  worktreeRoot: string,
@@ -263,7 +287,11 @@ export function materializeSandboxControl(params: Readonly<{
263
287
  }>;
264
288
  }>): SandboxControlSetup {
265
289
  const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
266
- assertSafeDirectory(root, params.identity.mode === 'task-bound' ? path.dirname(root) : path.resolve(params.base));
290
+ if (params.identity.mode === 'task-bound') {
291
+ assertSafeTaskBoundControlRoot(path.resolve(params.base), root);
292
+ } else {
293
+ assertSafeDirectory(root, path.resolve(params.base));
294
+ }
267
295
  if (fs.existsSync(root)) {
268
296
  if (!params.replacementLease || path.resolve(params.replacementLease.root) !== root) {
269
297
  throw new Error('SANDBOX_CONTROL_REPLACEMENT_REQUIRED');
@@ -329,9 +357,16 @@ export function finalizeSandboxControlManifest(
329
357
  containerIdentity: { id: identity.id, labels: { ...identity.labels } },
330
358
  authorityEvidence: identity.authorityEvidence
331
359
  };
360
+ const persistedManifest = {
361
+ ...manifest,
362
+ channelDir: 'channel',
363
+ publicStatusDir: 'public',
364
+ processingDir: 'processing',
365
+ runtimeDir: 'runtime'
366
+ };
332
367
  const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
333
368
  try {
334
- fs.writeFileSync(temporary, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' });
369
+ fs.writeFileSync(temporary, `${JSON.stringify(persistedManifest)}\n`, { mode: 0o600, flag: 'wx' });
335
370
  fs.renameSync(temporary, setup.manifestPath);
336
371
  } finally {
337
372
  fs.rmSync(temporary, { force: true });
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { normalizeAgentToken } from '../agent-clients/tokens.ts';
4
4
  import { isAgentClientId } from '../agent-clients/types.ts';
5
5
  import type { AgentClientId } from '../agent-clients/types.ts';
6
+ import { sandboxControlPaths } from '../sandbox/workspace-view.ts';
6
7
  import { getAgentClientAdapter } from '../agent-clients/registry.ts';
7
8
  import {
8
9
  activateMatchingOrchestrationDelegation,
@@ -41,18 +42,14 @@ import { resolveTaskContext, resolveTaskRef, TASK_ID_RE } from './resolve-ref.ts
41
42
  import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
42
43
  import { verifyTaskEvent } from './verification.ts';
43
44
 
44
- export type TaskControlControllerBinding = Readonly<{
45
- instanceDigest: string;
46
- controlGeneration: string;
47
- }>;
48
-
49
45
  export type TaskControlExecutionContext =
50
46
  | Readonly<{
51
47
  source: 'direct-host';
52
48
  mode: 'direct-host';
53
49
  repoRoot: string;
54
50
  runtimeDir?: string;
55
- controllerBinding?: TaskControlControllerBinding | null;
51
+ controlBase?: string;
52
+ project?: string;
56
53
  }>
57
54
  | Readonly<{
58
55
  source: 'sandbox-executor';
@@ -64,7 +61,6 @@ export type TaskControlExecutionContext =
64
61
  generation: string;
65
62
  manifestPath: string;
66
63
  requestId: string;
67
- controllerBinding?: TaskControlControllerBinding | null;
68
64
  }>;
69
65
 
70
66
  export type TaskControlOrchestrationIntent =
@@ -112,25 +108,19 @@ function absolute(name: string, value: string): string {
112
108
  return path.resolve(value);
113
109
  }
114
110
 
115
- function binding(value: TaskControlControllerBinding | null | undefined): TaskControlControllerBinding | null | undefined {
116
- if (value === null || value === undefined) return value;
117
- if (!/^[a-f0-9]{64}$/u.test(value.instanceDigest) || !value.controlGeneration) {
118
- invalidContext('controller binding is invalid');
119
- }
120
- return value;
121
- }
122
-
123
111
  export function createDirectHostExecutionContext(params: Readonly<{
124
112
  repoRoot: string;
125
113
  runtimeDir?: string;
126
- controllerBinding?: TaskControlControllerBinding | null;
114
+ controlBase?: string;
115
+ project?: string;
127
116
  }>): TaskControlExecutionContext {
128
117
  return {
129
118
  source: 'direct-host',
130
119
  mode: 'direct-host',
131
120
  repoRoot: absolute('repoRoot', params.repoRoot),
132
- ...(params.runtimeDir === undefined ? {} : { runtimeDir: absolute('runtimeDir', params.runtimeDir) }),
133
- ...(params.controllerBinding === undefined ? {} : { controllerBinding: binding(params.controllerBinding) }),
121
+ ...(params.controlBase === undefined ? {} : { controlBase: absolute('controlBase', params.controlBase) }),
122
+ ...(params.project === undefined ? {} : { project: params.project }),
123
+ ...(params.runtimeDir === undefined ? {} : { runtimeDir: absolute('runtimeDir', params.runtimeDir) })
134
124
  };
135
125
  }
136
126
 
@@ -142,7 +132,6 @@ export function createSandboxExecutorExecutionContext(params: Readonly<{
142
132
  generation: string;
143
133
  manifestPath: string;
144
134
  requestId: string;
145
- controllerBinding?: TaskControlControllerBinding | null;
146
135
  }>): TaskControlExecutionContext {
147
136
  if (!/^TASK-\d{8}-\d{6}$/u.test(params.taskId) || !params.generation || !params.requestId) {
148
137
  invalidContext('sandbox binding is incomplete');
@@ -160,8 +149,7 @@ export function createSandboxExecutorExecutionContext(params: Readonly<{
160
149
  taskId: params.taskId,
161
150
  generation: params.generation,
162
151
  manifestPath: absolute('manifestPath', params.manifestPath),
163
- requestId: params.requestId,
164
- ...(params.controllerBinding === undefined ? {} : { controllerBinding: binding(params.controllerBinding) }),
152
+ requestId: params.requestId
165
153
  };
166
154
  }
167
155
 
@@ -170,7 +158,8 @@ export function assertTaskControlExecutionContext(context: TaskControlExecutionC
170
158
  if (context.mode !== 'direct-host') invalidContext('direct-host mode is inconsistent');
171
159
  absolute('repoRoot', context.repoRoot);
172
160
  if (context.runtimeDir !== undefined) absolute('runtimeDir', context.runtimeDir);
173
- binding(context.controllerBinding);
161
+ if (Boolean(context.controlBase) !== Boolean(context.project)) invalidContext('control configuration is incomplete');
162
+ if (context.controlBase !== undefined) absolute('controlBase', context.controlBase);
174
163
  return;
175
164
  }
176
165
  if (context.mode !== 'task-bound-sandbox' || !/^TASK-\d{8}-\d{6}$/u.test(context.taskId)
@@ -184,7 +173,6 @@ export function assertTaskControlExecutionContext(context: TaskControlExecutionC
184
173
  if (path.resolve(context.runtimeDir) !== path.join(path.dirname(path.resolve(context.manifestPath)), 'runtime')) {
185
174
  invalidContext('runtimeDir is not bound to the manifest control root');
186
175
  }
187
- binding(context.controllerBinding);
188
176
  }
189
177
 
190
178
  function boundTaskRef(context: TaskControlExecutionContext, taskRef: string): string {
@@ -225,6 +213,8 @@ async function applyLifecycleWithAuthority(
225
213
  context: TaskControlExecutionContext,
226
214
  request: TaskLifecycleControlRequest
227
215
  ): Promise<TaskLifecycleResult | LifecycleRecoveryResult> {
216
+ const taskId = lifecycleTaskId(request, context.repoRoot);
217
+ const controlRoot = taskId ? controlRootForTask(context, taskId) : undefined;
228
218
  const execute = async (underTaskLock = false): Promise<TaskLifecycleResult | LifecycleRecoveryResult> => {
229
219
  if (request.intent === 'recover-started') {
230
220
  const adapter = isAgentClientId(request.agent)
@@ -240,10 +230,17 @@ async function applyLifecycleWithAuthority(
240
230
  return adapter.recoverStarted(request as LifecycleRecoveryRequest, { repoRoot: context.repoRoot });
241
231
  }
242
232
  return underTaskLock
243
- ? applyTaskLifecycleWithinTaskLock(request, { repoRoot: context.repoRoot }, taskId!)
244
- : applyTaskLifecycle(request, { repoRoot: context.repoRoot });
233
+ ? applyTaskLifecycleWithinTaskLock(request, {
234
+ repoRoot: context.repoRoot,
235
+ ...(controlRoot ? { controlRoot } : {}),
236
+ ...(context.source === 'direct-host' && context.project ? { controlProject: context.project } : {})
237
+ }, taskId!)
238
+ : applyTaskLifecycle(request, {
239
+ repoRoot: context.repoRoot,
240
+ ...(controlRoot ? { controlRoot } : {}),
241
+ ...(context.source === 'direct-host' && context.project ? { controlProject: context.project } : {})
242
+ });
245
243
  };
246
- const taskId = lifecycleTaskId(request, context.repoRoot);
247
244
  if (!taskId) return await execute();
248
245
  try {
249
246
  return await withTaskExecutionLock(
@@ -266,6 +263,17 @@ async function applyLifecycleWithAuthority(
266
263
  }
267
264
  }
268
265
 
266
+ function controlRootForTask(context: TaskControlExecutionContext, taskId: string): string | undefined {
267
+ if (context.source === 'sandbox-executor') return path.dirname(context.manifestPath);
268
+ if (!context.controlBase || !context.project) return undefined;
269
+ return sandboxControlPaths({
270
+ base: context.controlBase,
271
+ project: context.project,
272
+ container: '',
273
+ identity: { mode: 'task-bound', taskId }
274
+ }).root;
275
+ }
276
+
269
277
  function orchestration(
270
278
  context: TaskControlExecutionContext,
271
279
  operation: Extract<TaskControlOperation, { family: 'task-orchestration' }>
@@ -367,6 +375,10 @@ export function dispatchTaskControlOperation(
367
375
  });
368
376
  }
369
377
  if (operation.family === 'task-finalization') {
378
+ const resolved = resolveTaskRef(boundTaskRef(context, operation.request.taskRef), { repoRoot: context.repoRoot });
379
+ const controlRoot = context.source === 'sandbox-executor'
380
+ ? path.dirname(context.manifestPath)
381
+ : resolved.ok ? controlRootForTask(context, resolved.taskId) : undefined;
370
382
  return applyTaskFinalization({
371
383
  ...operation.request,
372
384
  taskRef: boundTaskRef(context, operation.request.taskRef)
@@ -376,6 +388,8 @@ export function dispatchTaskControlOperation(
376
388
  controlBinding: { generation: context.generation, requestId: context.requestId },
377
389
  manifestPath: context.manifestPath
378
390
  } : {}),
391
+ ...(controlRoot ? { controlRoot } : {}),
392
+ ...(context.source === 'direct-host' && context.project ? { controlProject: context.project } : {}),
379
393
  preflight: (request, options) => verifyTaskEvent(
380
394
  { ...request, event: 'complete-task.hard-preflight' }, options
381
395
  )
@@ -13,7 +13,7 @@ export function parseControlOutput(output: string | null): Record<string, unknow
13
13
  }
14
14
 
15
15
  export type ControlRecoveryOperation = Readonly<{
16
- family: 'task-lifecycle' | 'task-create' | 'agent-client';
16
+ family: 'task-lifecycle' | 'task-create';
17
17
  intent: string;
18
18
  class: string;
19
19
  }>;
@@ -54,8 +54,7 @@ export type ControlRecoveryDecision = Readonly<{
54
54
  const LIFECYCLE_INTENTS = ['block', 'activate', 'cancel', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'] as const;
55
55
  export const SANDBOX_CONTROL_RECOVERY_OPERATIONS: readonly ControlRecoveryOperation[] = Object.freeze([
56
56
  ...LIFECYCLE_INTENTS.map((intent) => ({ family: 'task-lifecycle' as const, intent, class: 'lifecycle-mutation' })),
57
- { family: 'task-create', intent: 'create', class: 'task-create' },
58
- ...(['controller.close', 'controller.verify'] as const).map((intent) => ({ family: 'agent-client' as const, intent, class: 'agent-client-operation' }))
57
+ { family: 'task-create', intent: 'create', class: 'task-create' }
59
58
  ]);
60
59
 
61
60
  export function digestControlRecoveryIntent(family: string, intent: string): string {
@@ -70,6 +70,8 @@ type TaskFinalizationRequest = Readonly<{
70
70
  type TaskFinalizationOptions = Readonly<{
71
71
  repoRoot: string;
72
72
  controlBinding?: Readonly<{ generation: string; requestId: string }>;
73
+ controlRoot?: string;
74
+ controlProject?: string;
73
75
  manifestPath?: string;
74
76
  metadataProvider?: TaskLifecycleOptions['metadataProvider'];
75
77
  lifecycle?: typeof applyTaskLifecycle;
@@ -1068,7 +1070,13 @@ async function prepareUnderLock(
1068
1070
  } else {
1069
1071
  const result = lifecycle(
1070
1072
  { taskRef: taskId, intent: 'complete', agent: request.agent },
1071
- { repoRoot, prepareOnly: true, ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {}) }
1073
+ {
1074
+ repoRoot,
1075
+ prepareOnly: true,
1076
+ ...(options.controlRoot ? { controlRoot: options.controlRoot } : {}),
1077
+ ...(options.controlProject ? { controlProject: options.controlProject } : {}),
1078
+ ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {})
1079
+ }
1072
1080
  );
1073
1081
  lifecycleResult = lifecycleStep(result);
1074
1082
  if (result.status !== 'applied' && result.status !== 'no-op') {
@@ -1207,7 +1215,12 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
1207
1215
  });
1208
1216
  receipt = updateReceipt(repoRoot, receipt, { verification: 'pending' });
1209
1217
  const lifecycleRequest = { taskRef: resolved.taskId, intent: 'complete' as const, agent: request.agent };
1210
- const lifecycleOptions = { repoRoot, ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {}) };
1218
+ const lifecycleOptions = {
1219
+ repoRoot,
1220
+ ...(options.controlRoot ? { controlRoot: options.controlRoot } : {}),
1221
+ ...(options.controlProject ? { controlProject: options.controlProject } : {}),
1222
+ ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {})
1223
+ };
1211
1224
  const result = options.lifecycle
1212
1225
  ? options.lifecycle(lifecycleRequest, lifecycleOptions)
1213
1226
  : applyTaskLifecycleWithinTaskLock(lifecycleRequest, lifecycleOptions, resolved.taskId);