@fitlab-ai/agent-infra 0.9.8 → 0.9.10

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 (182) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +11 -1
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +58 -21
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +25 -11
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +173 -10
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +127 -0
  8. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -0
  9. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +63 -92
  10. package/dist/lib/agent-clients/adapters/codex-sandbox.js +72 -11
  11. package/dist/lib/agent-clients/adapters/index.js +3 -1
  12. package/dist/lib/agent-clients/adapters/traecli.js +66 -0
  13. package/dist/lib/agent-clients/tokens.js +2 -2
  14. package/dist/lib/agent-clients/types.js +2 -1
  15. package/dist/lib/defaults.json +5 -0
  16. package/dist/lib/git/workflow.js +10 -1
  17. package/dist/lib/internal/codex-lifecycle.js +20 -6
  18. package/dist/lib/internal/codex-sandbox-controller.js +9 -15
  19. package/dist/lib/internal/git-workflow.js +5 -7
  20. package/dist/lib/internal/platform-pr.js +20 -7
  21. package/dist/lib/internal/release-workflow.js +2 -1
  22. package/dist/lib/internal/sandbox-control.js +79 -2
  23. package/dist/lib/internal/task-finalization.js +54 -0
  24. package/dist/lib/internal/task-orchestration.js +156 -291
  25. package/dist/lib/{task/commands/validate.js → internal/task-validate.js} +75 -42
  26. package/dist/lib/platform/pr-summary.js +48 -10
  27. package/dist/lib/platform/pull-requests.js +205 -41
  28. package/dist/lib/run/tui.js +5 -2
  29. package/dist/lib/sandbox/commands/rm.js +316 -55
  30. package/dist/lib/sandbox/control/client.js +190 -6
  31. package/dist/lib/sandbox/control/controller-registration.js +275 -0
  32. package/dist/lib/sandbox/control/executor.js +140 -5
  33. package/dist/lib/sandbox/control/lifecycle.js +3 -1
  34. package/dist/lib/sandbox/control/protocol.js +67 -6
  35. package/dist/lib/sandbox/index.js +5 -3
  36. package/dist/lib/sandbox/managed-fs.js +5 -0
  37. package/dist/lib/sandbox/task-resolver.js +50 -6
  38. package/dist/lib/sandbox/workspace-identity.js +27 -0
  39. package/dist/lib/sandbox/worktree-safety.js +215 -2
  40. package/dist/lib/task/codex-orchestration.js +161 -39
  41. package/dist/lib/task/commands/status.js +59 -1
  42. package/dist/lib/task/commit-operation.js +335 -0
  43. package/dist/lib/task/commit-policy.js +18 -0
  44. package/dist/lib/task/delegation-receipts.js +248 -6
  45. package/dist/lib/task/finalization.js +593 -0
  46. package/dist/lib/task/index.js +0 -9
  47. package/dist/lib/task/operation-outcome.js +33 -0
  48. package/dist/lib/task/orchestration.js +187 -761
  49. package/dist/lib/task/short-id.js +59 -1
  50. package/dist/lib/task/task-execution-lock.js +6 -1
  51. package/dist/lib/task/verification-engine.js +110 -60
  52. package/dist/lib/task/verification.js +2 -1
  53. package/dist/lib/task/workflow-warning-intents.js +50 -1
  54. package/dist/lifecycle-build-manifest.json +3 -3
  55. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +61 -19
  56. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +37 -10
  57. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +276 -12
  58. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +163 -0
  59. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -0
  60. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +9 -1
  61. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +91 -118
  62. package/lib/agent-clients/adapters/codex-sandbox.ts +89 -14
  63. package/lib/agent-clients/adapters/index.ts +3 -1
  64. package/lib/agent-clients/adapters/traecli.ts +67 -0
  65. package/lib/agent-clients/tokens.ts +2 -2
  66. package/lib/agent-clients/types.ts +2 -1
  67. package/lib/defaults.json +5 -0
  68. package/lib/git/workflow.ts +11 -2
  69. package/lib/internal/codex-lifecycle.ts +19 -6
  70. package/lib/internal/codex-sandbox-controller.ts +9 -14
  71. package/lib/internal/git-workflow.ts +5 -6
  72. package/lib/internal/platform-pr.ts +14 -7
  73. package/lib/internal/release-workflow.ts +2 -1
  74. package/lib/internal/sandbox-control.ts +92 -2
  75. package/lib/internal/task-finalization.ts +68 -0
  76. package/lib/internal/task-orchestration.ts +20 -103
  77. package/lib/{task/commands/validate.ts → internal/task-validate.ts} +75 -40
  78. package/lib/platform/pr-summary.ts +55 -11
  79. package/lib/platform/pull-requests.ts +211 -41
  80. package/lib/run/tui.ts +5 -3
  81. package/lib/sandbox/commands/rm.ts +413 -61
  82. package/lib/sandbox/control/client.ts +255 -6
  83. package/lib/sandbox/control/controller-registration.ts +352 -0
  84. package/lib/sandbox/control/executor.ts +169 -5
  85. package/lib/sandbox/control/lifecycle.ts +2 -1
  86. package/lib/sandbox/control/protocol.ts +80 -7
  87. package/lib/sandbox/index.ts +5 -3
  88. package/lib/sandbox/managed-fs.ts +5 -0
  89. package/lib/sandbox/task-resolver.ts +58 -7
  90. package/lib/sandbox/workspace-identity.ts +50 -2
  91. package/lib/sandbox/workspace-view.ts +3 -3
  92. package/lib/sandbox/worktree-safety.ts +236 -2
  93. package/lib/task/codex-orchestration.ts +168 -40
  94. package/lib/task/commands/status.ts +78 -1
  95. package/lib/task/commit-operation.ts +430 -0
  96. package/lib/task/commit-policy.ts +30 -0
  97. package/lib/task/delegation-receipts.ts +263 -4
  98. package/lib/task/finalization.ts +761 -0
  99. package/lib/task/index.ts +0 -8
  100. package/lib/task/operation-outcome.ts +48 -0
  101. package/lib/task/orchestration.ts +218 -982
  102. package/lib/task/short-id.ts +65 -1
  103. package/lib/task/task-execution-lock.ts +10 -1
  104. package/lib/task/verification-engine.ts +115 -63
  105. package/lib/task/verification.ts +3 -2
  106. package/lib/task/workflow-warning-intents.ts +57 -2
  107. package/package.json +2 -2
  108. package/templates/.agents/QUICKSTART.en.md +3 -4
  109. package/templates/.agents/QUICKSTART.zh-CN.md +3 -4
  110. package/templates/.agents/hooks/lifecycle-delegation.js +1 -21
  111. package/templates/.agents/rules/README.en.md +1 -0
  112. package/templates/.agents/rules/README.zh-CN.md +1 -0
  113. package/templates/.agents/rules/compatibility-policy.en.md +39 -0
  114. package/templates/.agents/rules/compatibility-policy.zh-CN.md +39 -0
  115. package/templates/.agents/rules/lifecycle-orchestration.en.md +4 -4
  116. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +4 -4
  117. package/templates/.agents/rules/pr-sync.en.md +1 -1
  118. package/templates/.agents/rules/pr-sync.github.en.md +2 -1
  119. package/templates/.agents/rules/pr-sync.github.zh-CN.md +2 -0
  120. package/templates/.agents/rules/pr-sync.zh-CN.md +1 -0
  121. package/templates/.agents/rules/review-method.en.md +1 -0
  122. package/templates/.agents/rules/review-method.zh-CN.md +1 -0
  123. package/templates/.agents/rules/task-management.en.md +2 -2
  124. package/templates/.agents/rules/task-management.zh-CN.md +2 -2
  125. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -0
  126. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -0
  127. package/templates/.agents/skills/block-task/SKILL.en.md +2 -4
  128. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -4
  129. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  130. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  131. package/templates/.agents/skills/close-codescan/SKILL.en.md +6 -2
  132. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +6 -2
  133. package/templates/.agents/skills/close-dependabot/SKILL.en.md +6 -2
  134. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +6 -2
  135. package/templates/.agents/skills/code-task/SKILL.en.md +1 -0
  136. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -0
  137. package/templates/.agents/skills/commit/SKILL.en.md +41 -125
  138. package/templates/.agents/skills/commit/SKILL.zh-CN.md +52 -123
  139. package/templates/.agents/skills/commit/reference/commit-message.en.md +1 -1
  140. package/templates/.agents/skills/commit/reference/commit-message.zh-CN.md +1 -1
  141. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +14 -43
  142. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +14 -49
  143. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
  144. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
  145. package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -35
  146. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -35
  147. package/templates/.agents/skills/complete-task/SKILL.en.md +31 -38
  148. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +31 -38
  149. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  150. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  151. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  152. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  153. package/templates/.agents/skills/create-pr/SKILL.en.md +17 -9
  154. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +17 -9
  155. package/templates/.agents/skills/create-pr/reference/change-report.en.md +41 -0
  156. package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +41 -0
  157. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
  158. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
  159. package/templates/.agents/skills/plan-task/SKILL.en.md +2 -1
  160. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -1
  161. package/templates/.agents/skills/review-code/reference/migration-risks.en.md +8 -6
  162. package/templates/.agents/skills/review-code/reference/migration-risks.zh-CN.md +8 -6
  163. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +3 -3
  164. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +3 -3
  165. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +1 -1
  166. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +1 -1
  167. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -2
  168. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -2
  169. package/templates/.agents/skills/run-task/SKILL.en.md +6 -6
  170. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +6 -6
  171. package/templates/.agents/skills/run-task/reference/host-validation.en.md +2 -2
  172. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +2 -2
  173. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +24 -0
  174. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
  175. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
  176. package/templates/.codex/hooks.json +4 -4
  177. package/templates/.trae/skills/update-agent-infra.en.md +8 -0
  178. package/templates/.trae/skills/update-agent-infra.zh-CN.md +8 -0
  179. package/dist/lib/task/commit-finalization.js +0 -305
  180. package/dist/lib/task/commit-intent.js +0 -165
  181. package/lib/task/commit-finalization.ts +0 -419
  182. package/lib/task/commit-intent.ts +0 -230
@@ -82,7 +82,7 @@ function readStartupOwner(filePath) {
82
82
  async function waitForStartupTransition(root, timeoutMs, identityProbe = getProcessIdentityState) {
83
83
  const transitionPath = path.join(root, BROKER_STARTING_FILE);
84
84
  const deadline = Date.now() + timeoutMs;
85
- while (Date.now() < deadline) {
85
+ while (true) {
86
86
  const transition = readStartupOwner(transitionPath);
87
87
  if (!fs.existsSync(transitionPath))
88
88
  return;
@@ -96,6 +96,8 @@ async function waitForStartupTransition(root, timeoutMs, identityProbe = getProc
96
96
  }
97
97
  continue;
98
98
  }
99
+ if (Date.now() >= deadline)
100
+ break;
99
101
  await new Promise((resolve) => setTimeout(resolve, 25));
100
102
  }
101
103
  throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
@@ -1,10 +1,11 @@
1
1
  import { validateTaskCreateCandidate } from '../../task/create.js';
2
+ import { normalizeAgentToken } from '../../agent-clients/tokens.js';
2
3
  export const SANDBOX_CONTROL_MAX_BYTES = 64 * 1024;
3
4
  export const SANDBOX_CONTROL_ADMISSION_WINDOW_MS = 2_000;
4
5
  export const SANDBOX_CONTROL_STATUS_INTERVAL_MS = 250;
5
6
  export const SANDBOX_CONTROL_STATUS_STALE_MS = 1_500;
6
7
  export const SANDBOX_CONTROL_FUTURE_SKEW_MS = 1_000;
7
- export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-create'];
8
+ export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-finalization', 'task-create', 'codex-controller'];
8
9
  export const DEFAULT_SANDBOX_CONTROL_TIMING = Object.freeze({
9
10
  controlTickMs: 250,
10
11
  parkedBindingInitialMs: 1_000,
@@ -33,7 +34,7 @@ export function validateSandboxControlRequest(value, manifest, options = {}) {
33
34
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request must be an object');
34
35
  }
35
36
  const request = value;
36
- if (request.version !== 2 || typeof request.id !== 'string'
37
+ if (request.version !== 3 || typeof request.id !== 'string'
37
38
  || !/^[a-f0-9-]{16,64}$/.test(request.id) || request.token !== manifest.token
38
39
  || typeof request.family !== 'string' || !isSandboxControlFamily(request.family)
39
40
  || !Number.isSafeInteger(request.issuedAt) || !Number.isSafeInteger(request.expiresAt))
@@ -53,13 +54,47 @@ export function validateSandboxControlRequest(value, manifest, options = {}) {
53
54
  fail('SANDBOX_CONTROL_REQUEST_TOO_LARGE', 'request exceeds the control limit');
54
55
  }
55
56
  if (request.family === 'task-create') {
56
- const expected = ['candidate', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
57
+ const expected = ['candidate', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
57
58
  if (Object.keys(request).sort().join(',') !== expected.sort().join(',')) {
58
59
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
59
60
  }
61
+ if (request.controllerProcess !== null || request.controllerProof !== null) {
62
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-create cannot carry controller authority');
63
+ }
60
64
  return { ...request, candidate: validateTaskCreateCandidate(request.candidate) };
61
65
  }
62
- const expected = ['args', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
66
+ if (request.family === 'codex-controller') {
67
+ const expected = ['args', 'command', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
68
+ if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
69
+ || !Array.isArray(request.args) || request.args.length !== 0
70
+ || !['open', 'close', 'verify'].includes(request.command)
71
+ || !validControllerProcess(request.controllerProcess)
72
+ || (request.command === 'open' && request.controllerProof !== null)
73
+ || (request.command !== 'open' && !validControllerProof(request.controllerProof))
74
+ || (request.command === 'verify'
75
+ && JSON.stringify(request.controllerProcess) !== JSON.stringify(request.controllerProof.controllerProcess))) {
76
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller request schema is invalid');
77
+ }
78
+ if (manifest.mode !== 'task-bound' || !manifest.taskId) {
79
+ fail('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot register a Codex controller');
80
+ }
81
+ return request;
82
+ }
83
+ if (request.family === 'task-finalization') {
84
+ const expected = ['agent', 'args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'operation', 'token', 'version'];
85
+ if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
86
+ || request.operation !== 'complete'
87
+ || !Array.isArray(request.args) || request.args.length !== 0
88
+ || typeof request.agent !== 'string' || normalizeAgentToken(request.agent) !== request.agent
89
+ || request.controllerProcess !== null || request.controllerProof !== null) {
90
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization request schema or authorization is invalid');
91
+ }
92
+ if (manifest.mode !== 'task-bound' || !manifest.taskId) {
93
+ fail('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot finalize tasks');
94
+ }
95
+ return request;
96
+ }
97
+ const expected = ['args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
63
98
  if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
64
99
  || !Array.isArray(request.args) || !request.args.every((arg) => typeof arg === 'string')) {
65
100
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
@@ -71,15 +106,41 @@ export function validateSandboxControlRequest(value, manifest, options = {}) {
71
106
  && request.args.some((arg) => arg === '--git-worktree-root' || arg.startsWith('--git-worktree-root='))) {
72
107
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'worktree binding is reserved for the control broker');
73
108
  }
109
+ if (request.controllerProcess !== null
110
+ || (request.controllerProof !== null && !validControllerProof(request.controllerProof))) {
111
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller authority is invalid');
112
+ }
113
+ if (request.family !== 'task-orchestration' && request.controllerProof !== null) {
114
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller proof is not allowed for this family');
115
+ }
74
116
  return request;
75
117
  }
76
118
  export function bindSandboxControlTask(request, taskId) {
77
- if (request.family === 'task-create')
78
- fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-create requests do not bind a current task');
119
+ if (request.family === 'task-create' || request.family === 'codex-controller' || request.family === 'task-finalization') {
120
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', `${request.family} requests do not bind a current task`);
121
+ }
79
122
  if (request.args.length === 0)
80
123
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'command arguments are required');
81
124
  return [taskId, ...request.args.slice(1)];
82
125
  }
126
+ function validControllerProcess(value) {
127
+ if (!value || typeof value !== 'object' || Array.isArray(value))
128
+ return false;
129
+ const processValue = value;
130
+ return Object.keys(processValue).sort().join(',') === 'pid,startTime'
131
+ && Number.isSafeInteger(processValue.pid) && processValue.pid > 0
132
+ && Number.isSafeInteger(processValue.startTime) && processValue.startTime >= 0;
133
+ }
134
+ function validControllerProof(value) {
135
+ if (!value || typeof value !== 'object' || Array.isArray(value))
136
+ return false;
137
+ const proof = value;
138
+ return Object.keys(proof).sort().join(',') === 'controllerProcess,leaseId,leaseSecret,version'
139
+ && proof.version === 1
140
+ && typeof proof.leaseId === 'string' && /^[a-f0-9]{64}$/u.test(proof.leaseId)
141
+ && typeof proof.leaseSecret === 'string' && /^[a-f0-9]{64}$/u.test(proof.leaseSecret)
142
+ && validControllerProcess(proof.controllerProcess);
143
+ }
83
144
  export function controlError(error) {
84
145
  if (error instanceof SandboxControlProtocolError) {
85
146
  return { code: error.code, message: error.message, retryable: error.retryable };
@@ -15,9 +15,11 @@ Commands:
15
15
  rebuild [--quiet] [--refresh]
16
16
  Rebuild the sandbox image (--refresh pulls base + tools)
17
17
  refresh Sync host Claude Code credentials to all sandbox copies
18
- rm <branch> | --unbound | --purge
19
- Remove one sandbox, all sandboxes not bound to an
20
- active task (--unbound), or tear down everything (--purge)
18
+ rm <branch | TASK-id> | --unbound | --purge
19
+ Remove one sandbox; use a full TASK-id for task-bound
20
+ cleanup and a branch for branch-only cleanup. --unbound
21
+ removes completed task-bound and branch-only sandboxes;
22
+ active, blocked, and archive tasks are protected.
21
23
  start [--recreate] <branch | TASK-id | N>
22
24
  Verify or recover an existing sandbox container;
23
25
  optionally replace only the container on failure
@@ -21,6 +21,11 @@ export function removeWorktreeDir(repoRoot, worktreeBase, dir, permit, { runFn =
21
21
  throw new Error(`Worktree permit target mismatch: ${dir}`);
22
22
  }
23
23
  verifyWorktreePermit(permit);
24
+ if (permit.snapshot.source === 'recovered') {
25
+ removeManagedDir(worktreeBase, dir);
26
+ runSafeFn('git', ['-C', repoRoot, 'worktree', 'prune']);
27
+ return;
28
+ }
24
29
  try {
25
30
  runFn('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
26
31
  }
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { isRemovedHashShortIdInput } from '../task/short-id.js';
5
5
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
6
  const SHORT_ID_RE = /^\d+$/;
7
- const WORKSPACE_DIRS = ['active', 'completed', 'blocked', 'archive'];
7
+ export const TASK_WORKSPACE_STATES = ['active', 'completed', 'blocked', 'archive'];
8
8
  function resolveShortIdStrict(arg, repoRoot) {
9
9
  const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
10
10
  if (!fs.existsSync(scriptPath)) {
@@ -20,14 +20,28 @@ function stripQuotes(value) {
20
20
  return value.replace(/^(["'])(.*)\1$/, '$2');
21
21
  }
22
22
  function readTaskContent(repoRoot, taskId) {
23
- for (const dir of WORKSPACE_DIRS) {
24
- const taskPath = path.join(repoRoot, '.agents', 'workspace', dir, taskId, 'task.md');
23
+ for (const state of TASK_WORKSPACE_STATES) {
24
+ const taskPath = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
25
25
  if (fs.existsSync(taskPath)) {
26
- return fs.readFileSync(taskPath, 'utf8');
26
+ return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
27
27
  }
28
28
  }
29
29
  throw new Error(`Task not found: ${taskId}`);
30
30
  }
31
+ function taskWorkspaceCandidates(repoRoot, taskId) {
32
+ return TASK_WORKSPACE_STATES.flatMap((state) => {
33
+ const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
34
+ if (!fs.existsSync(taskMd))
35
+ return [];
36
+ const content = fs.readFileSync(taskMd, 'utf8');
37
+ return [{
38
+ taskId,
39
+ branch: resolveBranchFromTaskContent(content, taskId),
40
+ state,
41
+ taskMd
42
+ }];
43
+ });
44
+ }
31
45
  function resolveBranchFromTaskContent(content, taskId) {
32
46
  const frontmatterBranch = content.match(/^branch:\s*(.+)$/m);
33
47
  if (frontmatterBranch?.[1]?.trim()) {
@@ -45,13 +59,43 @@ export function resolveTaskBranch(arg, repoRoot) {
45
59
  }
46
60
  if (SHORT_ID_RE.test(arg)) {
47
61
  const taskId = resolveShortIdStrict(arg, repoRoot);
48
- const content = readTaskContent(repoRoot, taskId);
62
+ const { content } = readTaskContent(repoRoot, taskId);
49
63
  return resolveBranchFromTaskContent(content, taskId);
50
64
  }
51
65
  if (!TASK_ID_RE.test(arg)) {
52
66
  return arg;
53
67
  }
54
- const content = readTaskContent(repoRoot, arg);
68
+ const { content } = readTaskContent(repoRoot, arg);
55
69
  return resolveBranchFromTaskContent(content, arg);
56
70
  }
71
+ export function resolveTaskWorkspace(taskId, repoRoot) {
72
+ if (!TASK_ID_RE.test(taskId))
73
+ throw new Error(`Invalid task id: ${taskId}`);
74
+ const matches = taskWorkspaceCandidates(repoRoot, taskId);
75
+ if (matches.length === 0)
76
+ throw new Error(`Task not found: ${taskId}`);
77
+ if (matches.length !== 1) {
78
+ throw new Error(`SANDBOX_TASK_STATE_AMBIGUOUS: task ${taskId} exists in ${matches.map((match) => match.state).join(', ')}`);
79
+ }
80
+ return matches[0];
81
+ }
82
+ export function listTaskWorkspaces(repoRoot, state) {
83
+ const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
84
+ if (!fs.existsSync(stateRoot))
85
+ return [];
86
+ return fs.readdirSync(stateRoot, { withFileTypes: true })
87
+ .filter((entry) => entry.isDirectory() && TASK_ID_RE.test(entry.name))
88
+ .flatMap((entry) => {
89
+ const taskMd = path.join(stateRoot, entry.name, 'task.md');
90
+ if (!fs.existsSync(taskMd))
91
+ return [];
92
+ const content = fs.readFileSync(taskMd, 'utf8');
93
+ return [{
94
+ taskId: entry.name,
95
+ branch: resolveBranchFromTaskContent(content, entry.name),
96
+ state,
97
+ taskMd
98
+ }];
99
+ });
100
+ }
57
101
  //# sourceMappingURL=task-resolver.js.map
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRemovedHashShortIdInput } from '../task/short-id.js';
4
+ import { resolveTaskWorkspace } from './task-resolver.js';
4
5
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
5
6
  const SHORT_ID_RE = /^\d+$/;
6
7
  function stripQuotes(value) {
@@ -78,6 +79,32 @@ export function resolveSandboxTarget(requestedRef, repoRoot) {
78
79
  }
79
80
  return { requestedRef, branch: requestedRef, workspace: { mode: 'branch-only' } };
80
81
  }
82
+ export function resolveSandboxCleanupTarget(requestedRef, repoRoot, options = {}) {
83
+ if (isRemovedHashShortIdInput(requestedRef)) {
84
+ throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
85
+ }
86
+ if (TASK_ID_RE.test(requestedRef)) {
87
+ const task = resolveTaskWorkspace(requestedRef, repoRoot);
88
+ if (!options.allowProtected && (task.state === 'blocked' || task.state === 'archive')) {
89
+ throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${requestedRef} is ${task.state}`);
90
+ }
91
+ return {
92
+ requestedRef,
93
+ branch: task.branch,
94
+ workspace: { mode: 'task-bound', taskId: task.taskId },
95
+ taskState: task.state
96
+ };
97
+ }
98
+ const target = resolveSandboxTarget(requestedRef, repoRoot);
99
+ return {
100
+ requestedRef,
101
+ branch: target.branch,
102
+ workspace: target.workspace.mode === 'task-bound'
103
+ ? { mode: 'task-bound', taskId: target.workspace.taskId }
104
+ : { mode: 'branch-only' },
105
+ taskState: target.workspace.mode === 'task-bound' ? 'active' : 'branch-only'
106
+ };
107
+ }
81
108
  export function parseSandboxWorkspaceIdentity(labels, keys) {
82
109
  const mode = labels[keys.mode];
83
110
  const taskId = labels[keys.taskId];
@@ -1,7 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { runProbe } from './shell.js';
6
+ import { resolveSandboxTarget } from './workspace-identity.js';
5
7
  function probeText(probe, cwd, args) {
6
8
  const result = probe('git', ['-C', cwd, ...args], { encoding: 'utf8' });
7
9
  if (result.error)
@@ -138,6 +140,197 @@ function captureOnce(worktree, probe) {
138
140
  }
139
141
  return { worktree: resolvedWorktree, branch, head, changes, identity: hash.digest('hex') };
140
142
  }
143
+ function probeTextWithEnv(probe, repoRoot, args, env) {
144
+ const result = probe('git', args, { cwd: repoRoot, encoding: 'utf8', env });
145
+ if (result.error)
146
+ throw result.error;
147
+ if (result.status !== 0) {
148
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : result.stderr.toString('utf8').trim();
149
+ throw new Error(stderr || `git ${args[0] ?? 'command'} failed with exit code ${result.status ?? 'unknown'}`);
150
+ }
151
+ return typeof result.stdout === 'string' ? result.stdout : result.stdout.toString('utf8');
152
+ }
153
+ function probeMetadataCommand(probe, repoRoot, args, env) {
154
+ const result = probe('git', args, { cwd: repoRoot, encoding: 'utf8', env });
155
+ if (result.error)
156
+ throw result.error;
157
+ return result.status === 0;
158
+ }
159
+ function hasValidHeadContent(content) {
160
+ return /^(?:ref:\s+refs\/\S+|[0-9a-f]{40}|[0-9a-f]{64})$/i.test(content.trim());
161
+ }
162
+ function assertRecoverableMetadata(worktree, repoRoot, probe) {
163
+ const dotGit = path.join(worktree, '.git');
164
+ let dotGitStat;
165
+ try {
166
+ dotGitStat = fs.lstatSync(dotGit);
167
+ }
168
+ catch (error) {
169
+ if (error.code === 'ENOENT')
170
+ return;
171
+ throw error;
172
+ }
173
+ if (dotGitStat.isSymbolicLink() || dotGitStat.isDirectory()) {
174
+ throw new Error('WORKTREE_RECOVERY_METADATA_NOT_ELIGIBLE');
175
+ }
176
+ if (!dotGitStat.isFile())
177
+ throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
178
+ const content = fs.readFileSync(dotGit, 'utf8').trim();
179
+ const match = /^gitdir:\s*(.+)$/i.exec(content);
180
+ if (!match)
181
+ throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
182
+ const commonDir = fs.realpathSync.native(path.resolve(repoRoot, probeText(probe, repoRoot, [
183
+ 'rev-parse', '--git-common-dir'
184
+ ]).trim()));
185
+ const adminRoot = path.join(commonDir, 'worktrees');
186
+ const adminPath = path.resolve(path.dirname(dotGit), match[1]);
187
+ const relative = path.relative(adminRoot, adminPath);
188
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
189
+ throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
190
+ }
191
+ if (!fs.existsSync(adminPath))
192
+ return;
193
+ const adminStat = fs.lstatSync(adminPath);
194
+ if (adminStat.isSymbolicLink())
195
+ throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
196
+ if (!adminStat.isDirectory())
197
+ return;
198
+ const requiredFiles = ['HEAD', 'gitdir', 'commondir', 'index'];
199
+ if (requiredFiles.some((name) => !fs.existsSync(path.join(adminPath, name))))
200
+ return;
201
+ for (const name of requiredFiles) {
202
+ const filePath = path.join(adminPath, name);
203
+ const stat = fs.lstatSync(filePath);
204
+ if (stat.isSymbolicLink() || !stat.isFile())
205
+ throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
206
+ }
207
+ const adminGitdir = fs.readFileSync(path.join(adminPath, 'gitdir'), 'utf8').trim();
208
+ const adminCommonDir = fs.readFileSync(path.join(adminPath, 'commondir'), 'utf8').trim();
209
+ const adminGitdirPath = adminGitdir
210
+ ? fs.realpathSync.native(path.resolve(adminPath, adminGitdir))
211
+ : '';
212
+ const canonicalDotGit = fs.realpathSync.native(dotGit);
213
+ const adminCommonDirPath = adminCommonDir
214
+ ? fs.realpathSync.native(path.resolve(adminPath, adminCommonDir))
215
+ : '';
216
+ if (!adminGitdir || adminGitdirPath !== canonicalDotGit
217
+ || !adminCommonDir || adminCommonDirPath !== commonDir) {
218
+ throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
219
+ }
220
+ const headContent = fs.readFileSync(path.join(adminPath, 'HEAD'), 'utf8');
221
+ const indexContent = fs.readFileSync(path.join(adminPath, 'index'));
222
+ if (!hasValidHeadContent(headContent) || indexContent.subarray(0, 4).toString('ascii') !== 'DIRC')
223
+ return;
224
+ const validationEnv = { ...process.env };
225
+ for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_WORK_TREE', 'GIT_PREFIX']) {
226
+ delete validationEnv[key];
227
+ }
228
+ validationEnv.GIT_DIR = adminPath;
229
+ validationEnv.GIT_WORK_TREE = path.resolve(worktree);
230
+ if (!probeMetadataCommand(probe, repoRoot, ['rev-parse', '--verify', 'HEAD^{commit}'], validationEnv))
231
+ return;
232
+ if (!probeMetadataCommand(probe, repoRoot, ['ls-files', '--stage', '-z'], validationEnv))
233
+ return;
234
+ throw new Error('WORKTREE_RECOVERY_METADATA_REGISTERED');
235
+ }
236
+ function parseRecoveredStatus(raw) {
237
+ const records = raw.split('\0');
238
+ const changes = [];
239
+ for (let index = 0; index < records.length;) {
240
+ const status = records[index++] ?? '';
241
+ if (!status)
242
+ continue;
243
+ const code = status[0] ?? '';
244
+ if (code === 'R' || code === 'C') {
245
+ const originalPath = records[index++];
246
+ const nextPath = records[index++];
247
+ if (!originalPath || !nextPath)
248
+ throw new Error('Unable to parse recovered Git rename record');
249
+ changes.push({ indexStatus: code, worktreeStatus: ' ', path: nextPath, originalPath });
250
+ continue;
251
+ }
252
+ const filePath = records[index++];
253
+ if (!filePath)
254
+ throw new Error('Unable to parse recovered Git status record');
255
+ changes.push({
256
+ indexStatus: code === 'D' ? ' ' : code,
257
+ worktreeStatus: code === 'D' ? 'D' : ' ',
258
+ path: filePath
259
+ });
260
+ }
261
+ return changes;
262
+ }
263
+ function captureRecoveredOnce(worktree, recovery, probe) {
264
+ const resolvedWorktree = path.resolve(worktree);
265
+ const resolvedBase = path.resolve(recovery.worktreeBase);
266
+ const relative = path.relative(resolvedBase, resolvedWorktree);
267
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
268
+ throw new Error(`WORKTREE_RECOVERY_PATH_INVALID: ${resolvedWorktree}`);
269
+ }
270
+ const stat = fs.lstatSync(resolvedWorktree);
271
+ if (!stat.isDirectory() || stat.isSymbolicLink())
272
+ throw new Error('WORKTREE_RECOVERY_PATH_INVALID');
273
+ const canonicalBase = fs.realpathSync.native(resolvedBase);
274
+ const canonicalWorktree = fs.realpathSync.native(resolvedWorktree);
275
+ const canonicalRelative = path.relative(canonicalBase, canonicalWorktree);
276
+ if (!canonicalRelative || canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
277
+ throw new Error('WORKTREE_RECOVERY_PATH_INVALID');
278
+ }
279
+ const resolvedWorkspace = resolveSandboxTarget(recovery.branch, recovery.repoRoot).workspace;
280
+ if (recovery.identitySource === 'branch-only' && resolvedWorkspace.mode !== 'branch-only') {
281
+ throw new Error('WORKTREE_RECOVERY_IDENTITY_CHANGED');
282
+ }
283
+ if (recovery.identitySource === 'task-bound'
284
+ && (resolvedWorkspace.mode !== 'task-bound' || resolvedWorkspace.taskId !== recovery.taskId)) {
285
+ throw new Error('WORKTREE_RECOVERY_IDENTITY_CHANGED');
286
+ }
287
+ assertRecoverableMetadata(resolvedWorktree, recovery.repoRoot, probe);
288
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-recovered-index-'));
289
+ const env = { ...process.env };
290
+ for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_PREFIX'])
291
+ delete env[key];
292
+ env.GIT_INDEX_FILE = path.join(tempDir, 'index');
293
+ env.GIT_WORK_TREE = resolvedWorktree;
294
+ try {
295
+ const baseline = probeTextWithEnv(probe, recovery.repoRoot, [
296
+ 'rev-parse', '--verify', `refs/heads/${recovery.branch}^{commit}`
297
+ ], env).trim();
298
+ if (!baseline)
299
+ throw new Error(`WORKTREE_RECOVERY_BRANCH_INVALID: ${recovery.branch}`);
300
+ probeTextWithEnv(probe, recovery.repoRoot, ['read-tree', baseline], env);
301
+ probeTextWithEnv(probe, recovery.repoRoot, ['add', '-A', '--', '.'], env);
302
+ const status = probeTextWithEnv(probe, recovery.repoRoot, [
303
+ 'diff', '--cached', '--name-status', '--find-renames', '-z', '--', '.'
304
+ ], env);
305
+ const changes = parseRecoveredStatus(status);
306
+ const hash = createHash('sha256');
307
+ updatePart(hash, 'source', 'recovered');
308
+ updatePart(hash, 'worktree', resolvedWorktree);
309
+ updatePart(hash, 'repo-root', path.resolve(recovery.repoRoot));
310
+ updatePart(hash, 'branch', recovery.branch);
311
+ updatePart(hash, 'baseline', baseline);
312
+ updatePart(hash, 'identity-source', recovery.identitySource);
313
+ updatePart(hash, 'task-id', recovery.taskId ?? '');
314
+ updatePart(hash, 'status', status);
315
+ for (const change of changes) {
316
+ hashPath(hash, resolvedWorktree, change.path, probe);
317
+ if (change.originalPath)
318
+ hashPath(hash, resolvedWorktree, change.originalPath, probe);
319
+ }
320
+ return {
321
+ worktree: resolvedWorktree,
322
+ branch: recovery.branch,
323
+ head: baseline,
324
+ changes,
325
+ identity: hash.digest('hex'),
326
+ source: 'recovered',
327
+ recovery: { ...recovery }
328
+ };
329
+ }
330
+ finally {
331
+ fs.rmSync(tempDir, { recursive: true, force: true });
332
+ }
333
+ }
141
334
  function inspectWorktree(worktree, { probe = runProbe } = {}) {
142
335
  const resolvedWorktree = path.resolve(worktree);
143
336
  try {
@@ -156,6 +349,24 @@ function inspectWorktree(worktree, { probe = runProbe } = {}) {
156
349
  };
157
350
  }
158
351
  }
352
+ function inspectRecoveredWorktree(worktree, recovery, { probe = runProbe } = {}) {
353
+ const resolvedWorktree = path.resolve(worktree);
354
+ try {
355
+ const first = captureRecoveredOnce(resolvedWorktree, recovery, probe);
356
+ const second = captureRecoveredOnce(resolvedWorktree, recovery, probe);
357
+ if (first.identity !== second.identity) {
358
+ return { status: 'failed', worktree: resolvedWorktree, message: 'Snapshot changed while recovering worktree' };
359
+ }
360
+ return { status: second.changes.length === 0 ? 'clean' : 'dirty', snapshot: second };
361
+ }
362
+ catch (error) {
363
+ return {
364
+ status: 'failed',
365
+ worktree: resolvedWorktree,
366
+ message: error instanceof Error ? error.message : String(error)
367
+ };
368
+ }
369
+ }
159
370
  function inspectWorktrees(worktrees) {
160
371
  return [...new Set(worktrees.map((worktree) => path.resolve(worktree)))].sort()
161
372
  .map((worktree) => inspectWorktree(worktree));
@@ -171,7 +382,9 @@ function createDiscardPermit(snapshot) {
171
382
  return { mode: 'discard', snapshot };
172
383
  }
173
384
  function verifyWorktreePermit(permit, options = {}) {
174
- const current = inspectWorktree(permit.snapshot.worktree, options);
385
+ const current = permit.snapshot.source === 'recovered' && permit.snapshot.recovery
386
+ ? inspectRecoveredWorktree(permit.snapshot.worktree, permit.snapshot.recovery, options)
387
+ : inspectWorktree(permit.snapshot.worktree, options);
175
388
  if (current.status === 'failed')
176
389
  throw new Error(`Unable to verify worktree permit: ${current.message}`);
177
390
  if (current.snapshot.identity !== permit.snapshot.identity || (permit.mode === 'clean' && current.status !== 'clean')) {
@@ -192,5 +405,5 @@ function formatWorktreeSnapshot(snapshot) {
192
405
  `Snapshot identity: ${snapshot.identity}`
193
406
  ].join('\n');
194
407
  }
195
- export { createCleanPermit, createDiscardPermit, formatWorktreeSnapshot, inspectWorktree, inspectWorktrees, verifyWorktreePermit };
408
+ export { createCleanPermit, createDiscardPermit, formatWorktreeSnapshot, inspectRecoveredWorktree, inspectWorktree, inspectWorktrees, verifyWorktreePermit };
196
409
  //# sourceMappingURL=worktree-safety.js.map