@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,25 +1,61 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { buildProcessTreeStopCommand } from '../../server/process-control.ts';
5
- import { processIdentityMatches } from '../../server/process-state.ts';
6
+ import {
7
+ getProcessIdentityState,
8
+ getProcessStartTime
9
+ } from '../../server/process-state.ts';
10
+ import type { ProcessIdentity, ProcessIdentityProbe, ProcessIdentityState } from '../../server/process-state.ts';
6
11
  import type {
7
12
  SandboxControlExecution,
13
+ SandboxControlManifestLike,
8
14
  SandboxControlManifest,
9
- SandboxControlStatus
15
+ SandboxControlLegacyManifest,
16
+ SandboxControlStatus,
17
+ SandboxControlTimingPolicy
10
18
  } from './protocol.ts';
19
+ import { DEFAULT_SANDBOX_CONTROL_TIMING } from './protocol.ts';
20
+ import { inspectSandboxControlContainer, type ContainerObservation } from './container-identity.ts';
11
21
  import {
12
22
  parseSandboxControlStatus,
13
23
  readExecution,
14
24
  terminateSandboxControlExecution
15
25
  } from './state.ts';
16
26
 
17
- type OwnerIdentity = Readonly<{ pid: number; startTime: string }>;
18
- type BrokerOwner = OwnerIdentity & Readonly<{ version: 2; token: string; generation: string }>;
27
+ type OwnerIdentity = ProcessIdentity & Readonly<{ brokerId?: string }>;
28
+ export type BrokerOwner = ProcessIdentity & Readonly<{ version: 3; brokerId: string; token: string; generation: string }>;
19
29
 
20
- const DEFAULT_QUIESCE_TIMEOUT_MS = 7_000;
30
+ const DEFAULT_QUIESCE_TIMEOUT_MS = DEFAULT_SANDBOX_CONTROL_TIMING.quiesceDeadlineMs;
21
31
  const QUIESCING_FILE = 'quiescing.json';
22
32
  const BROKER_STARTING_FILE = 'broker-starting.json';
33
+ const REPLACEMENT_FILE = 'replacement.json';
34
+ const REPLACEMENT_STATE_SUFFIX = '.replacement-state.json';
35
+
36
+ async function awaitWithDeadline<T>(
37
+ operation: () => Promise<T>,
38
+ deadlineAt: number,
39
+ errorCode: string
40
+ ): Promise<T> {
41
+ const remaining = deadlineAt - Date.now();
42
+ if (remaining <= 0) throw new Error(errorCode);
43
+ let timer: ReturnType<typeof setTimeout> | undefined;
44
+ try {
45
+ return await Promise.race([
46
+ operation(),
47
+ new Promise<T>((_, reject) => {
48
+ timer = setTimeout(() => reject(new Error(errorCode)), remaining);
49
+ })
50
+ ]);
51
+ } finally {
52
+ if (timer) clearTimeout(timer);
53
+ }
54
+ }
55
+
56
+ function forceDeadline(deadlineAt: number, timeoutMs: number): number {
57
+ return Math.max(Date.now(), deadlineAt - Math.max(1, Math.floor(timeoutMs / 2)));
58
+ }
23
59
 
24
60
  function regularFile(filePath: string): boolean {
25
61
  try {
@@ -54,21 +90,25 @@ function readStartupOwner(filePath: string): { raw: string; owner: OwnerIdentity
54
90
  const raw = fs.readFileSync(filePath, 'utf8');
55
91
  try {
56
92
  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;
93
+ if (record.version !== 2 || !Number.isSafeInteger(record.pid) || (record.pid ?? 0) <= 0
94
+ || typeof record.startTime !== 'number' || !Number.isSafeInteger(record.startTime)) return null;
59
95
  return { raw, owner: { pid: record.pid!, startTime: record.startTime } };
60
96
  } catch {
61
97
  return null;
62
98
  }
63
99
  }
64
100
 
65
- async function waitForStartupTransition(root: string, timeoutMs: number): Promise<void> {
101
+ async function waitForStartupTransition(
102
+ root: string,
103
+ timeoutMs: number,
104
+ identityProbe: ProcessIdentityProbe = getProcessIdentityState
105
+ ): Promise<void> {
66
106
  const transitionPath = path.join(root, BROKER_STARTING_FILE);
67
107
  const deadline = Date.now() + timeoutMs;
68
108
  while (Date.now() < deadline) {
69
109
  const transition = readStartupOwner(transitionPath);
70
110
  if (!fs.existsSync(transitionPath)) return;
71
- if (transition && !ownerLive(transition.owner)) {
111
+ if (transition && !ownerLive(transition.owner, identityProbe)) {
72
112
  try {
73
113
  if (fs.readFileSync(transitionPath, 'utf8') === transition.raw) fs.unlinkSync(transitionPath);
74
114
  } catch {
@@ -84,11 +124,12 @@ async function waitForStartupTransition(root: string, timeoutMs: number): Promis
84
124
  export async function acquireSandboxControlBrokerStartup(
85
125
  root: string,
86
126
  owner: OwnerIdentity,
87
- timeoutMs = 5_000
127
+ timeoutMs = 5_000,
128
+ options: Readonly<{ identityProbe?: ProcessIdentityProbe }> = {}
88
129
  ): Promise<() => void> {
89
130
  const resolvedRoot = path.resolve(root);
90
131
  const transitionPath = path.join(resolvedRoot, BROKER_STARTING_FILE);
91
- const record = `${JSON.stringify({ version: 1, ...owner })}\n`;
132
+ const record = `${JSON.stringify({ version: 2, ...owner })}\n`;
92
133
  const deadline = Date.now() + timeoutMs;
93
134
  while (Date.now() < deadline) {
94
135
  if (isSandboxControlRootQuiescing(resolvedRoot)) throw new Error('SANDBOX_CONTROL_QUIESCING');
@@ -112,7 +153,7 @@ export async function acquireSandboxControlBrokerStartup(
112
153
  } catch (error) {
113
154
  if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
114
155
  const transition = readStartupOwner(transitionPath);
115
- if (transition && !ownerLive(transition.owner)) {
156
+ if (transition && !ownerLive(transition.owner, options.identityProbe)) {
116
157
  try {
117
158
  if (fs.readFileSync(transitionPath, 'utf8') === transition.raw) fs.unlinkSync(transitionPath);
118
159
  } catch {
@@ -126,31 +167,381 @@ export async function acquireSandboxControlBrokerStartup(
126
167
  throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
127
168
  }
128
169
 
129
- export function readSandboxControlManifest(manifestPath: string): SandboxControlManifest {
170
+ type ReplacementRecord = Readonly<{
171
+ version: 1;
172
+ transitionId: string;
173
+ pid: number;
174
+ startTime: number;
175
+ }>;
176
+
177
+ type ReplacementStateRecord = Readonly<{
178
+ version: 1;
179
+ phase: 'prepared' | 'committed';
180
+ root: string;
181
+ snapshotRoot: string;
182
+ transitionId: string;
183
+ }>;
184
+
185
+ export type SandboxControlReplacementCutover = Readonly<{
186
+ root: string;
187
+ snapshotRoot: string;
188
+ transitionId: string;
189
+ }>;
190
+
191
+ export type SandboxControlCutoverSnapshot = Readonly<{
192
+ root: string;
193
+ manifestRaw: string | null;
194
+ brokerRaw: string | null;
195
+ statusRaw: string | null;
196
+ processing: ReadonlyArray<Readonly<{ name: string; directory: boolean; executionRaw: string | null }>>;
197
+ }>;
198
+
199
+ export type SandboxControlReplacementLease = Readonly<{
200
+ root: string;
201
+ transitionId: string;
202
+ owner: ProcessIdentity;
203
+ assertOwned(): void;
204
+ clearQuiescing(): void;
205
+ release(): void;
206
+ }>;
207
+
208
+ function replacementPath(root: string): string {
209
+ return path.join(root, REPLACEMENT_FILE);
210
+ }
211
+
212
+ function replacementStatePath(root: string): string {
213
+ const resolvedRoot = path.resolve(root);
214
+ return path.join(path.dirname(resolvedRoot), `.${path.basename(resolvedRoot)}${REPLACEMENT_STATE_SUFFIX}`);
215
+ }
216
+
217
+ function replacementSnapshotPath(root: string, transitionId: string): string {
218
+ const resolvedRoot = path.resolve(root);
219
+ return path.join(path.dirname(resolvedRoot), `.${path.basename(resolvedRoot)}.replacement-${transitionId}.snapshot`);
220
+ }
221
+
222
+ function writeAtomicText(filePath: string, content: string): void {
223
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
224
+ try {
225
+ fs.writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
226
+ fs.renameSync(temporary, filePath);
227
+ } finally {
228
+ fs.rmSync(temporary, { force: true });
229
+ }
230
+ }
231
+
232
+ function readReplacementState(root: string): ReplacementStateRecord | null {
233
+ const filePath = replacementStatePath(root);
234
+ if (!fs.existsSync(filePath)) return null;
235
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_REPLACEMENT_RECOVERY_INVALID');
236
+ try {
237
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Partial<ReplacementStateRecord>;
238
+ const resolvedRoot = path.resolve(root);
239
+ if (value.version !== 1 || (value.phase !== 'prepared' && value.phase !== 'committed')
240
+ || value.root !== resolvedRoot || typeof value.transitionId !== 'string' || value.transitionId.length === 0
241
+ || value.snapshotRoot !== replacementSnapshotPath(resolvedRoot, value.transitionId)) {
242
+ throw new Error('invalid replacement state');
243
+ }
244
+ return value as ReplacementStateRecord;
245
+ } catch {
246
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_RECOVERY_INVALID');
247
+ }
248
+ }
249
+
250
+ function writeReplacementState(state: ReplacementStateRecord): void {
251
+ writeAtomicText(replacementStatePath(state.root), `${JSON.stringify(state)}\n`);
252
+ }
253
+
254
+ function removeReplacementState(root: string): void {
255
+ fs.rmSync(replacementStatePath(root), { force: true });
256
+ }
257
+
258
+ function readReplacementRecord(filePath: string): { raw: string; record: ReplacementRecord } | null {
259
+ if (!fs.existsSync(filePath)) return null;
260
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_REPLACEMENT_INVALID');
261
+ const raw = fs.readFileSync(filePath, 'utf8');
262
+ try {
263
+ const value = JSON.parse(raw) as Partial<ReplacementRecord>;
264
+ if (value.version !== 1 || typeof value.transitionId !== 'string' || value.transitionId.length === 0
265
+ || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0
266
+ || !Number.isSafeInteger(value.startTime)) throw new Error('invalid replacement');
267
+ return { raw, record: value as ReplacementRecord };
268
+ } catch {
269
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_INVALID');
270
+ }
271
+ }
272
+
273
+ export function acquireSandboxControlReplacement(
274
+ root: string,
275
+ options: Readonly<{
276
+ owner?: ProcessIdentity;
277
+ probeOwner?: (identity: ProcessIdentity) => ProcessIdentityState;
278
+ }> = {}
279
+ ): SandboxControlReplacementLease {
280
+ const resolvedRoot = path.resolve(root);
281
+ if (fs.existsSync(resolvedRoot)) {
282
+ const stat = fs.lstatSync(resolvedRoot);
283
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
284
+ } else {
285
+ fs.mkdirSync(resolvedRoot, { recursive: true, mode: 0o700 });
286
+ }
287
+ const startTime = options.owner?.startTime ?? getProcessStartTime(process.pid);
288
+ if (startTime === null || startTime === undefined) throw new Error('SANDBOX_CONTROL_REPLACEMENT_OWNER_UNAVAILABLE');
289
+ const owner = options.owner ?? { pid: process.pid, startTime };
290
+ const transitionId = randomUUID();
291
+ const record = `${JSON.stringify({ version: 1, transitionId, ...owner })}\n`;
292
+ const filePath = replacementPath(resolvedRoot);
293
+ const existing = readReplacementRecord(filePath);
294
+ if (existing) {
295
+ const ownerState = (options.probeOwner ?? getProcessIdentityState)(existing.record);
296
+ if (ownerState === 'alive') {
297
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_BUSY');
298
+ }
299
+ if (ownerState === 'unknown') {
300
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_OWNER_UNAVAILABLE');
301
+ }
302
+ if (fs.readFileSync(filePath, 'utf8') !== existing.raw) {
303
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
304
+ }
305
+ fs.unlinkSync(filePath);
306
+ }
307
+ try {
308
+ fs.writeFileSync(filePath, record, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
309
+ } catch (error) {
310
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
311
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_BUSY');
312
+ }
313
+ throw error;
314
+ }
315
+ let released = false;
316
+ const verifyOwnership = (): void => {
317
+ if (released) throw new Error('SANDBOX_CONTROL_REPLACEMENT_RELEASED');
318
+ const current = readReplacementRecord(filePath);
319
+ if (!current || current.raw !== record || current.record.transitionId !== transitionId) {
320
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
321
+ }
322
+ };
323
+ return {
324
+ root: resolvedRoot,
325
+ transitionId,
326
+ owner,
327
+ assertOwned: verifyOwnership,
328
+ clearQuiescing(): void {
329
+ verifyOwnership();
330
+ const markerPath = path.join(resolvedRoot, QUIESCING_FILE);
331
+ if (fs.existsSync(markerPath)) {
332
+ if (!regularFile(markerPath)) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
333
+ fs.unlinkSync(markerPath);
334
+ }
335
+ },
336
+ release(): void {
337
+ if (released) return;
338
+ verifyOwnership();
339
+ fs.unlinkSync(filePath);
340
+ released = true;
341
+ }
342
+ };
343
+ }
344
+
345
+ function readReplacementRaw(root: string): string {
346
+ const current = readReplacementRecord(replacementPath(root));
347
+ if (!current) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
348
+ return current.raw;
349
+ }
350
+
351
+ export function beginSandboxControlReplacement(
352
+ root: string,
353
+ lease: Readonly<{ root: string; transitionId: string; assertOwned(): void }>
354
+ ): SandboxControlReplacementCutover {
355
+ const resolvedRoot = path.resolve(root);
356
+ if (path.resolve(lease.root) !== resolvedRoot) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
357
+ lease.assertOwned();
358
+ if (!fs.existsSync(resolvedRoot)) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
359
+ const rootStat = fs.lstatSync(resolvedRoot);
360
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
361
+ if (readReplacementState(resolvedRoot)) throw new Error('SANDBOX_CONTROL_REPLACEMENT_RECOVERY_REQUIRED');
362
+ const snapshotRoot = replacementSnapshotPath(resolvedRoot, lease.transitionId);
363
+ if (fs.existsSync(snapshotRoot)) throw new Error('SANDBOX_CONTROL_REPLACEMENT_RECOVERY_INVALID');
364
+ const replacementRaw = readReplacementRaw(resolvedRoot);
365
+ const state: ReplacementStateRecord = {
366
+ version: 1,
367
+ phase: 'prepared',
368
+ root: resolvedRoot,
369
+ snapshotRoot,
370
+ transitionId: lease.transitionId
371
+ };
372
+ writeReplacementState(state);
373
+ let moved = false;
374
+ try {
375
+ fs.renameSync(resolvedRoot, snapshotRoot);
376
+ moved = true;
377
+ fs.mkdirSync(resolvedRoot, { recursive: true, mode: 0o700 });
378
+ fs.writeFileSync(replacementPath(resolvedRoot), replacementRaw, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
379
+ markSandboxControlRootQuiescing(resolvedRoot);
380
+ return { root: resolvedRoot, snapshotRoot, transitionId: lease.transitionId };
381
+ } catch (error) {
382
+ if (moved) {
383
+ fs.rmSync(resolvedRoot, { recursive: true, force: true });
384
+ fs.renameSync(snapshotRoot, resolvedRoot);
385
+ }
386
+ removeReplacementState(resolvedRoot);
387
+ throw error;
388
+ }
389
+ }
390
+
391
+ function isRecoverableCutoverManifestError(error: unknown): boolean {
392
+ return error instanceof Error
393
+ && (error.message.startsWith('SANDBOX_CONTROL_MANIFEST_')
394
+ || error.message === 'SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
395
+ }
396
+
397
+ export async function recoverSandboxControlReplacement(
398
+ root: string,
399
+ lease: Readonly<{ root: string; assertOwned(): void }>
400
+ ): Promise<'none' | 'restored' | 'committed'> {
401
+ const resolvedRoot = path.resolve(root);
402
+ if (path.resolve(lease.root) !== resolvedRoot) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
403
+ const state = readReplacementState(resolvedRoot);
404
+ if (!state) return 'none';
405
+ lease.assertOwned();
406
+ if (state.phase === 'committed') {
407
+ fs.rmSync(state.snapshotRoot, { recursive: true, force: true });
408
+ removeReplacementState(resolvedRoot);
409
+ return 'committed';
410
+ }
411
+ if (!fs.existsSync(state.snapshotRoot)) {
412
+ removeReplacementState(resolvedRoot);
413
+ return 'none';
414
+ }
415
+ if (fs.existsSync(resolvedRoot) && fs.existsSync(path.join(resolvedRoot, 'manifest.json'))) {
416
+ try {
417
+ await quiesceSandboxControlRoot(resolvedRoot);
418
+ } catch (error) {
419
+ if (!isRecoverableCutoverManifestError(error)) throw error;
420
+ }
421
+ }
422
+ lease.assertOwned();
423
+ const replacementRaw = readReplacementRaw(resolvedRoot);
424
+ fs.rmSync(resolvedRoot, { recursive: true, force: true });
425
+ fs.renameSync(state.snapshotRoot, resolvedRoot);
426
+ writeAtomicText(replacementPath(resolvedRoot), replacementRaw);
427
+ removeReplacementState(resolvedRoot);
428
+ return 'restored';
429
+ }
430
+
431
+ export function commitSandboxControlReplacement(
432
+ root: string,
433
+ lease: Readonly<{ root: string; assertOwned(): void }>
434
+ ): void {
435
+ const resolvedRoot = path.resolve(root);
436
+ if (path.resolve(lease.root) !== resolvedRoot) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
437
+ const state = readReplacementState(resolvedRoot);
438
+ if (!state) return;
439
+ lease.assertOwned();
440
+ writeReplacementState({ ...state, phase: 'committed' });
441
+ fs.rmSync(state.snapshotRoot, { recursive: true, force: true });
442
+ removeReplacementState(resolvedRoot);
443
+ }
444
+
445
+ function readOptionalRaw(filePath: string): string | null {
446
+ if (!fs.existsSync(filePath)) return null;
447
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
448
+ return fs.readFileSync(filePath, 'utf8');
449
+ }
450
+
451
+ function readProcessingSnapshot(root: string): SandboxControlCutoverSnapshot['processing'] {
452
+ const processingDir = path.join(root, 'processing');
453
+ if (!fs.existsSync(processingDir)) return [];
454
+ const stat = fs.lstatSync(processingDir);
455
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
456
+ return fs.readdirSync(processingDir, { withFileTypes: true })
457
+ .map((entry) => ({
458
+ name: entry.name,
459
+ directory: entry.isDirectory() && !entry.isSymbolicLink(),
460
+ executionRaw: entry.isDirectory() && !entry.isSymbolicLink()
461
+ ? readOptionalRaw(path.join(processingDir, entry.name, 'execution.json'))
462
+ : null
463
+ }))
464
+ .sort((left, right) => left.name.localeCompare(right.name));
465
+ }
466
+
467
+ export function captureSandboxControlCutoverSnapshot(root: string): SandboxControlCutoverSnapshot {
468
+ const resolvedRoot = path.resolve(root);
469
+ const manifestPath = path.join(resolvedRoot, 'manifest.json');
470
+ if (fs.existsSync(manifestPath)) readSandboxControlManifestForTransition(manifestPath);
471
+ return {
472
+ root: resolvedRoot,
473
+ manifestRaw: readOptionalRaw(manifestPath),
474
+ brokerRaw: readOptionalRaw(path.join(resolvedRoot, 'broker.json')),
475
+ statusRaw: readOptionalRaw(path.join(resolvedRoot, 'public', 'status.json')),
476
+ processing: readProcessingSnapshot(resolvedRoot)
477
+ };
478
+ }
479
+
480
+ export function assertSandboxControlCutoverSnapshot(snapshot: SandboxControlCutoverSnapshot): void {
481
+ const current = captureSandboxControlCutoverSnapshot(snapshot.root);
482
+ if (JSON.stringify(current) !== JSON.stringify(snapshot)) {
483
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
484
+ }
485
+ }
486
+
487
+ function readSandboxControlManifestValue(
488
+ manifestPath: string,
489
+ allowLegacy: boolean
490
+ ): SandboxControlManifestLike {
130
491
  if (!regularFile(manifestPath)) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
131
- let manifest: Partial<SandboxControlManifest>;
492
+ let manifest: Record<string, unknown>;
132
493
  try {
133
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Partial<SandboxControlManifest>;
494
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown;
495
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
496
+ throw new Error('invalid manifest');
497
+ }
498
+ manifest = parsed as Record<string, unknown>;
134
499
  } catch {
135
500
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
136
501
  }
137
- if (manifest.version !== 3) {
138
- throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 3; container-only recreation is required');
502
+ const candidate = manifest as Partial<SandboxControlManifest> & Partial<SandboxControlLegacyManifest>;
503
+ if (candidate.version !== 4 && candidate.version !== 5) {
504
+ throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 5; container-only recreation is required');
139
505
  }
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');
506
+ if (typeof candidate.containerIdentity !== 'object' || candidate.containerIdentity === null
507
+ || Array.isArray(candidate.containerIdentity)) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
508
+ const containerIdentity = candidate.containerIdentity as Record<string, unknown>;
509
+ if (typeof candidate.engine !== 'string' || candidate.engine.length === 0
510
+ || typeof candidate.repoRoot !== 'string' || typeof candidate.worktreeRoot !== 'string'
511
+ || typeof candidate.project !== 'string' || typeof candidate.container !== 'string'
512
+ || typeof containerIdentity.id !== 'string' || containerIdentity.id.length === 0
513
+ || typeof containerIdentity.labels !== 'object' || containerIdentity.labels === null
514
+ || Array.isArray(containerIdentity.labels)
515
+ || Object.values(containerIdentity.labels).some((value) => typeof value !== 'string')
516
+ || typeof candidate.branch !== 'string' || !['task-bound', 'branch-only'].includes(candidate.mode ?? '')
517
+ || (candidate.taskId !== null && typeof candidate.taskId !== 'string')
518
+ || typeof candidate.channelDir !== 'string' || typeof candidate.publicStatusDir !== 'string'
519
+ || typeof candidate.processingDir !== 'string' || typeof candidate.token !== 'string'
520
+ || typeof candidate.generation !== 'string') throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
147
521
  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')) {
522
+ if (path.resolve(candidate.channelDir) !== path.join(root, 'channel')
523
+ || path.resolve(candidate.publicStatusDir) !== path.join(root, 'public')
524
+ || path.resolve(candidate.processingDir) !== path.join(root, 'processing')) {
151
525
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
152
526
  }
153
- return manifest as SandboxControlManifest;
527
+ if (candidate.version === 4) {
528
+ if (!allowLegacy) {
529
+ throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 5; container-only recreation is required');
530
+ }
531
+ return candidate as unknown as SandboxControlLegacyManifest;
532
+ }
533
+ if (typeof candidate.runtimeDir !== 'string' || path.resolve(candidate.runtimeDir) !== path.join(root, 'runtime')) {
534
+ throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
535
+ }
536
+ return candidate as unknown as SandboxControlManifest;
537
+ }
538
+
539
+ export function readSandboxControlManifest(manifestPath: string): SandboxControlManifest {
540
+ return readSandboxControlManifestValue(manifestPath, false) as SandboxControlManifest;
541
+ }
542
+
543
+ export function readSandboxControlManifestForTransition(manifestPath: string): SandboxControlManifestLike {
544
+ return readSandboxControlManifestValue(manifestPath, true);
154
545
  }
155
546
 
156
547
  function readBrokerOwner(filePath: string): BrokerOwner | null {
@@ -162,9 +553,10 @@ function readBrokerOwner(filePath: string): BrokerOwner | null {
162
553
  } catch {
163
554
  throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
164
555
  }
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');
556
+ if (!owner || owner.version !== 3 || !Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0
557
+ || typeof owner.startTime !== 'number' || !Number.isSafeInteger(owner.startTime)
558
+ || typeof owner.brokerId !== 'string' || owner.brokerId.length === 0
559
+ || typeof owner.token !== 'string' || typeof owner.generation !== 'string') throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
168
560
  return owner as BrokerOwner;
169
561
  }
170
562
 
@@ -179,14 +571,20 @@ function readStatusOwner(filePath: string): SandboxControlStatus | null {
179
571
  }
180
572
 
181
573
  function sameOwner(left: OwnerIdentity, right: OwnerIdentity): boolean {
182
- return left.pid === right.pid && left.startTime === right.startTime;
574
+ return left.pid === right.pid && left.startTime === right.startTime
575
+ && (!left.brokerId || !right.brokerId || left.brokerId === right.brokerId);
183
576
  }
184
577
 
185
- function ownerLive(owner: OwnerIdentity): boolean {
186
- return processIdentityMatches({ version: 1, pid: owner.pid, startTime: owner.startTime });
578
+ function ownerLive(
579
+ owner: OwnerIdentity,
580
+ identityProbe: ProcessIdentityProbe = getProcessIdentityState
581
+ ): boolean {
582
+ const state = identityProbe({ pid: owner.pid, startTime: owner.startTime });
583
+ if (state === 'unknown') throw new Error('SANDBOX_CONTROL_OWNER_UNAVAILABLE');
584
+ return state === 'alive';
187
585
  }
188
586
 
189
- function readExecutions(manifest: SandboxControlManifest): SandboxControlExecution[] {
587
+ function readExecutions(manifest: SandboxControlManifestLike): SandboxControlExecution[] {
190
588
  if (!fs.existsSync(manifest.processingDir)) return [];
191
589
  const stat = fs.lstatSync(manifest.processingDir);
192
590
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
@@ -213,37 +611,50 @@ function readExecutions(manifest: SandboxControlManifest): SandboxControlExecuti
213
611
  return executions;
214
612
  }
215
613
 
216
- function processGroupAlive(groupId: number): boolean {
614
+ function processGroupState(groupId: number): ProcessIdentityState {
217
615
  try {
218
616
  const rows = execFileSync('ps', ['-axo', 'pgid=,stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
219
617
  return rows.split('\n').some((row) => {
220
618
  const match = row.trim().match(/^(\d+)\s+(\S+)/);
221
619
  return Number(match?.[1]) === groupId && !match?.[2]?.startsWith('Z');
222
- });
620
+ }) ? 'alive' : 'dead';
223
621
  } catch {
224
622
  try {
225
623
  process.kill(-groupId, 0);
226
- return true;
624
+ return 'alive';
227
625
  } catch (error) {
228
- return (error as NodeJS.ErrnoException).code === 'EPERM';
626
+ const code = (error as NodeJS.ErrnoException).code;
627
+ if (code === 'ESRCH') return 'dead';
628
+ if (code === 'EPERM') return 'alive';
629
+ return 'unknown';
229
630
  }
230
631
  }
231
632
  }
232
633
 
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 });
634
+ function executionAlive(
635
+ execution: SandboxControlExecution,
636
+ platform: NodeJS.Platform,
637
+ identityProbe: ProcessIdentityProbe = getProcessIdentityState
638
+ ): boolean {
639
+ const state = platform !== 'win32' && execution.child.processGroupId
640
+ ? processGroupState(execution.child.processGroupId)
641
+ : identityProbe({ pid: execution.child.pid, startTime: execution.child.startTime });
642
+ if (state === 'unknown') throw new Error('SANDBOX_CONTROL_EXECUTION_OWNER_UNAVAILABLE');
643
+ return state === 'alive';
238
644
  }
239
645
 
240
- async function waitForExit(owner: OwnerIdentity, timeoutMs: number): Promise<boolean> {
241
- const deadline = Date.now() + timeoutMs;
646
+ async function waitForExit(
647
+ owner: OwnerIdentity,
648
+ timeoutMs: number,
649
+ deadlineAt?: number,
650
+ identityProbe: ProcessIdentityProbe = getProcessIdentityState
651
+ ): Promise<boolean> {
652
+ const deadline = deadlineAt ?? (Date.now() + timeoutMs);
242
653
  while (Date.now() < deadline) {
243
- if (!ownerLive(owner)) return true;
654
+ if (!ownerLive(owner, identityProbe)) return true;
244
655
  await new Promise((resolve) => setTimeout(resolve, 25));
245
656
  }
246
- return !ownerLive(owner);
657
+ return !ownerLive(owner, identityProbe);
247
658
  }
248
659
 
249
660
  function signalOwner(owner: OwnerIdentity, platform: NodeJS.Platform, force: boolean): void {
@@ -267,7 +678,12 @@ function mergeExecutions(...groups: readonly SandboxControlExecution[][]): Sandb
267
678
 
268
679
  export async function quiesceSandboxControlRoot(
269
680
  root: string,
270
- options: { platform?: NodeJS.Platform; timeoutMs?: number } = {}
681
+ options: {
682
+ platform?: NodeJS.Platform;
683
+ timeoutMs?: number;
684
+ timing?: SandboxControlTimingPolicy;
685
+ identityProbe?: ProcessIdentityProbe;
686
+ } = {}
271
687
  ): Promise<'missing' | 'stale' | 'stopped'> {
272
688
  const resolvedRoot = path.resolve(root);
273
689
  if (!fs.existsSync(resolvedRoot)) return 'missing';
@@ -280,16 +696,20 @@ export async function quiesceSandboxControlRoot(
280
696
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
281
697
  }
282
698
 
699
+ const timeoutMs = options.timeoutMs ?? options.timing?.quiesceDeadlineMs ?? DEFAULT_QUIESCE_TIMEOUT_MS;
700
+ const identityProbe = options.identityProbe ?? getProcessIdentityState;
701
+ const deadlineAt = Date.now() + timeoutMs;
702
+ const forceAt = forceDeadline(deadlineAt, timeoutMs);
283
703
  markSandboxControlRootQuiescing(resolvedRoot);
284
- await waitForStartupTransition(resolvedRoot, options.timeoutMs ?? DEFAULT_QUIESCE_TIMEOUT_MS);
704
+ await waitForStartupTransition(resolvedRoot, Math.max(0, forceAt - Date.now()), identityProbe);
285
705
 
286
706
  const manifestPath = path.join(resolvedRoot, 'manifest.json');
287
- const manifest = fs.existsSync(manifestPath) ? readSandboxControlManifest(manifestPath) : null;
707
+ const manifest = fs.existsSync(manifestPath) ? readSandboxControlManifestForTransition(manifestPath) : null;
288
708
  const broker = readBrokerOwner(path.join(resolvedRoot, 'broker.json'));
289
709
  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;
710
+ const statusOwner = status ? status.broker : null;
711
+ const brokerLive = broker ? ownerLive(broker, identityProbe) : false;
712
+ const statusLive = statusOwner ? ownerLive(statusOwner, identityProbe) : false;
293
713
 
294
714
  if (manifest && broker && (broker.token !== manifest.token || broker.generation !== manifest.generation)) {
295
715
  throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
@@ -303,40 +723,238 @@ export async function quiesceSandboxControlRoot(
303
723
  if (!manifest && brokerLive) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
304
724
 
305
725
  const platform = options.platform ?? process.platform;
306
- const timeoutMs = options.timeoutMs ?? DEFAULT_QUIESCE_TIMEOUT_MS;
307
726
  let executions = manifest ? readExecutions(manifest) : [];
308
727
  const owner: OwnerIdentity | null = brokerLive ? broker : statusLive ? statusOwner : null;
309
728
  if (!owner) {
310
729
  if (manifest && !broker && !status) throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
311
730
  for (const execution of executions) {
312
- if (!terminateSandboxControlExecution(execution, { platform, timeoutMs })) {
731
+ terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
732
+ }
733
+ const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
734
+ while (Date.now() < softDeadlineAt) {
735
+ if (!executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
736
+ await new Promise((resolve) => setTimeout(resolve, 25));
737
+ }
738
+ if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
739
+ if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
740
+ for (const execution of executions) {
741
+ if (!terminateSandboxControlExecution(execution, {
742
+ platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
743
+ })) {
313
744
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
314
745
  }
315
746
  }
316
- if (executions.some((execution) => executionAlive(execution, platform))) {
747
+ if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
317
748
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
318
749
  }
319
750
  return broker || status ? 'stale' : 'missing';
320
751
  }
321
752
 
753
+ const remaining = (): number => Math.max(0, deadlineAt - Date.now());
322
754
  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
- }
755
+ for (const execution of executions) {
756
+ terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
757
+ }
758
+ const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
759
+ while (Date.now() < softDeadlineAt) {
760
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
761
+ await new Promise((resolve) => setTimeout(resolve, 25));
762
+ }
763
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
327
764
  return 'stopped';
328
765
  }
329
-
330
766
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
767
+ if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
768
+ for (const execution of executions) {
769
+ terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, deadlineAt, forceAt: Date.now(), identityProbe });
770
+ }
771
+ signalOwner(owner, platform, true);
772
+ while (Date.now() < deadlineAt) {
773
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) return 'stopped';
774
+ await new Promise((resolve) => setTimeout(resolve, 25));
775
+ }
776
+ if (ownerLive(owner, identityProbe)) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
777
+ if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
778
+ return 'stopped';
779
+ }
780
+
781
+ export type RemoveSandboxControlOptions = Readonly<{
782
+ platform?: NodeJS.Platform;
783
+ timeoutMs?: number;
784
+ timing?: SandboxControlTimingPolicy;
785
+ identityProbe?: ProcessIdentityProbe;
786
+ inspectContainer?: (timeoutMs: number) => Promise<ContainerObservation>;
787
+ removeContainer: (timeoutMs: number) => Promise<void>;
788
+ requireAbsent?: boolean;
789
+ selfOwner?: BrokerOwner;
790
+ }>;
791
+
792
+ export async function removeSandboxControlRoot(
793
+ root: string,
794
+ options: RemoveSandboxControlOptions
795
+ ): Promise<void> {
796
+ const resolvedRoot = path.resolve(root);
797
+ if (!fs.existsSync(resolvedRoot)) return;
798
+ const stat = fs.lstatSync(resolvedRoot);
799
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
800
+ const manifestPath = path.join(resolvedRoot, 'manifest.json');
801
+ const manifest = readSandboxControlManifestForTransition(manifestPath);
802
+ const inspectContainer = options.inspectContainer
803
+ ?? ((timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }));
804
+ const timeoutMs = options.timeoutMs ?? options.timing?.quiesceDeadlineMs ?? DEFAULT_QUIESCE_TIMEOUT_MS;
805
+ const identityProbe = options.identityProbe ?? getProcessIdentityState;
806
+ const deadlineAt = Date.now() + timeoutMs;
807
+ const forceAt = forceDeadline(deadlineAt, timeoutMs);
808
+ const remaining = (): number => Math.max(0, deadlineAt - Date.now());
809
+ const observation = await awaitWithDeadline(
810
+ () => inspectContainer(remaining()), deadlineAt, 'SANDBOX_CONTROL_REMOVE_DEADLINE_EXCEEDED'
811
+ );
812
+ if (observation.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${observation.reason}`);
813
+ if (options.requireAbsent && observation.state !== 'absent') {
814
+ throw new Error('SANDBOX_CONTROL_CONTAINER_REAPPEARED');
815
+ }
816
+
817
+ markSandboxControlRootQuiescing(resolvedRoot);
818
+ await waitForStartupTransition(resolvedRoot, Math.max(0, forceAt - Date.now()), identityProbe);
819
+ const brokerPath = path.join(resolvedRoot, 'broker.json');
820
+ const statusPath = path.join(resolvedRoot, 'public', 'status.json');
821
+ const broker = readBrokerOwner(brokerPath);
822
+ const status = readStatusOwner(statusPath);
823
+ if (broker && (broker.token !== manifest.token || broker.generation !== manifest.generation)) {
824
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
825
+ }
826
+ if (status && status.generation !== manifest.generation) {
827
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
828
+ }
829
+ if (broker && status && !sameOwner(broker, status.broker)
830
+ && (ownerLive(broker, identityProbe) || ownerLive(status.broker, identityProbe))) {
831
+ throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
832
+ }
833
+ const platform = options.platform ?? process.platform;
834
+ const owner = broker && ownerLive(broker, identityProbe) ? broker
835
+ : status && ownerLive(status.broker, identityProbe) ? status.broker : null;
836
+ const selfOwner = options.selfOwner;
837
+ const selfOwned = Boolean(selfOwner && broker && sameOwner(broker, selfOwner));
838
+ if (selfOwner && (!broker || !selfOwned || broker.token !== manifest.token || broker.generation !== manifest.generation)) {
839
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
840
+ }
841
+ let executions = readExecutions(manifest);
842
+ if (owner && !selfOwned) signalOwner(owner, platform, false);
843
+ for (const execution of executions) {
844
+ terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
845
+ }
846
+ const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
847
+ while (Date.now() < softDeadlineAt) {
848
+ if ((!owner || selfOwned || !ownerLive(owner, identityProbe))
849
+ && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
850
+ await new Promise((resolve) => setTimeout(resolve, 25));
851
+ }
852
+
853
+ if (observation.state === 'found') {
854
+ if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_REMOVE_DEADLINE_EXCEEDED');
855
+ await awaitWithDeadline(
856
+ () => options.removeContainer(remaining()),
857
+ deadlineAt,
858
+ 'SANDBOX_CONTROL_REMOVE_DEADLINE_EXCEEDED'
859
+ );
860
+ const afterRemoval = await awaitWithDeadline(
861
+ () => inspectContainer(remaining()),
862
+ deadlineAt,
863
+ 'SANDBOX_CONTROL_REMOVE_DEADLINE_EXCEEDED'
864
+ );
865
+ if (afterRemoval.state !== 'absent') {
866
+ throw new Error(afterRemoval.state === 'unknown'
867
+ ? `SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterRemoval.reason}`
868
+ : 'SANDBOX_CONTROL_CONTAINER_STILL_EXISTS');
869
+ }
870
+ }
871
+
872
+ const currentManifest = readSandboxControlManifestForTransition(manifestPath);
873
+ if (currentManifest.token !== manifest.token || currentManifest.generation !== manifest.generation
874
+ || !isSandboxControlRootQuiescing(resolvedRoot)) {
875
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
876
+ }
877
+ const currentBroker = readBrokerOwner(brokerPath);
878
+ if (selfOwned && (!currentBroker || !selfOwner || !sameOwner(selfOwner, currentBroker)
879
+ || currentBroker.token !== manifest.token || currentBroker.generation !== manifest.generation)) {
880
+ throw new Error('SANDBOX_CONTROL_OWNER_REPLACED');
881
+ }
882
+ if (owner && currentBroker && !sameOwner(owner, currentBroker)) {
883
+ throw new Error('SANDBOX_CONTROL_OWNER_REPLACED');
884
+ }
885
+ if (!owner && currentBroker && ownerLive(currentBroker, identityProbe)) {
886
+ throw new Error('SANDBOX_CONTROL_OWNER_REPLACED');
887
+ }
888
+ if (owner && ownerLive(owner, identityProbe) && !currentBroker) {
889
+ throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
890
+ }
891
+
892
+ executions = mergeExecutions(executions, readExecutions(manifest));
893
+ if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_REMOVE_DEADLINE_EXCEEDED');
331
894
  for (const execution of executions) {
332
- if (!terminateSandboxControlExecution(execution, { platform, timeoutMs })) {
895
+ if (!terminateSandboxControlExecution(execution, {
896
+ platform, timeoutMs: remaining(), deadlineAt, forceAt: Date.now(), identityProbe
897
+ })) {
333
898
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
334
899
  }
335
900
  }
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))) {
901
+ if (owner && currentBroker && ownerLive(currentBroker, identityProbe) && !selfOwned) {
902
+ if (remaining() <= 0) throw new Error('SANDBOX_CONTROL_REMOVE_DEADLINE_EXCEEDED');
903
+ signalOwner(currentBroker, platform, true);
904
+ if (remaining() <= 0 || !await waitForExit(currentBroker, remaining(), deadlineAt, identityProbe)) {
905
+ throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
906
+ }
907
+ }
908
+
909
+ const finalExecutions = mergeExecutions(executions, readExecutions(manifest));
910
+ if (finalExecutions.some((execution) => executionAlive(execution, platform, identityProbe))) {
339
911
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
340
912
  }
341
- return 'stopped';
913
+
914
+ const finalBrokerRaw = fs.existsSync(brokerPath) ? fs.readFileSync(brokerPath, 'utf8') : null;
915
+ if (finalBrokerRaw !== null) {
916
+ const finalBroker = readBrokerOwner(brokerPath);
917
+ if (!finalBroker) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
918
+ const finalSelfOwner = Boolean(selfOwner && sameOwner(finalBroker, selfOwner)
919
+ && finalBroker.token === manifest.token && finalBroker.generation === manifest.generation);
920
+ if (ownerLive(finalBroker, identityProbe) && !finalSelfOwner) {
921
+ throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
922
+ }
923
+ if (fs.readFileSync(brokerPath, 'utf8') !== finalBrokerRaw) {
924
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
925
+ }
926
+ fs.unlinkSync(brokerPath);
927
+ }
928
+ const finalStatusRaw = fs.existsSync(statusPath) ? fs.readFileSync(statusPath, 'utf8') : null;
929
+ if (finalStatusRaw !== null) {
930
+ const finalStatus = readStatusOwner(statusPath);
931
+ if (!finalStatus) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
932
+ if (ownerLive(finalStatus.broker, identityProbe)
933
+ && !(selfOwner && sameOwner(finalStatus.broker, selfOwner))) {
934
+ throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
935
+ }
936
+ if (fs.readFileSync(statusPath, 'utf8') !== finalStatusRaw) {
937
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
938
+ }
939
+ fs.unlinkSync(statusPath);
940
+ }
941
+ if (fs.existsSync(brokerPath) || fs.existsSync(statusPath)) {
942
+ throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_REMAINS');
943
+ }
944
+ fs.rmSync(resolvedRoot, { recursive: true, force: true });
945
+ }
946
+
947
+ export type GarbageCollectSandboxControlOptions = Omit<RemoveSandboxControlOptions, 'removeContainer' | 'requireAbsent'>;
948
+
949
+ export async function garbageCollectSandboxControlRoot(
950
+ root: string,
951
+ options: GarbageCollectSandboxControlOptions = {}
952
+ ): Promise<void> {
953
+ await removeSandboxControlRoot(path.resolve(root), {
954
+ ...options,
955
+ requireAbsent: true,
956
+ removeContainer: async () => {
957
+ throw new Error('SANDBOX_CONTROL_CONTAINER_REAPPEARED');
958
+ }
959
+ });
342
960
  }