@fitlab-ai/agent-infra 0.11.6 → 0.11.8

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 (192) hide show
  1. package/dist/bin/internal-cli.js +8 -2
  2. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  3. package/dist/lib/agent-clients/config.js +11 -3
  4. package/dist/lib/defaults.json +6 -3
  5. package/dist/lib/git/primary-worktree.js +58 -0
  6. package/dist/lib/internal/cli-route-inventory.js +3 -0
  7. package/dist/lib/internal/platform-pr-review.js +20 -3
  8. package/dist/lib/internal/task-operation-registry.js +3 -1
  9. package/dist/lib/internal/task-platform-recovery.js +56 -0
  10. package/dist/lib/merge.js +25 -5
  11. package/dist/lib/platform/completion-backfill.js +2 -1
  12. package/dist/lib/platform/in-label-sync.js +12 -1
  13. package/dist/lib/platform/issue-comments.js +58 -4
  14. package/dist/lib/platform/issues.js +105 -5
  15. package/dist/lib/platform/operation-coordinator.js +74 -0
  16. package/dist/lib/platform/pr-review.js +107 -1
  17. package/dist/lib/platform/pr-summary.js +79 -57
  18. package/dist/lib/platform/pull-requests.js +125 -7
  19. package/dist/lib/sandbox/commands/create.js +6 -3
  20. package/dist/lib/sandbox/commands/ls.js +4 -3
  21. package/dist/lib/sandbox/commands/rm.js +1 -1
  22. package/dist/lib/sandbox/config.js +22 -4
  23. package/dist/lib/sandbox/control/server.js +6 -2
  24. package/dist/lib/sandbox/git-metadata.js +50 -0
  25. package/dist/lib/sandbox/index.js +1 -1
  26. package/dist/lib/sandbox/mounts.js +12 -0
  27. package/dist/lib/sandbox/recovery.js +11 -0
  28. package/dist/lib/sandbox/removal.js +58 -20
  29. package/dist/lib/sandbox/tools.js +29 -7
  30. package/dist/lib/server/config.js +7 -27
  31. package/dist/lib/server/daemon.js +4 -3
  32. package/dist/lib/server/process-control.js +4 -2
  33. package/dist/lib/server/runner.js +4 -3
  34. package/dist/lib/task/activity-intent.js +4 -3
  35. package/dist/lib/task/artifact-lifecycle.js +42 -10
  36. package/dist/lib/task/artifact-receipts.js +62 -22
  37. package/dist/lib/task/capabilities.js +22 -4
  38. package/dist/lib/task/completion-facts.js +56 -0
  39. package/dist/lib/task/create-service.js +11 -5
  40. package/dist/lib/task/create.js +60 -79
  41. package/dist/lib/task/events.js +268 -195
  42. package/dist/lib/task/finalization-artifacts.js +4 -1
  43. package/dist/lib/task/finalization-handoff.js +2 -1
  44. package/dist/lib/task/finalization.js +157 -24
  45. package/dist/lib/task/invalidation-command.js +4 -2
  46. package/dist/lib/task/invalidation.js +38 -9
  47. package/dist/lib/task/lifecycle.js +38 -2
  48. package/dist/lib/task/local-artifact-finalization.js +17 -3
  49. package/dist/lib/task/platform-operation-journal.js +152 -0
  50. package/dist/lib/task/platform-operation-recovery.js +459 -0
  51. package/dist/lib/task/qualification-audit.js +23 -105
  52. package/dist/lib/task/review-finalization.js +7 -0
  53. package/dist/lib/task/review-identity.js +69 -0
  54. package/dist/lib/task/sections.js +6 -5
  55. package/dist/lib/task/task-execution-lock.js +6 -3
  56. package/dist/lib/task/verification-engine.js +14 -63
  57. package/dist/lib/task/verification.js +35 -38
  58. package/dist/lib/task/workflow-warning-intents.js +7 -1
  59. package/dist/lifecycle-build-manifest.json +3 -3
  60. package/lib/agent-clients/adapters/codex.ts +1 -1
  61. package/lib/agent-clients/config.ts +9 -3
  62. package/lib/defaults.json +6 -3
  63. package/lib/git/primary-worktree.ts +62 -0
  64. package/lib/internal/cli-route-inventory.ts +2 -0
  65. package/lib/internal/platform-pr-review.ts +14 -3
  66. package/lib/internal/task-operation-registry.ts +3 -1
  67. package/lib/internal/task-platform-recovery.ts +53 -0
  68. package/lib/merge.ts +26 -6
  69. package/lib/platform/completion-backfill.ts +2 -1
  70. package/lib/platform/in-label-sync.ts +12 -0
  71. package/lib/platform/issue-comments.ts +59 -3
  72. package/lib/platform/issues.ts +103 -5
  73. package/lib/platform/operation-coordinator.ts +96 -0
  74. package/lib/platform/pr-review.ts +113 -1
  75. package/lib/platform/pr-summary.ts +22 -1
  76. package/lib/platform/pull-requests.ts +132 -8
  77. package/lib/sandbox/commands/create.ts +7 -8
  78. package/lib/sandbox/commands/ls.ts +4 -3
  79. package/lib/sandbox/commands/rm.ts +1 -1
  80. package/lib/sandbox/config.ts +23 -5
  81. package/lib/sandbox/control/server.ts +5 -2
  82. package/lib/sandbox/git-metadata.ts +66 -0
  83. package/lib/sandbox/index.ts +1 -1
  84. package/lib/sandbox/mounts.ts +14 -0
  85. package/lib/sandbox/recovery.ts +13 -0
  86. package/lib/sandbox/removal.ts +57 -19
  87. package/lib/sandbox/tool-types.ts +1 -1
  88. package/lib/sandbox/tools.ts +36 -7
  89. package/lib/server/config.ts +7 -29
  90. package/lib/server/daemon.ts +5 -3
  91. package/lib/server/process-control.ts +4 -2
  92. package/lib/server/runner.ts +7 -4
  93. package/lib/server/server.schema.json +0 -4
  94. package/lib/task/activity-intent.ts +4 -3
  95. package/lib/task/artifact-lifecycle.ts +35 -11
  96. package/lib/task/artifact-receipts.ts +55 -15
  97. package/lib/task/capabilities.ts +17 -3
  98. package/lib/task/completion-facts.ts +66 -0
  99. package/lib/task/create-service.ts +12 -6
  100. package/lib/task/create.ts +54 -69
  101. package/lib/task/events.ts +248 -182
  102. package/lib/task/finalization-artifacts.ts +4 -1
  103. package/lib/task/finalization-handoff.ts +2 -1
  104. package/lib/task/finalization.ts +154 -21
  105. package/lib/task/invalidation-command.ts +4 -2
  106. package/lib/task/invalidation.ts +35 -10
  107. package/lib/task/lifecycle.ts +35 -3
  108. package/lib/task/local-artifact-finalization.ts +13 -3
  109. package/lib/task/platform-operation-journal.ts +230 -0
  110. package/lib/task/platform-operation-recovery.ts +435 -0
  111. package/lib/task/qualification-audit.ts +21 -114
  112. package/lib/task/review-finalization.ts +7 -0
  113. package/lib/task/review-identity.ts +85 -0
  114. package/lib/task/sections.ts +6 -6
  115. package/lib/task/task-execution-lock.ts +7 -3
  116. package/lib/task/verification-engine.ts +13 -86
  117. package/lib/task/verification.ts +34 -38
  118. package/lib/task/workflow-warning-intents.ts +9 -1
  119. package/package.json +1 -1
  120. package/templates/.agents/README.en.md +19 -17
  121. package/templates/.agents/README.zh-CN.md +18 -16
  122. package/templates/.agents/rules/README.en.md +2 -1
  123. package/templates/.agents/rules/README.zh-CN.md +2 -1
  124. package/templates/.agents/rules/decision-qualification.en.md +4 -3
  125. package/templates/.agents/rules/decision-qualification.zh-CN.md +4 -3
  126. package/templates/.agents/rules/task-management.en.md +12 -0
  127. package/templates/.agents/rules/task-management.zh-CN.md +12 -0
  128. package/templates/.agents/rules/validation-output.en.md +19 -0
  129. package/templates/.agents/rules/validation-output.zh-CN.md +19 -0
  130. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
  131. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -3
  132. package/templates/.agents/skills/block-task/SKILL.en.md +2 -0
  133. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
  134. package/templates/.agents/skills/cancel-task/SKILL.en.md +6 -2
  135. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +5 -3
  136. package/templates/.agents/skills/code-task/SKILL.en.md +5 -3
  137. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -4
  138. package/templates/.agents/skills/code-task/reference/report-template.en.md +4 -8
  139. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +4 -8
  140. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  141. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  142. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  143. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  144. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  145. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  146. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  147. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  148. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  149. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  150. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -0
  151. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  152. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -0
  153. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  154. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -0
  155. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  156. package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
  157. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -3
  158. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  159. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  160. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -1
  161. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -1
  162. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -8
  163. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -8
  164. package/templates/.agents/skills/review-code/SKILL.en.md +13 -12
  165. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +11 -12
  166. package/templates/.agents/skills/review-code/config/verify.en.json +1 -3
  167. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -3
  168. package/templates/.agents/skills/review-code/reference/output-templates.en.md +23 -46
  169. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +27 -48
  170. package/templates/.agents/skills/review-code/reference/report-template.en.md +8 -11
  171. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +8 -11
  172. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -2
  173. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -2
  174. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -1
  175. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -1
  176. package/templates/.agents/skills/review-plan/reference/report-template.en.md +5 -9
  177. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +5 -9
  178. package/templates/.agents/skills/review-pr/SKILL.en.md +13 -11
  179. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +13 -11
  180. package/templates/.agents/skills/review-pr/reference/output-guidance.en.md +3 -3
  181. package/templates/.agents/skills/review-pr/reference/output-guidance.zh-CN.md +3 -3
  182. package/templates/.agents/skills/review-pr/reference/report-template.en.md +4 -0
  183. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +4 -0
  184. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  185. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  186. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  187. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  188. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +19 -6
  189. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -0
  190. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
  191. package/templates/.agents/templates/task.en.md +2 -2
  192. package/templates/.agents/templates/task.zh-CN.md +2 -2
@@ -32,7 +32,7 @@ type WriteStderr = (chunk: string) => unknown;
32
32
  type SandboxConfigInput = {
33
33
  engine?: SandboxEngineInput;
34
34
  runtimes?: string[];
35
- tools?: string[];
35
+ tools?: unknown;
36
36
  customTools?: unknown;
37
37
  refreshIntervalDays?: unknown;
38
38
  dockerfile?: string | null;
@@ -115,10 +115,22 @@ function resolveSandboxToolIds(
115
115
  sandbox: SandboxConfigInput,
116
116
  defaults: readonly string[]
117
117
  ): string[] {
118
- if (Array.isArray(sandbox.tools)) {
119
- return sandbox.tools.filter((tool): tool is string => typeof tool === 'string');
118
+ if (sandbox.tools === undefined) {
119
+ return [...defaults];
120
120
  }
121
- return [...defaults];
121
+ if (typeof sandbox.tools !== 'object' || sandbox.tools === null || Array.isArray(sandbox.tools)) {
122
+ throw new Error('sandbox.tools must be an object when configured');
123
+ }
124
+ const tools = sandbox.tools as Record<string, unknown>;
125
+ if (!Array.isArray(tools.ids)) {
126
+ throw new Error('sandbox.tools.ids must be an array');
127
+ }
128
+ return tools.ids.map((id, index) => {
129
+ if (typeof id !== 'string') {
130
+ throw new Error(`sandbox.tools.ids[${index}] must be a string`);
131
+ }
132
+ return id;
133
+ });
122
134
  }
123
135
 
124
136
  export function loadConfig({
@@ -180,7 +192,13 @@ export function loadConfig({
180
192
  }
181
193
  }
182
194
 
183
- const customTools = parseCustomTools(sandbox.customTools, { home });
195
+ const toolsConfig = sandbox.tools && typeof sandbox.tools === 'object' && !Array.isArray(sandbox.tools)
196
+ ? sandbox.tools as Record<string, unknown>
197
+ : undefined;
198
+ if (sandbox.customTools !== undefined) {
199
+ throw new Error('sandbox.customTools is no longer supported; use sandbox.tools.definitions');
200
+ }
201
+ const customTools = parseCustomTools(toolsConfig?.definitions, { home });
184
202
  const tools = resolveSandboxToolIds(sandbox, defaults.tools);
185
203
 
186
204
  return {
@@ -272,12 +272,15 @@ function taskCreateDomainEvidence(
272
272
  ? output.task as Record<string, unknown> : null;
273
273
  const taskId = typeof task?.id === 'string' ? task.id : null;
274
274
  const shortId = typeof task?.shortId === 'string' ? task.shortId : null;
275
- if (!taskId || !shortId) return { consistent: false };
275
+ const state = task?.state;
276
+ if (!taskId || !['active', 'blocked', 'completed', 'archive'].includes(String(state))) return { consistent: false };
277
+ if (state === 'active' ? !shortId : output.status !== 'no-op' || shortId !== null) return { consistent: false };
276
278
  try {
277
279
  const resolved = resolveTaskRef(taskId, { repoRoot: manifest.repoRoot });
278
280
  const shortIds = loadShortIdByTaskId(manifest.repoRoot);
279
281
  return {
280
- consistent: resolved.ok && resolved.taskId === taskId && resolved.state === 'active' && shortIds.get(taskId) === shortId,
282
+ consistent: resolved.ok && resolved.taskId === taskId && resolved.state === state
283
+ && (state === 'active' ? shortIds.get(taskId) === shortId : !shortIds.has(taskId)),
281
284
  taskId,
282
285
  shortId
283
286
  };
@@ -0,0 +1,66 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export const SANDBOX_GIT_COMMON_DIR = '/run/agent-infra/git';
6
+
7
+ export type SandboxGitMetadata = Readonly<{
8
+ commonDir: string;
9
+ worktreeGitFile: string;
10
+ worktreeGitFileContent: string;
11
+ }>;
12
+
13
+ function gitText(worktreeRoot: string, args: string[]): string {
14
+ const env = { ...process.env };
15
+ for (const key of [
16
+ 'GIT_DIR',
17
+ 'GIT_COMMON_DIR',
18
+ 'GIT_WORK_TREE',
19
+ 'GIT_INDEX_FILE',
20
+ 'GIT_PREFIX',
21
+ 'GIT_NAMESPACE',
22
+ 'GIT_OBJECT_DIRECTORY',
23
+ 'GIT_CEILING_DIRECTORIES',
24
+ 'GIT_DISCOVERY_ACROSS_FILESYSTEM'
25
+ ]) {
26
+ delete env[key];
27
+ }
28
+ return execFileSync('git', ['-C', worktreeRoot, ...args], {
29
+ encoding: 'utf8',
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ env
32
+ }).trim();
33
+ }
34
+
35
+ export function resolveSandboxGitMetadata(
36
+ worktreeRoot: string,
37
+ controlDir: string
38
+ ): SandboxGitMetadata {
39
+ const commonDir = fs.realpathSync.native(gitText(worktreeRoot, [
40
+ 'rev-parse', '--path-format=absolute', '--git-common-dir'
41
+ ]));
42
+ const gitDir = fs.realpathSync.native(gitText(worktreeRoot, ['rev-parse', '--absolute-git-dir']));
43
+ const relativeGitDir = path.relative(commonDir, gitDir);
44
+ if (path.isAbsolute(relativeGitDir) || relativeGitDir.startsWith(`..${path.sep}`) || relativeGitDir === '..') {
45
+ throw new Error('SANDBOX_GIT_METADATA_INVALID: worktree git directory is outside its common directory');
46
+ }
47
+
48
+ const containerGitDir = relativeGitDir === '.'
49
+ ? SANDBOX_GIT_COMMON_DIR
50
+ : path.posix.join(SANDBOX_GIT_COMMON_DIR, ...relativeGitDir.split(path.sep));
51
+ return {
52
+ commonDir,
53
+ worktreeGitFile: path.join(controlDir, 'workspace.git'),
54
+ worktreeGitFileContent: `gitdir: ${containerGitDir}\n`
55
+ };
56
+ }
57
+
58
+ export function materializeSandboxGitMetadata(
59
+ worktreeRoot: string,
60
+ controlDir: string
61
+ ): SandboxGitMetadata {
62
+ const metadata = resolveSandboxGitMetadata(worktreeRoot, controlDir);
63
+ fs.mkdirSync(controlDir, { recursive: true });
64
+ fs.writeFileSync(metadata.worktreeGitFile, metadata.worktreeGitFileContent, 'utf8');
65
+ return metadata;
66
+ }
@@ -20,7 +20,7 @@ Commands:
20
20
  rm <branch | TASK-id> | --unbound | --purge
21
21
  Remove one sandbox; use a full TASK-id for task-bound
22
22
  cleanup and a branch for branch-only cleanup. --unbound
23
- removes discovered task-bound and branch-only sandboxes.
23
+ removes sandboxes not bound to an active task short id.
24
24
  start [--recreate] <branch | TASK-id | N>
25
25
  Verify or recover an existing sandbox container;
26
26
  optionally replace only the container on failure
@@ -7,6 +7,7 @@ import {
7
7
  } from './constants.ts';
8
8
  import type { SandboxConfig } from './config.ts';
9
9
  import { sandboxWorkspaceViewStatePaths } from './workspace-view.ts';
10
+ import { SANDBOX_GIT_COMMON_DIR } from './git-metadata.ts';
10
11
 
11
12
  export type SandboxBindMountDeclaration = {
12
13
  hostPaths: string[];
@@ -26,6 +27,8 @@ export function sandboxCoreBindMounts(
26
27
  workspaceViewRoot: string;
27
28
  controlDir: string;
28
29
  controlStatusDir: string;
30
+ gitCommonDir?: string;
31
+ worktreeGitFile?: string;
29
32
  runtimeDir?: string;
30
33
  taskSources?: string[];
31
34
  taskId?: string;
@@ -46,7 +49,17 @@ export function sandboxCoreBindMounts(
46
49
  readOnly: true
47
50
  }
48
51
  );
52
+ if (Boolean(overrides.gitCommonDir) !== Boolean(overrides.worktreeGitFile)) {
53
+ throw new Error('SANDBOX_GIT_METADATA_INCOMPLETE');
54
+ }
55
+ const gitCommonMount = overrides.gitCommonDir
56
+ ? { hostPaths: [overrides.gitCommonDir], containerPath: SANDBOX_GIT_COMMON_DIR, readOnly: false }
57
+ : null;
58
+ const worktreeGitFileMount = overrides.worktreeGitFile
59
+ ? { hostPaths: [overrides.worktreeGitFile], containerPath: '/workspace/.git', readOnly: true }
60
+ : null;
49
61
  const mounts: SandboxBindMountDeclaration[] = [
62
+ ...(gitCommonMount ? [gitCommonMount] : []),
50
63
  {
51
64
  hostPaths: overrides.worktree
52
65
  ? [overrides.worktree]
@@ -54,6 +67,7 @@ export function sandboxCoreBindMounts(
54
67
  containerPath: '/workspace',
55
68
  readOnly: false
56
69
  },
70
+ ...(worktreeGitFileMount ? [worktreeGitFileMount] : []),
57
71
  ...workspaceMounts,
58
72
  ];
59
73
  if (taskBound) {
@@ -24,6 +24,7 @@ import type {
24
24
  import type { SandboxConfig } from './config.ts';
25
25
  import { toEnginePath } from './engines/wsl2-paths.ts';
26
26
  import { sandboxCoreBindMounts } from './mounts.ts';
27
+ import { resolveSandboxGitMetadata } from './git-metadata.ts';
27
28
  import {
28
29
  assertSandboxTaskSource,
29
30
  sandboxControlPaths,
@@ -764,7 +765,19 @@ function expectedMounts(params: {
764
765
  ? recoveryTaskSources(config.repoRoot, params.workspace.taskId)
765
766
  : null;
766
767
  const taskId = params.workspace.mode === 'task-bound' ? params.workspace.taskId : null;
768
+ const worktrees = worktreeDirCandidates(config, branch).filter((candidate) =>
769
+ fs.existsSync(path.join(candidate, '.git'))
770
+ );
771
+ const gitMetadata = worktrees.length === 1
772
+ ? resolveSandboxGitMetadata(worktrees[0]!, control.channelDir)
773
+ : null;
767
774
  const core = sandboxCoreBindMounts(config, branch, {
775
+ ...(gitMetadata === null
776
+ ? {}
777
+ : {
778
+ gitCommonDir: gitMetadata.commonDir,
779
+ worktreeGitFile: gitMetadata.worktreeGitFile
780
+ }),
768
781
  workspaceViewRoot: view.root,
769
782
  controlDir: control.channelDir,
770
783
  controlStatusDir: control.statusDir,
@@ -22,6 +22,7 @@ import { run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from './sh
22
22
  import {
23
23
  parseSandboxWorkspaceIdentity,
24
24
  resolveSandboxCleanupTarget,
25
+ resolveSandboxTarget,
25
26
  sameSandboxWorkspaceIdentity,
26
27
  type SandboxCleanupTarget,
27
28
  type SandboxWorkspaceKey
@@ -1112,7 +1113,10 @@ async function rmUnbound(
1112
1113
  tools: SandboxTool[],
1113
1114
  options: { dryRun: boolean; assumeYes: boolean }
1114
1115
  ): Promise<void> {
1115
- return rmUnboundCore(config, tools, options);
1116
+ return withRepositoryMutationLock(
1117
+ config.repoRoot,
1118
+ () => rmUnboundCore(config, tools, options)
1119
+ );
1116
1120
  }
1117
1121
 
1118
1122
  async function rmUnboundCore(
@@ -1127,32 +1131,66 @@ async function rmUnboundCore(
1127
1131
  sandboxBranchLabel(config),
1128
1132
  { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) }
1129
1133
  );
1130
- const rows = [...listed.running, ...listed.nonRunning];
1134
+ const discovered = [...listed.running, ...listed.nonRunning];
1135
+ const branchMismatches: Array<{ container: string; labelBranch: string; taskBranch: string }> = [];
1136
+ const classified = discovered.map((row) => {
1137
+ if (!row.branch) return { row, protected: true, cleanupTarget: null };
1138
+ if (row.workspaceMode === 'task-bound' && row.taskId) {
1139
+ try {
1140
+ resolveSandboxTarget(row.taskId, config.repoRoot);
1141
+ return { row, protected: true, cleanupTarget: null };
1142
+ } catch {
1143
+ try {
1144
+ const cleanupTarget = resolveSandboxCleanupTarget(row.taskId, config.repoRoot, { allowProtected: true });
1145
+ if (cleanupTarget.taskState === 'blocked'
1146
+ || cleanupTarget.taskState === 'completed'
1147
+ || cleanupTarget.taskState === 'archive') {
1148
+ if (row.branch !== cleanupTarget.branch) {
1149
+ branchMismatches.push({ container: row.name, labelBranch: row.branch, taskBranch: cleanupTarget.branch });
1150
+ return { row, protected: true, cleanupTarget: null };
1151
+ }
1152
+ const activeBranchTarget = resolveSandboxTarget(cleanupTarget.branch, config.repoRoot);
1153
+ if (activeBranchTarget.workspace.mode === 'branch-only') {
1154
+ return { row, protected: false, cleanupTarget };
1155
+ }
1156
+ }
1157
+ } catch {
1158
+ // An unresolved task identity has no active short-id evidence, so keep it out of bulk cleanup.
1159
+ }
1160
+ return { row, protected: true, cleanupTarget: null };
1161
+ }
1162
+ }
1163
+ if (row.workspaceMode !== 'branch-only') return { row, protected: true, cleanupTarget: null };
1164
+ const cleanupTarget = resolveSandboxCleanupTarget(row.branch, config.repoRoot);
1165
+ return cleanupTarget.workspace.mode === 'branch-only'
1166
+ ? { row, protected: false, cleanupTarget }
1167
+ : { row, protected: true, cleanupTarget: null };
1168
+ });
1169
+ const protectedBranches = new Set(classified
1170
+ .filter((entry) => entry.protected)
1171
+ .map(({ row }) => row.branch));
1172
+ const targets = classified
1173
+ .filter((entry) => !entry.protected && !protectedBranches.has(entry.row.branch) && entry.cleanupTarget)
1174
+ .map(({ row, cleanupTarget }) => ({
1175
+ row,
1176
+ cleanupTarget: cleanupTarget!,
1177
+ target: resolveRmTarget(config, tools, cleanupTarget!, { discoveredContainers: [row.name] })
1178
+ }));
1131
1179
  p.intro(pc.cyan(`Removing sandboxes for ${config.project}`));
1180
+ for (const mismatch of branchMismatches) {
1181
+ p.log.warn(`Skipping task-bound sandbox '${mismatch.container}': container branch '${mismatch.labelBranch}' differs from task record branch '${mismatch.taskBranch}'. Inspect the task record and container labels before retrying cleanup.`);
1182
+ }
1132
1183
  if (options.dryRun) {
1133
- p.outro(`Dry run: ${rows.length} sandbox(es) found, nothing deleted`);
1184
+ p.outro(`Dry run: ${targets.length} sandbox(es) found, nothing deleted`);
1134
1185
  return;
1135
1186
  }
1136
- for (const row of rows) {
1137
- if (!row.branch) {
1138
- runSafeEngine(engine, 'docker', ['rm', '-f', row.name]);
1139
- continue;
1140
- }
1141
- const cleanupTarget: SandboxCleanupTarget = {
1142
- requestedRef: row.branch,
1143
- branch: row.branch,
1144
- workspace: row.workspaceMode === 'task-bound' && row.taskId
1145
- ? { mode: 'task-bound', taskId: row.taskId }
1146
- : { mode: 'branch-only' },
1147
- taskState: 'branch-only'
1148
- };
1149
- const target = resolveRmTarget(config, tools, cleanupTarget, { discoveredContainers: [row.name] });
1150
- await rmOne(config, tools, cleanupTarget.branch, {
1187
+ for (const { row, cleanupTarget, target } of targets) {
1188
+ await runRmOneUnderRepositoryLock(config, tools, cleanupTarget.branch, {
1151
1189
  assumeYes: options.assumeYes,
1152
1190
  target,
1153
1191
  cleanupTarget
1154
1192
  });
1155
1193
  }
1156
- p.outro(pc.green(`Removed ${rows.length} sandbox(es)`));
1194
+ p.outro(pc.green(`Removed ${targets.length} sandbox(es)`));
1157
1195
  }
1158
1196
  export { authorizeWorktrees, rmOne, rmPurge, rmUnbound };
@@ -16,7 +16,7 @@ type SandboxTool = {
16
16
  pathRewriteFiles?: string[];
17
17
  hostLiveMounts?: Array<{ hostPath: string; containerSubpath: string }>;
18
18
  postSetupCmds?: string[];
19
- tmpfs?: { size?: string; seed?: string[] };
19
+ tmpfs?: { size?: string; seed?: string[]; exec?: boolean };
20
20
  };
21
21
 
22
22
  type SandboxAlias = Readonly<{
@@ -85,6 +85,16 @@ function asOptionalNonEmptyString(value: unknown, field: string, context: string
85
85
  return value;
86
86
  }
87
87
 
88
+ function asOptionalBoolean(value: unknown, field: string, context: string): boolean | undefined {
89
+ if (value === undefined) {
90
+ return undefined;
91
+ }
92
+ if (typeof value !== 'boolean') {
93
+ throw new Error(`${context}: field "${field}" must be a boolean when provided`);
94
+ }
95
+ return value;
96
+ }
97
+
88
98
  function asStringRecord(value: unknown, field: string, context: string): Record<string, string> | undefined {
89
99
  if (value === undefined) {
90
100
  return undefined;
@@ -193,18 +203,20 @@ function parseTmpfs(value: unknown, context: string): SandboxTool['tmpfs'] {
193
203
  if (!isPlainObject(value)) {
194
204
  throw new Error(`${context}: field "tmpfs" must be an object when provided`);
195
205
  }
206
+ const exec = asOptionalBoolean(value.exec, 'tmpfs.exec', context);
196
207
  return {
197
208
  size: asOptionalNonEmptyString(value.size, 'tmpfs.size', context),
198
- seed: asStringArray(value.seed, 'tmpfs.seed', context)
209
+ seed: asStringArray(value.seed, 'tmpfs.seed', context),
210
+ ...(exec === undefined ? {} : { exec })
199
211
  };
200
212
  }
201
213
 
202
214
  export function parseCustomTool(
203
215
  entry: unknown,
204
216
  index: number,
205
- options: { home: string }
217
+ options: { home: string },
218
+ context = `customTools[${index}]`
206
219
  ): SandboxTool {
207
- const context = `customTools[${index}]`;
208
220
  if (!isPlainObject(entry)) {
209
221
  throw new Error(`${context} must be an object`);
210
222
  }
@@ -243,13 +255,30 @@ export function parseCustomTool(
243
255
  }
244
256
 
245
257
  export function parseCustomTools(value: unknown, options: { home: string }): SandboxTool[] {
246
- if (value === undefined || value === null) {
258
+ if (value === undefined) {
247
259
  return [];
248
260
  }
249
- if (!Array.isArray(value)) {
250
- throw new Error('sandbox: "customTools" must be an array');
261
+ if (!isPlainObject(value)) {
262
+ throw new Error('sandbox.tools.definitions must be an object');
251
263
  }
252
- return value.map((entry, index) => parseCustomTool(entry, index, options));
264
+ return Object.entries(value).map(([id, definition], index) => {
265
+ const context = `sandbox.tools.definitions.${id}`;
266
+ if (!TOOL_ID_PATTERN.test(id)) {
267
+ throw new Error(`${context}: tool id must match ${TOOL_ID_PATTERN.source}`);
268
+ }
269
+ if (!isPlainObject(definition)) {
270
+ throw new Error(`${context} must be an object`);
271
+ }
272
+ if (Object.hasOwn(definition, 'id')) {
273
+ throw new Error(`${context}.id is not allowed; the definition key is the tool id`);
274
+ }
275
+ return parseCustomTool(
276
+ { ...definition, id },
277
+ index,
278
+ options,
279
+ context
280
+ );
281
+ });
253
282
  }
254
283
 
255
284
  export function resolveTools(config: ToolsConfig): SandboxTool[] {
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { createHash } from 'node:crypto';
5
4
  import { execFileSync } from 'node:child_process';
5
+ import { resolvePrimaryWorktreeRoot } from '../git/primary-worktree.ts';
6
6
 
7
7
  export type ServerLogConfig = {
8
8
  path: string;
@@ -52,17 +52,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
52
52
  return typeof value === 'object' && value !== null && !Array.isArray(value);
53
53
  }
54
54
 
55
- // Daemon runtime state (log + PID) lives OUTSIDE the repo, under the user's home
56
- // directory, keyed by the .airc.json "project" AND a stable hash of the repo
57
- // root path:
58
- // ~/.agent-infra/logs/<project>/<repo-hash>/server.log
59
- // ~/.agent-infra/run/<project>/<repo-hash>/server.pid
60
- // The <project> segment groups a project's checkouts for readability; the
61
- // <repo-hash> segment guarantees that two checkouts/worktrees of the same
62
- // project (same "project" but different absolute path) get ISOLATED runtime
63
- // dirs, so they never read/control each other's daemon. Using os.homedir() +
64
- // path.join keeps this correct on Windows too (C:\Users\<name>\.agent-infra\...).
65
- // An explicit log.path in server.json/.local/env still overrides the log default.
55
+ // Runtime state is shared by all worktrees through the primary checkout.
66
56
  function resolveProjectKey(repoRoot: string): string {
67
57
  try {
68
58
  const airc = JSON.parse(
@@ -77,13 +67,8 @@ function resolveProjectKey(repoRoot: string): string {
77
67
  return path.basename(repoRoot);
78
68
  }
79
69
 
80
- // Short, stable, filesystem-safe discriminator for a checkout's absolute path.
81
- function repoKey(repoRoot: string): string {
82
- return createHash('sha256').update(repoRoot).digest('hex').slice(0, 12);
83
- }
84
-
85
- function runtimePath(repoRoot: string, projectKey: string, kind: 'logs' | 'run', file: string): string {
86
- return path.join(homedir(), '.agent-infra', kind, projectKey, repoKey(repoRoot), file);
70
+ function runtimePath(projectKey: string, kind: 'logs' | 'run', file: string): string {
71
+ return path.join(homedir(), '.agent-infra', kind, projectKey, file);
87
72
  }
88
73
 
89
74
  function detectRepoRoot(): string {
@@ -185,7 +170,7 @@ export function validateServerConfig(committed: Record<string, unknown>): Server
185
170
  }
186
171
 
187
172
  export function loadServerConfig({ rootDir }: { rootDir?: string } = {}): ServerConfig {
188
- const repoRoot = rootDir ?? detectRepoRoot();
173
+ const repoRoot = resolvePrimaryWorktreeRoot(rootDir ?? detectRepoRoot());
189
174
  const agentsDir = path.join(repoRoot, '.agents');
190
175
 
191
176
  const committed = readJsonIfPresent(path.join(agentsDir, 'server.json'));
@@ -205,18 +190,11 @@ export function loadServerConfig({ rootDir }: { rootDir?: string } = {}): Server
205
190
  const projectKey = resolveProjectKey(repoRoot);
206
191
 
207
192
  const log = isPlainObject(merged.log) ? merged.log : {};
208
- // No explicit log.path → default under ~/.agent-infra/logs/<project>/.
209
- // Explicit relative path resolves against the repo root; absolute is used as-is.
210
- const explicitPath = typeof log.path === 'string' ? log.path : null;
211
- const resolvedLogPath = explicitPath === null
212
- ? runtimePath(repoRoot, projectKey, 'logs', 'server.log')
213
- : (path.isAbsolute(explicitPath) ? explicitPath : path.join(repoRoot, explicitPath));
214
-
215
193
  return {
216
194
  repoRoot,
217
- pidFile: runtimePath(repoRoot, projectKey, 'run', 'server.pid'),
195
+ pidFile: runtimePath(projectKey, 'run', 'server.pid'),
218
196
  log: {
219
- path: resolvedLogPath,
197
+ path: runtimePath(projectKey, 'logs', 'server.log'),
220
198
  rotateAtBytes: typeof log.rotateAtBytes === 'number' ? log.rotateAtBytes : DEFAULT_ROTATE_BYTES
221
199
  },
222
200
  heartbeatMs: typeof merged.heartbeatMs === 'number' ? merged.heartbeatMs : DEFAULT_SERVER_CONFIG.heartbeatMs,
@@ -83,7 +83,7 @@ export function createMessageDispatcher(options: MessageDispatcherOptions): (mes
83
83
  chunkChars: typeof options.config.stream?.chunkChars === 'number' ? options.config.stream.chunkChars : 4000,
84
84
  throttleMs: typeof options.config.stream?.throttleMs === 'number' ? options.config.stream.throttleMs : 1500
85
85
  },
86
- (emit) => runAiImpl(plan.argv, { onChunk: emit }),
86
+ (emit) => runAiImpl(plan.argv, { onChunk: emit, cwd: options.config.repoRoot }),
87
87
  (outbound) => replyOutbound(message, outbound)
88
88
  );
89
89
  }
@@ -100,15 +100,17 @@ export function createMessageDispatcher(options: MessageDispatcherOptions): (mes
100
100
  // - runDaemon() awaits a shutdown promise that only resolves once a
101
101
  // SIGINT/SIGTERM handler has finished graceful cleanup. We never unref()
102
102
  // the only keep-alive timer (that would let the process exit immediately).
103
- export async function runDaemon(): Promise<void> {
103
+ export async function runDaemon({ rootDir }: { rootDir?: string } = {}): Promise<void> {
104
104
  let config;
105
105
  try {
106
- config = loadServerConfig();
106
+ config = loadServerConfig({ rootDir });
107
107
  } catch (error) {
108
108
  process.stderr.write(`${errorMessage(error)}\n`);
109
109
  process.exit(1);
110
110
  }
111
111
 
112
+ process.chdir(config.repoRoot);
113
+
112
114
  const logger = createLogger(config.log);
113
115
  logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
114
116
  const ownStartTime = getProcessStartTime(process.pid);
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { spawn, execFileSync } from 'node:child_process';
3
4
  import { loadServerConfig } from './config.ts';
4
5
  import type { ServerConfig } from './config.ts';
@@ -91,7 +92,7 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
91
92
  }
92
93
 
93
94
  if (foreground) {
94
- await runDaemon();
95
+ await runDaemon({ rootDir: config.repoRoot });
95
96
  return;
96
97
  }
97
98
 
@@ -103,8 +104,9 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
103
104
  // Re-spawn ourselves detached. process.execArgv is forwarded so the dev path
104
105
  // (node --experimental-strip-types ./bin/cli.ts) and the built path
105
106
  // (node dist/bin/cli.js) both work.
106
- const child = spawn(process.execPath, [...process.execArgv, cliEntry, 'server', '__daemon'], {
107
+ const child = spawn(process.execPath, [...process.execArgv, path.resolve(cliEntry), 'server', '__daemon'], {
107
108
  detached: true,
109
+ cwd: config.repoRoot,
108
110
  stdio: 'ignore'
109
111
  });
110
112
  child.unref();
@@ -10,8 +10,9 @@ export type RunnerResult = {
10
10
  };
11
11
 
12
12
  export type RunnerOptions = {
13
- spawn?: (file: string, args: string[], onChunk?: (chunk: string) => void | Promise<void>) => Promise<RunnerResult>;
13
+ spawn?: (file: string, args: string[], onChunk?: (chunk: string) => void | Promise<void>, cwd?: string) => Promise<RunnerResult>;
14
14
  onChunk?: (chunk: string) => void | Promise<void>;
15
+ cwd?: string;
15
16
  };
16
17
 
17
18
  function resolveCommand(file: string): string {
@@ -40,13 +41,15 @@ function needsShell(file: string): boolean {
40
41
  function spawnCapture(
41
42
  file: string,
42
43
  args: string[],
43
- onChunk?: (chunk: string) => void | Promise<void>
44
+ onChunk?: (chunk: string) => void | Promise<void>,
45
+ cwd?: string
44
46
  ): Promise<RunnerResult> {
45
47
  return new Promise((resolve, reject) => {
46
48
  const resolvedFile = resolveCommand(file);
47
49
  const child = spawn(resolvedFile, args, {
48
50
  stdio: ['ignore', 'pipe', 'pipe'],
49
- shell: needsShell(resolvedFile)
51
+ shell: needsShell(resolvedFile),
52
+ ...(cwd === undefined ? {} : { cwd })
50
53
  });
51
54
  let stdout = '';
52
55
  let stderr = '';
@@ -91,5 +94,5 @@ function spawnCapture(
91
94
  }
92
95
 
93
96
  export async function runAi(args: string[], options: RunnerOptions = {}): Promise<RunnerResult> {
94
- return (options.spawn ?? spawnCapture)('ai', args, options.onChunk);
97
+ return (options.spawn ?? spawnCapture)('ai', args, options.onChunk, options.cwd);
95
98
  }
@@ -13,10 +13,6 @@
13
13
  "type": "object",
14
14
  "additionalProperties": false,
15
15
  "properties": {
16
- "path": {
17
- "type": "string",
18
- "description": "Log file path. Defaults to ~/.agent-infra/logs/<project>/<repo-hash>/server.log (outside the repo; the repo-hash isolates checkouts that share a project). Relative paths resolve against the repo root; absolute paths are used as-is."
19
- },
20
16
  "rotateAtBytes": {
21
17
  "type": "integer",
22
18
  "minimum": 0,
@@ -108,6 +108,7 @@ const REVIEW_ACTION_RE = /^Review PR \(Round ([1-9]\d*)\)( \[started\])?$/;
108
108
  const VERDICTS: ReadonlySet<string> = new Set(['approved', 'changes-requested', 'commented']);
109
109
  const OUTCOMES: ReadonlySet<string> = new Set(['aborted', 'superseded']);
110
110
  const SUCCESS_STATUSES: ReadonlySet<PrReviewArtifactStatus> = new Set(['applied', 'no-op']);
111
+ const LOCAL_COMPLETION_STATUSES: ReadonlySet<PrReviewArtifactStatus> = new Set(['applied', 'no-op', 'blocked', 'failed']);
111
112
  const ARTIFACT_STATUSES: ReadonlySet<string> = new Set([
112
113
  'pending', 'applied', 'no-op', 'blocked', 'failed', 'aborted', 'superseded'
113
114
  ]);
@@ -209,7 +210,7 @@ function readSnapshot(taskRef: string, repoRoot?: string):
209
210
  if (terminal.length === 1) {
210
211
  const note = terminal[0]!.note;
211
212
  const terminalMatchesStatus =
212
- (note.startsWith('Verdict: ') && SUCCESS_STATUSES.has(parsed.status))
213
+ (note.startsWith('Verdict: ') && LOCAL_COMPLETION_STATUSES.has(parsed.status))
213
214
  || (note.startsWith('Outcome: Aborted, ') && parsed.status === 'aborted')
214
215
  || (note.startsWith('Outcome: Superseded, ') && parsed.status === 'superseded')
215
216
  || (note.startsWith('receipt ') && SUCCESS_STATUSES.has(parsed.status));
@@ -338,8 +339,8 @@ function applyLocked(intent: PrReviewActivityIntent, normalizedAgent: string, op
338
339
  if (roundState.startedAgent === null) {
339
340
  return failed(intent, 'ACTIVITY_STATE_CONFLICT', `${base} has no matching started row`, state.taskId);
340
341
  }
341
- if (intent.kind === 'pr-review-complete' && !SUCCESS_STATUSES.has(roundState.status)) {
342
- return failed(intent, 'ACTIVITY_STATE_CONFLICT', `${intent.artifact} must be applied or no-op before completion`, state.taskId);
342
+ if (intent.kind === 'pr-review-complete' && !LOCAL_COMPLETION_STATUSES.has(roundState.status)) {
343
+ return failed(intent, 'ACTIVITY_STATE_CONFLICT', `${intent.artifact} must record a publication outcome before completion`, state.taskId);
343
344
  }
344
345
  if (intent.kind === 'pr-review-terminate' && roundState.status !== intent.outcome) {
345
346
  return failed(intent, 'ACTIVITY_STATE_CONFLICT', `${intent.artifact} status must match outcome ${intent.outcome}`, state.taskId);