@bitkyc08/opencodex 2.38.0 → 2.39.0
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.
- package/bin/ocx.mjs +58 -6
- package/gui/dist/assets/index-D-lchsPw.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/index.ts +420 -30
- package/src/cli/integrations.ts +24 -2
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/codex/account-store.ts +121 -8
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/catalog/provider-fetch.ts +76 -9
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/routing.ts +68 -16
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/generated/compatibility-version.json +84 -32
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/writer.ts +82 -7
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +20 -0
- package/src/responses/state.ts +288 -2
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management-api.ts +66 -12
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +155 -20
- package/src/update/index.ts +36 -4
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-C14iCj_Q.js +0 -112
- package/gui/dist/assets/index-D7PIz7_g.css +0 -1
package/src/responses/state.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
responseSpillDirectory,
|
|
17
17
|
responseSpillPayloadCap,
|
|
18
18
|
markResponseSpillPublicationSuperseded,
|
|
19
|
+
prospectiveResponseSpillBytes,
|
|
19
20
|
type ResponseSpillPublicationControl,
|
|
20
21
|
type ResponseSpillRef,
|
|
21
22
|
writeResponseSpillDurably,
|
|
@@ -35,6 +36,30 @@ const SNAPSHOT_DEBOUNCE_MAX_MS = 30_000;
|
|
|
35
36
|
* continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
|
|
36
37
|
* so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
|
|
37
38
|
export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* Aggregate ceiling for the durable spill directory: the disk-side counterpart to
|
|
41
|
+
* the RAM ceiling above. Without it the spilled set is bounded only per-file
|
|
42
|
+
* (MAX_RESPONSE_SPILL_PAYLOAD_BYTES, 256 MiB) and per-entry (MAX_STORED_RESPONSES,
|
|
43
|
+
* 1000), whose product is 250 GiB — larger than the disk of any host this runs on.
|
|
44
|
+
* The only effective bound was therefore RESPONSE_TTL_MS, which makes disk use a
|
|
45
|
+
* function of client request rate rather than of anything this process controls.
|
|
46
|
+
*
|
|
47
|
+
* Measured on one macOS host, 2026-08-30: a client spilling ~150 MB payloads at
|
|
48
|
+
* ~1.4/min held 6.8 GB after 44 minutes, still climbing toward the ~12 GB an
|
|
49
|
+
* hour-long window implies, and filled the volume. Retention itself was correct
|
|
50
|
+
* throughout — the TTL evicted that whole cohort an hour later — so what was
|
|
51
|
+
* missing is a budget, not a sweep.
|
|
52
|
+
*
|
|
53
|
+
* 1 GiB comes from the same sample (n=31), whose spilled sizes are strongly
|
|
54
|
+
* bimodal: median 1.1 MiB against a p90 of 198.7 MiB, near the per-file ceiling.
|
|
55
|
+
* At that median the count cap and this ceiling bind within 8% of each other
|
|
56
|
+
* (1000 x 1.1 MiB = 1.07 GiB), so ordinary traffic sees no eviction it would not
|
|
57
|
+
* already have seen and only the large tail is cut. Erring small is the safe
|
|
58
|
+
* direction: too low costs a replay miss, an already-handled path surfaced as
|
|
59
|
+
* previous_response_not_found, while too high costs the host's disk and every
|
|
60
|
+
* unrelated process on it.
|
|
61
|
+
*/
|
|
62
|
+
export const MAX_SPILLED_RESPONSE_BYTES = 1024 * 1024 * 1024;
|
|
38
63
|
/** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
|
|
39
64
|
const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
|
|
40
65
|
const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
|
|
@@ -187,12 +212,79 @@ interface PendingResponseSpill {
|
|
|
187
212
|
cancelled: boolean;
|
|
188
213
|
released: boolean;
|
|
189
214
|
sizeBytes: number;
|
|
215
|
+
/** Peak on-disk bytes reserved for this publication; released exactly once on settle. */
|
|
216
|
+
reservedBytes: number;
|
|
190
217
|
publicationControl: ResponseSpillPublicationControl;
|
|
191
218
|
}
|
|
192
219
|
|
|
193
220
|
const pendingResponseSpills = new Set<PendingResponseSpill>();
|
|
194
221
|
const pendingResponseSpillById = new Map<string, PendingResponseSpill>();
|
|
195
222
|
let pendingResponseSpillBytes = 0;
|
|
223
|
+
/**
|
|
224
|
+
* On-disk bytes a queued publication is about to occupy but has not yet installed into
|
|
225
|
+
* `states`.
|
|
226
|
+
*
|
|
227
|
+
* `spilledResponseBytes()` walks installed spills and deferred unlinks — files that
|
|
228
|
+
* already exist. It cannot see one that `writeResponseSpillDurablyAsync` is in the
|
|
229
|
+
* middle of creating, and on Windows that middle can last as long as `icacls` takes.
|
|
230
|
+
* Without a reservation the cap holds only when writes are fast, which is not a cap.
|
|
231
|
+
*
|
|
232
|
+
* The reserved figure is the PEAK footprint, not the payload: publication can fall back
|
|
233
|
+
* from hard-linking to an exclusive copy, and during that fallback the destination copy
|
|
234
|
+
* and the temp file exist simultaneously. Reserving one envelope would leave the overshoot
|
|
235
|
+
* intact at half its magnitude.
|
|
236
|
+
*
|
|
237
|
+
* Ownership is single: a job holds its reservation from queue until
|
|
238
|
+
* `releasePendingResponseSpill`, which every exit from the publication path reaches
|
|
239
|
+
* through the `finally` in `runPendingResponseSpill` and through cancellation of a
|
|
240
|
+
* not-yet-running job. A leaked reservation is monotonic — it would ratchet the usable
|
|
241
|
+
* cap toward zero — so the release must stay on the settlement path rather than in a
|
|
242
|
+
* parallel bookkeeping pass.
|
|
243
|
+
*/
|
|
244
|
+
let reservedResponseSpillBytes = 0;
|
|
245
|
+
/**
|
|
246
|
+
* Paths a failed cleanup left on the volume, with the bytes each one occupies.
|
|
247
|
+
*
|
|
248
|
+
* A failed unlink leaves a real file behind, so the cap has to keep seeing it. But a
|
|
249
|
+
* never-decremented total would be phantom debt: a Windows lock that clears a moment
|
|
250
|
+
* later, or the async writer's own retry, can remove the file while the charge stays
|
|
251
|
+
* forever — and with 256 MiB payloads two conservative charges consume the whole default
|
|
252
|
+
* cap, after which nothing can spill for the life of the process.
|
|
253
|
+
*
|
|
254
|
+
* So the debt is per PATH, priced at what that path actually holds, and settled the
|
|
255
|
+
* moment the path is gone. `reconcileUnreclaimableSpillPaths` re-checks on every read of
|
|
256
|
+
* the accounted total, which is the same tick that would otherwise refuse an admission.
|
|
257
|
+
*/
|
|
258
|
+
const unreclaimableSpillPaths = new Map<string, number>();
|
|
259
|
+
|
|
260
|
+
function chargeUnreclaimableSpillPath(path: string | null | undefined, bytes: number): void {
|
|
261
|
+
if (!path || bytes <= 0) return;
|
|
262
|
+
unreclaimableSpillPaths.set(path, bytes);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Drop charges for paths that have since disappeared; returns the surviving total. */
|
|
266
|
+
function reconcileUnreclaimableSpillPaths(): number {
|
|
267
|
+
let total = 0;
|
|
268
|
+
for (const [path, bytes] of [...unreclaimableSpillPaths]) {
|
|
269
|
+
if (existsSync(path)) total += bytes;
|
|
270
|
+
else unreclaimableSpillPaths.delete(path);
|
|
271
|
+
}
|
|
272
|
+
return total;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Peak on-disk footprint of publishing this candidate: temp plus destination copy.
|
|
277
|
+
*
|
|
278
|
+
* Measured from the production serializer rather than from `candidate.sizeBytes`. The
|
|
279
|
+
* resident measurement omits the `version` field the published envelope carries, so
|
|
280
|
+
* pricing an admission by it undercounts and lets a request sitting exactly at the cap
|
|
281
|
+
* still exceed it. Falls back to the resident figure only when serialization fails, which
|
|
282
|
+
* is the same condition that will fail the publication itself.
|
|
283
|
+
*/
|
|
284
|
+
function publicationFootprintBytes(id: string, candidate: ResidentResponseState): number {
|
|
285
|
+
const exact = prospectiveResponseSpillBytes(id, spillPayloadForResident(candidate));
|
|
286
|
+
return (exact ?? candidate.sizeBytes) * 2;
|
|
287
|
+
}
|
|
196
288
|
let responseSpillPublicationTail: Promise<void> = Promise.resolve();
|
|
197
289
|
let responseSpillShutdownBudgetOverride: { totalMs: number; fallbackReserveMs: number } | null = null;
|
|
198
290
|
let responseSpillShutdownTerminalizationPassLimitOverride: number | null = null;
|
|
@@ -210,6 +302,7 @@ function releasePendingResponseSpill(job: PendingResponseSpill): void {
|
|
|
210
302
|
if (job.released) return;
|
|
211
303
|
job.released = true;
|
|
212
304
|
pendingResponseSpillBytes = Math.max(0, pendingResponseSpillBytes - job.sizeBytes);
|
|
305
|
+
reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - job.reservedBytes);
|
|
213
306
|
pendingResponseSpills.delete(job);
|
|
214
307
|
if (pendingResponseSpillById.get(job.id) === job) pendingResponseSpillById.delete(job.id);
|
|
215
308
|
job.candidate = null;
|
|
@@ -222,6 +315,11 @@ function cancelPendingResponseSpill(id: string): ResponseSpillRef | undefined {
|
|
|
222
315
|
job.cancelled = true;
|
|
223
316
|
markResponseSpillPublicationSuperseded(job.publicationControl);
|
|
224
317
|
const superseded = job.supersededSpill;
|
|
318
|
+
// Ownership TRANSFERS to the caller. Leaving the ref on the cancelled job would let the
|
|
319
|
+
// accounting walk count the same physical file twice — once here and once on the
|
|
320
|
+
// replacement — and an overcount evicts live continuations to make room for bytes that
|
|
321
|
+
// are not there.
|
|
322
|
+
delete job.supersededSpill;
|
|
225
323
|
// A queued job has not captured the candidate in an async frame yet, so release it now.
|
|
226
324
|
// A running job retains its accounting until settlement and will discard its stale file.
|
|
227
325
|
if (!job.running) releasePendingResponseSpill(job);
|
|
@@ -313,6 +411,25 @@ function queuePendingResponseSpill(
|
|
|
313
411
|
deferSupersededSpill(inheritedSpill);
|
|
314
412
|
return;
|
|
315
413
|
}
|
|
414
|
+
// Enforce the disk cap BEFORE the temp or destination file is created. Deleting the
|
|
415
|
+
// overflow afterwards is not equivalent: on Windows the file can outlive the decision
|
|
416
|
+
// by as long as ACL hardening takes, which is the window the measured 6.8 GiB
|
|
417
|
+
// accumulated in. Reclaim first, and only refuse if the peak footprint still does not
|
|
418
|
+
// fit — an eviction pass can free a live continuation's worth of room.
|
|
419
|
+
const footprint = publicationFootprintBytes(id, candidate);
|
|
420
|
+
// The superseded generation this job is about to own is already off `states` and not
|
|
421
|
+
// yet on the job, so it is invisible to the walk. Price it here or admission decides
|
|
422
|
+
// against a total that is short by a whole envelope.
|
|
423
|
+
const inheritedBytes = inheritedSpill?.payloadBytes ?? 0;
|
|
424
|
+
if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) {
|
|
425
|
+
enforceSpilledResponseBudget();
|
|
426
|
+
if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) {
|
|
427
|
+
spillCounters.writeFailures += 1;
|
|
428
|
+
replaceWithSpillFailure(id, candidate);
|
|
429
|
+
deferSupersededSpill(inheritedSpill);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
316
433
|
const job: PendingResponseSpill = {
|
|
317
434
|
id,
|
|
318
435
|
candidate,
|
|
@@ -322,11 +439,13 @@ function queuePendingResponseSpill(
|
|
|
322
439
|
cancelled: false,
|
|
323
440
|
released: false,
|
|
324
441
|
sizeBytes: candidate.sizeBytes,
|
|
442
|
+
reservedBytes: footprint,
|
|
325
443
|
publicationControl: createResponseSpillPublicationControl(),
|
|
326
444
|
};
|
|
327
445
|
pendingResponseSpills.add(job);
|
|
328
446
|
pendingResponseSpillById.set(id, job);
|
|
329
447
|
pendingResponseSpillBytes += job.sizeBytes;
|
|
448
|
+
reservedResponseSpillBytes += job.reservedBytes;
|
|
330
449
|
recomputeOldestResident();
|
|
331
450
|
responseSpillPublicationTail = responseSpillPublicationTail
|
|
332
451
|
.then(() => runPendingResponseSpill(job), () => runPendingResponseSpill(job));
|
|
@@ -418,7 +537,36 @@ function installShutdownFallbackSpill(
|
|
|
418
537
|
aclBudgetMs: number,
|
|
419
538
|
): void {
|
|
420
539
|
let ref: ResponseSpillRef | null = null;
|
|
540
|
+
// Supersession released this job's reservation, but the synchronous write below is the
|
|
541
|
+
// largest publication of the shutdown path and has its own link-then-copy fallback
|
|
542
|
+
// holding a temp and a destination at once. Re-reserve for its duration so the cap is
|
|
543
|
+
// not blind exactly where the drain does its heaviest work, and settle in `finally` so
|
|
544
|
+
// every return, throw and mismatch releases it.
|
|
545
|
+
const footprint = publicationFootprintBytes(job.id, candidate);
|
|
546
|
+
reservedResponseSpillBytes += footprint;
|
|
421
547
|
try {
|
|
548
|
+
// Supersession released this job, so its superseded generation is no longer visible
|
|
549
|
+
// to the accounting walk — but the file is still on the volume until
|
|
550
|
+
// `deferSupersededSpill` or a delete takes it. Price it here or the fallback decides
|
|
551
|
+
// against a total short by that whole envelope, which is exactly the gap that lets
|
|
552
|
+
// `debt + footprint <= cap < old + debt + footprint` publish over budget.
|
|
553
|
+
const supersededBytes = job.supersededSpill?.payloadBytes ?? 0;
|
|
554
|
+
// The drain must not publish over the cap either. Reclaim first; if the footprint
|
|
555
|
+
// still does not fit — which is what unreclaimable cleanup debt looks like — the
|
|
556
|
+
// honest close-out is a tombstone, not another file on a volume that is already
|
|
557
|
+
// over budget. `replaceWithSpillFailure` is the same fail-closed ending the budget
|
|
558
|
+
// exhaustion path uses, so replay reports `spill_failed` and the client resends.
|
|
559
|
+
if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) {
|
|
560
|
+
enforceSpilledResponseBudget();
|
|
561
|
+
if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) {
|
|
562
|
+
if (states.get(job.id) === candidate) {
|
|
563
|
+
spillCounters.writeFailures += 1;
|
|
564
|
+
replaceWithSpillFailure(job.id, candidate);
|
|
565
|
+
deferSupersededSpill(job.supersededSpill);
|
|
566
|
+
}
|
|
567
|
+
throw Object.assign(new Error("Response spill shutdown fallback exceeds the durable disk cap"), { code: "ENOSPC" });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
422
570
|
ref = writeResponseSpillDurably(job.id, spillPayloadForResident(candidate), { aclBudgetMs });
|
|
423
571
|
if (ref.payloadBytes > responseSpillPayloadCap()) {
|
|
424
572
|
deleteResponseSpill(ref);
|
|
@@ -445,6 +593,8 @@ function installShutdownFallbackSpill(
|
|
|
445
593
|
deferSupersededSpill(job.supersededSpill);
|
|
446
594
|
}
|
|
447
595
|
throw error;
|
|
596
|
+
} finally {
|
|
597
|
+
reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - footprint);
|
|
448
598
|
}
|
|
449
599
|
}
|
|
450
600
|
|
|
@@ -477,7 +627,20 @@ function supersedeShutdownFallbackBatch(
|
|
|
477
627
|
}
|
|
478
628
|
for (const { job } of pending) {
|
|
479
629
|
const cleanupFailure = cleanupSupersededResponseSpillPublication(job.publicationControl);
|
|
480
|
-
if (cleanupFailure)
|
|
630
|
+
if (cleanupFailure) {
|
|
631
|
+
failures.push(cleanupFailure);
|
|
632
|
+
// Cleanup failed, so an async temp or destination is STILL on the volume. Releasing
|
|
633
|
+
// the reservation would un-account a file that exists, and the fallback write that
|
|
634
|
+
// follows reserves only its own footprint — three envelopes on disk priced as two.
|
|
635
|
+
//
|
|
636
|
+
// Charge the surviving PATHS rather than a flat two envelopes: `clearOwnedPath`
|
|
637
|
+
// nulls whichever it managed to remove, so one failure is one file, not two. The
|
|
638
|
+
// charge is settled automatically once the path disappears, which a retried unlink
|
|
639
|
+
// or a released Windows lock can still do.
|
|
640
|
+
const perPath = Math.max(1, Math.floor(job.reservedBytes / 2));
|
|
641
|
+
chargeUnreclaimableSpillPath(job.publicationControl.tempPath, perPath);
|
|
642
|
+
chargeUnreclaimableSpillPath(job.publicationControl.destinationPath, perPath);
|
|
643
|
+
}
|
|
481
644
|
releasePendingResponseSpill(job);
|
|
482
645
|
}
|
|
483
646
|
}
|
|
@@ -603,6 +766,69 @@ export function getStoredResponseBytesForTests(): number {
|
|
|
603
766
|
return storedResponseBytes;
|
|
604
767
|
}
|
|
605
768
|
|
|
769
|
+
let spillByteCapOverride: number | null = null;
|
|
770
|
+
|
|
771
|
+
function spillByteCap(): number {
|
|
772
|
+
return spillByteCapOverride ?? MAX_SPILLED_RESPONSE_BYTES;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Live total of durable spill payloads. Recomputed per call rather than carried as
|
|
777
|
+
* a running counter: spilled entries reach `states` through several insertion paths
|
|
778
|
+
* (demotion swap, direct oversized admission, snapshot reload), and one missed
|
|
779
|
+
* increment there would silently disable the cap, where an O(MAX_STORED_RESPONSES)
|
|
780
|
+
* walk cannot drift.
|
|
781
|
+
*/
|
|
782
|
+
function spilledResponseBytes(): number {
|
|
783
|
+
let total = 0;
|
|
784
|
+
for (const entry of states.values()) {
|
|
785
|
+
if (entry.kind === "spill") total += entry.spill.payloadBytes;
|
|
786
|
+
}
|
|
787
|
+
// Superseded generations awaiting a durable snapshot are still files on disk.
|
|
788
|
+
// Counting only `states` would let PENDING_SPILL_UNLINKS_MAX of them sit outside
|
|
789
|
+
// the budget while it reports itself satisfied.
|
|
790
|
+
for (const ref of pendingSpillUnlinks) total += ref.payloadBytes;
|
|
791
|
+
return total;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Accounted on-disk bytes: files that exist, plus the peak footprint of publications
|
|
796
|
+
* already in flight.
|
|
797
|
+
*
|
|
798
|
+
* The cap is enforced against this rather than against `spilledResponseBytes()` alone,
|
|
799
|
+
* because a publication that has not finished is still consuming the volume. On Windows
|
|
800
|
+
* the gap between "queued" and "installed" is however long `icacls` takes, and the
|
|
801
|
+
* measured incident this cap answers accumulated 6.8 GiB in 44 minutes.
|
|
802
|
+
*/
|
|
803
|
+
function accountedResponseSpillBytes(): number {
|
|
804
|
+
// Superseded generations a pending job still owns are files on disk too. A same-id
|
|
805
|
+
// replacement removes the old spill from `states` and hands its ref to the job, so
|
|
806
|
+
// counting only `states` plus `pendingSpillUnlinks` loses it for the whole publication
|
|
807
|
+
// — during a copy fallback that is old generation + new temp + new destination, three
|
|
808
|
+
// envelopes priced as two.
|
|
809
|
+
let ownedBySpillJobs = 0;
|
|
810
|
+
for (const job of pendingResponseSpills) {
|
|
811
|
+
if (job.supersededSpill) ownedBySpillJobs += job.supersededSpill.payloadBytes;
|
|
812
|
+
}
|
|
813
|
+
return spilledResponseBytes() + reservedResponseSpillBytes + ownedBySpillJobs
|
|
814
|
+
+ reconcileUnreclaimableSpillPaths();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Test-only: lower/restore the durable spill cap (null restores the default). */
|
|
818
|
+
export function setSpilledResponseByteCapForTests(bytes: number | null): void {
|
|
819
|
+
spillByteCapOverride = bytes;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Test-only: current durable spill accounting (proves evictions unlink their files). */
|
|
823
|
+
export function getSpilledResponseBytesForTests(): number {
|
|
824
|
+
return spilledResponseBytes();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Test-only: on-disk bytes plus in-flight publication reservations. */
|
|
828
|
+
export function getAccountedResponseSpillBytesForTests(): number {
|
|
829
|
+
return accountedResponseSpillBytes();
|
|
830
|
+
}
|
|
831
|
+
|
|
606
832
|
function serializedBytes(value: unknown): number | null {
|
|
607
833
|
try {
|
|
608
834
|
const serialized = JSON.stringify(value);
|
|
@@ -1495,6 +1721,60 @@ export function replayOverlapSkipsForTests(): number {
|
|
|
1495
1721
|
return replayOverlapSkips;
|
|
1496
1722
|
}
|
|
1497
1723
|
|
|
1724
|
+
/**
|
|
1725
|
+
* Bring the durable spill set inside MAX_SPILLED_RESPONSE_BYTES, and report the
|
|
1726
|
+
* bytes released.
|
|
1727
|
+
*
|
|
1728
|
+
* One owner, three callers: mutation pruning, the lazy load that follows a
|
|
1729
|
+
* restart, and the periodic sweep. The periodic caller is not redundant — the
|
|
1730
|
+
* mutation path only runs when traffic arrives, and a process can come up over
|
|
1731
|
+
* budget from a snapshot written under a larger ceiling and then sit idle. That
|
|
1732
|
+
* was observed in production at 1.8 GiB against a 1 GiB cap, held until the first
|
|
1733
|
+
* request.
|
|
1734
|
+
*
|
|
1735
|
+
* NOT covered here: spill files orphaned by a crash. They are absent from
|
|
1736
|
+
* `states`, so this function can neither see nor price them, and they stay with
|
|
1737
|
+
* recoverOrphanedResponseSpills and its RESPONSE_SPILL_ORPHAN_GRACE_MS window.
|
|
1738
|
+
* This ceiling therefore bounds what the store owns, which is every file it can
|
|
1739
|
+
* account for, and not the directory as a whole.
|
|
1740
|
+
*/
|
|
1741
|
+
function enforceSpilledResponseBudget(): number {
|
|
1742
|
+
// Price in-flight publications too: a file being created by
|
|
1743
|
+
// `writeResponseSpillDurablyAsync` occupies the volume before it reaches `states`.
|
|
1744
|
+
let spilledBytes = accountedResponseSpillBytes();
|
|
1745
|
+
if (spilledBytes <= spillByteCap()) return 0;
|
|
1746
|
+
const before = spilledBytes;
|
|
1747
|
+
// Deferred generations go first. They are already superseded, so releasing one
|
|
1748
|
+
// costs only the crash window the queue exists to cover — the same trade
|
|
1749
|
+
// PENDING_SPILL_UNLINKS_MAX already makes against unbounded disk. Evicting a
|
|
1750
|
+
// live continuation to make room for a dead file would be the wrong order.
|
|
1751
|
+
while (spilledBytes > spillByteCap() && pendingSpillUnlinks.length > 0) {
|
|
1752
|
+
const ref = pendingSpillUnlinks.shift()!;
|
|
1753
|
+
spilledBytes -= ref.payloadBytes;
|
|
1754
|
+
deleteResponseSpill(ref);
|
|
1755
|
+
}
|
|
1756
|
+
// Ordered by createdAt, not by map order. `states` is not an age index:
|
|
1757
|
+
// demotion and spill replacement delete and reinsert entries, and
|
|
1758
|
+
// writeBoundedSnapshot serializes the map reversed, so map order can put a
|
|
1759
|
+
// newer continuation first — and evicting that one spends a resume the older
|
|
1760
|
+
// entry would not have cost. Sorting is O(k log k) over the spilled subset and
|
|
1761
|
+
// runs only on a tick already over budget.
|
|
1762
|
+
const spilled = [...states]
|
|
1763
|
+
.filter((pair): pair is [string, SpilledResponseState] => pair[1].kind === "spill")
|
|
1764
|
+
// createdAt is millisecond-resolution, so ties are ordinary under load. A
|
|
1765
|
+
// stable sort would then fall back to insertion order — the very order this
|
|
1766
|
+
// is avoiding — so break ties on the response id. Not localeCompare: the
|
|
1767
|
+
// order must not depend on the host locale.
|
|
1768
|
+
.sort((a, b) => a[1].createdAt - b[1].createdAt
|
|
1769
|
+
|| (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
1770
|
+
for (const [id, entry] of spilled) {
|
|
1771
|
+
if (spilledBytes <= spillByteCap()) break;
|
|
1772
|
+
spilledBytes -= entry.spill.payloadBytes;
|
|
1773
|
+
deleteEntry(id);
|
|
1774
|
+
}
|
|
1775
|
+
return before - spilledBytes;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1498
1778
|
function pruneResponses(at = now()): void {
|
|
1499
1779
|
for (const [id, state] of states) {
|
|
1500
1780
|
if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
|
|
@@ -1537,6 +1817,7 @@ function pruneResponses(at = now()): void {
|
|
|
1537
1817
|
replaceWithSpillFailure(oldestId, entry);
|
|
1538
1818
|
}
|
|
1539
1819
|
}
|
|
1820
|
+
enforceSpilledResponseBudget();
|
|
1540
1821
|
}
|
|
1541
1822
|
|
|
1542
1823
|
/** Periodic TTL-only sweep; count/byte eviction remains owned by mutation paths. */
|
|
@@ -1547,7 +1828,10 @@ export function sweepExpiredResponseStates(at = now()): number {
|
|
|
1547
1828
|
deleteEntry(id);
|
|
1548
1829
|
removed += 1;
|
|
1549
1830
|
}
|
|
1550
|
-
|
|
1831
|
+
// The disk ceiling needs a caller that does not depend on traffic. The return
|
|
1832
|
+
// value stays the TTL count so this function's existing contract is unchanged.
|
|
1833
|
+
const reclaimed = enforceSpilledResponseBudget();
|
|
1834
|
+
if (removed > 0 || reclaimed > 0) schedulePersist();
|
|
1551
1835
|
return removed;
|
|
1552
1836
|
}
|
|
1553
1837
|
|
|
@@ -1995,6 +2279,8 @@ export function clearResponseStateMemoryForTests(): void {
|
|
|
1995
2279
|
export function clearResponseStateForTests(): void {
|
|
1996
2280
|
for (const entry of states.values()) deleteOwnedSpills(entry);
|
|
1997
2281
|
clearResponseStateMemoryForTests();
|
|
2282
|
+
reservedResponseSpillBytes = 0;
|
|
2283
|
+
unreclaimableSpillPaths.clear();
|
|
1998
2284
|
try {
|
|
1999
2285
|
unlinkSync(snapshotPath());
|
|
2000
2286
|
} catch {
|
|
@@ -21,6 +21,7 @@ import { createIntegrationStateStore, type IntegrationStateStore } from "../../i
|
|
|
21
21
|
import {
|
|
22
22
|
applyIntegrationCoordinated,
|
|
23
23
|
disableIntegrationCoordinated,
|
|
24
|
+
overwriteIntegrationCoordinated,
|
|
24
25
|
restoreIntegrationCoordinated,
|
|
25
26
|
type IntegrationRestoreInput,
|
|
26
27
|
type IntegrationWriteInput,
|
|
@@ -70,7 +71,7 @@ export interface IntegrationJournalEnvelope {
|
|
|
70
71
|
export interface IntegrationJournalRow {
|
|
71
72
|
opId: string;
|
|
72
73
|
clientId: IntegrationClientId;
|
|
73
|
-
kind: "apply" | "disable" | "refresh" | "restore";
|
|
74
|
+
kind: "apply" | "disable" | "refresh" | "restore" | "overwrite";
|
|
74
75
|
at: string;
|
|
75
76
|
configPath: string;
|
|
76
77
|
snapshot: "none" | "stored" | "expired";
|
|
@@ -79,6 +80,14 @@ export interface IntegrationJournalRow {
|
|
|
79
80
|
|
|
80
81
|
export interface IntegrationToggleBody {
|
|
81
82
|
enabled: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Opt in to replacing a conflicted block with the one opencodex would write.
|
|
85
|
+
*
|
|
86
|
+
* Absent and `false` behave identically and are the only states a caller
|
|
87
|
+
* reaches by accident, which is the point: the conflict refusal protects work
|
|
88
|
+
* we did not author, so it can only be waived by asking for it by name.
|
|
89
|
+
*/
|
|
90
|
+
overwriteConflict?: boolean;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
export interface IntegrationRestoreBody {
|
|
@@ -472,16 +481,38 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
472
481
|
code: "invalid_enabled",
|
|
473
482
|
}, 400, req, ctx.config);
|
|
474
483
|
}
|
|
484
|
+
if (parsed.overwriteConflict !== undefined && typeof parsed.overwriteConflict !== "boolean") {
|
|
485
|
+
return jsonResponse({
|
|
486
|
+
error: "overwriteConflict must be a boolean",
|
|
487
|
+
code: "invalid_overwrite_conflict",
|
|
488
|
+
}, 400, req, ctx.config);
|
|
489
|
+
}
|
|
490
|
+
/*
|
|
491
|
+
* Rejected rather than ignored. Disabling a block we do not own is precisely
|
|
492
|
+
* the deletion this subsystem exists to prevent, so a caller sending this
|
|
493
|
+
* combination has misunderstood the field, and silently dropping it would
|
|
494
|
+
* answer 200 for a request whose intent we refused.
|
|
495
|
+
*/
|
|
496
|
+
if (parsed.overwriteConflict === true && parsed.enabled === false) {
|
|
497
|
+
return jsonResponse({
|
|
498
|
+
error: "overwriteConflict applies only to enabling an integration",
|
|
499
|
+
code: "invalid_overwrite_conflict",
|
|
500
|
+
}, 400, req, ctx.config);
|
|
501
|
+
}
|
|
475
502
|
|
|
476
503
|
try {
|
|
477
504
|
const input = await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
|
|
478
505
|
const result = await runIntegrationMutationFlight(
|
|
479
506
|
requestedClient,
|
|
480
|
-
parsed.enabled ? "apply" : "disable",
|
|
507
|
+
parsed.enabled ? (parsed.overwriteConflict === true ? "overwrite" : "apply") : "disable",
|
|
481
508
|
input.io?.now ?? Date.now,
|
|
482
|
-
() =>
|
|
483
|
-
|
|
484
|
-
|
|
509
|
+
() => {
|
|
510
|
+
const options = { lockSeams: integrationMutationTestHooks?.lockSeams };
|
|
511
|
+
if (!parsed.enabled) return disableIntegrationCoordinated(input, options);
|
|
512
|
+
return parsed.overwriteConflict === true
|
|
513
|
+
? overwriteIntegrationCoordinated(input, options)
|
|
514
|
+
: applyIntegrationCoordinated(input, options);
|
|
515
|
+
},
|
|
485
516
|
);
|
|
486
517
|
if (!result.ok) return writerFailureResponse(requestedClient, result, ctx);
|
|
487
518
|
return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config);
|
|
@@ -256,10 +256,46 @@ export async function handleManagementAPI(
|
|
|
256
256
|
if (routed) return routed;
|
|
257
257
|
|
|
258
258
|
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
259
|
-
const {
|
|
260
|
-
|
|
259
|
+
const { installedServiceRespawnRisk, stopServiceIfInstalledDetailed, isServiceOwnershipError } = await import("../service");
|
|
260
|
+
// `ocx stop` performs its own shared teardown AFTER verifying the scheduler did not
|
|
261
|
+
// respawn the proxy (#3008). Without this the child restores native Codex and strips
|
|
262
|
+
// the Grok fence here, so a survivor found moments later has already had the shared
|
|
263
|
+
// config pulled out from under it — and the parent's `ownershipBlocked` guard can
|
|
264
|
+
// only prevent a second, redundant teardown. A direct caller sends nothing and keeps
|
|
265
|
+
// the self-contained behaviour.
|
|
266
|
+
//
|
|
267
|
+
// The query flag alone is not enough to hand over the obligation: any authenticated
|
|
268
|
+
// caller could set it and simply exit, leaving client config pointed at a proxy that
|
|
269
|
+
// no longer exists. Honour the deferral only when the caller left a pending-teardown
|
|
270
|
+
// receipt on disk, which a later stop/update can find and finish.
|
|
271
|
+
// Decide BEFORE touching the manager. Stopping the Task Scheduler task and then
|
|
272
|
+
// refusing left the proxy running with its manager stopped — worse than either
|
|
273
|
+
// outcome. This process cannot verify its own post-exit respawn window; only the
|
|
274
|
+
// receipt-backed parent `ocx stop` can, which is what the deferral exists for.
|
|
275
|
+
const { deferralMatchesReceipt } = await import("../config/pending-teardown");
|
|
276
|
+
const { deferralHonored, performStopTeardown } = await import("./stop-teardown");
|
|
277
|
+
const holdsReceipt = deferralHonored(url, deferralMatchesReceipt);
|
|
278
|
+
const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();
|
|
279
|
+
if (respawnRisk === "respawnable") {
|
|
280
|
+
return jsonResponse({
|
|
281
|
+
success: false,
|
|
282
|
+
code: "respawnable_service",
|
|
283
|
+
message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.",
|
|
284
|
+
}, 409, req, config);
|
|
285
|
+
}
|
|
286
|
+
if (respawnRisk === "unknown") {
|
|
287
|
+
// Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop
|
|
288
|
+
// failure, so that advice would be a loop. The scheduler query itself is what needs
|
|
289
|
+
// fixing (#3008).
|
|
290
|
+
return jsonResponse({
|
|
291
|
+
success: false,
|
|
292
|
+
code: "service_state_unknown",
|
|
293
|
+
message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Nothing was changed. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.",
|
|
294
|
+
}, 409, req, config);
|
|
295
|
+
}
|
|
296
|
+
let serviceStop: import("../service").ServiceStopOutcome;
|
|
261
297
|
try {
|
|
262
|
-
|
|
298
|
+
serviceStop = stopServiceIfInstalledDetailed();
|
|
263
299
|
} catch (err) {
|
|
264
300
|
if (isServiceOwnershipError(err)) {
|
|
265
301
|
// The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn
|
|
@@ -269,12 +305,30 @@ export async function handleManagementAPI(
|
|
|
269
305
|
}
|
|
270
306
|
throw err;
|
|
271
307
|
}
|
|
272
|
-
|
|
308
|
+
// The boolean helper collapses "failed" into the same false as "no service installed",
|
|
309
|
+
// so this route used to tear down shared config and exit while a manager that refused
|
|
310
|
+
// to stop was still there to respawn the proxy (#3008).
|
|
311
|
+
if (serviceStop === "failed") {
|
|
312
|
+
return jsonResponse({
|
|
313
|
+
success: false,
|
|
314
|
+
message: "The installed service manager did not stop; it may respawn the proxy. Shared client config was left alone. Run `ocx stop` from the home that owns the service.",
|
|
315
|
+
}, 409, req, config);
|
|
316
|
+
}
|
|
317
|
+
if (serviceStop === "state-unknown") {
|
|
318
|
+
// Same case, same remedy as the pre-check: the query is what needs fixing.
|
|
319
|
+
return jsonResponse({
|
|
320
|
+
success: false,
|
|
321
|
+
code: "service_state_unknown",
|
|
322
|
+
message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Shared client config was left alone. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.",
|
|
323
|
+
}, 409, req, config);
|
|
324
|
+
}
|
|
325
|
+
// The pre-check above already refused the respawnable case without a receipt, so
|
|
326
|
+
// reaching here with one means the parent owns the verification.
|
|
273
327
|
// Both managed configs come down together on an explicit teardown. The daemon's own
|
|
274
328
|
// syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence),
|
|
275
|
-
// which is exactly why an intentional stop has to do it here
|
|
276
|
-
|
|
277
|
-
const
|
|
329
|
+
// which is exactly why an intentional stop has to do it here — unless the caller is
|
|
330
|
+
// `ocx stop`, which does it itself once the proxy is proven down.
|
|
331
|
+
const teardown = await performStopTeardown(url, { ownsReceipt: deferralMatchesReceipt });
|
|
278
332
|
setTimeout(async () => {
|
|
279
333
|
let shutdownSucceeded = false;
|
|
280
334
|
try {
|
|
@@ -282,12 +336,12 @@ export async function handleManagementAPI(
|
|
|
282
336
|
} catch {
|
|
283
337
|
console.warn("[opencodex] shutdown drain failed");
|
|
284
338
|
}
|
|
285
|
-
|
|
339
|
+
// A drained proxy whose shared teardown failed did not finish the job. Exiting 0
|
|
340
|
+
// told a supervisor the stop was clean while native Codex or the Grok fence was
|
|
341
|
+
// still pointed at this process (#3008).
|
|
342
|
+
process.exit(shutdownSucceeded && teardown.success ? 0 : 1);
|
|
286
343
|
}, 200);
|
|
287
|
-
|
|
288
|
-
return jsonResponse(restore.success
|
|
289
|
-
? { success: true, message: `Proxy stopping, native Codex restored.${grokNote}` }
|
|
290
|
-
: { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` });
|
|
344
|
+
return jsonResponse(teardown);
|
|
291
345
|
}
|
|
292
346
|
|
|
293
347
|
if (url.pathname.startsWith("/api/native-main-profiles")) {
|
|
@@ -175,7 +175,24 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[])
|
|
|
175
175
|
return `${text}${payload.slice(last)}`;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
/**
|
|
179
|
+
* The routing header codex-rs writes above a delegated agent payload.
|
|
180
|
+
*
|
|
181
|
+
* `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK --
|
|
182
|
+
* recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the
|
|
183
|
+
* parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector
|
|
184
|
+
* decides "unreadable" by stripping the envelope and asking whether any plaintext
|
|
185
|
+
* survives, so an envelope shape it does not recognise counts as surviving text: a
|
|
186
|
+
* `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded
|
|
187
|
+
* verbatim.
|
|
188
|
+
*
|
|
189
|
+
* Widening the strip is not the same as widening recovery. Recovery decrypts, and
|
|
190
|
+
* decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a
|
|
191
|
+
* payload the parent's session may have no right to read. This only lets the proxy
|
|
192
|
+
* NOTICE that what it is about to forward is unreadable ciphertext, which is what the
|
|
193
|
+
* report asks for: fail closed with a structured error rather than paste the token.
|
|
194
|
+
*/
|
|
195
|
+
export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
|
|
179
196
|
|
|
180
197
|
// CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
|
|
181
198
|
// later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
|