Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d82b217
chore(release): open dev at 2.91.0 before releasing 2.90.0 (#7475)
github-actions[bot] Oct 21, 2026
cf9965c
fix(deps): clear high-severity audit findings blocking the 2.90.0 rel…
lidge-jun Oct 21, 2026
5f8bf52
test: cover Claude CLI JSON schema contract (#7387)
adirwt852-cloud Oct 21, 2026
3883e1e
fix(test): arm the real-home write guard when bun test skips the prel…
lidge-jun Oct 21, 2026
9446471
test: make update recovery, messages cancellation, and CLI parity har…
lidge-jun Oct 21, 2026
cdcd39e
fix(auth): refuse Codex runtime AgentAssertion auth and scope dual_he…
lidge-jun Oct 21, 2026
561347c
fix(responses): handle Codex 0.164 request controls (#7473)
lidge-jun Oct 21, 2026
b50cc80
feat(management): passive GET /api/quota-pool (ocx.quota-pool/1 produ…
lidge-jun Oct 21, 2026
2945ebb
feat(readyz): advertise peer features from one registry (#7477)
lidge-jun Oct 21, 2026
072e7fe
test(update): let the test-scoped Windows ACL belt through the host-f…
lidge-jun Oct 21, 2026
7a9dc77
fix(chat): carry Anthropic thinking signatures through reasoning_deta…
lidge-jun Oct 21, 2026
d4ecea1
test(launcher): place PATH-Bun fixtures under a trusted ancestry (#7466)
lidge-jun Oct 21, 2026
79fa350
ci(windows): flag Windows-sensitive PRs for a lane=all dispatch (poli…
lidge-jun Oct 21, 2026
776b1dc
fix(quota-pool): adopt a concurrent winner's identity instead of refu…
lidge-jun Oct 21, 2026
7c08a29
feat(link): parity on apply — restart the Child runtime, record and r…
lidge-jun Oct 21, 2026
6bce298
feat(remote): name the serving Home in x-opencodex-served-by (OCX-2) …
lidge-jun Oct 21, 2026
5740cf0
fix(mistral): scope queue_full to the waiting identity; address #7474…
lidge-jun Oct 21, 2026
e1d15ac
feat(cli): mark provider list rows active when they serve (OCX-12) (#…
lidge-jun Oct 21, 2026
f20afd9
fix(link): refuse Windows Children with a Hub-mode pointer and explai…
lidge-jun Oct 21, 2026
a4f335c
feat(link): report each Child version and a compatibility verdict in …
lidge-jun Oct 21, 2026
bf4eb12
test(server): expect the platform's exit signal for a replacement tha…
lidge-jun Oct 22, 2026
e501371
test: make ten Windows-sensitive flaky tests deterministic (#7501)
lidge-jun Oct 22, 2026
fc034c8
feat(service): passive `ocx service status --json` and install-method…
lidge-jun Oct 22, 2026
0706074
ci(selector): flag process-signal code as Windows-sensitive (#7503)
lidge-jun Oct 22, 2026
1025acd
feat(fallback): OCX-11 fallback off per scope, with home_unreachable …
lidge-jun Oct 22, 2026
63908d5
Merge main ancestry into the 2.91.0 promotion (ours: tree is dev 1025…
lidge-jun Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -997,12 +997,15 @@ jobs:
# now means Linux + macOS + the gates; Windows re-enters the gate when the
# tracked failures are fixed, not before.
#
# Dispatch-only proved too late. #6639, #6556, #6605 and #6606 each landed with
# green pull-request CI while breaking Windows, and the breakage surfaced days
# later on a manual dispatch. So a pull request whose changes job reports
# windows=true (a Windows path, or an added src/tests line naming a Windows
# marker; see scripts/ci/windows-sensitive-diff.sh) now runs the nine shards,
# and the daily schedule catches whatever that filter misses.
# Pull requests never run these shards (AGENTS.md "CI policy", a maintainer
# decision): Windows runs on the nightly schedule and on a lane=all dispatch.
# The changes job still computes windows=true for a Windows-sensitive pull
# request (a Windows path, or an added src/tests line naming a Windows marker;
# see scripts/ci/windows-sensitive-diff.sh). That verdict does not schedule
# these jobs; it posts a non-blocking notice, and a maintainer dispatches
# lane=all on the branch before merging. #6639, #6556, #6605, #6606 and #7468
# each landed with green pull-request CI while breaking Windows, which is why
# that dispatch is part of merging a Windows-sensitive pull request.
platform-windows:
name: windows ${{ matrix.shard }}/9
needs: [changes, select-windows-runner]
Expand Down
10 changes: 6 additions & 4 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ root = "tests"
#
# That qualifier is load-bearing. Bun resolves bunfig.toml from the CURRENT WORKING
# DIRECTORY, so `cd /tmp && bun test <repo>/tests/x.test.ts` loads no preload at all: nothing
# is sandboxed, the guard stays disarmed, and every `getConfigDir()` in that process returns
# the developer's real `~/.opencodex`. On 2026-09-15 such a run deleted one. A test that needs
# a config directory pins its own OPENCODEX_HOME instead of trusting this line, and
# `tests/ci-workflows/test-home-guard.test.ts` enforces that for the destructive case.
# is sandboxed, and every `getConfigDir()` in that process returns the developer's real
# `~/.opencodex`. On 2026-09-15 such a run deleted one, and in #7407 another wrote auth.json.
# The write guard now arms itself from the test-file entry in that case (src/lib/test-home-guard.ts),
# so such writes throw instead of landing, but a test that needs a config directory still pins its
# own OPENCODEX_HOME instead of trusting this line, and `tests/ci-workflows/test-home-guard.test.ts`
# enforces that for the destructive case.
preload = ["./tests/preload.ts"]
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "opencodex-desktop"
version = "2.90.0"
version = "2.91.0"
description = "OpenCodex desktop shell"
authors = ["OpenCodex contributors"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCodex",
"version": "2.90.0",
"version": "2.91.0",
"identifier": "com.opencodex.desktop",
"build": {
"frontendDist": "../ui",
Expand Down
145 changes: 145 additions & 0 deletions devlog/_plan/261021_fallback_off/000_design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# OCX-11 fallback off — design

> Written 2026-10-21, revised 2026-10-22 after the supervisor decisions (§9) · implemented in PR #7482.
> Consumer: GOAT hub design, goat-si/goat PR #684 (ADR-0021 §1.6, design doc §1.6 and DH3).
> The present-tense contract is [structure/fallback-policy.md](../../../structure/fallback-policy.md).

## 1. Goal

Give the operator one clear way to say "do not fall back; fail with a clear error instead", for two families:

- **Hub down.** A Remote Link Child or Hub client whose Home is unreachable never silently serves from
its own local providers, and the operator can see and set the per-device local fallback permission.
- **Fallback chains.** Model and provider fallback (combos, subagent fallback, the main-account
hard-lock fallback, routing profiles) can be refused per scope: global, per provider, per model or
route, and per device.

Defaults stay exactly as they are today (§7).

## 2. Terms adopted from GOAT

ADR-0021 §1.6 and its design doc define the hub-down policy. This note reuses these terms verbatim:

- "a Child never silently serves its local providers when the Home is unreachable, and model, account and
provider fallback chains can be turned off per scope" (design §1.6, describing this package). The
account part is settled differently: see §9 C3.
- "**local fallback** opt-in per device" (DH3: "pause plus decision item; local fallback opt-in per device").
GOAT's switch is `hub.devices[alias].localFallback`; the OpenCodex key is
`fallback.devices.<deviceId>.localFallback`, so the leaf reads the same on both sides.
- "goal work on that device **pauses** (health condition `HubUnreachable`), people's own threads see
OpenCodex's 503 as today". OpenCodex keeps that 503 and adds `code: "home_unreachable"` (§6).
- "GOAT reads that state; it never changes OpenCodex fallback settings", shown as "**on, off, unknown**
when the Home predates OCX-11". The Home advertises `fallback-policy/1` in `/readyz` `features`;
GOAT reads its absence as unknown.
- "**GOAT fallback**" (ADR-0018 chains) is GOAT's word for moving GOAT work to another provider instance.
OpenCodex never uses that phrase; its own hops are "model fallback", "provider fallback" and "local
fallback".

## 3. Inventory

A fallback is a hop that serves a request from a **different model** or a **different provider** than
the one chosen or requested.

| # | Fallback | Kind | Where | Governed |
|---|---|---|---|---|
| F1 | Combo failover | provider or model | `src/server/responses/core-combo.ts` `executeComboResponses`; decisions in `src/combos/failover.ts`, picker in `src/combos/resolve.ts` | yes: reactive hop at the dispatch loop, proactive for a cooling first `failover` target |
| F2 | Subagent model fallback | model or provider | `src/codex/subagent-model-fallback.ts` `applySubagentModelFallback` | yes, proactive |
| F3 | Main-account hard-lock fallback | provider or model | `src/server/responses/main-hard-lock-routing.ts` `applyMainHardLockFallbackOverride` | yes, proactive |
| F4 | Routing-profile fallback | provider or model | `src/server/responses/policy-fallback.ts` `handleResponsesWithPolicyFallback` | yes, reactive |
| F5 | Claude Code classifier fallbacks | — | `src/claude/inbound-model-options.ts` | not a hop: the first configured entry always wins, with no availability check |
| F6–F9 | Codex pool account switch (including pool → signed-in main), Anthropic OAuth, generic OAuth and API-key pool rotation | — | `src/codex/routing/selection.ts`, `src/oauth/anthropic-routing.ts`, `src/oauth/generic-account-failover.ts`, `src/providers/key-failover.ts` | not a fallback: intra-pool rotation (§9 C3) |
| F10 | Remote Link Child, Home unreachable | local | `src/client/link-relay.ts` | no local path exists; 503 gains `home_unreachable` |
| F11 | Hub client, Home unreachable | local | Codex on a Hub client talks to the Hub URL directly; the local listener carries only the dashboard relay (`src/client/hub-relay.ts`, 502) | no local path exists; 502 gains `home_unreachable` |

Neither client kind can serve from local providers: both run the relay-only client runtime
(`src/client/runtime.ts`), `ocx start` refuses an invalid client state instead of starting standalone
(`src/cli/index.ts`), and the standalone recycle runs only when the link itself ends, never on an outage.
`tests/clients/client-home-unreachable.test.ts` pins this with a load-time import-graph check.

Out of scope because the target does not change: same-target retries (`src/lib/upstream-retry.ts`),
the Antigravity direct lane's bridge fallback (`src/protocols/settings.ts`), transport fallbacks.

### Fallback versus intra-pool rotation

A **fallback** changes *what* answers: another model or another provider, with different capabilities,
pricing, privacy terms or quality. An operator can reasonably refuse that.

**Intra-pool rotation** changes only *which credential* answers for the same provider and model: the
next account of an OAuth pool or the next key of an API-key pool when one is rate-limited, or the Codex
pool's move away from an account at its usage threshold. The request, model and provider are unchanged.
The maintainer rule recorded in `src/types/config.ts` says rotating away from an account upstream has
just rate-limited "only ever runs after a refusal, so stranding it while a second logged-in account sits
idle is a defect rather than a configuration choice". OCX-11 keeps that rule: rotation is resilience
inside one pool and these controls do not govern it. An operator who wants one account only configures
one account, or pauses the others, with the existing pool controls.

## 4. Configuration

```jsonc
"fallback": {
"model": true, // absent = true
"provider": true,
"providers": { "anthropic": { "model": false } },
"routes": { "combo/coding": { "provider": false } },
"devices": { "lnk_0123456789abcdef": { "localFallback": false, "model": false } }
}
```

A hop's kind is decided by what changes: provider differs → `provider`; same provider, different model →
`model`. A malformed leaf reads as absent and diagnostics name the section. Device keys are a link id
or a Hub data key id; the resolver maps a request's admission key id to its link through the link store.

CLI: `ocx fallback status|off|on|clear [--kind model|provider|all] [--provider|--route|--device]` and
`ocx fallback local on|off --device <id|alias>`. Management: `GET|PUT /api/fallback`.

## 5. Resolver

`src/routing/fallback-policy.ts`: `resolveFallbackPolicy`, `fallbackHopKind`, `guardFallbackHop` (records
and refuses) and a 20-entry event ring. Nothing else reads `config.fallback` on the request path.
`src/routing/fallback-state.ts` builds the `ocx.fallback/1` view and applies the scoped write.

## 6. Error contract

A refused hop answers with the status of the failure that triggered it (with its `Retry-After`), or 429
for proactive quota/cooldown/hard-lock refusals and 503 otherwise, and:

```json
{ "error": { "type": "fallback_refused", "code": "fallback_refused", "message": "… To allow it: ocx fallback on --kind provider --route combo/coding",
"fallback": { "kind": "provider", "scope": "route", "scopeKey": "combo/coding", "from": "a/m", "to": "b/n", "trigger": "rate_limited" } } }
```

plus `x-opencodex-fallback: refused`. A taken hop is recorded in status; no header is added to the
answering response, because the combo and policy responses are built deep in child dispatch.

Hub down keeps status and retry semantics: `{ "error": "link tunnel unavailable", "code": "home_unreachable", "message": "…" }`
on a Child (503) and `{ "error": "hub relay unavailable", "code": "home_unreachable", … }` on a Hub
client's dashboard relay (502).

## 7. Defaults and migration

No default changes and no migration: absent `fallback` keeps every chain as it was, and an absent
`localFallback` reads false, which is what a Child already does.

## 8. Visibility

`ocx status --json` `fallback` (live, the Home's over the link on a Child, or the local policy), `GET
/api/fallback` (also over the link relay, aliases omitted), `/readyz` `fallback-policy/1`, and the
dashboard Fallback panel on the Dashboard overview and on Remote Link. Events carry time, site, route, kind, from, to,
trigger, outcome, scope and device id; never bodies, keys or account identities.

## 9. Decisions (supervisor, 2026-10-22)

- **C1 precedence: accepted.** Route > provider > global, most specific first; a device set to off always refuses.
- **C2 `localFallback: true`: accepted as permission only.** OpenCodex keeps answering 503 when the Home is
unreachable; GOAT reads the flag and moves the work itself (ADR-0021 §1.6, "pause + decision item").
- **C3 account rotation: overridden.** Account-pool 429 rotation stays not switchable; the maintainer rule
in `src/types/config.ts` stands. Rotation is intra-pool resilience, not fallback, so the `account` kind
is out of OCX-11 (§3, "Fallback versus intra-pool rotation").

## 10. Tests

`tests/routing/fallback-policy.test.ts` (81-case precedence matrix, degrade, device identity, contract,
privacy), `tests/responses/responses-combo-fallback-off.test.ts`, `tests/routing/routing-policy-fallback-off.test.ts`,
`tests/routing/subagent-model-fallback-off.test.ts`, `tests/codex-integration/main-account-hard-lock-fallback-off.test.ts`,
`tests/server/fallback-routes.test.ts`, `tests/clients/client-home-unreachable.test.ts`, `tests/cli/cli-fallback.test.ts`,
`tests/server/readyz-features.test.ts` and `gui/tests/fallback-panel.test.tsx`.
Loading
Loading