@bitkyc08/opencodex 2.8.2-preview.20260731 → 2.9.1

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 (164) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-CuVjugeE.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +86 -5
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +89 -14
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/doctor.ts +12 -0
  42. package/src/cli/export-command.ts +187 -0
  43. package/src/cli/help.ts +11 -0
  44. package/src/cli/index.ts +13 -3
  45. package/src/cli/opencode.ts +36 -151
  46. package/src/cli/star-prompt.ts +13 -4
  47. package/src/cli/status-oauth.ts +12 -2
  48. package/src/clients/config-export.ts +377 -0
  49. package/src/codex/account-runtime-state.ts +19 -1
  50. package/src/codex/account-store.ts +162 -82
  51. package/src/codex/auth-api.ts +467 -159
  52. package/src/codex/auth-context.ts +15 -2
  53. package/src/codex/catalog/aggregation.ts +15 -0
  54. package/src/codex/catalog/provider-fetch.ts +29 -0
  55. package/src/codex/catalog/sync.ts +7 -1
  56. package/src/codex/inject.ts +5 -5
  57. package/src/codex/main-account-cache.ts +8 -1
  58. package/src/codex/model-cache.ts +81 -2
  59. package/src/codex/pool-rotation.ts +39 -0
  60. package/src/codex/project-config-warnings.ts +12 -1
  61. package/src/codex/quota.ts +35 -3
  62. package/src/codex/routing.ts +46 -1
  63. package/src/codex/shim.ts +10 -4
  64. package/src/codex/subagent-model-fallback.ts +12 -0
  65. package/src/codex/websocket-registry.ts +27 -0
  66. package/src/combos/failover.ts +31 -1
  67. package/src/combos/request.ts +9 -0
  68. package/src/combos/resolve.ts +60 -4
  69. package/src/combos/types.ts +12 -0
  70. package/src/config.ts +510 -55
  71. package/src/github/star-state.ts +13 -1
  72. package/src/images/fulfill.ts +39 -1
  73. package/src/images/loop.ts +52 -12
  74. package/src/lib/admission.ts +83 -0
  75. package/src/lib/app-owned-memory-stores.ts +173 -0
  76. package/src/lib/app-owned-memory.ts +265 -0
  77. package/src/lib/bun-stream-caps.ts +31 -7
  78. package/src/lib/config-ownership.ts +33 -0
  79. package/src/lib/crash-guard.ts +65 -5
  80. package/src/lib/debug-log-buffer.ts +47 -6
  81. package/src/lib/errors.ts +3 -0
  82. package/src/lib/gcp-adc.ts +40 -2
  83. package/src/lib/injection-debug-log.ts +26 -2
  84. package/src/lib/sidecar-tracker.ts +5 -2
  85. package/src/lib/sse-decoder.ts +257 -37
  86. package/src/lib/state-store-registrations.ts +109 -0
  87. package/src/lib/state-store-sweeper.ts +184 -0
  88. package/src/lib/translator-budget.ts +356 -0
  89. package/src/lib/windows-secret-acl.ts +33 -12
  90. package/src/lib/winsw.ts +8 -1
  91. package/src/oauth/anthropic-routing.ts +31 -7
  92. package/src/oauth/google-antigravity.ts +2 -1
  93. package/src/oauth/health.ts +30 -12
  94. package/src/oauth/index.ts +127 -23
  95. package/src/oauth/kiro.ts +10 -2
  96. package/src/oauth/store.ts +165 -18
  97. package/src/oauth/token-guardian.ts +43 -4
  98. package/src/oauth/types.ts +2 -1
  99. package/src/providers/base-url-choices.ts +10 -0
  100. package/src/providers/derive.ts +12 -0
  101. package/src/providers/key-failover.ts +12 -0
  102. package/src/providers/openai-sidecar.ts +4 -1
  103. package/src/providers/quota.ts +68 -7
  104. package/src/providers/registry.ts +279 -3
  105. package/src/responses/parser.ts +5 -1
  106. package/src/responses/spill-store.ts +394 -0
  107. package/src/responses/state.ts +520 -102
  108. package/src/router.ts +18 -1
  109. package/src/server/adapter-resolve.ts +20 -3
  110. package/src/server/auth-cors.ts +121 -28
  111. package/src/server/chat-completions.ts +57 -12
  112. package/src/server/claude-messages.ts +85 -13
  113. package/src/server/index.ts +239 -97
  114. package/src/server/lifecycle.ts +155 -25
  115. package/src/server/management/agent-settings-routes.ts +75 -32
  116. package/src/server/management/api-key-usage.ts +167 -0
  117. package/src/server/management/body.ts +35 -0
  118. package/src/server/management/combo-routes.ts +5 -1
  119. package/src/server/management/config-routes.ts +37 -12
  120. package/src/server/management/logs-usage-routes.ts +41 -21
  121. package/src/server/management/model-routes.ts +173 -53
  122. package/src/server/management/oauth-account-routes.ts +115 -26
  123. package/src/server/management/provider-routes.ts +56 -6
  124. package/src/server/management/sidebar-routes.ts +50 -1
  125. package/src/server/management/system-restart.ts +13 -6
  126. package/src/server/management/system-routes.ts +15 -3
  127. package/src/server/management/usage-summary-cache.ts +86 -0
  128. package/src/server/management-api.ts +39 -5
  129. package/src/server/management-auth.ts +65 -14
  130. package/src/server/port-reclaim.ts +58 -12
  131. package/src/server/ports.ts +2 -0
  132. package/src/server/proxy-liveness.ts +51 -12
  133. package/src/server/relay-eager.ts +20 -4
  134. package/src/server/relay.ts +548 -154
  135. package/src/server/request-decompress.ts +51 -4
  136. package/src/server/request-log.ts +134 -15
  137. package/src/server/responses/collaboration.ts +15 -4
  138. package/src/server/responses/compact.ts +3 -0
  139. package/src/server/responses/core.ts +241 -66
  140. package/src/server/responses-image-gen-repair.ts +19 -5
  141. package/src/server/responses-item-id-repair.ts +23 -5
  142. package/src/server/sse-payload-rewrite.ts +71 -12
  143. package/src/server/startup-health-cache.ts +14 -1
  144. package/src/server/system-env.ts +8 -1
  145. package/src/server/windows-tcp-drop.ts +15 -5
  146. package/src/server/ws-bridge.ts +25 -0
  147. package/src/service.ts +23 -3
  148. package/src/storage/policy-job.ts +93 -23
  149. package/src/storage/policy-worker.ts +6 -0
  150. package/src/storage/restore-job.ts +62 -16
  151. package/src/storage/restore-worker.ts +6 -0
  152. package/src/storage/storage-mutation-coordinator.ts +36 -6
  153. package/src/storage/worker-lifecycle.ts +181 -47
  154. package/src/tray/windows.ts +45 -18
  155. package/src/types.ts +34 -6
  156. package/src/update/index.ts +24 -5
  157. package/src/update/job.ts +598 -73
  158. package/src/usage/log.ts +115 -17
  159. package/src/usage/summary.ts +67 -2
  160. package/src/vision/index.ts +112 -22
  161. package/src/web-search/loop.ts +38 -6
  162. package/src/web-search/progress-stream.ts +14 -3
  163. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
  164. package/gui/dist/assets/index-GC0Vlu1Z.js +0 -67
@@ -8,9 +8,9 @@
8
8
  import type { CleanupMode, CleanupResult } from "./cleanup";
9
9
  import { resolveCodexHomeDir } from "../codex/home";
10
10
  import {
11
- endStorageMutation,
12
11
  tryBeginStorageMutation,
13
12
  } from "./storage-mutation-coordinator";
13
+ import type { AdmissionLease } from "../lib/admission";
14
14
  import {
15
15
  isPolicyDue,
16
16
  readStorageCleanupPolicyFromConfig,
@@ -19,9 +19,12 @@ import {
19
19
  type PolicySkipReason,
20
20
  } from "./policy";
21
21
  import {
22
+ cancelQueuedStorageWorkerSpawns,
22
23
  drainStorageWorkers,
23
- registerStorageWorker,
24
+ StorageWorkerAdmissionBusyError,
24
25
  terminateStorageWorker,
26
+ tryReserveStorageWorker,
27
+ withStorageWorkerSpawnGate,
25
28
  } from "./worker-lifecycle";
26
29
 
27
30
  export type PolicyJobStatus = "idle" | "running";
@@ -82,12 +85,11 @@ let cancelActiveRun: (() => void) | null = null;
82
85
  /** Bumped on abort/reset so a late worker completion cannot clobber newer job state. */
83
86
  let runGeneration = 0;
84
87
  /** CODEX_HOME whose mutation slot this job holds (parent thread only). */
85
- let heldMutationHome: string | undefined;
88
+ let heldMutationLease: AdmissionLease | undefined;
86
89
 
87
90
  function releaseHeldMutationSlot(): void {
88
- if (heldMutationHome === undefined) return;
89
- endStorageMutation(heldMutationHome);
90
- heldMutationHome = undefined;
91
+ heldMutationLease?.release();
92
+ heldMutationLease = undefined;
91
93
  }
92
94
 
93
95
  export function setStorageCleanupPolicyJobLiveApply(
@@ -129,8 +131,16 @@ function disownActiveRun(): void {
129
131
  cancel?.();
130
132
  }
131
133
 
134
+ /**
135
+ * Fire-and-forget reset. Prefer {@link resetStorageCleanupPolicyJobForTestsAsync}
136
+ * from test beforeEach/afterEach — sync terminate races Windows
137
+ * `bun test --isolate` reclaim when the next case spawns immediately.
138
+ */
132
139
  export function resetStorageCleanupPolicyJobForTests(): void {
133
140
  disownActiveRun();
141
+ // Invalidate spawnGate callbacks that have not created a Worker yet — otherwise
142
+ // they can still spawn after this sync path releases the mutation slot.
143
+ cancelQueuedStorageWorkerSpawns();
134
144
  if (activeWorker) {
135
145
  void terminateStorageWorker(activeWorker);
136
146
  activeWorker = null;
@@ -148,19 +158,39 @@ export function resetStorageCleanupPolicyJobForTests(): void {
148
158
  * worker still exiting at that moment trips a Bun-internal assertion on Windows
149
159
  * and takes the whole run down, so a suite that spawns workers must be able to
150
160
  * wait for them rather than fire-and-forget.
161
+ *
162
+ * Do not route through the sync reset's `void terminate(...)`: that used to
163
+ * deregister the worker before the thread exited, so a follow-up drain saw an
164
+ * empty registry and returned while Windows was still reclaiming the thread.
151
165
  */
152
166
  export async function resetStorageCleanupPolicyJobForTestsAsync(): Promise<void> {
153
- resetStorageCleanupPolicyJobForTests();
154
- await drainStorageWorkers();
167
+ disownActiveRun();
168
+ cancelQueuedStorageWorkerSpawns();
169
+ const worker = activeWorker;
170
+ activeWorker = null;
171
+ inflight = null;
172
+ testHooks = null;
173
+ state = { status: "idle" };
174
+ // Join the worker before releasing the mutation slot so a concurrent run
175
+ // cannot acquire CODEX_HOME while the aborted thread is still mutating.
176
+ try {
177
+ if (worker) await terminateStorageWorker(worker);
178
+ await drainStorageWorkers();
179
+ } finally {
180
+ releaseHeldMutationSlot();
181
+ }
155
182
  }
156
183
 
157
184
  /** Terminate an in-flight worker during process shutdown. */
158
185
  export function abortStorageCleanupPolicyJob(): void {
159
186
  disownActiveRun();
187
+ cancelQueuedStorageWorkerSpawns();
160
188
  if (activeWorker) {
161
189
  void terminateStorageWorker(activeWorker);
162
190
  activeWorker = null;
163
191
  }
192
+ // Sync path cannot join; prefer abortStorageCleanupPolicyJobAsync when the
193
+ // caller can await so the mutation slot stays held until the thread exits.
164
194
  releaseHeldMutationSlot();
165
195
  if (state.status === "running") {
166
196
  state = {
@@ -177,6 +207,37 @@ export function abortStorageCleanupPolicyJob(): void {
177
207
  inflight = null;
178
208
  }
179
209
 
210
+ /**
211
+ * Shutdown path that joins worker threads. Prefer this over the sync abort when
212
+ * the caller can await (server drain, test teardown) so Windows isolate reclaim
213
+ * does not race a still-exiting storage worker.
214
+ */
215
+ export async function abortStorageCleanupPolicyJobAsync(): Promise<void> {
216
+ disownActiveRun();
217
+ cancelQueuedStorageWorkerSpawns();
218
+ const worker = activeWorker;
219
+ activeWorker = null;
220
+ if (state.status === "running") {
221
+ state = {
222
+ ...state,
223
+ status: "idle",
224
+ finishedAt: Date.now(),
225
+ lastError: "aborted",
226
+ lastOutcome: {
227
+ ok: false,
228
+ error: "evaluation_failed",
229
+ },
230
+ };
231
+ }
232
+ inflight = null;
233
+ try {
234
+ if (worker) await terminateStorageWorker(worker);
235
+ await drainStorageWorkers();
236
+ } finally {
237
+ releaseHeldMutationSlot();
238
+ }
239
+ }
240
+
180
241
  function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
181
242
  return {
182
243
  ok: result.ok,
@@ -232,22 +293,22 @@ function applyMutationBusy(): void {
232
293
  }
233
294
 
234
295
  function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Promise<PolicyRunResult> {
235
- return new Promise((resolve, reject) => {
296
+ const reservation = tryReserveStorageWorker();
297
+ if (!reservation) return Promise.reject(new StorageWorkerAdmissionBusyError());
298
+ return withStorageWorkerSpawnGate(() => new Promise<PolicyRunResult>((resolve, reject) => {
236
299
  const requestId = crypto.randomUUID();
237
300
  let settled = false;
238
- const worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
239
- registerStorageWorker(worker);
301
+ let worker: Worker;
302
+ try {
303
+ worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
304
+ reservation.bind(worker);
305
+ } catch (error) {
306
+ reservation.release();
307
+ reject(error);
308
+ return;
309
+ }
240
310
  activeWorker = worker;
241
311
 
242
- const timer = setTimeout(() => {
243
- if (settled) return;
244
- settled = true;
245
- cancelActiveRun = null;
246
- void terminateStorageWorker(worker);
247
- if (activeWorker === worker) activeWorker = null;
248
- reject(new Error("storage_cleanup_worker_timeout"));
249
- }, WORKER_TIMEOUT_MS);
250
-
251
312
  const finish = (fn: () => void) => {
252
313
  if (settled) return;
253
314
  settled = true;
@@ -256,10 +317,15 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
256
317
  if (activeWorker === worker) activeWorker = null;
257
318
  // Settle the caller only after the thread is actually gone, so a suite
258
319
  // that awaits its request cannot reach the next test file with a worker
259
- // still exiting behind it.
320
+ // still exiting behind it. Also keeps executeJob's finally from releasing
321
+ // the mutation slot while the thread may still mutate CODEX_HOME.
260
322
  void terminateStorageWorker(worker).then(fn, fn);
261
323
  };
262
324
 
325
+ const timer = setTimeout(() => {
326
+ finish(() => reject(new Error("storage_cleanup_worker_timeout")));
327
+ }, WORKER_TIMEOUT_MS);
328
+
263
329
  cancelActiveRun = () => {
264
330
  finish(() => reject(new Error("aborted")));
265
331
  };
@@ -296,6 +362,9 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
296
362
  ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
297
363
  },
298
364
  });
365
+ })).catch(error => {
366
+ reservation.release();
367
+ throw error;
299
368
  });
300
369
  }
301
370
 
@@ -309,7 +378,7 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise<void> {
309
378
  }
310
379
  return;
311
380
  }
312
- heldMutationHome = codexHome;
381
+ heldMutationLease = gate.lease;
313
382
  try {
314
383
  const blockMs = testHooks?.blockMs;
315
384
  let result: PolicyRunResult;
@@ -336,7 +405,8 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise<void> {
336
405
  applyFinished(result);
337
406
  } catch (err) {
338
407
  if (generation !== runGeneration) return;
339
- applyFailed(err instanceof Error ? err.message : "worker_failed");
408
+ if (err instanceof StorageWorkerAdmissionBusyError) applyMutationBusy();
409
+ else applyFailed(err instanceof Error ? err.message : "worker_failed");
340
410
  } finally {
341
411
  releaseHeldMutationSlot();
342
412
  }
@@ -49,5 +49,11 @@ self.onmessage = (event: MessageEvent<unknown>) => {
49
49
  requestId,
50
50
  message: err instanceof Error ? err.message : "worker_failed",
51
51
  });
52
+ } finally {
53
+ // Close from inside the worker so the thread begins exiting before the
54
+ // parent’s terminate() races isolate realm reclaim on Windows.
55
+ try {
56
+ (self as unknown as { close?: () => void }).close?.();
57
+ } catch { /* already closing */ }
52
58
  }
53
59
  };
@@ -17,9 +17,12 @@ import {
17
17
  type StorageMutationCoordinatorTestHooks,
18
18
  } from "./storage-mutation-coordinator";
19
19
  import {
20
+ cancelQueuedStorageWorkerSpawns,
20
21
  drainStorageWorkers,
21
- registerStorageWorker,
22
+ StorageWorkerAdmissionBusyError,
22
23
  terminateStorageWorker,
24
+ tryReserveStorageWorker,
25
+ withStorageWorkerSpawnGate,
23
26
  } from "./worker-lifecycle";
24
27
 
25
28
  export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks {
@@ -72,7 +75,12 @@ export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null):
72
75
  setStorageMutationCoordinatorTestHooks(hooks);
73
76
  }
74
77
 
78
+ /**
79
+ * Fire-and-forget reset. Prefer {@link resetRestoreTrashJobForTestsAsync} from
80
+ * test beforeEach/afterEach under `bun test --isolate` on Windows.
81
+ */
75
82
  export function resetRestoreTrashJobForTests(): void {
83
+ cancelQueuedStorageWorkerSpawns();
76
84
  if (activeWorker) {
77
85
  void terminateStorageWorker(activeWorker);
78
86
  activeWorker = null;
@@ -83,14 +91,31 @@ export function resetRestoreTrashJobForTests(): void {
83
91
  resetStorageMutationCoordinatorForTests();
84
92
  }
85
93
 
86
- /** Await-able reset for test teardown; see policy-job's equivalent for why. */
94
+ /**
95
+ * Await-able reset for test teardown; see policy-job's equivalent for why.
96
+ * Awaits the active worker directly so drain cannot race a fire-and-forget
97
+ * deregister from the sync reset.
98
+ */
87
99
  export async function resetRestoreTrashJobForTestsAsync(): Promise<void> {
88
- resetRestoreTrashJobForTests();
89
- await drainStorageWorkers();
100
+ cancelQueuedStorageWorkerSpawns();
101
+ const worker = activeWorker;
102
+ activeWorker = null;
103
+ cancelActiveRun?.();
104
+ cancelActiveRun = null;
105
+ testHooks = null;
106
+ // Join the worker before clearing the mutation coordinator so a concurrent
107
+ // run cannot acquire CODEX_HOME while the aborted thread is still mutating.
108
+ try {
109
+ if (worker) await terminateStorageWorker(worker);
110
+ await drainStorageWorkers();
111
+ } finally {
112
+ resetStorageMutationCoordinatorForTests();
113
+ }
90
114
  }
91
115
 
92
116
  /** Terminate an in-flight worker during process shutdown. */
93
117
  export function abortRestoreTrashJob(): void {
118
+ cancelQueuedStorageWorkerSpawns();
94
119
  if (activeWorker) {
95
120
  void terminateStorageWorker(activeWorker);
96
121
  activeWorker = null;
@@ -99,6 +124,17 @@ export function abortRestoreTrashJob(): void {
99
124
  cancelActiveRun = null;
100
125
  }
101
126
 
127
+ /** Await-able shutdown sibling; joins the restore worker thread before returning. */
128
+ export async function abortRestoreTrashJobAsync(): Promise<void> {
129
+ cancelQueuedStorageWorkerSpawns();
130
+ const worker = activeWorker;
131
+ activeWorker = null;
132
+ cancelActiveRun?.();
133
+ cancelActiveRun = null;
134
+ if (worker) await terminateStorageWorker(worker);
135
+ await drainStorageWorkers();
136
+ }
137
+
102
138
  /** Test-only SSE/text stream served from the proxy while a worker is blocked. */
103
139
  export function getRestoreTrashTestStreamResponse(): Response | null {
104
140
  if (!testHooks?.enableTestStream) return null;
@@ -124,31 +160,37 @@ function runInWorker(opts: {
124
160
  blockMs?: number;
125
161
  restoreTest?: RestoreTestHooks;
126
162
  }): Promise<RestoreResult> {
127
- return new Promise((resolve, reject) => {
163
+ const reservation = tryReserveStorageWorker();
164
+ if (!reservation) return Promise.reject(new StorageWorkerAdmissionBusyError());
165
+ return withStorageWorkerSpawnGate(() => new Promise<RestoreResult>((resolve, reject) => {
128
166
  const requestId = crypto.randomUUID();
129
167
  let settled = false;
130
- const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
131
- registerStorageWorker(worker);
168
+ let worker: Worker;
169
+ try {
170
+ worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
171
+ reservation.bind(worker);
172
+ } catch (error) {
173
+ reservation.release();
174
+ reject(error);
175
+ return;
176
+ }
132
177
  activeWorker = worker;
133
178
 
134
- const timer = setTimeout(() => {
135
- if (settled) return;
136
- settled = true;
137
- cancelActiveRun = null;
138
- void terminateStorageWorker(worker);
139
- if (activeWorker === worker) activeWorker = null;
140
- reject(new Error("restore_worker_timeout"));
141
- }, WORKER_TIMEOUT_MS);
142
-
143
179
  const finish = (fn: () => void) => {
144
180
  if (settled) return;
145
181
  settled = true;
146
182
  cancelActiveRun = null;
147
183
  clearTimeout(timer);
148
184
  if (activeWorker === worker) activeWorker = null;
185
+ // Join before settle so mutation coordination inside the restore worker
186
+ // cannot overlap a follow-on run after a watchdog timeout.
149
187
  void terminateStorageWorker(worker).then(fn, fn);
150
188
  };
151
189
 
190
+ const timer = setTimeout(() => {
191
+ finish(() => reject(new Error("restore_worker_timeout")));
192
+ }, WORKER_TIMEOUT_MS);
193
+
152
194
  cancelActiveRun = () => {
153
195
  finish(() => reject(new Error("aborted")));
154
196
  };
@@ -185,6 +227,9 @@ function runInWorker(opts: {
185
227
  ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
186
228
  },
187
229
  });
230
+ })).catch(error => {
231
+ reservation.release();
232
+ throw error;
188
233
  });
189
234
  }
190
235
 
@@ -215,6 +260,7 @@ async function executeRestore(opts: {
215
260
  ...(restoreTest ? { restoreTest } : {}),
216
261
  });
217
262
  } catch (err) {
263
+ if (err instanceof StorageWorkerAdmissionBusyError) return busyRestoreResult();
218
264
  return restoreResultFromWorkerRejection(err, opts.trashId);
219
265
  }
220
266
  }
@@ -46,6 +46,12 @@ self.onmessage = async (event: MessageEvent<unknown>) => {
46
46
  requestId,
47
47
  message: err instanceof Error ? err.message : "worker_failed",
48
48
  });
49
+ } finally {
50
+ // Close from inside the worker so the thread begins exiting before the
51
+ // parent’s terminate() races isolate realm reclaim on Windows.
52
+ try {
53
+ (self as unknown as { close?: () => void }).close?.();
54
+ } catch { /* already closing */ }
49
55
  }
50
56
  };
51
57
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { resolve } from "node:path";
9
9
  import { resolveCodexHomeDir } from "../codex/home";
10
+ import { createAdmissionGate, type AdmissionLease, type AdmissionMetrics } from "../lib/admission";
10
11
 
11
12
  export type StorageMutationKind = "cleanup" | "restore" | "policy";
12
13
 
@@ -20,9 +21,13 @@ export interface StorageMutationCoordinatorTestHooks {
20
21
  interface ActiveSlot {
21
22
  kind: StorageMutationKind;
22
23
  startedAt: number;
24
+ lease: AdmissionLease;
23
25
  }
24
26
 
27
+ export const MAX_ACTIVE_STORAGE_HOME_SLOTS = 32;
25
28
  const slots = new Map<string, ActiveSlot>();
29
+ const slotGate = createAdmissionGate("storage_home_slots", MAX_ACTIVE_STORAGE_HOME_SLOTS);
30
+ let releaseMisses = 0;
26
31
  let testHooks: StorageMutationCoordinatorTestHooks | null = null;
27
32
 
28
33
  function slotKey(codexHome?: string): string {
@@ -37,9 +42,15 @@ export function setStorageMutationCoordinatorTestHooks(
37
42
 
38
43
  export function resetStorageMutationCoordinatorForTests(): void {
39
44
  testHooks = null;
45
+ for (const slot of slots.values()) slot.lease.release();
40
46
  slots.clear();
41
47
  }
42
48
 
49
+ export function storageMutationAdmissionMetrics(): AdmissionMetrics {
50
+ const metrics = slotGate.metrics();
51
+ return { ...metrics, releaseMisses: metrics.releaseMisses + releaseMisses };
52
+ }
53
+
43
54
  export function getActiveStorageMutation(
44
55
  codexHome?: string,
45
56
  ): { kind: StorageMutationKind; startedAt: number } | null {
@@ -50,17 +61,36 @@ export function getActiveStorageMutation(
50
61
  export function tryBeginStorageMutation(
51
62
  kind: StorageMutationKind,
52
63
  codexHome?: string,
53
- ): { acquired: true } | { acquired: false; error: StorageMutationBusyError } {
64
+ ): { acquired: true; lease: AdmissionLease } | { acquired: false; error: StorageMutationBusyError } {
54
65
  const key = slotKey(codexHome);
55
66
  if (slots.has(key)) {
56
67
  return { acquired: false, error: "storage_mutation_busy" };
57
68
  }
58
- slots.set(key, { kind, startedAt: Date.now() });
59
- return { acquired: true };
69
+ const lease = slotGate.tryAcquire();
70
+ if (!lease) return { acquired: false, error: "storage_mutation_busy" };
71
+ let active = true;
72
+ const ownerLease: AdmissionLease = {
73
+ release() {
74
+ if (!active) return;
75
+ active = false;
76
+ const owner = slots.get(key);
77
+ if (owner?.lease === ownerLease) slots.delete(key);
78
+ lease.release();
79
+ },
80
+ };
81
+ slots.set(key, { kind, startedAt: Date.now(), lease: ownerLease });
82
+ return { acquired: true, lease: ownerLease };
60
83
  }
61
84
 
62
85
  export function endStorageMutation(codexHome?: string): void {
63
- slots.delete(slotKey(codexHome));
86
+ const key = slotKey(codexHome);
87
+ const slot = slots.get(key);
88
+ if (!slot) {
89
+ releaseMisses += 1;
90
+ return;
91
+ }
92
+ slots.delete(key);
93
+ slot.lease.release();
64
94
  }
65
95
 
66
96
  async function applyCoordinatorBlock(): Promise<void> {
@@ -87,7 +117,7 @@ export async function runPolicyStorageMutation<T>(
87
117
  await applyCoordinatorBlock();
88
118
  return await work();
89
119
  } finally {
90
- endStorageMutation(codexHome);
120
+ gate.lease.release();
91
121
  }
92
122
  }
93
123
 
@@ -104,6 +134,6 @@ export async function withStorageMutationSlot<T>(
104
134
  await applyCoordinatorBlock();
105
135
  return await work();
106
136
  } finally {
107
- endStorageMutation(codexHome);
137
+ gate.lease.release();
108
138
  }
109
139
  }