@bitkyc08/opencodex 2.67.0 → 2.68.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 +3 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/App-mpsibsFj.js +51 -0
- package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-kUE8tuAK.js +86 -0
- package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { loadConfig } from "../config";
|
|
3
|
+
import { readRuntimePort } from "../config/process-state";
|
|
4
|
+
import { readClientConnectionState } from "../client/state";
|
|
5
|
+
import { appendDebugLogLine } from "../lib/debug-log-buffer";
|
|
6
|
+
import { redactUserPath } from "../lib/redact";
|
|
7
|
+
import { isRecyclingForExit, isShutdownDraining } from "../server/lifecycle";
|
|
8
|
+
import { probeEndpointLiveness, type EndpointLiveness } from "../server/proxy-liveness";
|
|
9
|
+
import type { OcxConfig } from "../types";
|
|
10
|
+
import { shouldSyncCodexOnStart } from "./desired-state";
|
|
11
|
+
import { injectCodexConfig, type CodexInjectResult, type InjectCodexOptions } from "./inject";
|
|
12
|
+
import { standaloneCodexRoutingTarget } from "./inject/routing-target";
|
|
13
|
+
import { journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl, journalOwner } from "./journal";
|
|
14
|
+
import { effectiveLoopbackListenerPort, shouldInjectApiAuthHeader } from "./loopback-target";
|
|
15
|
+
import { CODEX_CONFIG_PATH } from "./paths";
|
|
16
|
+
import {
|
|
17
|
+
detectCodexRoutingDrift,
|
|
18
|
+
type CodexRoutingDrift,
|
|
19
|
+
type CodexRoutingDriftTarget,
|
|
20
|
+
type JournaledCodexRouting,
|
|
21
|
+
} from "./routing-drift";
|
|
22
|
+
import { siblingOfLivePort } from "./sibling-start";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The live owner re-points Codex routing that a dead instance left on another loopback port.
|
|
26
|
+
*
|
|
27
|
+
* Startup sync is the last time an owner looks at `config.toml`. When something else re-points the
|
|
28
|
+
* opencodex-owned routing at its own port and then dies, every new Codex thread fails with
|
|
29
|
+
* "Connection refused" while this proxy keeps serving its own port. This loop closes that gap.
|
|
30
|
+
*
|
|
31
|
+
* Cost: one read of `config.toml` every 10 s from an unref'd timer, and nothing else while the
|
|
32
|
+
* bytes are unchanged. Probes, gates and the journal run only while owned routing names a port this
|
|
33
|
+
* process does not serve. Nothing here touches the request path.
|
|
34
|
+
*
|
|
35
|
+
* It writes only when every foreign target has been dead on at least two probes spanning 20 s, a
|
|
36
|
+
* final probe still says dead, and every gate is open. The write is the ordinary injector with no
|
|
37
|
+
* catalog path (so no catalog gather and the current `model_catalog_json` stays), a short lock
|
|
38
|
+
* timeout, and a synchronous guard that re-reads `config.toml` under the lock and aborts when the
|
|
39
|
+
* routing changed since the probe. A refusal that raced another write of `config.toml` is the same
|
|
40
|
+
* abort, not a refusal. A live foreign opencodex is never fought; an unknown answer never advances
|
|
41
|
+
* the streak. The caps pause the loop for at most an hour and never stop it. Detection, gates and
|
|
42
|
+
* probes read the journal read-only, so watching never deletes an unreadable journal (a heal write
|
|
43
|
+
* goes through the injector's ordinary journal handling). Started only from `handleStart`'s owner
|
|
44
|
+
* path and stopped first in its exit cleanup.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export const ROUTING_HEAL_TICK_MS = 10_000;
|
|
48
|
+
export const ROUTING_HEAL_MIN_DEAD_PROBES = 2;
|
|
49
|
+
export const ROUTING_HEAL_MIN_DEAD_SPAN_MS = 20_000;
|
|
50
|
+
export const ROUTING_HEAL_LOCK_TIMEOUT_MS = 1_000;
|
|
51
|
+
/** While the bytes stay equal, a live foreign owner, an unknown answer or a closed gate is looked at again only this often. */
|
|
52
|
+
export const ROUTING_HEAL_RECHECK_MS = 30_000;
|
|
53
|
+
export const ROUTING_HEAL_REFUSED_BACKOFF_MS = 10 * 60_000;
|
|
54
|
+
export const ROUTING_HEAL_WINDOW_MS = 60 * 60_000;
|
|
55
|
+
/** Rate cap: write attempts (busy ones included) per rolling window; more pause until the oldest leaves it. */
|
|
56
|
+
export const ROUTING_HEAL_MAX_ATTEMPTS = 6;
|
|
57
|
+
/**
|
|
58
|
+
* Flap cap: this many successful heals in one window means another writer keeps re-pointing it, so a
|
|
59
|
+
* further heal waits until the oldest leaves the window, then needs a fresh dead streak.
|
|
60
|
+
*/
|
|
61
|
+
export const ROUTING_HEAL_MAX_HEALS = 3;
|
|
62
|
+
|
|
63
|
+
export type CodexRoutingHealGate =
|
|
64
|
+
| "sibling"
|
|
65
|
+
| "exiting"
|
|
66
|
+
| "runtime-record"
|
|
67
|
+
| "codex-off"
|
|
68
|
+
| "admission-token"
|
|
69
|
+
| "target-not-served"
|
|
70
|
+
| "client";
|
|
71
|
+
|
|
72
|
+
export interface CodexRoutingHealGates {
|
|
73
|
+
siblingOfLivePort(): number | null;
|
|
74
|
+
/** True while the process is recycling for exit or draining for shutdown. */
|
|
75
|
+
exiting(): boolean;
|
|
76
|
+
/** The port in this home's runtime record when that record names this process. */
|
|
77
|
+
runtimePortOfThisProcess(): number | null;
|
|
78
|
+
loadConfig(): OcxConfig;
|
|
79
|
+
/** Any client-connection state other than disconnected (connected, link, invalid). */
|
|
80
|
+
clientConnected(): boolean;
|
|
81
|
+
/** The journal names a connected client as its owner (read-only). */
|
|
82
|
+
clientJournalOwner(): boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type CodexRoutingHealGateResult =
|
|
86
|
+
| { readonly open: true; readonly config: OcxConfig; readonly targetPort: number }
|
|
87
|
+
| { readonly open: false; readonly gate: CodexRoutingHealGate };
|
|
88
|
+
|
|
89
|
+
/** Cheapest first; `loadConfig` and the journal only when the in-memory gates are open. */
|
|
90
|
+
export function evaluateCodexRoutingHealGates(
|
|
91
|
+
port: number,
|
|
92
|
+
ownPorts: ReadonlySet<number>,
|
|
93
|
+
gates: CodexRoutingHealGates,
|
|
94
|
+
): CodexRoutingHealGateResult {
|
|
95
|
+
if (gates.siblingOfLivePort() !== null) return { open: false, gate: "sibling" };
|
|
96
|
+
if (gates.exiting()) return { open: false, gate: "exiting" };
|
|
97
|
+
if (gates.runtimePortOfThisProcess() !== port) return { open: false, gate: "runtime-record" };
|
|
98
|
+
const config = gates.loadConfig();
|
|
99
|
+
// Codex ON and not hub-gated; the injector re-checks this under its lock.
|
|
100
|
+
if (!shouldSyncCodexOnStart(config)) return { open: false, gate: "codex-off" };
|
|
101
|
+
if (shouldInjectApiAuthHeader(config)) return { open: false, gate: "admission-token" };
|
|
102
|
+
const targetPort = Number(new URL(standaloneCodexRoutingTarget(port, config).baseUrl).port);
|
|
103
|
+
if (!ownPorts.has(targetPort)) return { open: false, gate: "target-not-served" };
|
|
104
|
+
if (gates.clientConnected() || gates.clientJournalOwner()) return { open: false, gate: "client" };
|
|
105
|
+
return { open: true, config, targetPort };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface CodexRoutingHealRecord {
|
|
109
|
+
readonly at: string;
|
|
110
|
+
readonly fromUrls: readonly string[];
|
|
111
|
+
readonly toPort: number;
|
|
112
|
+
readonly probes: number;
|
|
113
|
+
readonly deadForMs: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CodexRoutingHealerHandle {
|
|
117
|
+
stop(): void;
|
|
118
|
+
lastHeal(): CodexRoutingHealRecord | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface CodexRoutingHealerDeps {
|
|
122
|
+
/** Schedule `fn` after `ms`; defaults to an unref'd setTimeout. */
|
|
123
|
+
scheduleFn?: (fn: () => void, ms: number) => { cancel(): void };
|
|
124
|
+
/** Monotonic milliseconds. */
|
|
125
|
+
now?: () => number;
|
|
126
|
+
/** `config.toml` text, or null when absent or unreadable. */
|
|
127
|
+
readConfig?: () => string | null;
|
|
128
|
+
readJournaled?: () => JournaledCodexRouting;
|
|
129
|
+
probe?: (target: { port: number; hostname: string }) => Promise<EndpointLiveness>;
|
|
130
|
+
inject?: (port: number, config: OcxConfig, options: InjectCodexOptions) => Promise<CodexInjectResult>;
|
|
131
|
+
gates?: Partial<CodexRoutingHealGates>;
|
|
132
|
+
log?: Pick<Console, "warn">;
|
|
133
|
+
debugLine?: (line: string) => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Thrown by the under-lock guard: the routing changed after it was proven dead. */
|
|
137
|
+
class RoutingHealAborted extends Error {
|
|
138
|
+
constructor() {
|
|
139
|
+
super("Codex routing changed before the heal could be written.");
|
|
140
|
+
this.name = "RoutingHealAborted";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
|
|
145
|
+
const timer = setTimeout(fn, ms);
|
|
146
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
147
|
+
return { cancel: () => clearTimeout(timer) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readCodexConfigText(): string | null {
|
|
151
|
+
try {
|
|
152
|
+
return readFileSync(CODEX_CONFIG_PATH, "utf8");
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const defaultGates: CodexRoutingHealGates = {
|
|
159
|
+
siblingOfLivePort,
|
|
160
|
+
exiting: () => isRecyclingForExit() || isShutdownDraining(),
|
|
161
|
+
runtimePortOfThisProcess: () => readRuntimePort(process.pid)?.port ?? null,
|
|
162
|
+
loadConfig,
|
|
163
|
+
clientConnected: () => readClientConnectionState().kind !== "disconnected",
|
|
164
|
+
clientJournalOwner: () => journalOwner({ readOnly: true })?.kind === "client",
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** The first line of an injector or error message, with home paths and secret-shaped values masked. */
|
|
168
|
+
function logDetail(message: string): string {
|
|
169
|
+
return redactUserPath(message.split("\n")[0] ?? "");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function endpointLabel(target: CodexRoutingDriftTarget): string {
|
|
173
|
+
return `${target.hostname.includes(":") ? `[${target.hostname}]` : target.hostname}:${target.port}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function endpointKey(target: Pick<CodexRoutingDriftTarget, "hostname" | "port">): string {
|
|
177
|
+
return JSON.stringify([target.hostname, target.port]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function startCodexRoutingHealer(options: {
|
|
181
|
+
port: number;
|
|
182
|
+
config: OcxConfig;
|
|
183
|
+
deps?: CodexRoutingHealerDeps;
|
|
184
|
+
}): CodexRoutingHealerHandle {
|
|
185
|
+
const { port, config } = options;
|
|
186
|
+
const deps = options.deps ?? {};
|
|
187
|
+
let stopped = false;
|
|
188
|
+
let pending: { cancel(): void } | undefined;
|
|
189
|
+
let last: CodexRoutingHealRecord | null = null;
|
|
190
|
+
const handle: CodexRoutingHealerHandle = {
|
|
191
|
+
stop() {
|
|
192
|
+
stopped = true;
|
|
193
|
+
pending?.cancel();
|
|
194
|
+
pending = undefined;
|
|
195
|
+
},
|
|
196
|
+
lastHeal: () => last,
|
|
197
|
+
};
|
|
198
|
+
// Admission-token (non-loopback) routing is never healed, and the bind scope is fixed for the
|
|
199
|
+
// process lifetime, so such an owner starts no timer at all.
|
|
200
|
+
if (shouldInjectApiAuthHeader(config)) return handle;
|
|
201
|
+
|
|
202
|
+
const listenerPort = effectiveLoopbackListenerPort(config, port);
|
|
203
|
+
const ownPorts: ReadonlySet<number> = new Set(listenerPort === null ? [port] : [port, listenerPort]);
|
|
204
|
+
const scheduleFn = deps.scheduleFn ?? defaultSchedule;
|
|
205
|
+
const clock = deps.now ?? (() => performance.now());
|
|
206
|
+
const readConfig = deps.readConfig ?? readCodexConfigText;
|
|
207
|
+
const readJournaled = deps.readJournaled ?? ((): JournaledCodexRouting => ({
|
|
208
|
+
openaiBaseUrl: journaledInjectedOpenaiBaseUrl({ readOnly: true }),
|
|
209
|
+
realtimeWsBaseUrl: journaledInjectedRealtimeWsBaseUrl({ readOnly: true }),
|
|
210
|
+
}));
|
|
211
|
+
const probe = deps.probe ?? (target => probeEndpointLiveness(target));
|
|
212
|
+
const inject = deps.inject ?? injectCodexConfig;
|
|
213
|
+
const gates: CodexRoutingHealGates = { ...defaultGates, ...deps.gates };
|
|
214
|
+
const log = deps.log ?? console;
|
|
215
|
+
const debugLine = deps.debugLine ?? appendDebugLogLine;
|
|
216
|
+
|
|
217
|
+
// The verdict for the last bytes read. Unchanged bytes with a non-foreign verdict cost nothing.
|
|
218
|
+
let cache: { content: string; kind: CodexRoutingDrift["kind"] } | null = null;
|
|
219
|
+
let streak: { key: string; firstDeadAt: number; probes: number } | null = null;
|
|
220
|
+
let recheckAt = 0;
|
|
221
|
+
/** After a real refusal; cleared as soon as routing is not foreign any more. */
|
|
222
|
+
let backoffUntil = 0;
|
|
223
|
+
/** While a cap is reached: until the oldest counted attempt or heal leaves the window. */
|
|
224
|
+
let pausedUntil = 0;
|
|
225
|
+
const announcedLive = new Set<string>();
|
|
226
|
+
const attempts: number[] = [];
|
|
227
|
+
const heals: number[] = [];
|
|
228
|
+
let rateCapAnnounced = false;
|
|
229
|
+
let flapCapAnnounced = false;
|
|
230
|
+
|
|
231
|
+
const warn = (line: string) => {
|
|
232
|
+
try { log.warn(line); } catch { /* logging never breaks the loop */ }
|
|
233
|
+
try { debugLine(line); } catch { /* the debug buffer is best-effort */ }
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const detect = (content: string): CodexRoutingDrift =>
|
|
237
|
+
detectCodexRoutingDrift(content, { ownPorts, journaled: readJournaled });
|
|
238
|
+
|
|
239
|
+
/** "live" wins over "unknown", which wins over "dead": all must be dead to proceed. */
|
|
240
|
+
const probeAll = async (targets: readonly CodexRoutingDriftTarget[]): Promise<{ verdict: EndpointLiveness; liveEndpoints: CodexRoutingDriftTarget[] }> => {
|
|
241
|
+
const byEndpoint = new Map<string, CodexRoutingDriftTarget>();
|
|
242
|
+
for (const target of targets) byEndpoint.set(endpointKey(target), target);
|
|
243
|
+
const liveEndpoints: CodexRoutingDriftTarget[] = [];
|
|
244
|
+
let unknown = false;
|
|
245
|
+
for (const target of byEndpoint.values()) {
|
|
246
|
+
let result: EndpointLiveness;
|
|
247
|
+
try { result = await probe({ port: target.port, hostname: target.hostname }); }
|
|
248
|
+
catch { result = "unknown"; }
|
|
249
|
+
if (result === "live") liveEndpoints.push(target);
|
|
250
|
+
else if (result === "unknown") unknown = true;
|
|
251
|
+
}
|
|
252
|
+
return { verdict: liveEndpoints.length > 0 ? "live" : unknown ? "unknown" : "dead", liveEndpoints };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const standDownForLive = (liveEndpoints: readonly CodexRoutingDriftTarget[]) => {
|
|
256
|
+
streak = null;
|
|
257
|
+
recheckAt = clock() + ROUTING_HEAL_RECHECK_MS;
|
|
258
|
+
for (const target of liveEndpoints) {
|
|
259
|
+
const key = endpointKey(target);
|
|
260
|
+
if (announcedLive.has(key)) continue;
|
|
261
|
+
announcedLive.add(key);
|
|
262
|
+
warn(`Codex routing points at another running opencodex on ${endpointLabel(target)}; leaving it.`);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** A cap pauses probing and writing until `until`; the streak starts over once it has passed. */
|
|
267
|
+
const pauseUntil = (until: number) => {
|
|
268
|
+
streak = null;
|
|
269
|
+
pausedUntil = until;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const heal = async (
|
|
273
|
+
content: string,
|
|
274
|
+
targets: readonly CodexRoutingDriftTarget[],
|
|
275
|
+
proven: { firstDeadAt: number; probes: number },
|
|
276
|
+
) => {
|
|
277
|
+
const now = clock();
|
|
278
|
+
while (attempts.length > 0 && now - attempts[0]! >= ROUTING_HEAL_WINDOW_MS) attempts.shift();
|
|
279
|
+
while (heals.length > 0 && now - heals[0]! >= ROUTING_HEAL_WINDOW_MS) heals.shift();
|
|
280
|
+
if (heals.length >= ROUTING_HEAL_MAX_HEALS) {
|
|
281
|
+
pauseUntil(heals[0]! + ROUTING_HEAL_WINDOW_MS);
|
|
282
|
+
if (!flapCapAnnounced) warn(`Codex routing was re-pointed at a dead port ${heals.length} times within an hour; the self-heal pauses until that hour has passed so it does not fight another writer. Run 'ocx sync' to repair routing now.`);
|
|
283
|
+
flapCapAnnounced = true;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
flapCapAnnounced = false;
|
|
287
|
+
if (attempts.length >= ROUTING_HEAL_MAX_ATTEMPTS) {
|
|
288
|
+
pauseUntil(attempts[0]! + ROUTING_HEAL_WINDOW_MS);
|
|
289
|
+
if (!rateCapAnnounced) warn("Codex routing self-heal paused after repeated attempts; it resumes within the hour. Run 'ocx sync' to repair now.");
|
|
290
|
+
rateCapAnnounced = true;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
rateCapAnnounced = false;
|
|
294
|
+
// One final look right before the write, then the gates again: the probe may have taken seconds.
|
|
295
|
+
const final = await probeAll(targets);
|
|
296
|
+
if (stopped) return; // the exit cleanup ran while the probe was out
|
|
297
|
+
if (final.verdict === "live") { standDownForLive(final.liveEndpoints); return; }
|
|
298
|
+
if (final.verdict !== "dead") { recheckAt = clock() + ROUTING_HEAL_RECHECK_MS; return; }
|
|
299
|
+
const gate = evaluateCodexRoutingHealGates(port, ownPorts, gates);
|
|
300
|
+
if (!gate.open) {
|
|
301
|
+
streak = null;
|
|
302
|
+
if (gate.gate === "sibling") stopped = true;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
attempts.push(clock());
|
|
306
|
+
const deadEndpoints = new Set(targets.map(endpointKey));
|
|
307
|
+
const guard = () => {
|
|
308
|
+
const current = readConfig();
|
|
309
|
+
const drift = current === null ? null : detect(current);
|
|
310
|
+
if (current !== content || drift?.kind !== "foreign"
|
|
311
|
+
|| drift.targets.some(target => !deadEndpoints.has(endpointKey(target)))) {
|
|
312
|
+
throw new RoutingHealAborted();
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
/*
|
|
316
|
+
* Whether a failed write raced another writer. A coordinated home re-reads its admission under
|
|
317
|
+
* the lock BEFORE this guard runs, so a config.toml rewritten between the plan and the lock
|
|
318
|
+
* comes back as a plain refusal. Moved bytes are the same abort as the guard's; only a refusal
|
|
319
|
+
* over the very bytes that were proven dead backs off.
|
|
320
|
+
*/
|
|
321
|
+
const raced = (): boolean => {
|
|
322
|
+
const current = readConfig();
|
|
323
|
+
if (current !== content) return true;
|
|
324
|
+
const drift = detect(current);
|
|
325
|
+
return drift.kind !== "foreign" || drift.targets.some(target => !deadEndpoints.has(endpointKey(target)));
|
|
326
|
+
};
|
|
327
|
+
let result: CodexInjectResult;
|
|
328
|
+
try {
|
|
329
|
+
result = await inject(port, gate.config, { lockTimeoutMs: ROUTING_HEAL_LOCK_TIMEOUT_MS, beforeClientWrite: guard });
|
|
330
|
+
} catch (error) {
|
|
331
|
+
streak = null;
|
|
332
|
+
cache = null;
|
|
333
|
+
if (error instanceof RoutingHealAborted || raced()) return;
|
|
334
|
+
backoffUntil = clock() + ROUTING_HEAL_REFUSED_BACKOFF_MS;
|
|
335
|
+
warn(`Codex routing self-heal failed (${logDetail(error instanceof Error ? error.message : String(error))}); retrying in 10 minutes. Run 'ocx sync' to repair now.`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (result.success && result.status !== "skipped" && result.configApplied !== false) {
|
|
339
|
+
heals.push(clock());
|
|
340
|
+
streak = null;
|
|
341
|
+
cache = null;
|
|
342
|
+
const fromUrls = [...new Set(targets.map(target => target.url))];
|
|
343
|
+
last = {
|
|
344
|
+
at: new Date().toISOString(),
|
|
345
|
+
fromUrls,
|
|
346
|
+
toPort: gate.targetPort,
|
|
347
|
+
probes: proven.probes + 1,
|
|
348
|
+
deadForMs: Math.round(clock() - proven.firstDeadAt),
|
|
349
|
+
};
|
|
350
|
+
const from = [...new Set(targets.map(endpointLabel))].join(", ");
|
|
351
|
+
warn(`Codex routing pointed at ${from}, where no opencodex answers; re-pointed it at this proxy on port ${gate.targetPort}. Codex threads opened meanwhile keep the old address until you reopen them.`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Busy lock: keep the proven streak so the next tick re-probes and tries again.
|
|
355
|
+
if (result.retryable) return;
|
|
356
|
+
streak = null;
|
|
357
|
+
cache = null;
|
|
358
|
+
// Skipped under the lock (Codex OFF, hub, sibling), an external provider appeared, or a race.
|
|
359
|
+
if (result.success || raced()) return;
|
|
360
|
+
backoffUntil = clock() + ROUTING_HEAL_REFUSED_BACKOFF_MS;
|
|
361
|
+
warn(`Codex routing self-heal was refused (${logDetail(result.message)}); retrying in 10 minutes. Run 'ocx sync' to repair now.`);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const evaluate = async () => {
|
|
365
|
+
if (gates.siblingOfLivePort() !== null) { stopped = true; return; }
|
|
366
|
+
const content = readConfig();
|
|
367
|
+
if (content === null) { cache = null; streak = null; return; }
|
|
368
|
+
const unchanged = cache !== null && cache.content === content;
|
|
369
|
+
if (unchanged && cache!.kind !== "foreign") return;
|
|
370
|
+
const drift = detect(content);
|
|
371
|
+
cache = { content, kind: drift.kind };
|
|
372
|
+
if (drift.kind !== "foreign") {
|
|
373
|
+
streak = null;
|
|
374
|
+
recheckAt = 0;
|
|
375
|
+
backoffUntil = 0;
|
|
376
|
+
announcedLive.clear();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const now = clock();
|
|
380
|
+
if (now < backoffUntil || now < pausedUntil) return;
|
|
381
|
+
if (unchanged && now < recheckAt) return;
|
|
382
|
+
const gate = evaluateCodexRoutingHealGates(port, ownPorts, gates);
|
|
383
|
+
if (!gate.open) {
|
|
384
|
+
streak = null;
|
|
385
|
+
recheckAt = now + ROUTING_HEAL_RECHECK_MS;
|
|
386
|
+
if (gate.gate === "sibling") stopped = true;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const probed = await probeAll(drift.targets);
|
|
390
|
+
if (stopped) return;
|
|
391
|
+
if (probed.verdict === "live") { standDownForLive(probed.liveEndpoints); return; }
|
|
392
|
+
// Unknown never advances the streak and never resets it; it is asked again at the recheck pace.
|
|
393
|
+
if (probed.verdict !== "dead") { recheckAt = clock() + ROUTING_HEAL_RECHECK_MS; return; }
|
|
394
|
+
recheckAt = 0;
|
|
395
|
+
const probedAt = clock();
|
|
396
|
+
const key = JSON.stringify([...new Set(drift.targets.map(endpointKey))].sort());
|
|
397
|
+
if (!streak || streak.key !== key) {
|
|
398
|
+
streak = { key, firstDeadAt: probedAt, probes: 1 };
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
streak.probes += 1;
|
|
402
|
+
if (streak.probes < ROUTING_HEAL_MIN_DEAD_PROBES || probedAt - streak.firstDeadAt < ROUTING_HEAL_MIN_DEAD_SPAN_MS) return;
|
|
403
|
+
await heal(content, drift.targets, streak);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const tick = async () => {
|
|
407
|
+
pending = undefined;
|
|
408
|
+
if (stopped) return;
|
|
409
|
+
try { await evaluate(); } catch {
|
|
410
|
+
// A background check never throws into the timer; an unreadable config.json is looked at
|
|
411
|
+
// again at the slower recheck pace rather than on every tick.
|
|
412
|
+
recheckAt = clock() + ROUTING_HEAL_RECHECK_MS;
|
|
413
|
+
}
|
|
414
|
+
if (!stopped) pending = scheduleFn(() => { void tick(); }, ROUTING_HEAL_TICK_MS);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
pending = scheduleFn(() => { void tick(); }, ROUTING_HEAL_TICK_MS);
|
|
418
|
+
return handle;
|
|
419
|
+
}
|
package/src/codex/routing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clearIdleWindowSteering, pickIdleWindowAccount } from "./routing/idle-window";
|
|
1
2
|
import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
|
|
2
3
|
import { codexQuotaHasFreshUsage } from "./quota-observation-freshness";
|
|
3
4
|
import { saveConfigPreservingClaudeCode } from "../config";
|
|
@@ -234,6 +235,7 @@ export function clearCodexUpstreamHealth(): void {
|
|
|
234
235
|
// reset points. Leaving them behind lets a selection from one context suppress the
|
|
235
236
|
// automatic cursor in the next one.
|
|
236
237
|
clearAllManualPreferences();
|
|
238
|
+
clearIdleWindowSteering();
|
|
237
239
|
clearUpstreamHealthState();
|
|
238
240
|
forgetRuntimeActiveCodexAccount();
|
|
239
241
|
// The reconcile watermark is part of this state, not something that outlives it. Keeping
|
|
@@ -786,6 +788,10 @@ export function previewCodexAccountForRequest(
|
|
|
786
788
|
if (lineagePreview) return lineagePreview.accountId;
|
|
787
789
|
}
|
|
788
790
|
|
|
791
|
+
const idlePick = !entry && !peekPendingReleaseReason(threadId)
|
|
792
|
+
&& !(threadId && getModelDetourAffinity(threadId, modelId, quotaScope))
|
|
793
|
+
? pickIdleWindowAccount(config, threadId, now, false, quotaScope, selectionOptions) : null;
|
|
794
|
+
if (idlePick) return idlePick;
|
|
789
795
|
const strategyPick = pickUnboundStrategyAccount(
|
|
790
796
|
config,
|
|
791
797
|
threadId,
|
|
@@ -881,6 +887,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
881
887
|
// cold one: every branch that follows -- detour reuse, transient hold, quota re-eval --
|
|
882
888
|
// should treat it as the continuing conversation it is. No-op on a fresh process.
|
|
883
889
|
if (threadId) adoptLegacyLineageAffinity(threadId, lineage, now, quotaScope, modelId);
|
|
890
|
+
const hadModelAffinity = !!(threadId && getModelDetourAffinity(threadId, modelId, quotaScope));
|
|
884
891
|
|
|
885
892
|
if (threadId && modelScopedSelection) {
|
|
886
893
|
const detourEntry = getModelDetourAffinity(threadId, modelId, quotaScope);
|
|
@@ -1088,6 +1095,9 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1088
1095
|
}
|
|
1089
1096
|
}
|
|
1090
1097
|
|
|
1098
|
+
const idlePick = !entry && !releaseReason && !hadModelAffinity
|
|
1099
|
+
? pickIdleWindowAccount(config, threadId, now, true, quotaScope, selectionOptions) : null;
|
|
1100
|
+
if (idlePick) return { status: "selected", accountId: idlePick, affinity: affinityAfterRelease(threadId, releaseReason) };
|
|
1091
1101
|
// A request-scoped roster may still contain unhealthy candidates. Non-quota strategies return
|
|
1092
1102
|
// before the quota/failover helpers below, so prefer only shared-healthy roster members here;
|
|
1093
1103
|
// otherwise RR/fill-first can immediately re-pick a known failing account even when another
|