@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
@@ -78,6 +78,14 @@ function arrayValue(value, label) {
78
78
  throw new Error(`${label} must be an array`);
79
79
  return value;
80
80
  }
81
+ function positiveNumberValue(value, label) {
82
+ if (!Number.isSafeInteger(value) || Number(value) <= 0)
83
+ throw new Error(`${label} must be a positive integer`);
84
+ return Number(value);
85
+ }
86
+ function stringArray(value, label) {
87
+ return arrayValue(value, label).map((entry, index) => stringValue(entry, `${label}[${index}]`));
88
+ }
81
89
  function stableUnique(values, key, label) {
82
90
  const seen = new Set();
83
91
  for (const value of values) {
@@ -88,6 +96,16 @@ function stableUnique(values, key, label) {
88
96
  }
89
97
  return [...values].sort((left, right) => key(left).localeCompare(key(right)));
90
98
  }
99
+ function uniqueInOrder(values, key, label) {
100
+ const seen = new Set();
101
+ for (const value of values) {
102
+ const identityKey = key(value);
103
+ if (seen.has(identityKey))
104
+ throw new Error(`${label} contains duplicate identities`);
105
+ seen.add(identityKey);
106
+ }
107
+ return values;
108
+ }
91
109
  function declaredIdentityKind(declaration, resourceKind) {
92
110
  const kind = declaration?.[resourceKind];
93
111
  return kind === 'id' || kind === 'number' || kind === 'key' ? kind : undefined;
@@ -358,21 +376,73 @@ function validateMetadata(value) {
358
376
  }
359
377
  function validateReleaseNotes(value, declaration) {
360
378
  const item = record(value, 'release notes');
361
- exactKeys(item, ['history', 'mergedPullRequests', 'closingIssues', 'actors'], 'release notes');
379
+ exactKeys(item, ['history', 'commits', 'mergedPullRequests'], 'release notes');
380
+ const releaseAuthor = (raw, label) => {
381
+ if (raw === null)
382
+ return null;
383
+ const entry = record(raw, label);
384
+ exactKeys(entry, ['name', 'login', 'bot', 'resolution'], label);
385
+ if (typeof entry.bot !== 'boolean')
386
+ throw new Error(`${label}.bot must be boolean`);
387
+ if (!['platform-user', 'platform-noreply', 'unresolved'].includes(String(entry.resolution))) {
388
+ throw new Error(`${label}.resolution is invalid`);
389
+ }
390
+ return {
391
+ name: stringValue(entry.name, `${label}.name`, true),
392
+ login: nullableString(entry.login, `${label}.login`),
393
+ bot: entry.bot,
394
+ resolution: entry.resolution
395
+ };
396
+ };
397
+ const issue = (raw, label) => {
398
+ const entry = record(raw, label);
399
+ exactKeys(entry, ['id', 'identity', 'number', 'title', 'displayUrl', 'author'], label);
400
+ return {
401
+ id: stringValue(entry.id, `${label}.id`),
402
+ identity: identity(entry.identity, `${label}.identity`, declaredIdentityKind(declaration, 'issue')),
403
+ number: positiveNumberValue(entry.number, `${label}.number`),
404
+ title: stringValue(entry.title, `${label}.title`, true),
405
+ displayUrl: stringValue(entry.displayUrl, `${label}.displayUrl`, true),
406
+ author: releaseAuthor(entry.author, `${label}.author`)
407
+ };
408
+ };
362
409
  return {
363
- history: stableUnique(arrayValue(item.history, 'release notes.history').map((raw) => {
410
+ history: uniqueInOrder(arrayValue(item.history, 'release notes.history').map((raw) => {
364
411
  const entry = record(raw, 'release notes.history[]');
365
- exactKeys(entry, ['sha', 'message', 'authoredAt', 'author'], 'release notes.history[]');
366
- return { sha: stringValue(entry.sha, 'history.sha'), message: stringValue(entry.message, 'history.message', true), authoredAt: utcTimestamp(entry.authoredAt, 'history.authoredAt'), author: author(entry.author, 'history.author') };
367
- }), (entry) => entry.sha, 'release notes.history'),
368
- mergedPullRequests: stableUnique(arrayValue(item.mergedPullRequests, 'release notes.mergedPullRequests').map((entry) => validateChangeRequest(entry, declaredIdentityKind(declaration, 'pull-request'), true)), (entry) => serializeResourceIdentity(entry.identity), 'release notes.mergedPullRequests'),
369
- closingIssues: stableUnique(arrayValue(item.closingIssues, 'release notes.closingIssues').map((entry) => validateIssue(entry, declaredIdentityKind(declaration, 'issue'), true)), (entry) => serializeResourceIdentity(entry.identity), 'release notes.closingIssues'),
370
- actors: stableUnique(arrayValue(item.actors, 'release notes.actors').map((entry) => {
371
- const actor = author(entry, 'release notes.actor');
372
- if (!actor)
373
- throw new Error('release notes.actor must identify an actor');
374
- return actor;
375
- }), (entry) => entry.id || entry.name || '', 'release notes.actors')
412
+ exactKeys(entry, ['tag', 'body', 'url'], 'release notes.history[]');
413
+ return { tag: stringValue(entry.tag, 'history.tag'), body: stringValue(entry.body, 'history.body', true), url: nullableString(entry.url, 'history.url') };
414
+ }), (entry) => entry.tag, 'release notes.history'),
415
+ commits: stableUnique(arrayValue(item.commits, 'release notes.commits').map((raw) => {
416
+ const entry = record(raw, 'release notes.commits[]');
417
+ exactKeys(entry, ['sha', 'url', 'pullRequestNumbers', 'authors'], 'release notes.commits[]');
418
+ return {
419
+ sha: stringValue(entry.sha, 'commits.sha'),
420
+ url: nullableString(entry.url, 'commits.url'),
421
+ pullRequestNumbers: stableUnique(arrayValue(entry.pullRequestNumbers, 'commits.pullRequestNumbers').map((number) => positiveNumberValue(number, 'commits.pullRequestNumbers[]')), (number) => String(number), 'commits.pullRequestNumbers'),
422
+ authors: arrayValue(entry.authors, 'commits.authors').map((rawAuthor) => {
423
+ const normalized = releaseAuthor(rawAuthor, 'commits.author');
424
+ if (!normalized)
425
+ throw new Error('commits.author must identify an author');
426
+ return normalized;
427
+ })
428
+ };
429
+ }), (entry) => entry.sha, 'release notes.commits'),
430
+ mergedPullRequests: stableUnique(arrayValue(item.mergedPullRequests, 'release notes.mergedPullRequests').map((raw) => {
431
+ const entry = record(raw, 'release notes.mergedPullRequests[]');
432
+ exactKeys(entry, ['id', 'identity', 'number', 'title', 'body', 'mergedAt', 'displayUrl', 'labels', 'author', 'closingIssues'], 'release notes.mergedPullRequests[]');
433
+ return {
434
+ id: stringValue(entry.id, 'pullRequest.id'),
435
+ identity: identity(entry.identity, 'pullRequest.identity', declaredIdentityKind(declaration, 'pull-request')),
436
+ number: positiveNumberValue(entry.number, 'pullRequest.number'),
437
+ title: stringValue(entry.title, 'pullRequest.title', true),
438
+ body: stringValue(entry.body, 'pullRequest.body', true),
439
+ mergedAt: utcTimestamp(entry.mergedAt, 'pullRequest.mergedAt'),
440
+ displayUrl: stringValue(entry.displayUrl, 'pullRequest.displayUrl', true),
441
+ labels: stringArray(entry.labels, 'pullRequest.labels'),
442
+ author: releaseAuthor(entry.author, 'pullRequest.author'),
443
+ closingIssues: stableUnique(arrayValue(entry.closingIssues, 'pullRequest.closingIssues').map((entry) => issue(entry, 'pullRequest.closingIssues[]')), (entry) => serializeResourceIdentity(entry.identity), 'pullRequest.closingIssues')
444
+ };
445
+ }), (entry) => serializeResourceIdentity(entry.identity), 'release notes.mergedPullRequests')
376
446
  };
377
447
  }
378
448
  function utcTimestamp(value, label) {
@@ -154,16 +154,16 @@ async function releaseNoteContext(input, options = {}) {
154
154
  return { ...baseResult(platform.error.retryable ? 'blocked' : 'failed'), error: providerError(platform.error, 'PLATFORM_PROVIDER_OPERATION_FAILED') };
155
155
  const commits = commitFacts.map((commit) => {
156
156
  const seen = new Set();
157
- const authors = platform.value.history.filter((item) => item.sha === commit.oid).flatMap((item) => item.author ? [item.author] : [])
158
- .concat(platform.value.actors)
157
+ const facts = platform.value.commits.find((item) => item.sha === commit.oid);
158
+ const authors = (facts?.authors || [])
159
159
  .filter((actor) => {
160
- const key = actor.id || actor.name || '';
160
+ const key = actor.login || actor.name;
161
161
  if (seen.has(key))
162
162
  return false;
163
163
  seen.add(key);
164
164
  return true;
165
165
  });
166
- return { ...commit, authors };
166
+ return { ...commit, url: facts?.url || null, pullRequestNumbers: facts?.pullRequestNumbers || [], authors };
167
167
  });
168
168
  const pullRequests = platform.value.mergedPullRequests
169
169
  .map((item) => ({
@@ -174,14 +174,13 @@ async function releaseNoteContext(input, options = {}) {
174
174
  url: item.displayUrl || '',
175
175
  mergedAt: item.mergedAt || '',
176
176
  labels: item.labels || [],
177
- author: item.author ? { login: item.author.name || item.author.id || '', bot: false } : null,
178
- closingIssues: platform.value.closingIssues.filter((issue) => issue.id === item.id).map((issue) => ({
177
+ author: item.author,
178
+ closingIssues: item.closingIssues.map((issue) => ({
179
179
  number: issue.number || resourceIdentityNumber(issue.identity) || 0,
180
180
  ...(issue.identity ? { identity: issue.identity } : {}),
181
181
  title: issue.title,
182
182
  url: issue.displayUrl || '',
183
- labels: issue.labels || [],
184
- author: issue.author ? { login: issue.author.name || issue.author.id || '', bot: false } : null
183
+ author: issue.author
185
184
  }))
186
185
  }))
187
186
  .sort((left, right) => left.number - right.number);
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { execFileSync } from "node:child_process";
6
+ import { resolveBranchWorktree } from "../git/branch-worktree.js";
6
7
  import { resolvePlatformProviderContext } from "./context.js";
7
8
  import { hasCheckedRequirement, resolveRequirementSection } from "./issue-metadata.js";
8
9
  import { requirementSectionAnchors } from "./issues.js";
@@ -807,7 +808,10 @@ function computeExpectedInLabels(taskDir, repositoryLabels, mappingOverride, sha
807
808
  return { ok: false, type: "check_failed", message: "Task has no delivery_base_ref for in-label evidence" };
808
809
  }
809
810
  const branch = typeof task.metadata?.branch === "string" ? task.metadata.branch.trim() : "";
810
- const gitCwd = branch ? worktreeForBranch(shared.repoRoot, branch) ?? taskDir : taskDir;
811
+ const gitCwd = branch ? resolveBranchWorktree(shared.repoRoot, branch) : taskDir;
812
+ if (!gitCwd) {
813
+ return { ok: false, type: "check_failed", message: `Task branch '${branch}' is not checked out in a usable worktree` };
814
+ }
811
815
  const changedFilesResult = gitText(["diff", `${baseRef}...HEAD`, "--name-only"], gitCwd);
812
816
  if (!changedFilesResult.ok) {
813
817
  return { ...changedFilesResult, type: "network_error" };
@@ -831,26 +835,6 @@ function computeExpectedInLabels(taskDir, repositoryLabels, mappingOverride, sha
831
835
  }
832
836
  return { ok: true, labels: planned.target, mode: "mapped" };
833
837
  }
834
- function worktreeForBranch(repositoryRoot, branch) {
835
- try {
836
- const records = execFileSync("git", ["worktree", "list", "--porcelain"], {
837
- cwd: repositoryRoot,
838
- encoding: "utf8",
839
- stdio: ["pipe", "pipe", "pipe"]
840
- }).trim().split(/\r?\n\r?\n/);
841
- const expectedRef = `refs/heads/${branch}`;
842
- for (const record of records) {
843
- const worktree = /^worktree (.+)$/m.exec(record)?.[1];
844
- const branchRef = /^branch (.+)$/m.exec(record)?.[1];
845
- if (worktree && branchRef === expectedRef)
846
- return worktree;
847
- }
848
- }
849
- catch {
850
- // Fall back to taskDir so ordinary in-repository task workspaces retain their behavior.
851
- }
852
- return null;
853
- }
854
838
  function loadInLabelMapping(shared) {
855
839
  const configPath = path.join(shared.repoRoot, ".agents", ".airc.json");
856
840
  if (!fs.existsSync(configPath)) {
@@ -888,36 +872,15 @@ function resolvePrHeadSha(context) {
888
872
  if (!branch) {
889
873
  return fallback();
890
874
  }
891
- const worktreeList = gitText(["worktree", "list", "--porcelain"], context.taskDir);
892
- if (!worktreeList.ok) {
893
- return fallback();
894
- }
895
- const matchedWorktree = findWorktreeForBranch(worktreeList.value, branch);
875
+ const matchedWorktree = resolveBranchWorktree(context.taskDir, branch);
896
876
  if (!matchedWorktree) {
897
- return fallback();
898
- }
899
- const headInWorktree = gitText(["rev-parse", "HEAD"], matchedWorktree);
900
- if (!headInWorktree.ok) {
901
- return fallback();
902
- }
903
- return headInWorktree;
904
- }
905
- function findWorktreeForBranch(porcelainOutput, branch) {
906
- let currentWorktree = "";
907
- for (const rawLine of String(porcelainOutput || "").split("\n")) {
908
- const line = rawLine.trimEnd();
909
- if (line.startsWith("worktree ")) {
910
- currentWorktree = line.slice("worktree ".length).trim();
911
- continue;
912
- }
913
- if (line.startsWith("branch refs/heads/")) {
914
- const usedBranch = line.slice("branch refs/heads/".length).trim();
915
- if (usedBranch === branch && currentWorktree) {
916
- return currentWorktree;
917
- }
918
- }
877
+ return {
878
+ ok: false,
879
+ type: "check_failed",
880
+ message: `Task branch '${branch}' is not checked out in a usable worktree`
881
+ };
919
882
  }
920
- return null;
883
+ return gitText(["rev-parse", "HEAD"], matchedWorktree);
921
884
  }
922
885
  function interpolate(template, taskDir, artifactFile) {
923
886
  const artifactStem = artifactFile ? path.basename(artifactFile, path.extname(artifactFile)) : "";
@@ -154,7 +154,7 @@ export async function runInSandbox(request, options = {}) {
154
154
  allowRecreate: request.recreate,
155
155
  recreate: async () => {
156
156
  const { create } = await import('./commands/create.js');
157
- await create([request.taskRef, '--no-refresh']);
157
+ await create([request.taskRef, '--no-refresh'], { runProjectInitCommand: false });
158
158
  }
159
159
  })).container;
160
160
  }
@@ -18,6 +18,7 @@ import { createSandboxCapabilityPlan, runSandboxHooks, runBoundedSandboxHookComm
18
18
  import { resolveTools, tmpfsSeedStagingPath, tmpfsSeedTargetPath, toolConfigDirCandidates } from '../tools.js';
19
19
  import { assertFreshSandboxReady, hydrateTmpfsSeedEntries, prepareTmpfsMounts, startSandboxControlBroker } from '../recovery.js';
20
20
  import { acquireSandboxControlReplacement, assertSandboxControlCutoverSnapshot, beginSandboxControlReplacement, captureSandboxControlCutoverSnapshot, commitSandboxControlReplacement, quiesceSandboxControlRoot, recoverSandboxControlReplacement, readSandboxControlManifest } from '../control/lifecycle.js';
21
+ import { atomicWriteJson } from '../control/state.js';
21
22
  import { inspectSandboxControlContainer } from '../control/container-identity.js';
22
23
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
23
24
  import { sandboxCoreBindMounts } from '../mounts.js';
@@ -652,7 +653,24 @@ function readImageLabels(config, engine) {
652
653
  config.imageName
653
654
  ]));
654
655
  }
655
- export async function create(args) {
656
+ function readProjectInitCommandDigest(controlRoot) {
657
+ const markerPath = path.join(controlRoot, 'project-init.json');
658
+ if (!fs.existsSync(markerPath))
659
+ return null;
660
+ try {
661
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
662
+ return marker.version === 1 && typeof marker.commandSha256 === 'string' && /^[a-f0-9]{64}$/.test(marker.commandSha256)
663
+ ? marker.commandSha256
664
+ : null;
665
+ }
666
+ catch {
667
+ return null;
668
+ }
669
+ }
670
+ function writeProjectInitCommandDigest(controlRoot, commandSha256) {
671
+ atomicWriteJson(path.join(controlRoot, 'project-init.json'), { version: 1, commandSha256 });
672
+ }
673
+ export async function create(args, { runProjectInitCommand = true } = {}) {
656
674
  const { values, positionals } = parseArgs({
657
675
  args,
658
676
  allowPositionals: true,
@@ -720,6 +738,7 @@ export async function create(args) {
720
738
  identity: target.workspace
721
739
  });
722
740
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
741
+ let worktreeCreated = false;
723
742
  const shareCommon = shareCommonDir(effectiveConfig);
724
743
  const shareBranch = shareBranchDir(effectiveConfig, branch);
725
744
  const preparedDockerfile = prepareDockerfile(effectiveConfig, capabilityPlan.image);
@@ -821,6 +840,7 @@ export async function create(args) {
821
840
  toEnginePath(engine, worktree),
822
841
  branch
823
842
  ]);
843
+ worktreeCreated = true;
824
844
  }
825
845
  else {
826
846
  message(`Creating branch '${branch}' from '${baseBranch}'...`);
@@ -834,6 +854,7 @@ export async function create(args) {
834
854
  toEnginePath(engine, worktree),
835
855
  baseBranch
836
856
  ]);
857
+ worktreeCreated = true;
837
858
  }
838
859
  return `Worktree ready at ${worktree}`;
839
860
  }
@@ -886,6 +907,7 @@ export async function create(args) {
886
907
  let replacementCutover = null;
887
908
  try {
888
909
  const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
910
+ const previousProjectInitCommandDigest = readProjectInitCommandDigest(controlPaths.root);
889
911
  const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
890
912
  const previousManifest = fs.existsSync(controlPaths.manifestPath)
891
913
  ? readSandboxControlManifest(controlPaths.manifestPath)
@@ -1295,6 +1317,29 @@ export async function create(args) {
1295
1317
  throw new Error(afterStartFailure.message
1296
1318
  ?? `Sandbox hook '${afterStartFailure.hookId}' failed.`);
1297
1319
  }
1320
+ const initCommand = effectiveConfig.initCommand;
1321
+ const initCommandDigest = initCommand === null ? null : createHash('sha256').update(initCommand).digest('hex');
1322
+ const shouldRunProjectInitCommand = runProjectInitCommand
1323
+ && initCommand !== null
1324
+ && initCommandDigest !== null
1325
+ && (worktreeCreated || previousProjectInitCommandDigest !== initCommandDigest);
1326
+ let projectInitCompleted = !worktreeCreated
1327
+ && initCommandDigest !== null
1328
+ && previousProjectInitCommandDigest === initCommandDigest;
1329
+ if (shouldRunProjectInitCommand && initCommand !== null) {
1330
+ try {
1331
+ runVerboseEngine(engine, 'docker', [
1332
+ 'exec', '--workdir', '/workspace', container, 'bash', '-lc', initCommand
1333
+ ]);
1334
+ projectInitCompleted = true;
1335
+ }
1336
+ catch (error) {
1337
+ throw new Error(`Project initialization failed for '${branch}': ${error instanceof Error ? error.message : String(error)}`);
1338
+ }
1339
+ }
1340
+ if (projectInitCompleted && initCommandDigest !== null) {
1341
+ writeProjectInitCommandDigest(controlPaths.root, initCommandDigest);
1342
+ }
1298
1343
  replacementLease.release();
1299
1344
  replacementLeaseHeld = false;
1300
1345
  return 'Container started';
@@ -109,7 +109,7 @@ export async function enter(args) {
109
109
  allowRecreate: parsed.recreate,
110
110
  recreate: async () => {
111
111
  const { create } = await import('./create.js');
112
- await create([target.requestedRef, '--no-refresh']);
112
+ await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
113
113
  }
114
114
  });
115
115
  const container = ready.container;
@@ -65,7 +65,7 @@ export async function start(args) {
65
65
  forceRecreate: parsed.recreate,
66
66
  recreate: async () => {
67
67
  const { create } = await import('./create.js');
68
- await create([target.requestedRef, '--no-refresh']);
68
+ await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
69
69
  }
70
70
  });
71
71
  const message = ready.path === 'healthy'
@@ -14,6 +14,7 @@ const DEFAULTS = Object.freeze({
14
14
  tools: ['agent-infra'],
15
15
  refreshIntervalDays: 7,
16
16
  dockerfile: null,
17
+ initCommand: null,
17
18
  vm: {
18
19
  cpu: null,
19
20
  memory: null,
@@ -44,6 +45,7 @@ function cloneDefaults() {
44
45
  tools: [...DEFAULTS.tools],
45
46
  refreshIntervalDays: DEFAULTS.refreshIntervalDays,
46
47
  dockerfile: DEFAULTS.dockerfile,
48
+ initCommand: DEFAULTS.initCommand,
47
49
  vm: { ...DEFAULTS.vm }
48
50
  };
49
51
  }
@@ -77,6 +79,13 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
77
79
  ? [...sandbox.runtimes]
78
80
  : defaults.runtimes;
79
81
  const dockerfile = typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null;
82
+ if (sandbox.initCommand !== undefined &&
83
+ (typeof sandbox.initCommand !== 'string' || sandbox.initCommand.trim().length === 0)) {
84
+ throw new Error('sandbox.initCommand must be a non-empty string when configured');
85
+ }
86
+ const initCommand = typeof sandbox.initCommand === 'string'
87
+ ? sandbox.initCommand
88
+ : null;
80
89
  if (!dockerfile) {
81
90
  let enginesNode;
82
91
  try {
@@ -118,6 +127,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
118
127
  },
119
128
  refreshIntervalDays: asNonNegativeIntegerOrDefault(sandbox.refreshIntervalDays, defaults.refreshIntervalDays),
120
129
  dockerfile,
130
+ initCommand,
121
131
  vm: {
122
132
  cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
123
133
  memory: asPositiveNumberOrNull(sandbox.vm?.memory) ?? defaults.vm.memory,
@@ -340,15 +340,18 @@ export function requestSandboxTaskFinalization(params) {
340
340
  clientError('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization agent is invalid', false);
341
341
  const auth = authority(params);
342
342
  const issuedAt = Date.now();
343
+ const id = randomUUID();
344
+ const handoffSha256 = (params.prepareHandoff ?? (() => '0'.repeat(64)))({ generation: auth.generation, requestId: id });
343
345
  const request = {
344
346
  version: 3,
345
- id: randomUUID(),
347
+ id,
346
348
  ...auth,
347
349
  issuedAt,
348
350
  expiresAt: issuedAt + SANDBOX_CONTROL_ADMISSION_WINDOW_MS,
349
351
  family: 'task-finalization',
350
352
  operation: 'complete',
351
353
  agent,
354
+ handoffSha256,
352
355
  args: [],
353
356
  controllerProcess: null,
354
357
  controllerProof: null
@@ -2,6 +2,7 @@ import { spawn } 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.js';
6
7
  import { createTask, projectTaskCreateResult, taskCreateExitCode, taskCreateFailure } from '../../task/create-service.js';
7
8
  import { applyTaskFinalization } from '../../task/finalization.js';
@@ -13,6 +14,10 @@ import { createSandboxExecutorExecutionContext, dispatchTaskControlOperation, pa
13
14
  import { assertSandboxControlBrokerOwner, readSandboxControlManifest } from './lifecycle.js';
14
15
  import { computeLifecycleBuildIdentity } from '../../agent-clients/adapters/codex-lifecycle/build-identity.js';
15
16
  import { closeCodexControllerRegistration, CodexControllerRegistrationError, openCodexControllerRegistration, resolveCodexControllerBinding } from './controller-registration.js';
17
+ import { shareBranchDir } from '../constants.js';
18
+ function handoffDirectory(manifest) {
19
+ return shareBranchDir({ shareBase: path.join(homedir(), '.agent-infra', 'share', manifest.project) }, manifest.branch);
20
+ }
16
21
  function sameBrokerOwner(left, right) {
17
22
  return left.version === right.version
18
23
  && left.pid === right.pid
@@ -295,9 +300,10 @@ async function executeRequestInner(manifest, manifestPath, request, options = {}
295
300
  }
296
301
  }
297
302
  if (request.family === 'task-finalization') {
298
- const operation = parseTaskControlOperation('task-finalization', [manifest.taskId, 'complete', '--agent', request.agent]);
299
- if (operation.family !== 'task-finalization')
303
+ const parsed = parseTaskControlOperation('task-finalization', [manifest.taskId, 'complete', '--agent', request.agent]);
304
+ if (parsed.family !== 'task-finalization')
300
305
  throw new Error('SANDBOX_CONTROL_FINALIZATION_OPERATION_INVALID');
306
+ const operation = { ...parsed, request: { ...parsed.request, handoffSha256: request.handoffSha256 } };
301
307
  const result = dispatchTaskControlOperation(createSandboxExecutorExecutionContext({
302
308
  repoRoot: manifest.repoRoot,
303
309
  worktreeRoot: manifest.worktreeRoot,
@@ -305,7 +311,8 @@ async function executeRequestInner(manifest, manifestPath, request, options = {}
305
311
  taskId: manifest.taskId,
306
312
  generation: manifest.generation,
307
313
  manifestPath,
308
- requestId: request.id
314
+ requestId: request.id,
315
+ handoffDirectory: handoffDirectory(manifest)
309
316
  }), operation);
310
317
  return finalizationResult(await result);
311
318
  }
@@ -326,7 +333,8 @@ async function executeRequestInner(manifest, manifestPath, request, options = {}
326
333
  taskId: manifest.taskId,
327
334
  generation: manifest.generation,
328
335
  manifestPath,
329
- requestId: request.id
336
+ requestId: request.id,
337
+ handoffDirectory: handoffDirectory(manifest)
330
338
  });
331
339
  const format = (value) => {
332
340
  const result = value;
@@ -1152,17 +1152,25 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
1152
1152
  if (!manifest && brokerLive)
1153
1153
  throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
1154
1154
  const platform = options.platform ?? process.platform;
1155
+ const processControl = options.executionProcessControl ?? {
1156
+ isAlive: (execution) => executionAlive(execution, platform, identityProbe),
1157
+ terminate: (execution, phase) => phase === 'graceful'
1158
+ ? terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe })
1159
+ : terminateSandboxControlExecution(execution, {
1160
+ platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1161
+ })
1162
+ };
1155
1163
  let executions = manifest ? readExecutions(manifest) : [];
1156
1164
  const owner = brokerLive ? broker : statusLive ? statusOwner : null;
1157
1165
  if (!owner) {
1158
1166
  if (manifest && !broker && !status)
1159
1167
  throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
1160
1168
  for (const execution of executions) {
1161
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1169
+ processControl.terminate(execution, 'graceful');
1162
1170
  }
1163
1171
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1164
1172
  while (Date.now() < softDeadlineAt) {
1165
- if (!executions.some((execution) => executionAlive(execution, platform, identityProbe)))
1173
+ if (!executions.some((execution) => processControl.isAlive(execution)))
1166
1174
  break;
1167
1175
  await new Promise((resolve) => setTimeout(resolve, 25));
1168
1176
  }
@@ -1171,29 +1179,26 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
1171
1179
  if (Date.now() >= deadlineAt)
1172
1180
  throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1173
1181
  for (const execution of executions) {
1174
- if (!terminateSandboxControlExecution(execution, {
1175
- platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1176
- })) {
1182
+ if (!processControl.terminate(execution, 'force')) {
1177
1183
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
1178
1184
  }
1179
1185
  }
1180
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1186
+ if (executions.some((execution) => processControl.isAlive(execution))) {
1181
1187
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1182
1188
  }
1183
1189
  return broker || status ? 'stale' : 'missing';
1184
1190
  }
1185
- const remaining = () => Math.max(0, deadlineAt - Date.now());
1186
1191
  signalOwner(owner, platform, false);
1187
1192
  for (const execution of executions) {
1188
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1193
+ processControl.terminate(execution, 'graceful');
1189
1194
  }
1190
1195
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1191
1196
  while (Date.now() < softDeadlineAt) {
1192
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe)))
1197
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution)))
1193
1198
  break;
1194
1199
  await new Promise((resolve) => setTimeout(resolve, 25));
1195
1200
  }
1196
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1201
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) {
1197
1202
  return 'stopped';
1198
1203
  }
1199
1204
  if (manifest)
@@ -1201,17 +1206,17 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
1201
1206
  if (Date.now() >= deadlineAt)
1202
1207
  throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1203
1208
  for (const execution of executions) {
1204
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, deadlineAt, forceAt: Date.now(), identityProbe });
1209
+ processControl.terminate(execution, 'force');
1205
1210
  }
1206
1211
  signalOwner(owner, platform, true);
1207
1212
  while (Date.now() < deadlineAt) {
1208
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe)))
1213
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution)))
1209
1214
  return 'stopped';
1210
1215
  await new Promise((resolve) => setTimeout(resolve, 25));
1211
1216
  }
1212
1217
  if (ownerLive(owner, identityProbe))
1213
1218
  throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
1214
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe)))
1219
+ if (executions.some((execution) => processControl.isAlive(execution)))
1215
1220
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1216
1221
  return 'stopped';
1217
1222
  }
@@ -158,11 +158,12 @@ export function validateSandboxControlRequest(value, manifest, options = {}) {
158
158
  return request;
159
159
  }
160
160
  if (request.family === 'task-finalization') {
161
- const expected = ['agent', 'args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'operation', 'token', 'version'];
161
+ const expected = ['agent', 'args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'handoffSha256', 'id', 'issuedAt', 'operation', 'token', 'version'];
162
162
  if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
163
163
  || request.operation !== 'complete'
164
164
  || !Array.isArray(request.args) || request.args.length !== 0
165
165
  || typeof request.agent !== 'string' || normalizeAgentToken(request.agent) !== request.agent
166
+ || typeof request.handoffSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(request.handoffSha256)
166
167
  || request.controllerProcess !== null || request.controllerProof !== null) {
167
168
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization request schema or authorization is invalid');
168
169
  }