@fitlab-ai/agent-infra 0.11.4 → 0.11.6

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 (149) hide show
  1. package/dist/bin/internal-cli.js +0 -5
  2. package/dist/lib/decide.js +4 -41
  3. package/dist/lib/git/branch-worktree.js +45 -0
  4. package/dist/lib/internal/cli-route-inventory.js +0 -3
  5. package/dist/lib/internal/task-activity.js +7 -40
  6. package/dist/lib/internal/task-event.js +4 -37
  7. package/dist/lib/internal/task-finalization.js +9 -2
  8. package/dist/lib/internal/task-ledger.js +5 -43
  9. package/dist/lib/internal/task-lifecycle.js +1 -1
  10. package/dist/lib/internal/task-operation-registry.js +1 -4
  11. package/dist/lib/internal/task-review.js +5 -37
  12. package/dist/lib/internal/task-verify.js +3 -47
  13. package/dist/lib/internal/task-warning.js +4 -40
  14. package/dist/lib/merge.js +62 -10
  15. package/dist/lib/platform/github-provider.js +9 -14
  16. package/dist/lib/platform/github-release-notes.js +80 -11
  17. package/dist/lib/platform/none-provider.js +1 -1
  18. package/dist/lib/platform/pr-summary.js +4 -23
  19. package/dist/lib/platform/provider-contract.d.ts +39 -16
  20. package/dist/lib/platform/provider-contract.js +1 -1
  21. package/dist/lib/platform/provider-validation.js +83 -13
  22. package/dist/lib/platform/release-notes.js +7 -8
  23. package/dist/lib/platform/verification-sync.js +12 -49
  24. package/dist/lib/sandbox/capture.js +1 -1
  25. package/dist/lib/sandbox/commands/create.js +46 -1
  26. package/dist/lib/sandbox/commands/enter.js +1 -1
  27. package/dist/lib/sandbox/commands/start.js +1 -1
  28. package/dist/lib/sandbox/config.js +10 -0
  29. package/dist/lib/sandbox/control/client.js +4 -1
  30. package/dist/lib/sandbox/control/executor.js +12 -4
  31. package/dist/lib/sandbox/control/lifecycle.js +18 -13
  32. package/dist/lib/sandbox/control/protocol.js +2 -1
  33. package/dist/lib/sandbox/removal.js +15 -8
  34. package/dist/lib/sandbox/task-resolver.js +62 -2
  35. package/dist/lib/task/activity-intent.js +1 -2
  36. package/dist/lib/task/artifact-operations.js +36 -2
  37. package/dist/lib/task/artifact-schema.js +7 -7
  38. package/dist/lib/task/control-authority.js +9 -166
  39. package/dist/lib/task/decision-intents.js +2 -4
  40. package/dist/lib/task/events.js +7 -28
  41. package/dist/lib/task/finalization-handoff.js +92 -0
  42. package/dist/lib/task/finalization.js +123 -12
  43. package/dist/lib/task/intermediate-cleanup.js +9 -148
  44. package/dist/lib/task/ledger-intents.js +3 -6
  45. package/dist/lib/task/lifecycle.js +50 -11
  46. package/dist/lib/task/local-artifact-finalization.js +13 -28
  47. package/dist/lib/task/resolve-ref.js +3 -3
  48. package/dist/lib/task/review-command.js +1 -2
  49. package/dist/lib/task/review-finalization.js +1 -2
  50. package/dist/lib/task/snapshot.js +6 -6
  51. package/dist/lib/task/verification-engine.js +16 -11
  52. package/dist/lib/task/verification.js +1 -13
  53. package/dist/lib/task/workflow-warning-intents.js +2 -4
  54. package/dist/lib/task/write.js +122 -3
  55. package/dist/lifecycle-build-manifest.json +3 -3
  56. package/lib/decide.ts +5 -35
  57. package/lib/git/branch-worktree.ts +41 -0
  58. package/lib/internal/cli-route-inventory.ts +0 -2
  59. package/lib/internal/task-activity.ts +6 -34
  60. package/lib/internal/task-event.ts +4 -37
  61. package/lib/internal/task-finalization.ts +9 -2
  62. package/lib/internal/task-ledger.ts +5 -38
  63. package/lib/internal/task-lifecycle.ts +2 -2
  64. package/lib/internal/task-operation-registry.ts +1 -4
  65. package/lib/internal/task-review.ts +5 -31
  66. package/lib/internal/task-verify.ts +3 -39
  67. package/lib/internal/task-warning.ts +3 -32
  68. package/lib/merge.ts +59 -10
  69. package/lib/platform/github-provider.ts +11 -18
  70. package/lib/platform/github-release-notes.ts +89 -14
  71. package/lib/platform/none-provider.ts +1 -1
  72. package/lib/platform/pr-summary.ts +4 -25
  73. package/lib/platform/provider-contract.ts +38 -12
  74. package/lib/platform/provider-validation.ts +83 -12
  75. package/lib/platform/release-notes.ts +11 -12
  76. package/lib/platform/verification-sync.ts +13 -53
  77. package/lib/sandbox/capture.ts +1 -1
  78. package/lib/sandbox/commands/create.ts +52 -1
  79. package/lib/sandbox/commands/enter.ts +1 -1
  80. package/lib/sandbox/commands/start.ts +1 -1
  81. package/lib/sandbox/config.ts +12 -0
  82. package/lib/sandbox/control/client.ts +5 -1
  83. package/lib/sandbox/control/executor.ts +13 -4
  84. package/lib/sandbox/control/lifecycle.ts +22 -13
  85. package/lib/sandbox/control/protocol.ts +3 -2
  86. package/lib/sandbox/removal.ts +23 -7
  87. package/lib/sandbox/task-resolver.ts +51 -2
  88. package/lib/task/activity-intent.ts +1 -2
  89. package/lib/task/artifact-operations.ts +58 -2
  90. package/lib/task/artifact-schema.ts +9 -7
  91. package/lib/task/control-authority.ts +14 -188
  92. package/lib/task/decision-intents.ts +2 -4
  93. package/lib/task/events.ts +8 -27
  94. package/lib/task/finalization-handoff.ts +104 -0
  95. package/lib/task/finalization.ts +120 -12
  96. package/lib/task/intermediate-cleanup.ts +9 -125
  97. package/lib/task/ledger-intents.ts +3 -6
  98. package/lib/task/lifecycle.ts +45 -13
  99. package/lib/task/local-artifact-finalization.ts +16 -35
  100. package/lib/task/resolve-ref.ts +3 -3
  101. package/lib/task/review-command.ts +2 -5
  102. package/lib/task/review-finalization.ts +1 -4
  103. package/lib/task/snapshot.ts +6 -6
  104. package/lib/task/verification-engine.ts +20 -10
  105. package/lib/task/verification.ts +1 -15
  106. package/lib/task/workflow-warning-intents.ts +2 -4
  107. package/lib/task/write.ts +116 -5
  108. package/package.json +1 -1
  109. package/templates/.agents/rules/evidence-reporting.en.md +1 -2
  110. package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
  111. package/templates/.agents/skills/analyze-task/SKILL.en.md +11 -2
  112. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +11 -2
  113. package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
  114. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
  115. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
  116. package/templates/.agents/skills/code-task/reference/report-template.en.md +6 -6
  117. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +6 -6
  118. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +2 -2
  119. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +2 -2
  120. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +1 -3
  121. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +1 -3
  122. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
  123. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
  124. package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
  125. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
  126. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +3 -3
  127. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +3 -3
  128. package/templates/.agents/skills/review-code/reference/report-template.en.md +3 -3
  129. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +3 -3
  130. package/templates/.agents/skills/review-plan/reference/report-template.en.md +3 -3
  131. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +3 -3
  132. package/templates/.agents/skills/review-pr/config/verify.en.json +3 -1
  133. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +3 -1
  134. package/templates/.agents/skills/review-pr/reference/report-template.en.md +3 -3
  135. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +3 -3
  136. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +2 -2
  137. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +2 -2
  138. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -2
  139. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -2
  140. package/dist/lib/internal/task-override.js +0 -138
  141. package/dist/lib/task/artifact-recovery.js +0 -476
  142. package/dist/lib/task/artifact-repair-intent.js +0 -181
  143. package/dist/lib/task/guard-override.js +0 -31
  144. package/dist/lib/task/human-override.js +0 -1259
  145. package/lib/internal/task-override.ts +0 -144
  146. package/lib/task/artifact-recovery.ts +0 -572
  147. package/lib/task/artifact-repair-intent.ts +0 -227
  148. package/lib/task/guard-override.ts +0 -50
  149. package/lib/task/human-override.ts +0 -1522
@@ -7,6 +7,7 @@ import { resolvePlatformProviderContext } from './context.ts';
7
7
  import type { PlatformClient } from './context.ts';
8
8
  import { providerError, providerOperationContext, providerStatus, unsupportedProviderOperation } from './provider-bridge.ts';
9
9
  import { resourceIdentityNumber } from './resource-identity.ts';
10
+ import type { ReleaseNoteAuthor } from './provider-contract.ts';
10
11
  import type { ResourceIdentity } from './resource-identity.ts';
11
12
 
12
13
  type ReleaseNoteOptions = {
@@ -15,10 +16,9 @@ type ReleaseNoteOptions = {
15
16
  client?: PlatformClient;
16
17
  };
17
18
 
18
- type ReleaseNoteActor = { id?: string; name?: string };
19
- type ReleaseNoteCommit = { oid: string; title: string; authors: ReleaseNoteActor[] };
20
- type ReleaseNoteIssue = { number: number; identity?: ResourceIdentity; title: string; url: string; labels: string[]; author: { login: string; bot: boolean } | null };
21
- type ReleaseNotePullRequest = { number: number; identity?: ResourceIdentity; title: string; body: string; url: string; mergedAt: string; labels: string[]; author: { login: string; bot: boolean } | null; closingIssues: ReleaseNoteIssue[] };
19
+ type ReleaseNoteCommit = { oid: string; title: string; url: string | null; pullRequestNumbers: number[]; authors: ReleaseNoteAuthor[] };
20
+ type ReleaseNoteIssue = { number: number; identity?: ResourceIdentity; title: string; url: string; author: ReleaseNoteAuthor | null };
21
+ type ReleaseNotePullRequest = { number: number; identity?: ResourceIdentity; title: string; body: string; url: string; mergedAt: string; labels: string[]; author: ReleaseNoteAuthor | null; closingIssues: ReleaseNoteIssue[] };
22
22
 
23
23
  const unsupportedError = {
24
24
  code: 'PLATFORM_RELEASE_NOTES_UNSUPPORTED',
@@ -180,15 +180,15 @@ async function releaseNoteContext(
180
180
  if (!platform.ok) return { ...baseResult(platform.error.retryable ? 'blocked' : 'failed'), error: providerError(platform.error, 'PLATFORM_PROVIDER_OPERATION_FAILED') };
181
181
  const commits = commitFacts.map((commit) => {
182
182
  const seen = new Set<string>();
183
- const authors = platform.value.history.filter((item) => item.sha === commit.oid).flatMap((item) => item.author ? [item.author] : [])
184
- .concat(platform.value.actors)
183
+ const facts = platform.value.commits.find((item) => item.sha === commit.oid);
184
+ const authors = (facts?.authors || [])
185
185
  .filter((actor) => {
186
- const key = actor.id || actor.name || '';
186
+ const key = actor.login || actor.name;
187
187
  if (seen.has(key)) return false;
188
188
  seen.add(key);
189
189
  return true;
190
190
  });
191
- return { ...commit, authors };
191
+ return { ...commit, url: facts?.url || null, pullRequestNumbers: facts?.pullRequestNumbers || [], authors };
192
192
  });
193
193
  const pullRequests: ReleaseNotePullRequest[] = platform.value.mergedPullRequests
194
194
  .map((item) => ({
@@ -199,14 +199,13 @@ async function releaseNoteContext(
199
199
  url: item.displayUrl || '',
200
200
  mergedAt: item.mergedAt || '',
201
201
  labels: item.labels || [],
202
- author: item.author ? { login: item.author.name || item.author.id || '', bot: false } : null,
203
- closingIssues: platform.value.closingIssues.filter((issue) => issue.id === item.id).map((issue) => ({
202
+ author: item.author,
203
+ closingIssues: item.closingIssues.map((issue) => ({
204
204
  number: issue.number || resourceIdentityNumber(issue.identity) || 0,
205
205
  ...(issue.identity ? { identity: issue.identity } : {}),
206
206
  title: issue.title,
207
207
  url: issue.displayUrl || '',
208
- labels: issue.labels || [],
209
- author: issue.author ? { login: issue.author.name || issue.author.id || '', bot: false } : null
208
+ author: issue.author
210
209
  }))
211
210
  }))
212
211
  .sort((left, right) => left.number - right.number);
@@ -5,6 +5,8 @@ import path from "node:path";
5
5
  import process from "node:process";
6
6
  import { execFileSync } from "node:child_process";
7
7
 
8
+ import { resolveBranchWorktree } from "../git/branch-worktree.ts";
9
+
8
10
  import { resolvePlatformProviderContext } from "./context.ts";
9
11
  import { hasCheckedRequirement, resolveRequirementSection } from "./issue-metadata.ts";
10
12
  import { requirementSectionAnchors } from "./issues.ts";
@@ -1054,7 +1056,10 @@ function computeExpectedInLabels(taskDir: any, repositoryLabels: string[], mappi
1054
1056
  return { ok: false, type: "check_failed", message: "Task has no delivery_base_ref for in-label evidence" };
1055
1057
  }
1056
1058
  const branch = typeof task.metadata?.branch === "string" ? task.metadata.branch.trim() : "";
1057
- const gitCwd = branch ? worktreeForBranch(shared.repoRoot, branch) ?? taskDir : taskDir;
1059
+ const gitCwd = branch ? resolveBranchWorktree(shared.repoRoot, branch) : taskDir;
1060
+ if (!gitCwd) {
1061
+ return { ok: false, type: "check_failed", message: `Task branch '${branch}' is not checked out in a usable worktree` };
1062
+ }
1058
1063
  const changedFilesResult = gitText(["diff", `${baseRef}...HEAD`, "--name-only"], gitCwd);
1059
1064
  if (!changedFilesResult.ok) {
1060
1065
  return { ...changedFilesResult, type: "network_error" };
@@ -1082,25 +1087,6 @@ function computeExpectedInLabels(taskDir: any, repositoryLabels: string[], mappi
1082
1087
  return { ok: true, labels: planned.target, mode: "mapped" };
1083
1088
  }
1084
1089
 
1085
- function worktreeForBranch(repositoryRoot: string, branch: string): string | null {
1086
- try {
1087
- const records = execFileSync("git", ["worktree", "list", "--porcelain"], {
1088
- cwd: repositoryRoot,
1089
- encoding: "utf8",
1090
- stdio: ["pipe", "pipe", "pipe"]
1091
- }).trim().split(/\r?\n\r?\n/);
1092
- const expectedRef = `refs/heads/${branch}`;
1093
- for (const record of records) {
1094
- const worktree = /^worktree (.+)$/m.exec(record)?.[1];
1095
- const branchRef = /^branch (.+)$/m.exec(record)?.[1];
1096
- if (worktree && branchRef === expectedRef) return worktree;
1097
- }
1098
- } catch {
1099
- // Fall back to taskDir so ordinary in-repository task workspaces retain their behavior.
1100
- }
1101
- return null;
1102
- }
1103
-
1104
1090
  function loadInLabelMapping(shared: VerificationShared): any {
1105
1091
  const configPath = path.join(shared.repoRoot, ".agents", ".airc.json");
1106
1092
  if (!fs.existsSync(configPath)) {
@@ -1141,42 +1127,16 @@ function resolvePrHeadSha(context: any): any {
1141
1127
  return fallback();
1142
1128
  }
1143
1129
 
1144
- const worktreeList = gitText(["worktree", "list", "--porcelain"], context.taskDir);
1145
- if (!worktreeList.ok) {
1146
- return fallback();
1147
- }
1148
-
1149
- const matchedWorktree = findWorktreeForBranch(worktreeList.value, branch);
1130
+ const matchedWorktree = resolveBranchWorktree(context.taskDir, branch);
1150
1131
  if (!matchedWorktree) {
1151
- return fallback();
1152
- }
1153
-
1154
- const headInWorktree = gitText(["rev-parse", "HEAD"], matchedWorktree);
1155
- if (!headInWorktree.ok) {
1156
- return fallback();
1157
- }
1158
-
1159
- return headInWorktree;
1160
- }
1161
-
1162
- function findWorktreeForBranch(porcelainOutput: any, branch: any): any {
1163
- let currentWorktree = "";
1164
- for (const rawLine of String(porcelainOutput || "").split("\n")) {
1165
- const line = rawLine.trimEnd();
1166
- if (line.startsWith("worktree ")) {
1167
- currentWorktree = line.slice("worktree ".length).trim();
1168
- continue;
1169
- }
1170
-
1171
- if (line.startsWith("branch refs/heads/")) {
1172
- const usedBranch = line.slice("branch refs/heads/".length).trim();
1173
- if (usedBranch === branch && currentWorktree) {
1174
- return currentWorktree;
1175
- }
1176
- }
1132
+ return {
1133
+ ok: false,
1134
+ type: "check_failed",
1135
+ message: `Task branch '${branch}' is not checked out in a usable worktree`
1136
+ };
1177
1137
  }
1178
1138
 
1179
- return null;
1139
+ return gitText(["rev-parse", "HEAD"], matchedWorktree);
1180
1140
  }
1181
1141
 
1182
1142
  function interpolate(template: any, taskDir: any, artifactFile: any): any {
@@ -219,7 +219,7 @@ export async function runInSandbox(
219
219
  allowRecreate: request.recreate,
220
220
  recreate: async () => {
221
221
  const { create } = await import('./commands/create.ts');
222
- await create([request.taskRef, '--no-refresh']);
222
+ await create([request.taskRef, '--no-refresh'], { runProjectInitCommand: false });
223
223
  }
224
224
  })).container;
225
225
  } else if (!found.running) {
@@ -79,6 +79,7 @@ import {
79
79
  recoverSandboxControlReplacement,
80
80
  readSandboxControlManifest
81
81
  } from '../control/lifecycle.ts';
82
+ import { atomicWriteJson } from '../control/state.ts';
82
83
  import { inspectSandboxControlContainer } from '../control/container-identity.ts';
83
84
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
84
85
  import { sandboxCoreBindMounts } from '../mounts.ts';
@@ -1008,7 +1009,27 @@ function readImageLabels(config: Pick<SandboxCreateConfig, 'imageName'> & Pick<S
1008
1009
  ]));
1009
1010
  }
1010
1011
 
1011
- export async function create(args: string[]): Promise<void> {
1012
+ function readProjectInitCommandDigest(controlRoot: string): string | null {
1013
+ const markerPath = path.join(controlRoot, 'project-init.json');
1014
+ if (!fs.existsSync(markerPath)) return null;
1015
+ try {
1016
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')) as { version?: unknown; commandSha256?: unknown };
1017
+ return marker.version === 1 && typeof marker.commandSha256 === 'string' && /^[a-f0-9]{64}$/.test(marker.commandSha256)
1018
+ ? marker.commandSha256
1019
+ : null;
1020
+ } catch {
1021
+ return null;
1022
+ }
1023
+ }
1024
+
1025
+ function writeProjectInitCommandDigest(controlRoot: string, commandSha256: string): void {
1026
+ atomicWriteJson(path.join(controlRoot, 'project-init.json'), { version: 1, commandSha256 });
1027
+ }
1028
+
1029
+ export async function create(
1030
+ args: string[],
1031
+ { runProjectInitCommand = true }: { runProjectInitCommand?: boolean } = {}
1032
+ ): Promise<void> {
1012
1033
  const { values, positionals } = parseArgs({
1013
1034
  args,
1014
1035
  allowPositionals: true,
@@ -1079,6 +1100,7 @@ export async function create(args: string[]): Promise<void> {
1079
1100
  identity: target.workspace
1080
1101
  });
1081
1102
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
1103
+ let worktreeCreated = false;
1082
1104
  const shareCommon = shareCommonDir(effectiveConfig);
1083
1105
  const shareBranch = shareBranchDir(effectiveConfig, branch);
1084
1106
  const preparedDockerfile = prepareDockerfile(effectiveConfig, capabilityPlan.image);
@@ -1203,6 +1225,7 @@ export async function create(args: string[]): Promise<void> {
1203
1225
  toEnginePath(engine, worktree),
1204
1226
  branch
1205
1227
  ]);
1228
+ worktreeCreated = true;
1206
1229
  } else {
1207
1230
  message(`Creating branch '${branch}' from '${baseBranch}'...`);
1208
1231
  runEngineTaskCommand(engine, 'git', [
@@ -1215,6 +1238,7 @@ export async function create(args: string[]): Promise<void> {
1215
1238
  toEnginePath(engine, worktree),
1216
1239
  baseBranch
1217
1240
  ]);
1241
+ worktreeCreated = true;
1218
1242
  }
1219
1243
 
1220
1244
  return `Worktree ready at ${worktree}`;
@@ -1272,6 +1296,7 @@ export async function create(args: string[]): Promise<void> {
1272
1296
  let replacementCutover: ReturnType<typeof beginSandboxControlReplacement> | null = null;
1273
1297
  try {
1274
1298
  const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
1299
+ const previousProjectInitCommandDigest = readProjectInitCommandDigest(controlPaths.root);
1275
1300
  const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
1276
1301
  const previousManifest = fs.existsSync(controlPaths.manifestPath)
1277
1302
  ? readSandboxControlManifest(controlPaths.manifestPath)
@@ -1728,6 +1753,32 @@ export async function create(args: string[]): Promise<void> {
1728
1753
  );
1729
1754
  }
1730
1755
 
1756
+ const initCommand = effectiveConfig.initCommand;
1757
+ const initCommandDigest = initCommand === null ? null : createHash('sha256').update(initCommand).digest('hex');
1758
+ const shouldRunProjectInitCommand = runProjectInitCommand
1759
+ && initCommand !== null
1760
+ && initCommandDigest !== null
1761
+ && (worktreeCreated || previousProjectInitCommandDigest !== initCommandDigest);
1762
+ let projectInitCompleted = !worktreeCreated
1763
+ && initCommandDigest !== null
1764
+ && previousProjectInitCommandDigest === initCommandDigest;
1765
+
1766
+ if (shouldRunProjectInitCommand && initCommand !== null) {
1767
+ try {
1768
+ runVerboseEngine(engine, 'docker', [
1769
+ 'exec', '--workdir', '/workspace', container, 'bash', '-lc', initCommand
1770
+ ]);
1771
+ projectInitCompleted = true;
1772
+ } catch (error) {
1773
+ throw new Error(
1774
+ `Project initialization failed for '${branch}': ${error instanceof Error ? error.message : String(error)}`
1775
+ );
1776
+ }
1777
+ }
1778
+ if (projectInitCompleted && initCommandDigest !== null) {
1779
+ writeProjectInitCommandDigest(controlPaths.root, initCommandDigest);
1780
+ }
1781
+
1731
1782
  replacementLease.release();
1732
1783
  replacementLeaseHeld = false;
1733
1784
  return 'Container started';
@@ -169,7 +169,7 @@ export async function enter(args: string[]): Promise<number> {
169
169
  allowRecreate: parsed.recreate,
170
170
  recreate: async () => {
171
171
  const { create } = await import('./create.ts');
172
- await create([target.requestedRef, '--no-refresh']);
172
+ await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
173
173
  }
174
174
  });
175
175
  const container = ready.container;
@@ -86,7 +86,7 @@ export async function start(args: string[]): Promise<void> {
86
86
  forceRecreate: parsed.recreate,
87
87
  recreate: async () => {
88
88
  const { create } = await import('./create.ts');
89
- await create([target.requestedRef, '--no-refresh']);
89
+ await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
90
90
  }
91
91
  });
92
92
  const message = ready.path === 'healthy'
@@ -18,6 +18,7 @@ const DEFAULTS = Object.freeze({
18
18
  tools: ['agent-infra'],
19
19
  refreshIntervalDays: 7,
20
20
  dockerfile: null,
21
+ initCommand: null,
21
22
  vm: {
22
23
  cpu: null,
23
24
  memory: null,
@@ -35,6 +36,7 @@ type SandboxConfigInput = {
35
36
  customTools?: unknown;
36
37
  refreshIntervalDays?: unknown;
37
38
  dockerfile?: string | null;
39
+ initCommand?: unknown;
38
40
  vm?: Record<string, unknown>;
39
41
  };
40
42
 
@@ -66,6 +68,7 @@ export type SandboxConfig = {
66
68
  delivery?: { remote: string; baseRef: string };
67
69
  refreshIntervalDays: number;
68
70
  dockerfile: string | null;
71
+ initCommand: string | null;
69
72
  vm: SandboxVmConfig;
70
73
  };
71
74
 
@@ -103,6 +106,7 @@ function cloneDefaults(): SandboxConfigInput & { vm: SandboxVmConfig; runtimes:
103
106
  tools: [...DEFAULTS.tools],
104
107
  refreshIntervalDays: DEFAULTS.refreshIntervalDays,
105
108
  dockerfile: DEFAULTS.dockerfile,
109
+ initCommand: DEFAULTS.initCommand,
106
110
  vm: { ...DEFAULTS.vm }
107
111
  };
108
112
  }
@@ -149,6 +153,13 @@ export function loadConfig({
149
153
  ? [...sandbox.runtimes]
150
154
  : defaults.runtimes;
151
155
  const dockerfile = typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null;
156
+ if (sandbox.initCommand !== undefined &&
157
+ (typeof sandbox.initCommand !== 'string' || sandbox.initCommand.trim().length === 0)) {
158
+ throw new Error('sandbox.initCommand must be a non-empty string when configured');
159
+ }
160
+ const initCommand = typeof sandbox.initCommand === 'string'
161
+ ? sandbox.initCommand
162
+ : null;
152
163
 
153
164
  if (!dockerfile) {
154
165
  let enginesNode: string | undefined;
@@ -200,6 +211,7 @@ export function loadConfig({
200
211
  defaults.refreshIntervalDays
201
212
  ),
202
213
  dockerfile,
214
+ initCommand,
203
215
  vm: {
204
216
  cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
205
217
  memory: asPositiveNumberOrNull(sandbox.vm?.memory) ?? defaults.vm.memory,
@@ -393,6 +393,7 @@ export function requestSandboxControl(params: Readonly<{
393
393
 
394
394
  export function requestSandboxTaskFinalization(params: Readonly<{
395
395
  agent: string;
396
+ prepareHandoff?: (binding: Readonly<{ generation: string; requestId: string }>) => string;
396
397
  channelDir?: string;
397
398
  statusDir?: string;
398
399
  token?: string;
@@ -403,15 +404,18 @@ export function requestSandboxTaskFinalization(params: Readonly<{
403
404
  if (!agent) clientError('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization agent is invalid', false);
404
405
  const auth = authority(params);
405
406
  const issuedAt = Date.now();
407
+ const id = randomUUID();
408
+ const handoffSha256 = (params.prepareHandoff ?? (() => '0'.repeat(64)))({ generation: auth.generation, requestId: id });
406
409
  const request: SandboxTaskFinalizationRequest = {
407
410
  version: 3,
408
- id: randomUUID(),
411
+ id,
409
412
  ...auth,
410
413
  issuedAt,
411
414
  expiresAt: issuedAt + SANDBOX_CONTROL_ADMISSION_WINDOW_MS,
412
415
  family: 'task-finalization',
413
416
  operation: 'complete',
414
417
  agent,
418
+ handoffSha256,
415
419
  args: [],
416
420
  controllerProcess: null,
417
421
  controllerProof: null
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { homedir } from 'node:os';
5
6
  import { getProcessStartTime } from '../../server/process-state.ts';
6
7
  import {
7
8
  createTask,
@@ -41,6 +42,11 @@ import {
41
42
  openCodexControllerRegistration,
42
43
  resolveCodexControllerBinding
43
44
  } from './controller-registration.ts';
45
+ import { shareBranchDir } from '../constants.ts';
46
+
47
+ function handoffDirectory(manifest: SandboxControlManifest): string {
48
+ return shareBranchDir({ shareBase: path.join(homedir(), '.agent-infra', 'share', manifest.project) }, manifest.branch);
49
+ }
44
50
 
45
51
  export type SandboxControlExecutionResult = {
46
52
  exitCode: number;
@@ -373,10 +379,11 @@ async function executeRequestInner(
373
379
  }
374
380
  }
375
381
  if (request.family === 'task-finalization') {
376
- const operation = parseTaskControlOperation(
382
+ const parsed = parseTaskControlOperation(
377
383
  'task-finalization', [manifest.taskId!, 'complete', '--agent', request.agent]
378
384
  );
379
- if (operation.family !== 'task-finalization') throw new Error('SANDBOX_CONTROL_FINALIZATION_OPERATION_INVALID');
385
+ if (parsed.family !== 'task-finalization') throw new Error('SANDBOX_CONTROL_FINALIZATION_OPERATION_INVALID');
386
+ const operation = { ...parsed, request: { ...parsed.request, handoffSha256: request.handoffSha256 } };
380
387
  const result = dispatchTaskControlOperation(
381
388
  createSandboxExecutorExecutionContext({
382
389
  repoRoot: manifest.repoRoot,
@@ -385,7 +392,8 @@ async function executeRequestInner(
385
392
  taskId: manifest.taskId!,
386
393
  generation: manifest.generation,
387
394
  manifestPath,
388
- requestId: request.id
395
+ requestId: request.id,
396
+ handoffDirectory: handoffDirectory(manifest)
389
397
  }),
390
398
  operation
391
399
  );
@@ -407,7 +415,8 @@ async function executeRequestInner(
407
415
  taskId: manifest.taskId!,
408
416
  generation: manifest.generation,
409
417
  manifestPath,
410
- requestId: request.id
418
+ requestId: request.id,
419
+ handoffDirectory: handoffDirectory(manifest)
411
420
  });
412
421
  const format = (value: unknown): SandboxControlExecutionResult => {
413
422
  const result = value as { status?: string };
@@ -1298,6 +1298,10 @@ export async function quiesceSandboxControlRoot(
1298
1298
  timeoutMs?: number;
1299
1299
  timing?: SandboxControlTimingPolicy;
1300
1300
  identityProbe?: ProcessIdentityProbe;
1301
+ executionProcessControl?: Readonly<{
1302
+ isAlive: (execution: SandboxControlExecution) => boolean;
1303
+ terminate: (execution: SandboxControlExecution, phase: 'graceful' | 'force') => boolean;
1304
+ }>;
1301
1305
  } = {}
1302
1306
  ): Promise<'missing' | 'stale' | 'stopped'> {
1303
1307
  const resolvedRoot = path.resolve(root);
@@ -1338,58 +1342,63 @@ export async function quiesceSandboxControlRoot(
1338
1342
  if (!manifest && brokerLive) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
1339
1343
 
1340
1344
  const platform = options.platform ?? process.platform;
1345
+ const processControl = options.executionProcessControl ?? {
1346
+ isAlive: (execution: SandboxControlExecution) => executionAlive(execution, platform, identityProbe),
1347
+ terminate: (execution: SandboxControlExecution, phase: 'graceful' | 'force') => phase === 'graceful'
1348
+ ? terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe })
1349
+ : terminateSandboxControlExecution(execution, {
1350
+ platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1351
+ })
1352
+ };
1341
1353
  let executions = manifest ? readExecutions(manifest) : [];
1342
1354
  const owner: OwnerIdentity | null = brokerLive ? broker : statusLive ? statusOwner : null;
1343
1355
  if (!owner) {
1344
1356
  if (manifest && !broker && !status) throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
1345
1357
  for (const execution of executions) {
1346
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1358
+ processControl.terminate(execution, 'graceful');
1347
1359
  }
1348
1360
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1349
1361
  while (Date.now() < softDeadlineAt) {
1350
- if (!executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
1362
+ if (!executions.some((execution) => processControl.isAlive(execution))) break;
1351
1363
  await new Promise((resolve) => setTimeout(resolve, 25));
1352
1364
  }
1353
1365
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
1354
1366
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1355
1367
  for (const execution of executions) {
1356
- if (!terminateSandboxControlExecution(execution, {
1357
- platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1358
- })) {
1368
+ if (!processControl.terminate(execution, 'force')) {
1359
1369
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
1360
1370
  }
1361
1371
  }
1362
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1372
+ if (executions.some((execution) => processControl.isAlive(execution))) {
1363
1373
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1364
1374
  }
1365
1375
  return broker || status ? 'stale' : 'missing';
1366
1376
  }
1367
1377
 
1368
- const remaining = (): number => Math.max(0, deadlineAt - Date.now());
1369
1378
  signalOwner(owner, platform, false);
1370
1379
  for (const execution of executions) {
1371
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1380
+ processControl.terminate(execution, 'graceful');
1372
1381
  }
1373
1382
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1374
1383
  while (Date.now() < softDeadlineAt) {
1375
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
1384
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) break;
1376
1385
  await new Promise((resolve) => setTimeout(resolve, 25));
1377
1386
  }
1378
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1387
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) {
1379
1388
  return 'stopped';
1380
1389
  }
1381
1390
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
1382
1391
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1383
1392
  for (const execution of executions) {
1384
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, deadlineAt, forceAt: Date.now(), identityProbe });
1393
+ processControl.terminate(execution, 'force');
1385
1394
  }
1386
1395
  signalOwner(owner, platform, true);
1387
1396
  while (Date.now() < deadlineAt) {
1388
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) return 'stopped';
1397
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) return 'stopped';
1389
1398
  await new Promise((resolve) => setTimeout(resolve, 25));
1390
1399
  }
1391
1400
  if (ownerLive(owner, identityProbe)) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
1392
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1401
+ if (executions.some((execution) => processControl.isAlive(execution))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1393
1402
  return 'stopped';
1394
1403
  }
1395
1404
 
@@ -65,7 +65,7 @@ export type SandboxTaskCommandRequest = RequestBase & Readonly<{
65
65
  family: 'task-lifecycle'; args: string[];
66
66
  }>;
67
67
  export type SandboxTaskFinalizationRequest = RequestBase & Readonly<{
68
- family: 'task-finalization'; operation: 'complete'; agent: string; args: [];
68
+ family: 'task-finalization'; operation: 'complete'; agent: string; handoffSha256: string; args: [];
69
69
  }>;
70
70
  export type SandboxTaskCreateRequest = RequestBase & Readonly<{
71
71
  family: 'task-create'; candidate: TaskCreateCandidateV1;
@@ -279,11 +279,12 @@ export function validateSandboxControlRequest(
279
279
  return request as SandboxCodexControllerRequest;
280
280
  }
281
281
  if (request.family === 'task-finalization') {
282
- const expected = ['agent', 'args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'operation', 'token', 'version'];
282
+ const expected = ['agent', 'args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'handoffSha256', 'id', 'issuedAt', 'operation', 'token', 'version'];
283
283
  if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
284
284
  || request.operation !== 'complete'
285
285
  || !Array.isArray(request.args) || request.args.length !== 0
286
286
  || typeof request.agent !== 'string' || normalizeAgentToken(request.agent) !== request.agent
287
+ || typeof request.handoffSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(request.handoffSha256)
287
288
  || request.controllerProcess !== null || request.controllerProof !== null) {
288
289
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization request schema or authorization is invalid');
289
290
  }
@@ -47,6 +47,7 @@ import { toolConfigDirCandidates, toolProjectDirCandidates } from './tools.ts';
47
47
  import type { SandboxTool } from './tools.ts';
48
48
  import { getProcessStartTime } from '../server/process-state.ts';
49
49
  import { releaseStaleShortIdRegistry } from '../task/short-id.ts';
50
+ import { withRepositoryMutationLock } from '../task/task-execution-lock.ts';
50
51
  import { fetchSandboxRows, type SandboxRow } from './commands/list-running.ts';
51
52
  import {
52
53
  formatIntermediateCleanupReport,
@@ -959,7 +960,10 @@ async function rmOne(
959
960
  branch: string,
960
961
  options: RmOneOptions = {}
961
962
  ): Promise<void> {
962
- await runRmOneUnderRepositoryLock(config, tools, branch, options);
963
+ await withRepositoryMutationLock(
964
+ config.repoRoot,
965
+ () => runRmOneUnderRepositoryLock(config, tools, branch, options)
966
+ );
963
967
  }
964
968
 
965
969
  async function removeUncheckedSandbox(
@@ -998,7 +1002,16 @@ async function removeUncheckedSandbox(
998
1002
  return null;
999
1003
  }
1000
1004
 
1005
+ const worktreePermits = options.permits ?? await authorizeWorktrees(
1006
+ existingWorktrees,
1007
+ { allowDirtyDiscard: true, assumeYes: Boolean(options.assumeYes) },
1008
+ { ...options.prompt, interactive: options.interactive }
1009
+ );
1010
+
1001
1011
  for (const container of matchedContainers) runSafeEngine(engine, 'docker', ['rm', '-f', container]);
1012
+ if (!sandboxContainersRemoved(engine, matchedContainers)) {
1013
+ throw new Error(`SANDBOX_REMOVAL_CONTAINER_STILL_PRESENT: ${matchedContainers.join(', ')}`);
1014
+ }
1002
1015
  for (const root of controlRoots) {
1003
1016
  fs.rmSync(root, { recursive: true, force: true });
1004
1017
  removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
@@ -1011,12 +1024,15 @@ async function removeUncheckedSandbox(
1011
1024
  for (const shell of shellConfigDirCandidates(config, effectiveBranch)) fs.rmSync(shell, { recursive: true, force: true });
1012
1025
  if (shouldRemoveShare) fs.rmSync(sharePath, { recursive: true, force: true });
1013
1026
  if (shouldRemoveWorktree) {
1014
- for (const worktree of existingWorktrees) fs.rmSync(worktree, { recursive: true, force: true });
1015
- runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
1027
+ for (const worktree of existingWorktrees) {
1028
+ const permit = worktreePermits.get(path.resolve(worktree));
1029
+ if (!permit) throw new Error(`SANDBOX_WORKTREE_REMOVAL_PERMIT_MISSING: ${worktree}`);
1030
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree, permit);
1031
+ }
1016
1032
  }
1017
- if (shouldDeleteBranch) runSafe('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch]);
1018
- if (!sandboxContainersRemoved(engine, matchedContainers)) {
1019
- throw new Error(`SANDBOX_REMOVAL_CONTAINER_STILL_PRESENT: ${matchedContainers.join(', ')}`);
1033
+ if (shouldDeleteBranch) {
1034
+ assertBranchRemovalIdentity(config, effectiveBranch, worktreePermits);
1035
+ runSafe('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch]);
1020
1036
  }
1021
1037
  if (target.workspace.mode === 'task-bound') {
1022
1038
  releaseStaleShortIdRegistry(config.repoRoot, target.workspace.taskId);
@@ -1045,7 +1061,7 @@ async function rmPurge(
1045
1061
  tools: SandboxTool[],
1046
1062
  prompt: PromptDependencies = {}
1047
1063
  ): Promise<void> {
1048
- return rmPurgeCore(config, tools, prompt);
1064
+ return withRepositoryMutationLock(config.repoRoot, () => rmPurgeCore(config, tools, prompt));
1049
1065
  }
1050
1066
 
1051
1067
  async function rmPurgeCore(