@fitlab-ai/agent-infra 0.9.4 → 0.9.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 (130) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +13 -3
  4. package/dist/lib/agent-clients/adapter.js +2 -2
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +492 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +175 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +255 -0
  8. package/dist/lib/agent-clients/adapters/codex.js +3 -3
  9. package/dist/lib/git/workflow.js +0 -1
  10. package/dist/lib/git/worktree-identity.js +64 -0
  11. package/dist/lib/internal/codex-lifecycle.js +300 -0
  12. package/dist/lib/internal/release-workflow.js +108 -11
  13. package/dist/lib/internal/sandbox-control.js +43 -6
  14. package/dist/lib/internal/task-create.js +56 -0
  15. package/dist/lib/internal/task-orchestration.js +71 -31
  16. package/dist/lib/platform/issue-comments.js +2 -1
  17. package/dist/lib/release/workflow.js +29 -21
  18. package/dist/lib/sandbox/commands/create.js +16 -16
  19. package/dist/lib/sandbox/commands/prune.js +40 -16
  20. package/dist/lib/sandbox/commands/rm.js +318 -89
  21. package/dist/lib/sandbox/control/client.js +152 -22
  22. package/dist/lib/sandbox/control/executor.js +136 -0
  23. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  24. package/dist/lib/sandbox/control/protocol.js +73 -18
  25. package/dist/lib/sandbox/control/server.js +235 -80
  26. package/dist/lib/sandbox/control/state.js +181 -0
  27. package/dist/lib/sandbox/index.js +2 -2
  28. package/dist/lib/sandbox/managed-fs.js +14 -2
  29. package/dist/lib/sandbox/mounts.js +35 -24
  30. package/dist/lib/sandbox/recovery.js +262 -45
  31. package/dist/lib/sandbox/workspace-view.js +80 -11
  32. package/dist/lib/sandbox/worktree-safety.js +196 -0
  33. package/dist/lib/server/process-control.js +6 -0
  34. package/dist/lib/task/activity-log.js +32 -5
  35. package/dist/lib/task/artifact-lifecycle.js +5 -4
  36. package/dist/lib/task/codex-orchestration.js +161 -0
  37. package/dist/lib/task/commands/status.js +3 -0
  38. package/dist/lib/task/commands/validate.js +289 -0
  39. package/dist/lib/task/commit-finalization.js +50 -12
  40. package/dist/lib/task/create-service.js +137 -0
  41. package/dist/lib/task/create.js +333 -0
  42. package/dist/lib/task/delegation-receipts.js +25 -0
  43. package/dist/lib/task/events.js +10 -4
  44. package/dist/lib/task/finalization-artifacts.js +2 -1
  45. package/dist/lib/task/implementation-inputs.js +1 -7
  46. package/dist/lib/task/index.js +9 -0
  47. package/dist/lib/task/orchestration.js +283 -18
  48. package/dist/lib/task/review-fingerprint.js +4 -0
  49. package/dist/lib/task/verification-engine.js +38 -6
  50. package/dist/lib/task/verification.js +1 -0
  51. package/dist/lib/task/workspace-snapshot.js +78 -3
  52. package/lib/agent-clients/adapter.ts +4 -4
  53. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +569 -0
  54. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +323 -0
  55. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +298 -0
  56. package/lib/agent-clients/adapters/codex.ts +3 -3
  57. package/lib/git/workflow.ts +0 -1
  58. package/lib/git/worktree-identity.ts +86 -0
  59. package/lib/internal/codex-lifecycle.ts +308 -0
  60. package/lib/internal/release-workflow.ts +107 -11
  61. package/lib/internal/sandbox-control.ts +38 -6
  62. package/lib/internal/task-create.ts +61 -0
  63. package/lib/internal/task-orchestration.ts +57 -31
  64. package/lib/platform/issue-comments.ts +2 -1
  65. package/lib/release/workflow.ts +69 -20
  66. package/lib/sandbox/commands/create.ts +18 -21
  67. package/lib/sandbox/commands/prune.ts +53 -15
  68. package/lib/sandbox/commands/rm.ts +368 -103
  69. package/lib/sandbox/control/client.ts +173 -25
  70. package/lib/sandbox/control/executor.ts +163 -0
  71. package/lib/sandbox/control/lifecycle.ts +342 -0
  72. package/lib/sandbox/control/protocol.ts +110 -48
  73. package/lib/sandbox/control/server.ts +252 -85
  74. package/lib/sandbox/control/state.ts +196 -0
  75. package/lib/sandbox/index.ts +2 -2
  76. package/lib/sandbox/managed-fs.ts +17 -2
  77. package/lib/sandbox/mounts.ts +31 -13
  78. package/lib/sandbox/recovery.ts +301 -60
  79. package/lib/sandbox/workspace-view.ts +89 -12
  80. package/lib/sandbox/worktree-safety.ts +234 -0
  81. package/lib/server/process-control.ts +11 -0
  82. package/lib/task/activity-log.ts +46 -7
  83. package/lib/task/artifact-lifecycle.ts +5 -4
  84. package/lib/task/codex-orchestration.ts +233 -0
  85. package/lib/task/commands/status.ts +2 -0
  86. package/lib/task/commands/validate.ts +282 -0
  87. package/lib/task/commit-finalization.ts +75 -12
  88. package/lib/task/create-service.ts +171 -0
  89. package/lib/task/create.ts +372 -0
  90. package/lib/task/delegation-receipts.ts +52 -3
  91. package/lib/task/events.ts +9 -4
  92. package/lib/task/finalization-artifacts.ts +2 -1
  93. package/lib/task/implementation-inputs.ts +1 -5
  94. package/lib/task/index.ts +8 -0
  95. package/lib/task/orchestration.ts +381 -22
  96. package/lib/task/review-fingerprint.ts +9 -0
  97. package/lib/task/verification-engine.ts +42 -8
  98. package/lib/task/verification.ts +2 -1
  99. package/lib/task/workspace-snapshot.ts +98 -4
  100. package/package.json +7 -7
  101. package/templates/.agents/hooks/lifecycle-delegation.js +88 -8
  102. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  103. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  104. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  105. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  106. package/templates/.agents/rules/task-management.en.md +1 -0
  107. package/templates/.agents/rules/task-management.zh-CN.md +1 -0
  108. package/templates/.agents/skills/commit/SKILL.en.md +3 -9
  109. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -10
  110. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +4 -3
  111. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +5 -4
  112. package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
  113. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
  114. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  115. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  116. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  117. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  118. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  119. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  120. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  121. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  122. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  123. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  124. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  125. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  126. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  127. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  128. package/templates/.codex/hooks.json +50 -0
  129. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  130. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -0,0 +1,342 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { buildProcessTreeStopCommand } from '../../server/process-control.ts';
5
+ import { processIdentityMatches } from '../../server/process-state.ts';
6
+ import type {
7
+ SandboxControlExecution,
8
+ SandboxControlManifest,
9
+ SandboxControlStatus
10
+ } from './protocol.ts';
11
+ import {
12
+ parseSandboxControlStatus,
13
+ readExecution,
14
+ terminateSandboxControlExecution
15
+ } from './state.ts';
16
+
17
+ type OwnerIdentity = Readonly<{ pid: number; startTime: string }>;
18
+ type BrokerOwner = OwnerIdentity & Readonly<{ version: 2; token: string; generation: string }>;
19
+
20
+ const DEFAULT_QUIESCE_TIMEOUT_MS = 7_000;
21
+ const QUIESCING_FILE = 'quiescing.json';
22
+ const BROKER_STARTING_FILE = 'broker-starting.json';
23
+
24
+ function regularFile(filePath: string): boolean {
25
+ try {
26
+ const stat = fs.lstatSync(filePath);
27
+ return stat.isFile() && !stat.isSymbolicLink();
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ export function isSandboxControlRootQuiescing(root: string): boolean {
35
+ return fs.existsSync(path.join(path.resolve(root), QUIESCING_FILE));
36
+ }
37
+
38
+ function markSandboxControlRootQuiescing(root: string): void {
39
+ const markerPath = path.join(root, QUIESCING_FILE);
40
+ try {
41
+ fs.writeFileSync(markerPath, `${JSON.stringify({ version: 1, startedAt: Date.now() })}\n`, {
42
+ mode: 0o600,
43
+ flag: 'wx'
44
+ });
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
47
+ if (!regularFile(markerPath)) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
48
+ }
49
+ }
50
+
51
+ function readStartupOwner(filePath: string): { raw: string; owner: OwnerIdentity } | null {
52
+ if (!fs.existsSync(filePath)) return null;
53
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
54
+ const raw = fs.readFileSync(filePath, 'utf8');
55
+ try {
56
+ const record = JSON.parse(raw) as Partial<OwnerIdentity> & { version?: unknown };
57
+ if (record.version !== 1 || !Number.isSafeInteger(record.pid) || (record.pid ?? 0) <= 0
58
+ || typeof record.startTime !== 'string') return null;
59
+ return { raw, owner: { pid: record.pid!, startTime: record.startTime } };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ async function waitForStartupTransition(root: string, timeoutMs: number): Promise<void> {
66
+ const transitionPath = path.join(root, BROKER_STARTING_FILE);
67
+ const deadline = Date.now() + timeoutMs;
68
+ while (Date.now() < deadline) {
69
+ const transition = readStartupOwner(transitionPath);
70
+ if (!fs.existsSync(transitionPath)) return;
71
+ if (transition && !ownerLive(transition.owner)) {
72
+ try {
73
+ if (fs.readFileSync(transitionPath, 'utf8') === transition.raw) fs.unlinkSync(transitionPath);
74
+ } catch {
75
+ // A concurrent startup transition changed or removed the record.
76
+ }
77
+ continue;
78
+ }
79
+ await new Promise((resolve) => setTimeout(resolve, 25));
80
+ }
81
+ throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
82
+ }
83
+
84
+ export async function acquireSandboxControlBrokerStartup(
85
+ root: string,
86
+ owner: OwnerIdentity,
87
+ timeoutMs = 5_000
88
+ ): Promise<() => void> {
89
+ const resolvedRoot = path.resolve(root);
90
+ const transitionPath = path.join(resolvedRoot, BROKER_STARTING_FILE);
91
+ const record = `${JSON.stringify({ version: 1, ...owner })}\n`;
92
+ const deadline = Date.now() + timeoutMs;
93
+ while (Date.now() < deadline) {
94
+ if (isSandboxControlRootQuiescing(resolvedRoot)) throw new Error('SANDBOX_CONTROL_QUIESCING');
95
+ try {
96
+ fs.writeFileSync(transitionPath, record, { mode: 0o600, flag: 'wx' });
97
+ if (isSandboxControlRootQuiescing(resolvedRoot)) {
98
+ try {
99
+ if (fs.readFileSync(transitionPath, 'utf8') === record) fs.unlinkSync(transitionPath);
100
+ } catch {
101
+ // The deletion side owns the transition now.
102
+ }
103
+ throw new Error('SANDBOX_CONTROL_QUIESCING');
104
+ }
105
+ return () => {
106
+ try {
107
+ if (fs.readFileSync(transitionPath, 'utf8') === record) fs.unlinkSync(transitionPath);
108
+ } catch {
109
+ // A newer transition or deletion owns the path.
110
+ }
111
+ };
112
+ } catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
114
+ const transition = readStartupOwner(transitionPath);
115
+ if (transition && !ownerLive(transition.owner)) {
116
+ try {
117
+ if (fs.readFileSync(transitionPath, 'utf8') === transition.raw) fs.unlinkSync(transitionPath);
118
+ } catch {
119
+ // A concurrent transition changed the record.
120
+ }
121
+ continue;
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, 25));
124
+ }
125
+ }
126
+ throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
127
+ }
128
+
129
+ export function readSandboxControlManifest(manifestPath: string): SandboxControlManifest {
130
+ if (!regularFile(manifestPath)) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
131
+ let manifest: Partial<SandboxControlManifest>;
132
+ try {
133
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Partial<SandboxControlManifest>;
134
+ } catch {
135
+ throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
136
+ }
137
+ if (manifest.version !== 3) {
138
+ throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 3; container-only recreation is required');
139
+ }
140
+ if (typeof manifest.repoRoot !== 'string' || typeof manifest.worktreeRoot !== 'string'
141
+ || typeof manifest.project !== 'string' || typeof manifest.container !== 'string'
142
+ || typeof manifest.branch !== 'string' || !['task-bound', 'branch-only'].includes(manifest.mode ?? '')
143
+ || (manifest.taskId !== null && typeof manifest.taskId !== 'string')
144
+ || typeof manifest.channelDir !== 'string' || typeof manifest.publicStatusDir !== 'string'
145
+ || typeof manifest.processingDir !== 'string' || typeof manifest.token !== 'string'
146
+ || typeof manifest.generation !== 'string') throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
147
+ const root = path.dirname(path.resolve(manifestPath));
148
+ if (path.resolve(manifest.channelDir) !== path.join(root, 'channel')
149
+ || path.resolve(manifest.publicStatusDir) !== path.join(root, 'public')
150
+ || path.resolve(manifest.processingDir) !== path.join(root, 'processing')) {
151
+ throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
152
+ }
153
+ return manifest as SandboxControlManifest;
154
+ }
155
+
156
+ function readBrokerOwner(filePath: string): BrokerOwner | null {
157
+ if (!fs.existsSync(filePath)) return null;
158
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
159
+ let owner: Partial<BrokerOwner> | null;
160
+ try {
161
+ owner = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Partial<BrokerOwner> | null;
162
+ } catch {
163
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
164
+ }
165
+ if (!owner || owner.version !== 2 || !Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0
166
+ || typeof owner.startTime !== 'string' || typeof owner.token !== 'string'
167
+ || typeof owner.generation !== 'string') throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
168
+ return owner as BrokerOwner;
169
+ }
170
+
171
+ function readStatusOwner(filePath: string): SandboxControlStatus | null {
172
+ if (!fs.existsSync(filePath)) return null;
173
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
174
+ try {
175
+ return parseSandboxControlStatus(JSON.parse(fs.readFileSync(filePath, 'utf8')));
176
+ } catch {
177
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
178
+ }
179
+ }
180
+
181
+ function sameOwner(left: OwnerIdentity, right: OwnerIdentity): boolean {
182
+ return left.pid === right.pid && left.startTime === right.startTime;
183
+ }
184
+
185
+ function ownerLive(owner: OwnerIdentity): boolean {
186
+ return processIdentityMatches({ version: 1, pid: owner.pid, startTime: owner.startTime });
187
+ }
188
+
189
+ function readExecutions(manifest: SandboxControlManifest): SandboxControlExecution[] {
190
+ if (!fs.existsSync(manifest.processingDir)) return [];
191
+ const stat = fs.lstatSync(manifest.processingDir);
192
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
193
+ const executions: SandboxControlExecution[] = [];
194
+ for (const entry of fs.readdirSync(manifest.processingDir, { withFileTypes: true })) {
195
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
196
+ const descriptor = path.join(manifest.processingDir, entry.name, 'execution.json');
197
+ if (!fs.existsSync(descriptor)) continue;
198
+ let execution: SandboxControlExecution;
199
+ try {
200
+ execution = readExecution(descriptor);
201
+ } catch {
202
+ throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${entry.name}`);
203
+ }
204
+ if (execution.generation !== manifest.generation || execution.requestId !== entry.name) {
205
+ throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${entry.name}`);
206
+ }
207
+ if (execution.child.pid <= 0 || (execution.child.processGroupId !== null
208
+ && (!Number.isSafeInteger(execution.child.processGroupId) || execution.child.processGroupId <= 0))) {
209
+ throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${entry.name}`);
210
+ }
211
+ executions.push(execution);
212
+ }
213
+ return executions;
214
+ }
215
+
216
+ function processGroupAlive(groupId: number): boolean {
217
+ try {
218
+ const rows = execFileSync('ps', ['-axo', 'pgid=,stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
219
+ return rows.split('\n').some((row) => {
220
+ const match = row.trim().match(/^(\d+)\s+(\S+)/);
221
+ return Number(match?.[1]) === groupId && !match?.[2]?.startsWith('Z');
222
+ });
223
+ } catch {
224
+ try {
225
+ process.kill(-groupId, 0);
226
+ return true;
227
+ } catch (error) {
228
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
229
+ }
230
+ }
231
+ }
232
+
233
+ function executionAlive(execution: SandboxControlExecution, platform: NodeJS.Platform): boolean {
234
+ if (platform !== 'win32' && execution.child.processGroupId) {
235
+ return processGroupAlive(execution.child.processGroupId);
236
+ }
237
+ return processIdentityMatches({ version: 1, pid: execution.child.pid, startTime: execution.child.startTime });
238
+ }
239
+
240
+ async function waitForExit(owner: OwnerIdentity, timeoutMs: number): Promise<boolean> {
241
+ const deadline = Date.now() + timeoutMs;
242
+ while (Date.now() < deadline) {
243
+ if (!ownerLive(owner)) return true;
244
+ await new Promise((resolve) => setTimeout(resolve, 25));
245
+ }
246
+ return !ownerLive(owner);
247
+ }
248
+
249
+ function signalOwner(owner: OwnerIdentity, platform: NodeJS.Platform, force: boolean): void {
250
+ try {
251
+ if (platform === 'win32') {
252
+ const command = buildProcessTreeStopCommand(owner.pid, platform);
253
+ if (command.kind === 'exec') execFileSync(command.command, command.args, { stdio: 'ignore' });
254
+ return;
255
+ }
256
+ process.kill(owner.pid, force ? 'SIGKILL' : 'SIGTERM');
257
+ } catch {
258
+ // The identity checks decide whether the owner actually exited.
259
+ }
260
+ }
261
+
262
+ function mergeExecutions(...groups: readonly SandboxControlExecution[][]): SandboxControlExecution[] {
263
+ const merged = new Map<string, SandboxControlExecution>();
264
+ for (const execution of groups.flat()) merged.set(`${execution.requestId}:${execution.nonce}`, execution);
265
+ return [...merged.values()];
266
+ }
267
+
268
+ export async function quiesceSandboxControlRoot(
269
+ root: string,
270
+ options: { platform?: NodeJS.Platform; timeoutMs?: number } = {}
271
+ ): Promise<'missing' | 'stale' | 'stopped'> {
272
+ const resolvedRoot = path.resolve(root);
273
+ if (!fs.existsSync(resolvedRoot)) return 'missing';
274
+ const rootStat = fs.lstatSync(resolvedRoot);
275
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
276
+
277
+ for (const directory of [path.join(resolvedRoot, 'public'), path.join(resolvedRoot, 'processing')]) {
278
+ if (!fs.existsSync(directory)) continue;
279
+ const stat = fs.lstatSync(directory);
280
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
281
+ }
282
+
283
+ markSandboxControlRootQuiescing(resolvedRoot);
284
+ await waitForStartupTransition(resolvedRoot, options.timeoutMs ?? DEFAULT_QUIESCE_TIMEOUT_MS);
285
+
286
+ const manifestPath = path.join(resolvedRoot, 'manifest.json');
287
+ const manifest = fs.existsSync(manifestPath) ? readSandboxControlManifest(manifestPath) : null;
288
+ const broker = readBrokerOwner(path.join(resolvedRoot, 'broker.json'));
289
+ const status = readStatusOwner(path.join(resolvedRoot, 'public', 'status.json'));
290
+ const statusOwner = status ? { version: 1 as const, ...status.broker } : null;
291
+ const brokerLive = broker ? ownerLive(broker) : false;
292
+ const statusLive = statusOwner ? ownerLive(statusOwner) : false;
293
+
294
+ if (manifest && broker && (broker.token !== manifest.token || broker.generation !== manifest.generation)) {
295
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
296
+ }
297
+ if (manifest && status && status.generation !== manifest.generation) {
298
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
299
+ }
300
+ if (broker && statusOwner && !sameOwner(broker, statusOwner) && (brokerLive || statusLive)) {
301
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
302
+ }
303
+ if (!manifest && brokerLive) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
304
+
305
+ const platform = options.platform ?? process.platform;
306
+ const timeoutMs = options.timeoutMs ?? DEFAULT_QUIESCE_TIMEOUT_MS;
307
+ let executions = manifest ? readExecutions(manifest) : [];
308
+ const owner: OwnerIdentity | null = brokerLive ? broker : statusLive ? statusOwner : null;
309
+ if (!owner) {
310
+ if (manifest && !broker && !status) throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
311
+ for (const execution of executions) {
312
+ if (!terminateSandboxControlExecution(execution, { platform, timeoutMs })) {
313
+ throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
314
+ }
315
+ }
316
+ if (executions.some((execution) => executionAlive(execution, platform))) {
317
+ throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
318
+ }
319
+ return broker || status ? 'stale' : 'missing';
320
+ }
321
+
322
+ signalOwner(owner, platform, false);
323
+ if (await waitForExit(owner, timeoutMs)) {
324
+ if (executions.some((execution) => executionAlive(execution, platform))) {
325
+ throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
326
+ }
327
+ return 'stopped';
328
+ }
329
+
330
+ if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
331
+ for (const execution of executions) {
332
+ if (!terminateSandboxControlExecution(execution, { platform, timeoutMs })) {
333
+ throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
334
+ }
335
+ }
336
+ signalOwner(owner, platform, true);
337
+ if (!await waitForExit(owner, timeoutMs)) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
338
+ if (executions.some((execution) => executionAlive(execution, platform))) {
339
+ throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
340
+ }
341
+ return 'stopped';
342
+ }
@@ -1,35 +1,62 @@
1
+ import { validateTaskCreateCandidate, type TaskCreateCandidateV1 } from '../../task/create.ts';
2
+
1
3
  export const SANDBOX_CONTROL_MAX_BYTES = 64 * 1024;
2
- export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration'] as const;
4
+ export const SANDBOX_CONTROL_ADMISSION_WINDOW_MS = 2_000;
5
+ export const SANDBOX_CONTROL_STATUS_INTERVAL_MS = 250;
6
+ export const SANDBOX_CONTROL_STATUS_STALE_MS = 1_500;
7
+ export const SANDBOX_CONTROL_FUTURE_SKEW_MS = 1_000;
8
+ export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-create'] as const;
3
9
 
4
10
  export type SandboxControlFamily = typeof SANDBOX_CONTROL_FAMILIES[number];
5
-
6
11
  export type SandboxControlManifest = Readonly<{
7
- version: 1;
8
- repoRoot: string;
9
- project: string;
10
- container: string;
11
- branch: string;
12
- mode: 'task-bound' | 'branch-only';
13
- taskId: string | null;
14
- token: string;
15
- channelDir: string;
12
+ version: 3; repoRoot: string; worktreeRoot: string; project: string; container: string;
13
+ branch: string; mode: 'task-bound' | 'branch-only'; taskId: string | null; token: string;
14
+ generation: string; channelDir: string; publicStatusDir: string; processingDir: string;
16
15
  }>;
17
-
18
- export type SandboxControlRequest = Readonly<{
19
- version: 1;
20
- id: string;
21
- token: string;
22
- family: SandboxControlFamily;
23
- args: string[];
16
+ type RequestBase = Readonly<{
17
+ version: 2; id: string; token: string; generation: string; issuedAt: number; expiresAt: number;
24
18
  }>;
25
-
19
+ export type SandboxTaskCommandRequest = RequestBase & Readonly<{
20
+ family: 'task-lifecycle' | 'task-orchestration'; args: string[];
21
+ }>;
22
+ export type SandboxTaskCreateRequest = RequestBase & Readonly<{
23
+ family: 'task-create'; candidate: TaskCreateCandidateV1;
24
+ }>;
25
+ export type SandboxControlRequest = SandboxTaskCommandRequest | SandboxTaskCreateRequest;
26
+ export type SandboxControlError = Readonly<{ code: string; message: string; retryable: boolean }>;
26
27
  export type SandboxControlResponse = Readonly<{
27
- version: 1;
28
- id: string;
29
- exitCode: number;
30
- stdout: string;
31
- stderr: string;
28
+ version: 2; id: string; phase: 'accepted' | 'completed' | 'rejected'; exitCode: number | null;
29
+ stdout: string; stderr: string; error: SandboxControlError | null;
32
30
  }>;
31
+ export type SandboxControlStatus = Readonly<{
32
+ version: 1; generation: string; broker: { pid: number; startTime: string };
33
+ state: 'starting' | 'healthy' | 'busy' | 'parked'; reasonCode: string | null;
34
+ activeRequestId: string | null; updatedAt: number;
35
+ }>;
36
+ export type SandboxControlLease = Readonly<{
37
+ version: 1; generation: string; nonce: string; owner: { pid: number; startTime: string };
38
+ issuedAt: number; expiresAt: number; taskId: string | null; branch: string; reason: string;
39
+ }>;
40
+ export type SandboxControlExecution = Readonly<{
41
+ version: 1; generation: string; requestId: string; nonce: string;
42
+ child: { pid: number; startTime: string; processGroupId: number | null };
43
+ phase: 'prepared' | 'running' | 'terminating'; updatedAt: number;
44
+ }>;
45
+
46
+ export class SandboxControlProtocolError extends Error {
47
+ readonly code: string;
48
+ readonly retryable: boolean;
49
+ constructor(code: string, message: string, retryable = false) {
50
+ super(`${code}: ${message}`);
51
+ this.name = 'SandboxControlProtocolError';
52
+ this.code = code;
53
+ this.retryable = retryable;
54
+ }
55
+ }
56
+
57
+ function fail(code: string, message: string, retryable = false): never {
58
+ throw new SandboxControlProtocolError(code, message, retryable);
59
+ }
33
60
 
34
61
  export function isSandboxControlFamily(value: string): value is SandboxControlFamily {
35
62
  return SANDBOX_CONTROL_FAMILIES.includes(value as SandboxControlFamily);
@@ -37,39 +64,74 @@ export function isSandboxControlFamily(value: string): value is SandboxControlFa
37
64
 
38
65
  export function validateSandboxControlRequest(
39
66
  value: unknown,
40
- manifest: SandboxControlManifest
67
+ manifest: SandboxControlManifest,
68
+ options: { now?: number } = {}
41
69
  ): SandboxControlRequest {
42
70
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
43
- throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: request must be an object');
71
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request must be an object');
44
72
  }
45
- const request = value as Partial<SandboxControlRequest>;
73
+ const request = value as Record<string, unknown>;
46
74
  if (
47
- request.version !== 1
48
- || typeof request.id !== 'string'
49
- || !/^[a-f0-9-]{16,64}$/.test(request.id)
50
- || request.token !== manifest.token
51
- || typeof request.family !== 'string'
52
- || !isSandboxControlFamily(request.family)
53
- || !Array.isArray(request.args)
54
- || !request.args.every((arg) => typeof arg === 'string')
55
- ) {
56
- throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: request schema or authorization is invalid');
75
+ request.version !== 2 || typeof request.id !== 'string'
76
+ || !/^[a-f0-9-]{16,64}$/.test(request.id) || request.token !== manifest.token
77
+ || typeof request.family !== 'string' || !isSandboxControlFamily(request.family)
78
+ || !Number.isSafeInteger(request.issuedAt) || !Number.isSafeInteger(request.expiresAt)
79
+ ) fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
80
+ if (request.generation !== manifest.generation) {
81
+ fail('SANDBOX_CONTROL_GENERATION_INVALID', 'request generation does not match the broker');
57
82
  }
58
- if (manifest.mode !== 'task-bound' || !manifest.taskId) {
59
- throw new Error('SANDBOX_CONTROL_BRANCH_ONLY: branch-only sandboxes cannot coordinate tasks');
83
+ const issuedAt = request.issuedAt as number;
84
+ const expiresAt = request.expiresAt as number;
85
+ const now = options.now ?? Date.now();
86
+ if (expiresAt <= issuedAt || expiresAt - issuedAt > SANDBOX_CONTROL_ADMISSION_WINDOW_MS || issuedAt > now + SANDBOX_CONTROL_FUTURE_SKEW_MS) {
87
+ fail('SANDBOX_CONTROL_REQUEST_DEADLINE_INVALID', 'request admission deadline is invalid');
60
88
  }
89
+ if (now > expiresAt) fail('SANDBOX_CONTROL_REQUEST_EXPIRED', 'request admission deadline has passed', true);
61
90
  if (Buffer.byteLength(JSON.stringify(request), 'utf8') > SANDBOX_CONTROL_MAX_BYTES) {
62
- throw new Error('SANDBOX_CONTROL_REQUEST_TOO_LARGE: request exceeds the control limit');
91
+ fail('SANDBOX_CONTROL_REQUEST_TOO_LARGE', 'request exceeds the control limit');
92
+ }
93
+ if (request.family === 'task-create') {
94
+ const expected = ['candidate', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
95
+ if (Object.keys(request).sort().join(',') !== expected.sort().join(',')) {
96
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
97
+ }
98
+ return { ...request, candidate: validateTaskCreateCandidate(request.candidate) } as SandboxTaskCreateRequest;
99
+ }
100
+ const expected = ['args', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
101
+ if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
102
+ || !Array.isArray(request.args) || !request.args.every((arg) => typeof arg === 'string')) {
103
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
104
+ }
105
+ if (manifest.mode !== 'task-bound' || !manifest.taskId) {
106
+ fail(
107
+ 'SANDBOX_CONTROL_BRANCH_ONLY',
108
+ "branch-only sandboxes cannot coordinate tasks; return to the host and run 'ai sandbox start --recreate <task-ref-or-correct-branch>'"
109
+ );
110
+ }
111
+ if (request.family === 'task-orchestration'
112
+ && request.args.some((arg) => arg === '--git-worktree-root' || arg.startsWith('--git-worktree-root='))) {
113
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'worktree binding is reserved for the control broker');
63
114
  }
64
- return request as SandboxControlRequest;
115
+ return request as SandboxTaskCommandRequest;
65
116
  }
66
117
 
67
- export function bindSandboxControlTask(
68
- request: SandboxControlRequest,
69
- taskId: string
70
- ): string[] {
71
- if (request.args.length === 0) {
72
- throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: command arguments are required');
73
- }
118
+ export function bindSandboxControlTask(request: SandboxControlRequest, taskId: string): string[] {
119
+ if (request.family === 'task-create') fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-create requests do not bind a current task');
120
+ if (request.args.length === 0) fail('SANDBOX_CONTROL_REQUEST_INVALID', 'command arguments are required');
74
121
  return [taskId, ...request.args.slice(1)];
75
122
  }
123
+
124
+ export function controlError(error: unknown): SandboxControlError {
125
+ if (error instanceof SandboxControlProtocolError) {
126
+ return { code: error.code, message: error.message, retryable: error.retryable };
127
+ }
128
+ const message = error instanceof Error ? error.message : String(error);
129
+ const code = /^([A-Z][A-Z0-9_]+)/.exec(message)?.[1] ?? 'SANDBOX_CONTROL_INTERNAL_ERROR';
130
+ const retryable = new Set([
131
+ 'SANDBOX_CONTROL_BUSY',
132
+ 'SANDBOX_CONTROL_HANDOFF_ACTIVE',
133
+ 'SANDBOX_CONTROL_REQUEST_EXPIRED',
134
+ 'SANDBOX_WORKTREE_BINDING_LOST'
135
+ ]).has(code);
136
+ return { code, message, retryable };
137
+ }