Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion app/modules/proxy/_load_balancer/sticky_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ def _direct_error(
abandon_unavailable_legacy_owner
and hard_sticky
and sticky_existing_is_legacy
and sticky_source == "session_header"
and sticky_source in {"session_header", "thread_header"}
and legacy_sticky_key is not None
and isinstance(sticky_existing_account_id, str)
and legacy_owner_in_effective_policy_scope
Expand Down
3 changes: 3 additions & 0 deletions app/modules/proxy/_service/codex_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ async def _select_codex_control_account_without_budget(
reallocate_sticky=affinity.reallocate_sticky,
sticky_source=affinity.codex_session_source,
legacy_sticky_key=affinity.legacy_selection_key,
legacy_continuity_source=affinity.legacy_continuity_source,
sticky_seed_key=affinity.seed_selection_key,
sticky_seed_kind=affinity.seed_selection_kind,
sticky_max_age_seconds=affinity.max_age_seconds,
Expand Down Expand Up @@ -398,6 +399,7 @@ async def _select_control_failover(excluded_account_ids: set[str]) -> AccountSel
reallocate_sticky=affinity.reallocate_sticky,
sticky_source=affinity.codex_session_source,
legacy_sticky_key=affinity.legacy_selection_key,
legacy_continuity_source=affinity.legacy_continuity_source,
sticky_seed_key=affinity.seed_selection_key,
sticky_seed_kind=affinity.seed_selection_kind,
sticky_max_age_seconds=affinity.max_age_seconds,
Expand Down Expand Up @@ -492,6 +494,7 @@ async def _select_control_failover(excluded_account_ids: set[str]) -> AccountSel
reallocate_sticky=affinity.reallocate_sticky,
sticky_source=affinity.codex_session_source,
legacy_sticky_key=affinity.legacy_selection_key,
legacy_continuity_source=affinity.legacy_continuity_source,
sticky_seed_key=affinity.seed_selection_key,
sticky_seed_kind=affinity.seed_selection_kind,
sticky_max_age_seconds=affinity.max_age_seconds,
Expand Down
2 changes: 1 addition & 1 deletion app/modules/proxy/_service/http_bridge/request_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3599,7 +3599,7 @@ async def _claim_http_bridge_replacement_before_swap(
# remains durable hard ownership.
kind=StickySessionKind.CODEX_SESSION,
max_age_seconds=None,
continuity_source=owner_rebind_affinity.codex_session_source,
continuity_source=(owner_rebind_affinity.legacy_continuity_source or "session_header"),
)
if legacy_owner_id is not None and legacy_owner_id != account_id:
raise ProxyResponseError(
Expand Down
1 change: 1 addition & 0 deletions app/modules/proxy/_service/websocket/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3476,6 +3476,7 @@ async def _select_websocket_connect_account(
reallocate_sticky=reallocate_sticky,
sticky_source=request_state.affinity_policy.codex_session_source,
legacy_sticky_key=request_state.affinity_policy.legacy_selection_key,
legacy_continuity_source=request_state.affinity_policy.legacy_continuity_source,
sticky_seed_key=request_state.affinity_policy.seed_selection_key,
sticky_seed_kind=request_state.affinity_policy.seed_selection_kind,
spill_bare_session_on_account_cap=request_state.affinity_policy.spill_on_account_cap,
Expand Down
22 changes: 18 additions & 4 deletions app/modules/proxy/affinity.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class _AffinitySelectionKwargs(TypedDict):
reallocate_sticky: bool
sticky_source: _CodexSessionSource | None
legacy_sticky_key: str | None
legacy_continuity_source: _CodexSessionSource | None
sticky_seed_key: str | None
sticky_seed_kind: StickySessionKind | None
spill_bare_session_on_account_cap: bool
Expand All @@ -69,6 +70,10 @@ class _AffinityPolicy:
# compatibility lookup explicit instead of trying to reconstruct it from
# the new opaque thread key.
legacy_codex_session_key: str | None = None
# Interpretation used when consulting that raw key. Process-session text
# is session_header even on a thread-scoped request; a thread-only raw
# key stays thread_header so a session_header tombstone cannot hide it.
legacy_continuity_source: _CodexSessionSource | None = None
# A previously unseen thread should inherit the healthy process preference
# once, then persist its own bounded row. This is never ownership: a
# missing process default may be initialized once by insert-if-absent, but
Expand Down Expand Up @@ -109,6 +114,9 @@ def selection_kwargs(self) -> _AffinitySelectionKwargs:
"reallocate_sticky": self.reallocate_sticky,
"sticky_source": self.codex_session_source,
"legacy_sticky_key": self.legacy_selection_key,
"legacy_continuity_source": (
None if self.legacy_selection_key is None else (self.legacy_continuity_source or "session_header")
),
"sticky_seed_key": self.seed_selection_key,
"sticky_seed_kind": self.seed_selection_kind,
"spill_bare_session_on_account_cap": self.spill_on_account_cap,
Expand Down Expand Up @@ -433,6 +441,7 @@ def _thread_codex_session_affinity(
max_age_seconds=max_age_seconds,
codex_session_source="thread_header",
legacy_codex_session_key=legacy_key,
legacy_continuity_source=("session_header" if identity.process_session is not None else "thread_header"),
Comment thread
mastertyko marked this conversation as resolved.
seed_selection_key=(
_codex_session_selection_key(identity.process_session) if identity.process_session is not None else None
),
Expand Down Expand Up @@ -735,10 +744,15 @@ def _sticky_key_for_responses_request(
else:
policy = _AffinityPolicy()
if (
# Only typed process-session provenance can represent the legacy row
# this escape hatch targets. An explicit turn-state header stays hard
# even when a client includes the same goal marker.
policy.codex_session_source == "session_header"
# The raw row this escape hatch retires is the process-session key.
# Current Codex also sends thread-id, so locality source is often
# thread_header; that must not hide the process-session exception.
# An explicit turn-state header stays hard even with the same marker.
policy.codex_session_source in {"session_header", "thread_header"}
and (
policy.codex_session_source == "session_header"
or _codex_backend_identity(headers).process_session is not None
)
and _request_allows_unavailable_legacy_owner_abandonment(payload)
):
policy = replace(policy, abandon_unavailable_legacy_owner=True)
Expand Down
7 changes: 6 additions & 1 deletion app/modules/proxy/load_balancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ async def select_account(
reallocate_sticky: bool = False,
sticky_source: _CodexSessionSource | None = None,
legacy_sticky_key: str | None = None,
legacy_continuity_source: _CodexSessionSource | None = None,
sticky_seed_key: str | None = None,
sticky_seed_kind: StickySessionKind | None = None,
spill_bare_session_on_account_cap: bool = False,
Expand Down Expand Up @@ -731,7 +732,11 @@ async def load_selection_inputs() -> _SelectionInputs:
# Raw rows may be historical turn-state ownership. The
# bounded thread TTL must never age out that hard evidence.
max_age_seconds=None,
continuity_source=sticky_source,
# Process-session raw text is session_header even when
# request locality is thread_header. Thread-only raw keys
# keep thread_header so a session_header tombstone cannot
# hide a distinct thread owner.
continuity_source=legacy_continuity_source or "session_header",
)
legacy_existing_account_id = legacy_owner_lookup.account_id
abandoned_account_id = legacy_owner_lookup.abandoned_account_id
Expand Down
4 changes: 4 additions & 0 deletions app/modules/proxy/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@ async def _select_goal_failover(excluded_account_ids: set[str]) -> AccountSelect
reallocate_sticky=affinity.reallocate_sticky,
sticky_source=affinity.codex_session_source,
legacy_sticky_key=affinity.legacy_selection_key,
legacy_continuity_source=affinity.legacy_continuity_source,
sticky_seed_key=affinity.seed_selection_key,
sticky_seed_kind=affinity.seed_selection_kind,
sticky_max_age_seconds=affinity.max_age_seconds,
Expand Down Expand Up @@ -1702,6 +1703,7 @@ async def _select_account_with_budget(
reallocate_sticky: bool = False,
sticky_source: _CodexSessionSource | None = None,
legacy_sticky_key: str | None = None,
legacy_continuity_source: _CodexSessionSource | None = None,
sticky_seed_key: str | None = None,
sticky_seed_kind: StickySessionKind | None = None,
spill_bare_session_on_account_cap: bool = False,
Expand Down Expand Up @@ -1862,6 +1864,7 @@ def log_account_id(account_id: str | None) -> str | None:
sticky_max_age_seconds=preferred_sticky_inputs[3],
sticky_source=preferred_sticky_inputs[4],
legacy_sticky_key=preferred_sticky_inputs[5],
legacy_continuity_source=legacy_continuity_source,
# Exact ownership chooses the account; a first-ever thread
# still seeds atomically without overwriting a process default.
sticky_seed_key=sticky_seed_key,
Expand Down Expand Up @@ -1924,6 +1927,7 @@ def log_account_id(account_id: str | None) -> str | None:
reallocate_sticky=reallocate_sticky,
sticky_source=sticky_source,
legacy_sticky_key=legacy_sticky_key,
legacy_continuity_source=legacy_continuity_source,
sticky_seed_key=sticky_seed_key,
sticky_seed_kind=sticky_seed_kind,
spill_bare_session_on_account_cap=_AffinityPolicy.cap_spillover_allowed(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-15
24 changes: 24 additions & 0 deletions openspec/changes/goal-restart-thread-header-abandonment/context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Purpose

Close the `#1703` × `#1680` composition hole: current Codex always
sends `thread-id`, so the merged goal-restart recovery never fires.

## Decision

Abandonment stays a `session_header` *interpretation* of the raw
process-session key. Request locality may be `thread_header`. Explicit
`turn_state` is unchanged.

## Failure modes

- Incremental or file-pinned restarts must still fail closed on the
required owner.
- After retirement, a later thread-id turn must not revive the raw
row as hard ownership.

## Example

Process session `sid` maps to quota-exceeded account A. Codex resends
an account-neutral goal body with `session-id: sid` and
`thread-id: t1`. Selection retires `sid` for `session_header`, routes
to B, and later `t1` turns stay on B.
56 changes: 56 additions & 0 deletions openspec/changes/goal-restart-thread-header-abandonment/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
## Context

`#1679` / `#1680` added a proof-gated exception that retires an
unavailable raw `codex_session` owner for `session_header`
interpretation. `#1703` then made `thread-id` the winning locality
source for current Codex. The two compose incorrectly: the flag and
CAS both require `sticky_source == "session_header"`, which current
Codex never is.

The raw compatibility row is the process-session key. Looking it up
with `continuity_source=thread_header` treats a `session_header`
tombstone as a live hard owner, so even a successful session-only
restart is undone by the next thread-id turn.

## Goals / Non-Goals

**Goals:**

- Account-neutral goal restart with `session-id` + `thread-id` retires
the unavailable raw owner for process-session interpretation and
routes to a replacement.
- Later same-thread turns without a new hard owner stay on that
replacement.
- Explicit `turn_state` of the same text stays hard-bound.

**Non-Goals:**

- Changing file-pin, previous-response, conversation, or tool-state
fail-closed ownership.
- Making `thread_header` an abandonment scope on the raw row.
- Dashboard, settings, or schema changes.

## Decisions

- Grant `abandon_unavailable_legacy_owner` for `thread_header` only
when a process session is also present. Thread-only clients have no
process-session raw row to retire.
- Allow retirement CAS when request source is `thread_header`. The
write remains `abandonment_scope=session_header`.
- Load the raw `legacy_sticky_key` with `continuity_source=session_header`.
That lookup is process-session interpretation, not thread identity.

**Alternative considered:** keep CAS gated on request source and only
set the flag. Rejected because the CAS would still not run.

**Alternative considered:** abandon the raw row for every source.
Rejected because colliding explicit `turn_state` must stay hard.

## Risks / Trade-offs

- [Risk] A thread-header request could retire a raw row that was
written as turn-state with equal text. → Mitigation: CAS still
writes `session_header` scope only; turn-state lookup of that text
keeps the stored owner.
- [Risk] Existing tests only exercise `session_id` without `thread-id`.
→ Mitigation: add the missing header combination next to those tests.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## Why

Current Codex sends both a shared process `session-id` and a distinct
`thread-id` on a self-contained goal restart. Affinity classifies that
request as `thread_header`, so the one-shot
`abandon_unavailable_legacy_owner` flag never sets and retirement CAS
never runs. The restart stays fail-closed on the unavailable legacy
owner even though the payload is account-neutral.

## What Changes

- Grant goal-restart abandonment when a thread-scoped request still
carries a process session, not only when locality source is
`session_header`.
- Let retirement CAS retire the raw process-session row for
`session_header` interpretation from that thread-scoped request.
- Consult the raw process-session row as `session_header`
interpretation so a scoped tombstone hides it from later thread-id
turns. Explicit `turn_state` of the same text stays hard.
- Keep incremental, file-pinned, conversation-bound, and unresolved
tool-state requests fail-closed.

## Capabilities

### New Capabilities

- None.

### Modified Capabilities

- `sticky-session-operations`: Current Codex `thread-id` on a
self-contained goal restart MUST still abandon the unavailable raw
process-session owner for `session_header` interpretation and keep
later same-thread continuity on the replacement.

## Impact

- `app/modules/proxy/affinity.py` restart-capability gate.
- `app/modules/proxy/_load_balancer/sticky_selection.py` retirement CAS
source check.
- `app/modules/proxy/load_balancer.py` raw-row lookup source.
- Focused affinity and sticky-selection tests.
- No API, schema, setting, dashboard, or wire-format change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## ADDED Requirements

### Requirement: Thread-scoped current Codex restarts still abandon a raw process-session owner

A self-contained Codex goal-continuation restart that also carries a distinct `thread-id` MUST still be eligible for the existing process-session abandonment exception. The request's thread-scoped locality source MUST NOT prevent the one-shot abandonment capability or the compare-and-set retirement of the raw process-session row.

The retirement write MUST remain scoped to `session_header`
interpretation of that raw key. An explicit `turn_state` lookup of the
same text MUST stay hard-bound to the stored account. After a
successful retirement, later same-thread turns that have no new hard
owner MUST keep continuity on the replacement account and MUST NOT
treat the `session_header`-abandoned raw row as live hard ownership.

Ordinary incremental, file-pinned, conversation-bound, and unresolved
tool-state requests MUST remain fail-closed on their required owner.

#### Scenario: Goal restart with process session and thread-id abandons the unavailable raw owner

- **GIVEN** a process-session identifier has a raw legacy `codex_session` mapping to account A
- **AND** account A is paused, rate-limited, or quota-exceeded
- **AND** account B is eligible
- **AND** the request also carries a distinct `thread-id`
- **WHEN** Codex sends the recognized goal-continuation marker with an account-neutral self-contained full resend and no other continuity dependency
- **THEN** the proxy marks the still-current raw mapping to account A abandoned only for process-session interpretation
- **AND** it routes the restarted turn to account B
- **AND** subsequent same-thread continuity remains on account B

#### Scenario: Thread-id on a goal restart cannot erase colliding explicit turn-state ownership

- **GIVEN** a raw legacy `codex_session` row was written as explicit turn-state ownership for account A
- **AND** a later request carries the same text as a process-session header plus a distinct `thread-id`
- **WHEN** a marked self-contained goal restart abandons that text for process-session interpretation
- **THEN** the restart may select account B
- **AND** an explicit turn-state lookup of the same text remains hard-bound to account A

#### Scenario: Account-dependent thread-scoped restart stays fail-closed

- **GIVEN** a process-session identifier has a raw legacy mapping to unavailable account A
- **AND** the request carries a distinct `thread-id`
- **AND** the body has a previous response, conversation, file pin, or unresolved tool state
- **WHEN** the request is selected
- **THEN** the request fails closed on account A
- **AND** the raw mapping is neither deleted nor rebound
23 changes: 23 additions & 0 deletions openspec/changes/goal-restart-thread-header-abandonment/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## 1. Implementation

- [x] 1.1 Grant `abandon_unavailable_legacy_owner` for `thread_header`
when a process session is present and the payload is
account-neutral.
- [x] 1.2 Allow retirement CAS when request source is `thread_header`.
Keep the write scoped to `session_header`.
- [x] 1.3 Load the raw `legacy_sticky_key` as `session_header`
interpretation so a scoped tombstone hides it from later
thread-id turns.

## 2. Regression coverage

- [x] 2.1 Assert session-id + thread-id goal restart sets the
abandonment flag; turn-state and account-dependent payloads do
not.
- [x] 2.2 Assert sticky selection retires the raw owner and selects a
replacement when source is `thread_header`.

## 3. Validation

- [x] 3.1 Run the focused affinity and sticky-selection tests.
- [x] 3.2 Run strict OpenSpec validation for this change.
Loading
Loading