@fitlab-ai/agent-infra 0.11.7 → 0.11.9

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 (128) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +8 -2
  4. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  5. package/dist/lib/agent-clients/config.js +11 -3
  6. package/dist/lib/agent-clients/custom-tool-invocations.js +92 -0
  7. package/dist/lib/agent-clients/next-steps.js +1 -1
  8. package/dist/lib/agent-clients/reconcile.js +3 -3
  9. package/dist/lib/agent-clients/schema.js +18 -10
  10. package/dist/lib/defaults.json +6 -3
  11. package/dist/lib/init.js +2 -2
  12. package/dist/lib/internal/agent-client.js +3 -3
  13. package/dist/lib/internal/cli-route-inventory.js +3 -0
  14. package/dist/lib/internal/platform-pr-review.js +20 -3
  15. package/dist/lib/internal/task-operation-registry.js +3 -1
  16. package/dist/lib/internal/task-platform-recovery.js +56 -0
  17. package/dist/lib/merge.js +25 -5
  18. package/dist/lib/platform/completion-backfill.js +2 -1
  19. package/dist/lib/platform/in-label-sync.js +12 -1
  20. package/dist/lib/platform/issue-comments.js +58 -4
  21. package/dist/lib/platform/issues.js +105 -5
  22. package/dist/lib/platform/operation-coordinator.js +74 -0
  23. package/dist/lib/platform/pr-review.js +107 -1
  24. package/dist/lib/platform/pr-summary.js +79 -57
  25. package/dist/lib/platform/pull-requests.js +96 -4
  26. package/dist/lib/sandbox/commands/create.js +6 -3
  27. package/dist/lib/sandbox/commands/ls.js +4 -3
  28. package/dist/lib/sandbox/commands/rm.js +1 -1
  29. package/dist/lib/sandbox/config.js +22 -4
  30. package/dist/lib/sandbox/git-metadata.js +50 -0
  31. package/dist/lib/sandbox/index.js +1 -1
  32. package/dist/lib/sandbox/mounts.js +12 -0
  33. package/dist/lib/sandbox/recovery.js +12 -22
  34. package/dist/lib/sandbox/removal.js +58 -20
  35. package/dist/lib/sandbox/tools.js +31 -7
  36. package/dist/lib/task/activity-intent.js +4 -3
  37. package/dist/lib/task/create-service.js +5 -3
  38. package/dist/lib/task/create.js +22 -5
  39. package/dist/lib/task/finalization-artifacts.js +4 -1
  40. package/dist/lib/task/finalization.js +243 -113
  41. package/dist/lib/task/lifecycle.js +38 -2
  42. package/dist/lib/task/platform-operation-journal.js +152 -0
  43. package/dist/lib/task/platform-operation-recovery.js +459 -0
  44. package/dist/lib/task/verification.js +0 -1
  45. package/dist/lib/task/workflow-warning-intents.js +7 -1
  46. package/dist/lib/update.js +2 -2
  47. package/dist/lifecycle-build-manifest.json +3 -3
  48. package/lib/agent-clients/adapters/codex.ts +1 -1
  49. package/lib/agent-clients/config.ts +9 -3
  50. package/lib/agent-clients/custom-tool-invocations.ts +129 -0
  51. package/lib/agent-clients/next-steps.ts +3 -3
  52. package/lib/agent-clients/reconcile.ts +3 -3
  53. package/lib/agent-clients/schema.ts +18 -10
  54. package/lib/defaults.json +6 -3
  55. package/lib/init.ts +2 -2
  56. package/lib/internal/agent-client.ts +3 -3
  57. package/lib/internal/cli-route-inventory.ts +2 -0
  58. package/lib/internal/platform-pr-review.ts +14 -3
  59. package/lib/internal/task-operation-registry.ts +3 -1
  60. package/lib/internal/task-platform-recovery.ts +53 -0
  61. package/lib/merge.ts +26 -6
  62. package/lib/platform/completion-backfill.ts +2 -1
  63. package/lib/platform/in-label-sync.ts +12 -0
  64. package/lib/platform/issue-comments.ts +59 -3
  65. package/lib/platform/issues.ts +103 -5
  66. package/lib/platform/operation-coordinator.ts +96 -0
  67. package/lib/platform/pr-review.ts +113 -1
  68. package/lib/platform/pr-summary.ts +22 -1
  69. package/lib/platform/pull-requests.ts +105 -5
  70. package/lib/sandbox/commands/create.ts +7 -8
  71. package/lib/sandbox/commands/ls.ts +4 -3
  72. package/lib/sandbox/commands/rm.ts +1 -1
  73. package/lib/sandbox/config.ts +23 -5
  74. package/lib/sandbox/git-metadata.ts +66 -0
  75. package/lib/sandbox/index.ts +1 -1
  76. package/lib/sandbox/mounts.ts +14 -0
  77. package/lib/sandbox/recovery.ts +13 -25
  78. package/lib/sandbox/removal.ts +57 -19
  79. package/lib/sandbox/tool-types.ts +2 -1
  80. package/lib/sandbox/tools.ts +38 -7
  81. package/lib/task/activity-intent.ts +4 -3
  82. package/lib/task/create-service.ts +5 -3
  83. package/lib/task/create.ts +17 -5
  84. package/lib/task/finalization-artifacts.ts +4 -1
  85. package/lib/task/finalization.ts +242 -106
  86. package/lib/task/lifecycle.ts +35 -3
  87. package/lib/task/platform-operation-journal.ts +230 -0
  88. package/lib/task/platform-operation-recovery.ts +435 -0
  89. package/lib/task/verification.ts +1 -2
  90. package/lib/task/workflow-warning-intents.ts +9 -1
  91. package/lib/update.ts +2 -3
  92. package/package.json +1 -1
  93. package/templates/.agents/README.en.md +20 -66
  94. package/templates/.agents/README.zh-CN.md +19 -65
  95. package/templates/.agents/rules/README.en.md +1 -1
  96. package/templates/.agents/rules/README.zh-CN.md +1 -1
  97. package/templates/.agents/rules/next-step-output.en.md +1 -1
  98. package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
  99. package/templates/.agents/rules/task-management.en.md +12 -0
  100. package/templates/.agents/rules/task-management.zh-CN.md +12 -0
  101. package/templates/.agents/scripts/lib/agent-infra-package.js +0 -7
  102. package/templates/.agents/skills/cancel-task/SKILL.en.md +4 -2
  103. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +4 -2
  104. package/templates/.agents/skills/code-task/SKILL.en.md +2 -2
  105. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  106. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  107. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  108. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
  109. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
  110. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  111. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  112. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  113. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  114. package/templates/.agents/skills/review-code/SKILL.en.md +1 -1
  115. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +1 -1
  116. package/templates/.agents/skills/review-code/reference/output-templates.en.md +15 -6
  117. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +15 -6
  118. package/templates/.agents/skills/review-pr/SKILL.en.md +11 -11
  119. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +11 -11
  120. package/templates/.agents/skills/review-pr/reference/output-guidance.en.md +3 -3
  121. package/templates/.agents/skills/review-pr/reference/output-guidance.zh-CN.md +3 -3
  122. package/templates/.agents/skills/review-pr/reference/report-template.en.md +4 -0
  123. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +4 -0
  124. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -1
  125. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -1
  126. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +21 -274
  127. package/dist/lib/agent-clients/custom-tuis.js +0 -99
  128. package/lib/agent-clients/custom-tuis.ts +0 -149
@@ -1,6 +1,7 @@
1
1
  import { normalizePullRequest, type RemotePullRequest } from './github-data.ts';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
4
5
  import { execFileSync } from 'node:child_process';
5
6
 
6
7
  import { parseTypedTaskFrontmatter } from '../task/frontmatter.ts';
@@ -41,6 +42,8 @@ import {
41
42
  import { isResourceIdentity, resourceIdentityEquals, resourceIdentityNumber, serializeResourceIdentity } from './resource-identity.ts';
42
43
  import type { ResourceIdentity } from './resource-identity.ts';
43
44
  import { taskIssueIdentity, taskIssueIdentityError } from './task-identities.ts';
45
+ import { coordinatePlatformWrite } from './operation-coordinator.ts';
46
+ import type { PlatformPullRequestIntent } from '../task/platform-operation-journal.ts';
44
47
  import type { ChangeRequestSnapshot as ProviderChangeRequestSnapshot, IssueSnapshot as ProviderIssueSnapshot } from './provider-contract.ts';
45
48
 
46
49
  type PullRequestSnapshot = PlatformChangeRequestSnapshot;
@@ -63,7 +66,7 @@ type PullRequestResult = PlatformResult & {
63
66
  }>;
64
67
  };
65
68
  type InspectionOptions = { cwd?: string; client?: PlatformClient; runtimeVersion?: string };
66
- type SharedOptions = { cwd?: string; client?: PlatformClient; runtimeVersion?: string };
69
+ type SharedOptions = { cwd?: string; client?: PlatformClient; runtimeVersion?: string; skipQueue?: boolean };
67
70
  type CreateOptions = SharedOptions & {
68
71
  agent: string;
69
72
  base: string;
@@ -137,6 +140,30 @@ function withCreation(output: PullRequestResult, creation: CreationOutcome): Pul
137
140
  return { ...output, creation };
138
141
  }
139
142
 
143
+ async function journalPullRequestOperation(
144
+ taskRef: string,
145
+ options: SharedOptions & { agent: string; dryRun?: boolean },
146
+ intent: PlatformPullRequestIntent,
147
+ target: string,
148
+ identityInput: unknown,
149
+ execute: () => Promise<PullRequestResult>
150
+ ): Promise<PullRequestResult> {
151
+ if (options.dryRun) return execute();
152
+ const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
153
+ if (!resolved.ok) return execute();
154
+ const expectedDigest = createHash('sha256').update(JSON.stringify({ taskId: resolved.taskId, intent, identityInput })).digest('hex');
155
+ const operation = { kind: 'pull-request' as const, target: intent.action === 'sync' ? `bound-pr:${resolved.taskId}` : target, expectedDigest };
156
+ const dependency = intent.action === 'sync' ? 'deferred' : 'required';
157
+ if (options.skipQueue) return execute();
158
+ return coordinatePlatformWrite({
159
+ operation: { taskRef: resolved.taskId, cwd: resolved.repoRoot, ...operation, pullRequest: intent, dependency },
160
+ agent: options.agent,
161
+ execute,
162
+ block: (error) => result('blocked', resolved.taskId, null, null, { error }),
163
+ persistenceFailure: (error) => result('failed', resolved.taskId, null, null, { error })
164
+ });
165
+ }
166
+
140
167
  const PRECONDITION_NOT_CREATED: CreationOutcome = {
141
168
  kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false
142
169
  };
@@ -851,7 +878,7 @@ function bindIdentity(
851
878
  }, { repoRoot: base.resolved.repoRoot, metadataProvider: () => metadata });
852
879
  }
853
880
 
854
- async function bindPlatformPullRequest(taskRef: string, options: BindOptions): Promise<PullRequestResult> {
881
+ async function bindPlatformPullRequestImpl(taskRef: string, options: BindOptions): Promise<PullRequestResult> {
855
882
  const base = await resolvedContext(taskRef, options);
856
883
  if (!base.ok) return base.output;
857
884
  if (!base.fact) return result('failed', base.resolved.taskId, base.issueNumber, null, {
@@ -884,6 +911,12 @@ async function bindPlatformPullRequest(taskRef: string, options: BindOptions): P
884
911
  });
885
912
  }
886
913
 
914
+ async function bindPlatformPullRequest(taskRef: string, options: BindOptions): Promise<PullRequestResult> {
915
+ const intent: PlatformPullRequestIntent = { action: 'bind', prToken: String(options.pr) };
916
+ return journalPullRequestOperation(taskRef, options, intent, `pr:${options.pr}`, { pr: String(options.pr) },
917
+ () => bindPlatformPullRequestImpl(taskRef, options));
918
+ }
919
+
887
920
  function externalEvidenceNote(
888
921
  issueIdentity: ResourceIdentity | null,
889
922
  source: 'unique' | 'explicit',
@@ -1077,7 +1110,7 @@ async function resolveExternalPullRequest(taskRef: string, options: ResolveExter
1077
1110
  }), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
1078
1111
  }
1079
1112
 
1080
- async function createPlatformPullRequest(taskRef: string, options: CreateOptions): Promise<PullRequestResult> {
1113
+ async function createPlatformPullRequestImpl(taskRef: string, options: CreateOptions): Promise<PullRequestResult> {
1081
1114
  const base = await resolvedContext(taskRef, options);
1082
1115
  if (!base.ok) return withCreation(base.output, PRECONDITION_NOT_CREATED);
1083
1116
  if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) {
@@ -1112,6 +1145,66 @@ async function createPlatformPullRequest(taskRef: string, options: CreateOptions
1112
1145
  }
1113
1146
  }
1114
1147
 
1148
+ async function recoverCreatedPullRequest(
1149
+ taskRef: string,
1150
+ options: SharedOptions & { agent: string; base: string; head: string }
1151
+ ): Promise<PullRequestResult> {
1152
+ const base = await resolvedContext(taskRef, options);
1153
+ if (!base.ok) return base.output;
1154
+ if (!base.issueIdentity) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1155
+ error: { code: 'PLATFORM_OPERATION_ISSUE_REQUIRED', message: 'Pull-request recovery requires a bound Issue identity', retryable: false }
1156
+ });
1157
+ const head = base.provider.changeRequests?.verifyHead
1158
+ ? await base.provider.changeRequests.verifyHead({ context: providerOperationContext(base.loadedContext), head: options.head })
1159
+ : unsupportedProviderOperation(base.provider, 'changeRequests.verifyHead');
1160
+ if (!head.ok) return providerPullRequestError(base, head.error, base.prNumber);
1161
+ if (base.prIdentity) {
1162
+ const inspected = await inspectExternalPullRequest(base, base.prIdentity);
1163
+ if (!inspected.pullRequest || inspected.status === 'failed' || inspected.status === 'blocked'
1164
+ || inspected.pullRequest.base.repository !== base.context.platform.repository
1165
+ || inspected.pullRequest.base.ref !== options.base || inspected.pullRequest.head.ref !== options.head
1166
+ || inspected.pullRequest.head.sha !== head.value.sha) {
1167
+ return result('blocked', base.resolved.taskId, base.issueNumber, base.prNumber, {
1168
+ error: inspected.error ?? { code: 'PLATFORM_OPERATION_PR_NOT_CONFIRMED', message: 'Bound pull request does not match the pending create intent', retryable: true }
1169
+ });
1170
+ }
1171
+ return inspected;
1172
+ }
1173
+ const candidates = base.provider.changeRequests?.listClosing
1174
+ ? await base.provider.changeRequests.listClosing({ context: providerOperationContext(base.loadedContext), issue: base.issueIdentity })
1175
+ : unsupportedProviderOperation(base.provider, 'changeRequests.listClosing');
1176
+ if (!candidates.ok) return providerPullRequestError(base, candidates.error, null);
1177
+ const matches = candidates.value.map((candidate) => normalizeProviderPullRequest(candidate, base.context.platform.repository!, candidate.number || 0))
1178
+ .filter((candidate) => candidate.base.repository === base.context.platform.repository
1179
+ && candidate.base.ref === options.base
1180
+ && candidate.head.repository === base.context.platform.repository
1181
+ && candidate.head.ref === options.head
1182
+ && candidate.head.sha === head.value.sha);
1183
+ if (matches.length !== 1 || !matches[0]?.number) return result('blocked', base.resolved.taskId, base.issueNumber, null, {
1184
+ error: { code: matches.length > 1 ? 'PLATFORM_OPERATION_PR_AMBIGUOUS' : 'PLATFORM_OPERATION_PR_NOT_CONFIRMED',
1185
+ message: 'Pending pull-request creation does not resolve to one exact repository/base/head identity', retryable: true }
1186
+ });
1187
+ const bound = await bindPlatformPullRequest(base.resolved.taskId, { agent: options.agent, cwd: base.resolved.repoRoot, pr: matches[0].number });
1188
+ if (!bound.pullRequest || (bound.status !== 'applied' && bound.status !== 'no-op') || bound.error) return bound;
1189
+ if (bound.pullRequest.base.repository !== base.context.platform.repository || bound.pullRequest.base.ref !== options.base
1190
+ || bound.pullRequest.head.repository !== base.context.platform.repository || bound.pullRequest.head.ref !== options.head
1191
+ || bound.pullRequest.head.sha !== head.value.sha) return result('blocked', base.resolved.taskId, base.issueNumber, bound.task.prNumber, {
1192
+ error: { code: 'PLATFORM_OPERATION_PR_NOT_CONFIRMED', message: 'Bound pull request changed while recovering the create intent', retryable: true }
1193
+ });
1194
+ return bound;
1195
+ }
1196
+
1197
+ async function createPlatformPullRequest(taskRef: string, options: CreateOptions): Promise<PullRequestResult> {
1198
+ const intent: PlatformPullRequestIntent = { action: 'create', baseRef: options.base, headRef: options.head };
1199
+ const identityInput = {
1200
+ title: createHash('sha256').update(options.title).digest('hex'),
1201
+ body: createHash('sha256').update(options.body).digest('hex'),
1202
+ draft: options.draft === true
1203
+ };
1204
+ return journalPullRequestOperation(taskRef, options, intent, `head:${options.head}:base:${options.base}`, identityInput,
1205
+ () => createPlatformPullRequestImpl(taskRef, options));
1206
+ }
1207
+
1115
1208
  async function createExternalPullRequest(
1116
1209
  taskRef: string,
1117
1210
  options: CreateOptions,
@@ -1266,7 +1359,7 @@ async function createExternalPullRequest(
1266
1359
  }), { kind: 'created', createdByCurrentOperation: true });
1267
1360
  }
1268
1361
 
1269
- async function syncPlatformPullRequest(taskRef: string, options: SyncOptions): Promise<PullRequestResult> {
1362
+ async function syncPlatformPullRequestImpl(taskRef: string, options: SyncOptions): Promise<PullRequestResult> {
1270
1363
  const warningResult = warningResultForPrimary(options.primaryResult);
1271
1364
  const softenFailure = (output: PullRequestResult): PullRequestResult => {
1272
1365
  const authorityError = output.error?.code.startsWith('IN_LABEL_SYNC') || output.error?.code.startsWith('PR_IDENTITY');
@@ -1430,6 +1523,13 @@ async function syncPlatformPullRequest(taskRef: string, options: SyncOptions): P
1430
1523
  }
1431
1524
  }
1432
1525
 
1526
+ async function syncPlatformPullRequest(taskRef: string, options: SyncOptions): Promise<PullRequestResult> {
1527
+ const intent: PlatformPullRequestIntent = { action: 'sync', metadata: options.metadata === true, closingIssue: options.closingIssue === true };
1528
+ return journalPullRequestOperation(taskRef, options, intent, `bound-pr:${taskRef}`, {
1529
+ metadata: options.metadata === true, closingIssue: options.closingIssue === true
1530
+ }, () => syncPlatformPullRequestImpl(taskRef, options));
1531
+ }
1532
+
1433
1533
  function readProjectPrFlow(repoRoot: string): 'required' | 'disabled' | undefined {
1434
1534
  try {
1435
1535
  const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { prFlow?: unknown };
@@ -1527,6 +1627,6 @@ async function skipPlatformPullRequestFact(taskRef: string, options: SkipFactOpt
1527
1627
  }
1528
1628
  }
1529
1629
 
1530
- export { bindPlatformPullRequest, createPlatformPullRequest, inspectPlatformPullRequest, inspectPlatformPullRequestByNumber, resolveExternalPullRequest, skipPlatformPullRequestFact, selectExternalPullRequest, selectPullRequest, syncPlatformPullRequest, syncPlatformPullRequestInLabels };
1630
+ export { bindPlatformPullRequest, createPlatformPullRequest, inspectPlatformPullRequest, inspectPlatformPullRequestByNumber, recoverCreatedPullRequest, resolveExternalPullRequest, skipPlatformPullRequestFact, selectExternalPullRequest, selectPullRequest, syncPlatformPullRequest, syncPlatformPullRequestInLabels };
1531
1631
  export type { BindOptions, CreateOptions, ExternalPullRequestResult, ExternalPullRequestSelection, PullRequestPrimaryResult, PullRequestResult, PullRequestSnapshot, ResolveExternalOptions, SkipFactOptions, SyncOptions };
1532
1632
  export { warningResultForPrimary };
@@ -83,6 +83,7 @@ import { atomicWriteJson } from '../control/state.ts';
83
83
  import { inspectSandboxControlContainer } from '../control/container-identity.ts';
84
84
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
85
85
  import { sandboxCoreBindMounts } from '../mounts.ts';
86
+ import { materializeSandboxGitMetadata } from '../git-metadata.ts';
86
87
  import {
87
88
  assertSandboxTaskSource,
88
89
  finalizeSandboxControlManifest,
@@ -950,9 +951,10 @@ function runSandboxAuthoritySafeCommand(
950
951
 
951
952
  // `docker run` args for mounting a tool's containerMount as an in-container
952
953
  // tmpfs. containerMount is an in-container path, so it is NOT engine-converted.
953
- export function buildTmpfsRunArgs(containerMount: string, tmpfs: { size?: string }): string[] {
954
+ export function buildTmpfsRunArgs(containerMount: string, tmpfs: { size?: string; exec?: boolean }): string[] {
954
955
  const size = tmpfs.size ?? '512m';
955
- return ['--tmpfs', `${containerMount}:rw,size=${size}`];
956
+ const options = ['rw', ...(tmpfs.exec ? ['exec'] : []), `size=${size}`];
957
+ return ['--tmpfs', `${containerMount}:${options.join(',')}`];
956
958
  }
957
959
 
958
960
  export function buildImage(
@@ -1459,6 +1461,7 @@ export async function create(
1459
1461
  engine,
1460
1462
  replacementLease,
1461
1463
  });
1464
+ const gitMetadata = materializeSandboxGitMetadata(worktree, control.channelDir);
1462
1465
  hostShellConfig = prepareHostShellConfig({
1463
1466
  home: effectiveConfig.home,
1464
1467
  project: effectiveConfig.project,
@@ -1466,6 +1469,8 @@ export async function create(
1466
1469
  repoRoot: effectiveConfig.repoRoot
1467
1470
  });
1468
1471
  const coreVolumes = sandboxCoreBindMounts(effectiveConfig, branch, {
1472
+ gitCommonDir: gitMetadata.commonDir,
1473
+ worktreeGitFile: gitMetadata.worktreeGitFile,
1469
1474
  worktree,
1470
1475
  shellConfigHostDir: hostShellConfig.hostDir,
1471
1476
  workspaceViewRoot: workspaceView.root,
@@ -1572,12 +1577,6 @@ export async function create(
1572
1577
  `${sandboxRuntimeCapabilityLabel(effectiveConfig)}=${capabilityPlan.runtimeSignature}`,
1573
1578
  ...coreVolumes,
1574
1579
  ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1575
- '-v',
1576
- volumeArg(
1577
- engine,
1578
- path.join(effectiveConfig.repoRoot, '.git'),
1579
- `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`
1580
- ),
1581
1580
  ...dotfilesMount,
1582
1581
  ...toolVolumes,
1583
1582
  ...tmpfsArgs,
@@ -22,8 +22,9 @@ display-only row number; the 'SHORT' column shows the active task short
22
22
  id bound to each container's branch (via
23
23
  .agents/workspace/active/.short-ids.json), or '-' if no active task is
24
24
  bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
25
- A '-' means no active task is bound to that branch, so the sandbox is free
26
- to remove with "ai sandbox rm <branch>".
25
+ A SHORT value of '-' means this container has no active task short id and may
26
+ be eligible for "ai sandbox rm --unbound" cleanup. Use a full TASK-id to
27
+ remove a specific task-bound sandbox.
27
28
 
28
29
  Use "ai sandbox show <ref>" for a single sandbox's worktree and per-tool
29
30
  state paths.`;
@@ -97,7 +98,7 @@ export function ls(args: string[] = []): void {
97
98
  }
98
99
  if (tableRows.some((r) => r.shortId === '-')) {
99
100
  process.stdout.write(
100
- ` SHORT '-' = no active task bound; that sandbox is free to remove with 'ai sandbox rm <branch>'.\n`
101
+ ` SHORT '-' = no active task short id is displayed; --unbound may clean this sandbox if its branch is not tied to an active task.\n`
101
102
  );
102
103
  }
103
104
  }
@@ -14,7 +14,7 @@ import { rmOne, rmPurge, rmUnbound } from '../removal.ts';
14
14
 
15
15
  const USAGE = `Usage:
16
16
  ai sandbox rm <branch | TASK-id | short id> Remove one sandbox; use a full TASK-id for a task-bound sandbox and a branch for branch-only sandboxes
17
- ai sandbox rm --unbound [--dry-run] [--yes] Remove discovered task-bound and branch-only sandboxes
17
+ ai sandbox rm --unbound [--dry-run] [--yes] Remove sandboxes not bound to an active task short id
18
18
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
19
19
 
20
20
  function resolveMissingTaskSandboxBranch(taskId: string, config: ReturnType<typeof loadConfig>): string | null {
@@ -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 {
@@ -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) {
@@ -8,12 +8,10 @@ import {
8
8
  containerNameCandidates,
9
9
  sandboxBranchLabel,
10
10
  sandboxLabel,
11
- sandboxRuntimeCapabilityLabel,
12
11
  sandboxTaskIdLabel,
13
12
  sandboxWorkspaceModeLabel,
14
13
  worktreeDirCandidates
15
14
  } from './constants.ts';
16
- import { isAgentClientId } from '../agent-clients/types.ts';
17
15
  import {
18
16
  createSandboxCapabilityPlan
19
17
  } from './agent-client-reconciler.ts';
@@ -24,6 +22,7 @@ import type {
24
22
  import type { SandboxConfig } from './config.ts';
25
23
  import { toEnginePath } from './engines/wsl2-paths.ts';
26
24
  import { sandboxCoreBindMounts } from './mounts.ts';
25
+ import { resolveSandboxGitMetadata } from './git-metadata.ts';
27
26
  import {
28
27
  assertSandboxTaskSource,
29
28
  sandboxControlPaths,
@@ -99,8 +98,6 @@ export type SandboxRecoverySnapshot = {
99
98
  workspaceTopology?: 'legacy-parent' | 'per-state' | 'unknown';
100
99
  taskView?: { path: string; readable: boolean };
101
100
  runtimeStoreOk?: boolean;
102
- runtimeCapabilityOk?: boolean;
103
- unexpectedCapabilityMounts?: string[];
104
101
  mounts: Array<{
105
102
  path: string;
106
103
  expectedType: string;
@@ -451,12 +448,6 @@ export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): Sand
451
448
  : `Container identity ${actualIdentity} on branch ${snapshot.actualBranch ?? 'unknown'} does not match requested ${expectedIdentity} on branch ${snapshot.expectedBranch}.`
452
449
  });
453
450
  }
454
- if (snapshot.runtimeCapabilityOk === false) {
455
- findings.push({
456
- repairKind: 'hard-failure',
457
- message: 'Container runtime capability signature does not match the current sandbox plan.'
458
- });
459
- }
460
451
  if (snapshot.workspaceTopology === 'legacy-parent') {
461
452
  findings.push({
462
453
  repairKind: 'hard-failure',
@@ -481,14 +472,6 @@ export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): Sand
481
472
  path: '/run/agent-infra/runtime'
482
473
  });
483
474
  }
484
- for (const mountPath of snapshot.unexpectedCapabilityMounts ?? []) {
485
- findings.push({
486
- repairKind: 'hard-failure',
487
- message: `Disabled Agent Client capability remains mounted at ${mountPath}.`,
488
- path: mountPath
489
- });
490
- }
491
-
492
475
  for (const mount of snapshot.mounts) {
493
476
  if (
494
477
  snapshot.workspaceTopology === 'legacy-parent'
@@ -764,7 +747,19 @@ function expectedMounts(params: {
764
747
  ? recoveryTaskSources(config.repoRoot, params.workspace.taskId)
765
748
  : null;
766
749
  const taskId = params.workspace.mode === 'task-bound' ? params.workspace.taskId : null;
750
+ const worktrees = worktreeDirCandidates(config, branch).filter((candidate) =>
751
+ fs.existsSync(path.join(candidate, '.git'))
752
+ );
753
+ const gitMetadata = worktrees.length === 1
754
+ ? resolveSandboxGitMetadata(worktrees[0]!, control.channelDir)
755
+ : null;
767
756
  const core = sandboxCoreBindMounts(config, branch, {
757
+ ...(gitMetadata === null
758
+ ? {}
759
+ : {
760
+ gitCommonDir: gitMetadata.commonDir,
761
+ worktreeGitFile: gitMetadata.worktreeGitFile
762
+ }),
768
763
  workspaceViewRoot: view.root,
769
764
  controlDir: control.channelDir,
770
765
  controlStatusDir: control.statusDir,
@@ -951,11 +946,6 @@ export function collectSandboxRecoverySnapshot(params: {
951
946
  mode: sandboxWorkspaceModeLabel(params.config),
952
947
  taskId: sandboxTaskIdLabel(params.config)
953
948
  });
954
- const selectedToolIds = new Set(tools.map((tool) => tool.id));
955
- const unexpectedCapabilityMounts = capabilityPlan.cleanupInventory
956
- .filter((tool) => isAgentClientId(tool.id) && !selectedToolIds.has(tool.id))
957
- .map((tool) => tool.containerMount)
958
- .filter((mountPath) => mountsByDestination.has(mountPath));
959
949
  const mountSnapshots = expectedMounts({
960
950
  config: params.config,
961
951
  branch: params.branch,
@@ -1022,8 +1012,6 @@ export function collectSandboxRecoverySnapshot(params: {
1022
1012
  identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
1023
1013
  && branchLabel === params.branch
1024
1014
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
1025
- runtimeCapabilityOk: labels[sandboxRuntimeCapabilityLabel(params.config)] === capabilityPlan.runtimeSignature,
1026
- unexpectedCapabilityMounts,
1027
1015
  mounts: mountSnapshots,
1028
1016
  tmpfs,
1029
1017
  seeds,
@@ -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 };
@@ -10,13 +10,14 @@ type SandboxTool = {
10
10
  containerMount: string;
11
11
  versionCmd: string;
12
12
  setupHint: string;
13
+ invoke?: string;
13
14
  envVars?: Record<string, string>;
14
15
  hostPreSeedFiles?: Array<{ hostPath: string; sandboxName: string }>;
15
16
  hostPreSeedDirs?: Array<{ hostDir: string; sandboxSubdir: string }>;
16
17
  pathRewriteFiles?: string[];
17
18
  hostLiveMounts?: Array<{ hostPath: string; containerSubpath: string }>;
18
19
  postSetupCmds?: string[];
19
- tmpfs?: { size?: string; seed?: string[] };
20
+ tmpfs?: { size?: string; seed?: string[]; exec?: boolean };
20
21
  };
21
22
 
22
23
  type SandboxAlias = Readonly<{