@fitlab-ai/agent-infra 0.9.6 → 0.9.8

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 (178) hide show
  1. package/dist/bin/internal-cli.js +10 -0
  2. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +47 -5
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +245 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +398 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +23 -6
  8. package/dist/lib/decide.js +68 -14
  9. package/dist/lib/defaults.json +10 -2
  10. package/dist/lib/git/workflow.js +30 -3
  11. package/dist/lib/init.js +1 -1
  12. package/dist/lib/internal/codex-lifecycle.js +69 -1
  13. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  14. package/dist/lib/internal/release-workflow.js +1 -3
  15. package/dist/lib/internal/task-activity.js +57 -7
  16. package/dist/lib/internal/task-event.js +57 -4
  17. package/dist/lib/internal/task-ledger.js +59 -5
  18. package/dist/lib/internal/task-lifecycle.js +59 -8
  19. package/dist/lib/internal/task-orchestration.js +48 -8
  20. package/dist/lib/internal/task-override.js +131 -0
  21. package/dist/lib/internal/task-review.js +64 -6
  22. package/dist/lib/internal/task-verify.js +65 -3
  23. package/dist/lib/internal/task-warning.js +57 -4
  24. package/dist/lib/platform/context.js +6 -4
  25. package/dist/lib/platform/github-client.js +55 -2
  26. package/dist/lib/platform/pr-summary.js +5 -3
  27. package/dist/lib/process-data/checkpoint.js +319 -0
  28. package/dist/lib/process-data/index.js +128 -60
  29. package/dist/lib/process-data/materialize.js +84 -0
  30. package/dist/lib/process-data/normalize.js +34 -1
  31. package/dist/lib/process-data/reconcile.js +55 -1
  32. package/dist/lib/process-data/sources.js +422 -1
  33. package/dist/lib/process-data/store.js +341 -11
  34. package/dist/lib/runtime/agent-runtime.js +56 -0
  35. package/dist/lib/sandbox/commands/create.js +394 -264
  36. package/dist/lib/sandbox/commands/rm.js +64 -15
  37. package/dist/lib/sandbox/commands/start.js +4 -3
  38. package/dist/lib/sandbox/control/container-identity.js +64 -0
  39. package/dist/lib/sandbox/control/executor.js +23 -9
  40. package/dist/lib/sandbox/control/lifecycle.js +590 -61
  41. package/dist/lib/sandbox/control/protocol.js +7 -0
  42. package/dist/lib/sandbox/control/server.js +202 -26
  43. package/dist/lib/sandbox/control/state.js +51 -25
  44. package/dist/lib/sandbox/control/timing.js +8 -0
  45. package/dist/lib/sandbox/engine.js +14 -7
  46. package/dist/lib/sandbox/mounts.js +7 -0
  47. package/dist/lib/sandbox/recovery.js +135 -50
  48. package/dist/lib/sandbox/workspace-view.js +59 -20
  49. package/dist/lib/server/daemon.js +1 -1
  50. package/dist/lib/server/process-control.js +11 -1
  51. package/dist/lib/server/process-state.js +107 -20
  52. package/dist/lib/task/activity-intent.js +4 -1
  53. package/dist/lib/task/artifact-lifecycle.js +82 -11
  54. package/dist/lib/task/artifact-receipts.js +128 -0
  55. package/dist/lib/task/codex-orchestration.js +81 -6
  56. package/dist/lib/task/commands/validate.js +1 -1
  57. package/dist/lib/task/create.js +10 -2
  58. package/dist/lib/task/decision-intents.js +4 -2
  59. package/dist/lib/task/delegation-receipts.js +120 -17
  60. package/dist/lib/task/events.js +148 -7
  61. package/dist/lib/task/guard-override.js +31 -0
  62. package/dist/lib/task/human-override.js +1240 -0
  63. package/dist/lib/task/ledger-intents.js +6 -3
  64. package/dist/lib/task/ledger.js +13 -1
  65. package/dist/lib/task/lifecycle.js +69 -14
  66. package/dist/lib/task/orchestration.js +293 -18
  67. package/dist/lib/task/review-finalization.js +3 -2
  68. package/dist/lib/task/sections.js +5 -1
  69. package/dist/lib/task/task-execution-lock.js +5 -4
  70. package/dist/lib/task/verification-engine.js +80 -22
  71. package/dist/lib/task/verification.js +13 -1
  72. package/dist/lib/task/workflow-warning-intents.js +4 -2
  73. package/dist/lib/task/write.js +2 -1
  74. package/dist/lifecycle-build-manifest.json +6 -0
  75. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  76. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +62 -4
  77. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  78. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +322 -0
  79. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  80. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +480 -0
  81. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +35 -5
  82. package/lib/decide.ts +59 -14
  83. package/lib/defaults.json +10 -2
  84. package/lib/git/workflow.ts +25 -2
  85. package/lib/init.ts +1 -1
  86. package/lib/internal/codex-lifecycle.ts +68 -1
  87. package/lib/internal/codex-sandbox-controller.ts +109 -0
  88. package/lib/internal/release-workflow.ts +1 -3
  89. package/lib/internal/task-activity.ts +46 -6
  90. package/lib/internal/task-event.ts +53 -4
  91. package/lib/internal/task-ledger.ts +48 -4
  92. package/lib/internal/task-lifecycle.ts +56 -12
  93. package/lib/internal/task-orchestration.ts +43 -8
  94. package/lib/internal/task-override.ts +138 -0
  95. package/lib/internal/task-review.ts +51 -6
  96. package/lib/internal/task-verify.ts +52 -3
  97. package/lib/internal/task-warning.ts +44 -3
  98. package/lib/platform/context.ts +8 -5
  99. package/lib/platform/github-client.ts +61 -3
  100. package/lib/platform/pr-summary.ts +10 -3
  101. package/lib/process-data/checkpoint.ts +335 -0
  102. package/lib/process-data/index.ts +112 -60
  103. package/lib/process-data/materialize.ts +83 -0
  104. package/lib/process-data/normalize.ts +29 -1
  105. package/lib/process-data/reconcile.ts +64 -2
  106. package/lib/process-data/sources.ts +449 -2
  107. package/lib/process-data/store.ts +304 -11
  108. package/lib/process-data/types.ts +124 -2
  109. package/lib/runtime/agent-runtime.ts +76 -0
  110. package/lib/sandbox/commands/create.ts +139 -6
  111. package/lib/sandbox/commands/rm.ts +64 -14
  112. package/lib/sandbox/commands/start.ts +4 -3
  113. package/lib/sandbox/config.ts +2 -1
  114. package/lib/sandbox/control/container-identity.ts +93 -0
  115. package/lib/sandbox/control/executor.ts +24 -12
  116. package/lib/sandbox/control/lifecycle.ts +685 -67
  117. package/lib/sandbox/control/protocol.ts +35 -6
  118. package/lib/sandbox/control/server.ts +184 -24
  119. package/lib/sandbox/control/state.ts +67 -33
  120. package/lib/sandbox/control/timing.ts +12 -0
  121. package/lib/sandbox/engine.ts +20 -9
  122. package/lib/sandbox/mounts.ts +8 -0
  123. package/lib/sandbox/recovery.ts +159 -49
  124. package/lib/sandbox/workspace-view.ts +73 -22
  125. package/lib/server/daemon.ts +1 -1
  126. package/lib/server/process-control.ts +13 -3
  127. package/lib/server/process-state.ts +129 -27
  128. package/lib/task/activity-intent.ts +4 -2
  129. package/lib/task/artifact-lifecycle.ts +73 -10
  130. package/lib/task/artifact-receipts.ts +161 -0
  131. package/lib/task/codex-orchestration.ts +91 -5
  132. package/lib/task/commands/validate.ts +1 -1
  133. package/lib/task/create.ts +11 -2
  134. package/lib/task/decision-intents.ts +4 -2
  135. package/lib/task/delegation-receipts.ts +205 -25
  136. package/lib/task/events.ts +132 -6
  137. package/lib/task/guard-override.ts +50 -0
  138. package/lib/task/human-override.ts +1503 -0
  139. package/lib/task/ledger-intents.ts +6 -3
  140. package/lib/task/ledger.ts +16 -1
  141. package/lib/task/lifecycle.ts +71 -14
  142. package/lib/task/orchestration.ts +443 -22
  143. package/lib/task/review-finalization.ts +6 -2
  144. package/lib/task/sections.ts +6 -2
  145. package/lib/task/task-execution-lock.ts +11 -9
  146. package/lib/task/verification-engine.ts +93 -32
  147. package/lib/task/verification.ts +15 -1
  148. package/lib/task/workflow-warning-intents.ts +4 -2
  149. package/lib/task/write.ts +4 -1
  150. package/package.json +1 -1
  151. package/templates/.agents/README.en.md +7 -0
  152. package/templates/.agents/README.zh-CN.md +5 -0
  153. package/templates/.agents/hooks/lifecycle-delegation.js +33 -4
  154. package/templates/.agents/rules/lifecycle-orchestration.en.md +10 -3
  155. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +10 -3
  156. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  157. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  158. package/templates/.agents/rules/review-handshake.en.md +3 -1
  159. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  160. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  161. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  162. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  163. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  164. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +6 -6
  165. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +6 -6
  166. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -3
  167. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -3
  168. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  169. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  170. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +25 -0
  171. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +25 -0
  172. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  173. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  174. package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -1
  175. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -1
  176. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  177. package/templates/.agents/workspace/README.en.md +0 -26
  178. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -1,4 +1,5 @@
1
1
  import { validateTaskCreateCandidate, type TaskCreateCandidateV1 } from '../../task/create.ts';
2
+ import type { ProcessIdentity } from '../../server/process-state.ts';
2
3
 
3
4
  export const SANDBOX_CONTROL_MAX_BYTES = 64 * 1024;
4
5
  export const SANDBOX_CONTROL_ADMISSION_WINDOW_MS = 2_000;
@@ -6,13 +7,41 @@ export const SANDBOX_CONTROL_STATUS_INTERVAL_MS = 250;
6
7
  export const SANDBOX_CONTROL_STATUS_STALE_MS = 1_500;
7
8
  export const SANDBOX_CONTROL_FUTURE_SKEW_MS = 1_000;
8
9
  export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-create'] as const;
10
+ export type SandboxControlTimingPolicy = Readonly<{
11
+ controlTickMs: number;
12
+ parkedBindingInitialMs: number;
13
+ slowCheckMs: number;
14
+ containerHeartbeatMs: number;
15
+ quiesceDeadlineMs: number;
16
+ }>;
17
+ export const DEFAULT_SANDBOX_CONTROL_TIMING: SandboxControlTimingPolicy = Object.freeze({
18
+ controlTickMs: 250,
19
+ parkedBindingInitialMs: 1_000,
20
+ slowCheckMs: 5_000,
21
+ containerHeartbeatMs: 5_000,
22
+ quiesceDeadlineMs: 7_000
23
+ } as const);
9
24
 
10
25
  export type SandboxControlFamily = typeof SANDBOX_CONTROL_FAMILIES[number];
11
- export type SandboxControlManifest = Readonly<{
12
- version: 3; repoRoot: string; worktreeRoot: string; project: string; container: string;
26
+ export type SandboxControlContainerIdentity = Readonly<{
27
+ id: string;
28
+ labels: Readonly<Record<string, string>>;
29
+ }>;
30
+ export type SandboxControlManifestBase = Readonly<{
31
+ engine: string; repoRoot: string; worktreeRoot: string; project: string; container: string;
32
+ containerIdentity: SandboxControlContainerIdentity;
13
33
  branch: string; mode: 'task-bound' | 'branch-only'; taskId: string | null; token: string;
14
34
  generation: string; channelDir: string; publicStatusDir: string; processingDir: string;
15
35
  }>;
36
+ export type SandboxControlLegacyManifest = SandboxControlManifestBase & Readonly<{ version: 4 }>;
37
+ export type SandboxControlManifest = SandboxControlManifestBase & Readonly<{ version: 5; runtimeDir: string }>;
38
+ export type SandboxControlManifestLike = SandboxControlLegacyManifest | SandboxControlManifest;
39
+ export type SandboxControlBrokerOwner = ProcessIdentity & Readonly<{
40
+ version: 3;
41
+ brokerId: string;
42
+ token: string;
43
+ generation: string;
44
+ }>;
16
45
  type RequestBase = Readonly<{
17
46
  version: 2; id: string; token: string; generation: string; issuedAt: number; expiresAt: number;
18
47
  }>;
@@ -29,17 +58,17 @@ export type SandboxControlResponse = Readonly<{
29
58
  stdout: string; stderr: string; error: SandboxControlError | null;
30
59
  }>;
31
60
  export type SandboxControlStatus = Readonly<{
32
- version: 1; generation: string; broker: { pid: number; startTime: string };
61
+ version: 2; generation: string; broker: ProcessIdentity & { brokerId: string };
33
62
  state: 'starting' | 'healthy' | 'busy' | 'parked'; reasonCode: string | null;
34
63
  activeRequestId: string | null; updatedAt: number;
35
64
  }>;
36
65
  export type SandboxControlLease = Readonly<{
37
- version: 1; generation: string; nonce: string; owner: { pid: number; startTime: string };
66
+ version: 2; generation: string; nonce: string; owner: ProcessIdentity;
38
67
  issuedAt: number; expiresAt: number; taskId: string | null; branch: string; reason: string;
39
68
  }>;
40
69
  export type SandboxControlExecution = Readonly<{
41
- version: 1; generation: string; requestId: string; nonce: string;
42
- child: { pid: number; startTime: string; processGroupId: number | null };
70
+ version: 2; generation: string; requestId: string; nonce: string;
71
+ child: ProcessIdentity & { processGroupId: number | null };
43
72
  phase: 'prepared' | 'running' | 'terminating'; updatedAt: number;
44
73
  }>;
45
74
 
@@ -1,13 +1,15 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { getProcessStartTime } from '../../server/process-state.ts';
4
5
  import { assertGitWorktreeBinding } from '../../git/worktree-identity.ts';
5
6
  import {
6
7
  controlError,
7
- SANDBOX_CONTROL_STATUS_INTERVAL_MS,
8
+ DEFAULT_SANDBOX_CONTROL_TIMING,
8
9
  type SandboxControlManifest,
9
10
  type SandboxControlRequest,
10
- type SandboxControlResponse
11
+ type SandboxControlResponse,
12
+ type SandboxControlTimingPolicy
11
13
  } from './protocol.ts';
12
14
  import { prepareSandboxControlExecution, type PreparedSandboxControlExecution, type SandboxControlExecutionResult } from './executor.ts';
13
15
  import {
@@ -21,11 +23,15 @@ import {
21
23
  writeSandboxControlStatus
22
24
  } from './state.ts';
23
25
  import { validateSandboxControlRequest } from './protocol.ts';
26
+ import { inspectSandboxControlContainer, type ContainerObservation } from './container-identity.ts';
24
27
  import {
25
28
  acquireSandboxControlBrokerStartup,
29
+ garbageCollectSandboxControlRoot,
26
30
  isSandboxControlRootQuiescing,
27
31
  readSandboxControlManifest
28
32
  } from './lifecycle.ts';
33
+ import type { BrokerOwner } from './lifecycle.ts';
34
+ import { nextSandboxControlBackoff } from './timing.ts';
29
35
 
30
36
  type ActiveExecution = {
31
37
  request: SandboxControlRequest;
@@ -105,9 +111,10 @@ function bindingReason(manifest: SandboxControlManifest): string | null {
105
111
  }
106
112
  }
107
113
 
108
- function recoverProcessing(manifest: SandboxControlManifest): void {
114
+ function recoverProcessing(manifest: SandboxControlManifest, brokerOwns: () => boolean): boolean {
109
115
  for (const entry of fs.readdirSync(manifest.processingDir, { withFileTypes: true })) {
110
116
  if (!entry.isDirectory() || !/^[a-f0-9-]{16,64}$/.test(entry.name)) continue;
117
+ if (!brokerOwns()) return false;
111
118
  const descriptor = executionPath(manifest, entry.name);
112
119
  const existingResponse = responsePath(manifest, entry.name);
113
120
  if (fs.existsSync(descriptor)) {
@@ -115,6 +122,7 @@ function recoverProcessing(manifest: SandboxControlManifest): void {
115
122
  if (!terminateSandboxControlExecution(execution)) {
116
123
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${entry.name}`);
117
124
  }
125
+ if (!brokerOwns()) return false;
118
126
  appendSandboxControlAudit(manifest, 'orphan-tree-terminated', { requestId: entry.name });
119
127
  let terminal = false;
120
128
  if (fs.existsSync(existingResponse)) {
@@ -126,12 +134,18 @@ function recoverProcessing(manifest: SandboxControlManifest): void {
126
134
  terminal = false;
127
135
  }
128
136
  }
129
- if (!terminal) writeResponse(manifest, unknown(entry.name));
137
+ if (!terminal) {
138
+ if (!brokerOwns()) return false;
139
+ writeResponse(manifest, unknown(entry.name));
140
+ }
130
141
  } else {
142
+ if (!brokerOwns()) return false;
131
143
  writeResponse(manifest, notExecuted(entry.name));
132
144
  }
145
+ if (!brokerOwns()) return false;
133
146
  fs.rmSync(path.join(manifest.processingDir, entry.name), { recursive: true, force: true });
134
147
  }
148
+ return true;
135
149
  }
136
150
 
137
151
  function delay(ms: number): Promise<void> {
@@ -144,19 +158,42 @@ export function sandboxControlSafeEnv(env: NodeJS.ProcessEnv = process.env): Nod
144
158
 
145
159
  export async function serveSandboxControl(
146
160
  manifestPath: string,
147
- signal: AbortSignal = new AbortController().signal
161
+ signal: AbortSignal = new AbortController().signal,
162
+ options: {
163
+ timing?: SandboxControlTimingPolicy;
164
+ inspectContainer?: (manifest: SandboxControlManifest) => Promise<ContainerObservation>;
165
+ bindingCheck?: (manifest: SandboxControlManifest) => string | null;
166
+ } = {}
148
167
  ): Promise<void> {
168
+ const timing = options.timing ?? DEFAULT_SANDBOX_CONTROL_TIMING;
169
+ const inspectContainer = options.inspectContainer ?? ((value: SandboxControlManifest) => inspectSandboxControlContainer(value));
170
+ const bindingCheck = options.bindingCheck ?? bindingReason;
149
171
  const manifest = readSandboxControlManifest(manifestPath);
150
172
  const root = path.dirname(manifestPath);
151
173
  const startTime = getProcessStartTime(process.pid);
152
174
  if (!startTime) throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
153
- const releaseStartup = await acquireSandboxControlBrokerStartup(root, { pid: process.pid, startTime });
175
+ const brokerId = randomUUID();
176
+ const releaseStartup = await acquireSandboxControlBrokerStartup(root, { pid: process.pid, startTime, brokerId });
154
177
  const requestsDir = path.join(manifest.channelDir, 'requests');
155
178
  const responsesDir = path.join(manifest.channelDir, 'responses');
156
179
  const consumedDir = path.join(root, 'consumed');
157
- const broker = { pid: process.pid, startTime };
180
+ const broker: BrokerOwner = {
181
+ version: 3,
182
+ pid: process.pid,
183
+ startTime,
184
+ brokerId,
185
+ token: manifest.token,
186
+ generation: manifest.generation
187
+ };
158
188
  const brokerPath = path.join(root, 'broker.json');
159
- const brokerRecord = `${JSON.stringify({ version: 2, ...broker, token: manifest.token, generation: manifest.generation })}\n`;
189
+ const brokerRecord = `${JSON.stringify(broker)}\n`;
190
+ const brokerOwns = (): boolean => {
191
+ try {
192
+ return fs.readFileSync(brokerPath, 'utf8') === brokerRecord;
193
+ } catch {
194
+ return false;
195
+ }
196
+ };
160
197
  try {
161
198
  for (const directory of [manifest.channelDir, requestsDir, responsesDir, consumedDir, manifest.publicStatusDir, manifest.processingDir]) {
162
199
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
@@ -178,11 +215,20 @@ export async function serveSandboxControl(
178
215
  let active: ActiveExecution | null = null;
179
216
  let lastState = '';
180
217
  let lastStatusAt = 0;
218
+ let nextBindingCheckAt = 0;
219
+ let nextContainerHeartbeatAt = Date.now() + timing.containerHeartbeatMs;
220
+ let containerReasonCode: string | null = null;
221
+ let containerBackoffMs = timing.parkedBindingInitialMs;
222
+ let bindingReasonCode: string | null = null;
223
+ let bindingBackoffMs: number = timing.parkedBindingInitialMs;
181
224
  try {
225
+ if (!brokerOwns()) return;
182
226
  writeSandboxControlStatus(manifest, broker, 'starting', null, null);
227
+ if (!brokerOwns()) return;
183
228
  appendSandboxControlAudit(manifest, 'broker-start', { pid: broker.pid });
184
- recoverProcessing(manifest);
229
+ if (!recoverProcessing(manifest, brokerOwns)) return;
185
230
  while (!signal.aborted) {
231
+ if (!brokerOwns()) break;
186
232
  let current: SandboxControlManifest;
187
233
  try {
188
234
  current = readSandboxControlManifest(manifestPath);
@@ -190,54 +236,123 @@ export async function serveSandboxControl(
190
236
  break;
191
237
  }
192
238
  if (current.token !== manifest.token || current.generation !== manifest.generation) break;
239
+ const heartbeatNow = Date.now();
240
+ if (heartbeatNow >= nextContainerHeartbeatAt) {
241
+ let observation: ContainerObservation;
242
+ try {
243
+ observation = await inspectContainer(manifest);
244
+ } catch (error) {
245
+ observation = { state: 'unknown', reason: controlError(error).code };
246
+ }
247
+ if (!brokerOwns()) break;
248
+ if (observation.state === 'found') {
249
+ containerReasonCode = null;
250
+ containerBackoffMs = timing.parkedBindingInitialMs;
251
+ nextContainerHeartbeatAt = Date.now() + timing.containerHeartbeatMs;
252
+ } else if (observation.state === 'unknown') {
253
+ containerReasonCode = 'SANDBOX_CONTROL_CONTAINER_UNKNOWN';
254
+ appendSandboxControlAudit(manifest, 'container-heartbeat-unknown', { reason: observation.reason });
255
+ const backoff = nextSandboxControlBackoff(containerBackoffMs, timing.slowCheckMs);
256
+ nextContainerHeartbeatAt = Date.now() + backoff.delayMs;
257
+ containerBackoffMs = backoff.nextDelayMs;
258
+ } else {
259
+ try {
260
+ await garbageCollectSandboxControlRoot(root, {
261
+ timeoutMs: timing.quiesceDeadlineMs,
262
+ inspectContainer: (timeoutMs) => inspectContainer(manifest),
263
+ selfOwner: broker
264
+ });
265
+ active = null;
266
+ return;
267
+ } catch (error) {
268
+ if (!brokerOwns()) break;
269
+ containerReasonCode = 'SANDBOX_CONTROL_CONTAINER_ABSENT_GC_FAILED';
270
+ appendSandboxControlAudit(manifest, 'container-gc-failed', { reason: controlError(error).code });
271
+ const backoff = nextSandboxControlBackoff(containerBackoffMs, timing.slowCheckMs);
272
+ nextContainerHeartbeatAt = Date.now() + backoff.delayMs;
273
+ containerBackoffMs = backoff.nextDelayMs;
274
+ }
275
+ }
276
+ }
193
277
 
194
278
  if (active?.settled) {
279
+ if (!brokerOwns()) break;
195
280
  if (active.result) {
281
+ if (!brokerOwns()) break;
196
282
  writeResponse(manifest, {
197
283
  version: 2, id: active.request.id, phase: 'completed', exitCode: active.result.exitCode,
198
284
  stdout: active.result.stdout, stderr: active.result.stderr, error: null
199
285
  });
200
286
  } else {
287
+ if (!brokerOwns()) break;
201
288
  writeResponse(manifest, unknown(active.request.id));
202
289
  }
290
+ if (!brokerOwns()) break;
203
291
  fs.rmSync(path.join(manifest.processingDir, active.request.id), { recursive: true, force: true });
204
292
  active = null;
205
293
  }
206
294
 
207
295
  let reasonCode: string | null = null;
208
296
  try {
297
+ if (!brokerOwns()) break;
209
298
  if (cleanupStaleSandboxControlLease(manifest)) {
299
+ if (!brokerOwns()) break;
210
300
  appendSandboxControlAudit(manifest, 'lease-stale-cleanup');
211
301
  }
212
302
  if (readActiveLease(manifest)) reasonCode = 'SANDBOX_CONTROL_HANDOFF_ACTIVE';
213
303
  } catch {
214
304
  reasonCode = 'SANDBOX_CONTROL_HANDOFF_ACTIVE';
215
305
  }
216
- reasonCode ??= bindingReason(manifest);
306
+ const now = Date.now();
307
+ if (now >= nextBindingCheckAt) {
308
+ bindingReasonCode = bindingCheck(manifest);
309
+ if (bindingReasonCode) {
310
+ const backoff = nextSandboxControlBackoff(bindingBackoffMs, timing.slowCheckMs);
311
+ nextBindingCheckAt = now + backoff.delayMs;
312
+ bindingBackoffMs = backoff.nextDelayMs;
313
+ } else {
314
+ nextBindingCheckAt = now + timing.slowCheckMs;
315
+ bindingBackoffMs = timing.parkedBindingInitialMs;
316
+ }
317
+ }
318
+ reasonCode ??= containerReasonCode;
319
+ reasonCode ??= bindingReasonCode;
217
320
  const state = reasonCode ? 'parked' : active ? 'busy' : 'healthy';
218
321
  const stateKey = `${state}:${reasonCode ?? ''}:${active?.request.id ?? ''}`;
219
- const now = Date.now();
220
- if (stateKey !== lastState || now - lastStatusAt >= SANDBOX_CONTROL_STATUS_INTERVAL_MS) {
322
+ if (brokerOwns() && (stateKey !== lastState || now - lastStatusAt >= timing.controlTickMs)) {
221
323
  writeSandboxControlStatus(manifest, broker, state, reasonCode, active?.request.id ?? null, now);
222
324
  lastStatusAt = now;
223
325
  }
224
- if (stateKey !== lastState) {
326
+ if (brokerOwns() && stateKey !== lastState) {
225
327
  appendSandboxControlAudit(manifest, 'broker-state', { state, reasonCode, requestId: active?.request.id ?? null });
226
328
  lastState = stateKey;
227
329
  }
228
330
 
331
+ let retiring = false;
229
332
  for (const name of fs.readdirSync(requestsDir).sort()) {
230
333
  if (!/^[a-f0-9-]{16,64}\.json$/.test(name)) continue;
231
334
  const id = name.slice(0, -5);
232
335
  const source = path.join(requestsDir, name);
233
336
  let claimed: string | null = null;
234
337
  try {
338
+ if (!brokerOwns()) {
339
+ retiring = true;
340
+ break;
341
+ }
235
342
  claimed = claimRequest(manifest, source, id);
343
+ if (!brokerOwns()) {
344
+ retiring = true;
345
+ break;
346
+ }
236
347
  consumeRequest(consumedDir, id);
348
+ if (!brokerOwns()) {
349
+ retiring = true;
350
+ break;
351
+ }
237
352
  if (reasonCode) throw new Error(reasonCode);
238
353
  if (active) throw new Error('SANDBOX_CONTROL_BUSY');
239
354
  const request = validateSandboxControlRequest(JSON.parse(fs.readFileSync(claimed, 'utf8')), manifest);
240
- if (bindingReason(manifest)) throw new Error('SANDBOX_WORKTREE_BINDING_LOST');
355
+ if (bindingCheck(manifest)) throw new Error('SANDBOX_WORKTREE_BINDING_LOST');
241
356
  if (readActiveLease(manifest)) throw new Error('SANDBOX_CONTROL_HANDOFF_ACTIVE');
242
357
  const prepared = await prepareSandboxControlExecution({
243
358
  manifest, manifestPath, request, requestPath: claimed, internalCliPath: process.argv[1]!
@@ -248,37 +363,82 @@ export async function serveSandboxControl(
248
363
  (result) => { execution.result = result; execution.settled = true; },
249
364
  (error) => { execution.failure = error; execution.settled = true; }
250
365
  );
366
+ if (!brokerOwns()) {
367
+ prepared.terminate(false);
368
+ active = null;
369
+ retiring = true;
370
+ continue;
371
+ }
372
+ if (!brokerOwns()) {
373
+ prepared.terminate(false);
374
+ active = null;
375
+ retiring = true;
376
+ continue;
377
+ }
251
378
  writeResponse(manifest, {
252
379
  version: 2, id, phase: 'accepted', exitCode: null, stdout: '', stderr: '', error: null
253
380
  });
381
+ if (!brokerOwns()) {
382
+ prepared.terminate(false);
383
+ active = null;
384
+ retiring = true;
385
+ break;
386
+ }
254
387
  try {
255
- prepared.start();
388
+ prepared.start(brokerOwns);
256
389
  } catch (error) {
257
- prepared.terminate();
390
+ const owned = brokerOwns();
391
+ prepared.terminate(owned);
392
+ if (!owned || !brokerOwns()) {
393
+ active = null;
394
+ retiring = true;
395
+ break;
396
+ }
258
397
  writeResponse(manifest, unknown(id));
398
+ if (!brokerOwns()) {
399
+ active = null;
400
+ retiring = true;
401
+ break;
402
+ }
259
403
  fs.rmSync(path.join(manifest.processingDir, id), { recursive: true, force: true });
260
404
  active = null;
261
405
  claimed = null;
406
+ if (!brokerOwns()) {
407
+ retiring = true;
408
+ break;
409
+ }
262
410
  appendSandboxControlAudit(manifest, 'executor-gate-failed', { requestId: id });
263
411
  continue;
264
412
  }
265
413
  } catch (error) {
414
+ if (!brokerOwns()) {
415
+ retiring = true;
416
+ break;
417
+ }
266
418
  writeResponse(manifest, rejected(id, error));
419
+ if (!brokerOwns()) {
420
+ retiring = true;
421
+ break;
422
+ }
267
423
  if (claimed) fs.rmSync(path.dirname(claimed), { recursive: true, force: true });
268
424
  }
269
425
  }
270
- await delay(25);
426
+ if (retiring || !brokerOwns()) break;
427
+ await delay(timing.controlTickMs);
271
428
  }
272
429
  } finally {
273
430
  if (active) {
274
- active.prepared.terminate();
275
- writeResponse(manifest, unknown(active.request.id));
431
+ const owned = brokerOwns();
432
+ active.prepared.terminate(owned);
433
+ if (owned && brokerOwns()) writeResponse(manifest, unknown(active.request.id));
276
434
  }
277
- appendSandboxControlAudit(manifest, 'broker-stop', { pid: broker.pid });
278
- try {
279
- if (fs.readFileSync(brokerPath, 'utf8') === brokerRecord) fs.unlinkSync(brokerPath);
280
- } catch {
281
- // A newer owner or recovery path owns the record.
435
+ if (brokerOwns()) {
436
+ appendSandboxControlAudit(manifest, 'broker-stop', { pid: broker.pid, brokerId: broker.brokerId });
437
+ try {
438
+ if (fs.readFileSync(brokerPath, 'utf8') === brokerRecord) fs.unlinkSync(brokerPath);
439
+ } catch {
440
+ // A newer owner or recovery path owns the record.
441
+ }
282
442
  }
283
443
  }
284
444
  }
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { buildProcessTreeStopCommand } from '../../server/process-control.ts';
6
- import { processIdentityMatches } from '../../server/process-state.ts';
6
+ import { getProcessIdentityState } from '../../server/process-state.ts';
7
+ import type { ProcessIdentity, ProcessIdentityProbe } from '../../server/process-state.ts';
7
8
  import type {
8
9
  SandboxControlExecution,
9
10
  SandboxControlLease,
10
- SandboxControlManifest,
11
+ SandboxControlManifestLike,
11
12
  SandboxControlStatus
12
13
  } from './protocol.ts';
13
14
 
@@ -35,20 +36,20 @@ export function readJsonFile(filePath: string): unknown {
35
36
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
36
37
  }
37
38
 
38
- export function statusPath(manifest: SandboxControlManifest): string {
39
+ export function statusPath(manifest: SandboxControlManifestLike): string {
39
40
  return path.join(manifest.publicStatusDir, 'status.json');
40
41
  }
41
42
 
42
43
  export function writeSandboxControlStatus(
43
- manifest: SandboxControlManifest,
44
- broker: { pid: number; startTime: string },
44
+ manifest: SandboxControlManifestLike,
45
+ broker: ProcessIdentity & { brokerId: string },
45
46
  state: SandboxControlStatus['state'],
46
47
  reasonCode: string | null,
47
48
  activeRequestId: string | null,
48
49
  now = Date.now()
49
50
  ): SandboxControlStatus {
50
51
  const status: SandboxControlStatus = {
51
- version: 1, generation: manifest.generation, broker, state, reasonCode, activeRequestId, updatedAt: now
52
+ version: 2, generation: manifest.generation, broker, state, reasonCode, activeRequestId, updatedAt: now
52
53
  };
53
54
  atomicWriteJson(statusPath(manifest), status, 0o600, false);
54
55
  return status;
@@ -56,8 +57,10 @@ export function writeSandboxControlStatus(
56
57
 
57
58
  export function parseSandboxControlStatus(value: unknown): SandboxControlStatus {
58
59
  const status = value as Partial<SandboxControlStatus> | null;
59
- if (!status || status.version !== 1 || typeof status.generation !== 'string'
60
- || !status.broker || !Number.isSafeInteger(status.broker.pid) || typeof status.broker.startTime !== 'string'
60
+ if (!status || status.version !== 2 || typeof status.generation !== 'string'
61
+ || !status.broker || !Number.isSafeInteger(status.broker.pid) || typeof status.broker.startTime !== 'number'
62
+ || !Number.isSafeInteger(status.broker.startTime) || typeof status.broker.brokerId !== 'string'
63
+ || status.broker.brokerId.length === 0
61
64
  || !['starting', 'healthy', 'busy', 'parked'].includes(status.state ?? '')
62
65
  || (status.reasonCode !== null && typeof status.reasonCode !== 'string')
63
66
  || (status.activeRequestId !== null && typeof status.activeRequestId !== 'string')
@@ -69,13 +72,14 @@ export function readSandboxControlStatus(directory: string): SandboxControlStatu
69
72
  return parseSandboxControlStatus(readJsonFile(path.join(directory, 'status.json')));
70
73
  }
71
74
 
72
- export function readActiveLease(manifest: SandboxControlManifest, now = Date.now()): SandboxControlLease | null {
75
+ export function readActiveLease(manifest: SandboxControlManifestLike, now = Date.now()): SandboxControlLease | null {
73
76
  const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
74
77
  if (!fs.existsSync(filePath)) return null;
75
78
  const lease = readJsonFile(filePath) as Partial<SandboxControlLease> | null;
76
- if (!lease || lease.version !== 1
79
+ if (!lease || lease.version !== 2
77
80
  || typeof lease.nonce !== 'string' || !lease.owner || !Number.isSafeInteger(lease.owner.pid)
78
- || typeof lease.owner.startTime !== 'string' || !Number.isSafeInteger(lease.issuedAt)
81
+ || typeof lease.owner.startTime !== 'number' || !Number.isSafeInteger(lease.owner.startTime)
82
+ || !Number.isSafeInteger(lease.issuedAt)
79
83
  || !Number.isSafeInteger(lease.expiresAt) || typeof lease.generation !== 'string'
80
84
  || (lease.taskId !== null && typeof lease.taskId !== 'string')
81
85
  || typeof lease.branch !== 'string' || typeof lease.reason !== 'string') {
@@ -86,23 +90,30 @@ export function readActiveLease(manifest: SandboxControlManifest, now = Date.now
86
90
  return lease as SandboxControlLease;
87
91
  }
88
92
 
89
- export function cleanupStaleSandboxControlLease(manifest: SandboxControlManifest, now = Date.now()): boolean {
93
+ export function cleanupStaleSandboxControlLease(
94
+ manifest: SandboxControlManifestLike,
95
+ now = Date.now(),
96
+ options: Readonly<{ identityProbe?: ProcessIdentityProbe }> = {}
97
+ ): boolean {
90
98
  const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
91
99
  if (!fs.existsSync(filePath)) return false;
92
100
  const raw = fs.readFileSync(filePath, 'utf8');
93
101
  const lease = JSON.parse(raw) as Partial<SandboxControlLease> | null;
94
- if (!lease || lease.version !== 1
102
+ if (!lease || lease.version !== 2
95
103
  || typeof lease.nonce !== 'string' || !lease.owner || !Number.isSafeInteger(lease.owner.pid)
96
- || typeof lease.owner.startTime !== 'string' || !Number.isSafeInteger(lease.issuedAt)
104
+ || typeof lease.owner.startTime !== 'number' || !Number.isSafeInteger(lease.owner.startTime)
105
+ || !Number.isSafeInteger(lease.issuedAt)
97
106
  || !Number.isSafeInteger(lease.expiresAt) || typeof lease.generation !== 'string'
98
107
  || (lease.taskId !== null && typeof lease.taskId !== 'string')
99
108
  || typeof lease.branch !== 'string' || typeof lease.reason !== 'string') {
100
109
  throw new Error('SANDBOX_CONTROL_LEASE_INVALID');
101
110
  }
102
- const stale = lease.generation !== manifest.generation || lease.taskId !== manifest.taskId
103
- || lease.branch !== manifest.branch || lease.expiresAt! <= now || !processIdentityMatches({
104
- version: 1, pid: lease.owner.pid!, startTime: lease.owner.startTime!
111
+ const ownerState = (options.identityProbe ?? getProcessIdentityState)({
112
+ pid: lease.owner.pid!, startTime: lease.owner.startTime!
105
113
  });
114
+ if (ownerState === 'unknown') throw new Error('SANDBOX_CONTROL_LEASE_OWNER_UNAVAILABLE');
115
+ const stale = lease.generation !== manifest.generation || lease.taskId !== manifest.taskId
116
+ || lease.branch !== manifest.branch || lease.expiresAt! <= now || ownerState === 'dead';
106
117
  if (!stale) return false;
107
118
  if (fs.readFileSync(filePath, 'utf8') !== raw) throw new Error('SANDBOX_CONTROL_LEASE_OWNERSHIP_LOST');
108
119
  fs.unlinkSync(filePath);
@@ -110,7 +121,7 @@ export function cleanupStaleSandboxControlLease(manifest: SandboxControlManifest
110
121
  }
111
122
 
112
123
  export function appendSandboxControlAudit(
113
- manifest: SandboxControlManifest,
124
+ manifest: SandboxControlManifestLike,
114
125
  event: string,
115
126
  fields: Record<string, string | number | boolean | null> = {},
116
127
  now = Date.now()
@@ -125,15 +136,16 @@ export function appendSandboxControlAudit(
125
136
  });
126
137
  }
127
138
 
128
- export function executionPath(manifest: SandboxControlManifest, requestId: string): string {
139
+ export function executionPath(manifest: SandboxControlManifestLike, requestId: string): string {
129
140
  return path.join(manifest.processingDir, requestId, 'execution.json');
130
141
  }
131
142
 
132
143
  export function readExecution(filePath: string): SandboxControlExecution {
133
144
  const execution = readJsonFile(filePath) as Partial<SandboxControlExecution> | null;
134
- if (!execution || execution.version !== 1 || typeof execution.generation !== 'string'
145
+ if (!execution || execution.version !== 2 || typeof execution.generation !== 'string'
135
146
  || typeof execution.requestId !== 'string' || typeof execution.nonce !== 'string'
136
- || !execution.child || !Number.isSafeInteger(execution.child.pid) || typeof execution.child.startTime !== 'string'
147
+ || !execution.child || !Number.isSafeInteger(execution.child.pid) || typeof execution.child.startTime !== 'number'
148
+ || !Number.isSafeInteger(execution.child.startTime)
137
149
  || !['prepared', 'running', 'terminating'].includes(execution.phase ?? '')
138
150
  || !Number.isSafeInteger(execution.updatedAt)) throw new Error('SANDBOX_CONTROL_EXECUTION_INVALID');
139
151
  return execution as SandboxControlExecution;
@@ -141,31 +153,48 @@ export function readExecution(filePath: string): SandboxControlExecution {
141
153
 
142
154
  export function terminateSandboxControlExecution(
143
155
  execution: SandboxControlExecution,
144
- options: { platform?: NodeJS.Platform; timeoutMs?: number } = {}
156
+ options: {
157
+ platform?: NodeJS.Platform;
158
+ timeoutMs?: number;
159
+ deadlineAt?: number;
160
+ forceAt?: number;
161
+ allowForce?: boolean;
162
+ identityProbe?: ProcessIdentityProbe;
163
+ } = {}
145
164
  ): boolean {
146
165
  const platform = options.platform ?? process.platform;
147
166
  const processGroupId = execution.child.processGroupId;
148
- const processGroupAlive = (groupId: number): boolean => {
167
+ const processGroupState = (groupId: number): 'alive' | 'dead' | 'unknown' => {
149
168
  try {
150
169
  const rows = execFileSync('ps', ['-axo', 'pgid=,stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
151
170
  return rows.split('\n').some((row) => {
152
171
  const match = row.trim().match(/^(\d+)\s+(\S+)/);
153
172
  return Number(match?.[1]) === groupId && !match?.[2]?.startsWith('Z');
154
- });
173
+ }) ? 'alive' : 'dead';
155
174
  } catch {
156
175
  try {
157
176
  process.kill(-groupId, 0);
158
- return true;
177
+ return 'alive';
159
178
  } catch (error) {
160
- return (error as NodeJS.ErrnoException).code === 'EPERM';
179
+ const code = (error as NodeJS.ErrnoException).code;
180
+ if (code === 'ESRCH') return 'dead';
181
+ if (code === 'EPERM') return 'alive';
182
+ return 'unknown';
161
183
  }
162
184
  }
163
185
  };
164
- const treeAlive = (): boolean => {
186
+ const treeState = (): 'alive' | 'dead' | 'unknown' => {
165
187
  if (platform === 'win32' || !processGroupId) {
166
- return processIdentityMatches({ version: 1, pid: execution.child.pid, startTime: execution.child.startTime });
188
+ return (options.identityProbe ?? getProcessIdentityState)({
189
+ pid: execution.child.pid, startTime: execution.child.startTime
190
+ });
167
191
  }
168
- return processGroupAlive(processGroupId);
192
+ return processGroupState(processGroupId);
193
+ };
194
+ const treeAlive = (): boolean => {
195
+ const state = treeState();
196
+ if (state === 'unknown') throw new Error('SANDBOX_CONTROL_EXECUTION_OWNER_UNAVAILABLE');
197
+ return state === 'alive';
169
198
  };
170
199
  if (!treeAlive()) return true;
171
200
  const command = buildProcessTreeStopCommand(processGroupId ?? execution.child.pid, platform);
@@ -175,18 +204,23 @@ export function terminateSandboxControlExecution(
175
204
  } catch {
176
205
  // The identity check below distinguishes an already-exited tree from a failed termination.
177
206
  }
178
- const deadline = Date.now() + (options.timeoutMs ?? SANDBOX_CONTROL_EXECUTION_STOP_MS);
179
- while (Date.now() < deadline) {
207
+ const deadline = options.deadlineAt ?? (Date.now() + (options.timeoutMs ?? SANDBOX_CONTROL_EXECUTION_STOP_MS));
208
+ const forceAt = options.forceAt ?? (options.deadlineAt === undefined
209
+ ? deadline
210
+ : Math.min(deadline, Date.now() + Math.floor(Math.max(0, deadline - Date.now()) / 2)));
211
+ while (Date.now() < forceAt) {
180
212
  if (!treeAlive()) return true;
181
213
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
182
214
  }
183
- if (platform !== 'win32') {
215
+ if (platform !== 'win32' && options.allowForce !== false
216
+ && (options.deadlineAt === undefined || Date.now() < deadline)) {
184
217
  try {
185
218
  process.kill(processGroupId ? -processGroupId : execution.child.pid, 'SIGKILL');
186
219
  } catch {
187
220
  // The final identity check handles an already-exited tree.
188
221
  }
189
- const killDeadline = Date.now() + (options.timeoutMs ?? SANDBOX_CONTROL_EXECUTION_STOP_MS);
222
+ const killDeadline = options.deadlineAt
223
+ ?? (Date.now() + (options.timeoutMs ?? SANDBOX_CONTROL_EXECUTION_STOP_MS));
190
224
  while (Date.now() < killDeadline) {
191
225
  if (!treeAlive()) return true;
192
226
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
@@ -0,0 +1,12 @@
1
+ export type SandboxControlBackoffStep = Readonly<{
2
+ delayMs: number;
3
+ nextDelayMs: number;
4
+ }>;
5
+
6
+ export function nextSandboxControlBackoff(currentDelayMs: number, capMs: number): SandboxControlBackoffStep {
7
+ const delayMs = Math.min(currentDelayMs, capMs);
8
+ return {
9
+ delayMs,
10
+ nextDelayMs: Math.min(capMs, delayMs * 2)
11
+ };
12
+ }