Skip to content

fix(proxy): name the pre-response eventless timeout honestly and derive its budget from settings - #1633

Open
Komzpa wants to merge 9 commits into
mainfrom
bridge-eventless-semantics
Open

fix(proxy): name the pre-response eventless timeout honestly and derive its budget from settings#1633
Komzpa wants to merge 9 commits into
mainfrom
bridge-eventless-semantics

Conversation

@Komzpa

@Komzpa Komzpa commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1625. An audit of today's live incident found the bridge conflating two different timers and blaming the upstream for local recovery:

  • The pre-response.created silence watchdog killed requests after an implicit 6 x sse_keepalive_interval = ~60s, but emitted the kill as stream_idle_timeout — whose configured budget is 7200s. Zero-event bridge handoff wedges were logged as upstream stream failures all day (57/64 reader failures had response_events_seen=0; successful luna turns' first event p95 is 930ms, so no healthy turn ever needed 60s).
  • Four local bridge-reset sites reported "Upstream websocket closed before response.completed" for purely local recovery.

Changes

  • Distinct bridge_eventless_timeout classification for every pre-response terminal (logs, request-log detail, retry-circuit last_detail, Prometheus surface label). Client shape stays a retryable 503 with an honest message.
  • The pre-response budget is now a named, settings-derived value: min(stuck_gate_retire_after_seconds, stream_idle_timeout_seconds, bridge_request_budget_seconds) (300s at defaults) — no more magic 60s; _STREAM_KEEPALIVE_MAX_COUNT is demoted to a keepalive-cadence floor. The owner-side missing-created watchdog still fires first on the common path, so recovery latency is unchanged — only the label is fixed.
  • Unmatched upstream frames that prove transport liveness are now counted and logged (unmatched_upstream_liveness=N on the eventless terminal), distinguishing a local matching wedge from a genuinely silent upstream. codex.keepalive is excluded.
  • Local resets say so: "HTTP responses session bridge reset this session locally before response.completed". "Upstream websocket closed" is reserved for genuine upstream closes (remaining call sites audited in the PR discussion).

Verification: 744 passed across bridge regression files; tests/unit 5672 passed; tests/integration has only the pre-existing warm-now failure fixed separately in #1623; ruff, architecture check, openspec validate name-bridge-eventless-timeout --strict, and type check (no new diagnostics) all green. Five new behavior tests cover each fix, including a grep-style guard that local resets never blame the upstream again.

@github-actions github-actions Bot added the db migration PR changes Alembic database migrations; maintainer must coordinate merge order label Aug 6, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 248ccfb7de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/modules/proxy/_service/http_bridge/streaming.py
Comment thread app/modules/proxy/_service/http_bridge/helpers.py
Comment thread app/modules/proxy/_service/http_bridge/helpers.py
Comment thread app/modules/proxy/_service/http_bridge/streaming.py
Komzpa pushed a commit that referenced this pull request Aug 6, 2026
Every new bug class extends the leg that should have caught it, so the three
classes proven on the live stack today each get model state, an invariant or
liveness property, and a negative control that reproduces the failure.

Anchor account ownership (PR #1638). A continuity anchor now carries the
account that owns it. Upstream accepts a request carrying a
previous_response_id owned by a different account and then never sends
response.created, so UpstreamRespondsTo gates StartStream, CompleteTurn and
ClaimCompletedDelivery: a foreign-anchored turn can only leave the
pre-response phase through a timer or a cancel. Inv10AnchorAccountOwnership
forbids dispatching with a foreign-owned anchor;
weak-cross-account-anchor.cfg reproduces the wedge.

Pre-response eventless phase (PR #1633). The "active" phase - dispatched
upstream, response.created not seen yet - now has its own bound instead of
sharing the request/stream-idle budget, and ExpireDeadline picks both its
bound and its budget label per phase. Inv11PreResponseBudget requires the
pre-response bound to be the minimum of the named gate-retire and stream-idle
budgets, at or above the keepalive cadence floor, strictly below the
post-start budget, and forbids reporting a kill under the post-start
stream-idle budget while the response had not started.
weak-conflated-timers.cfg collapses the two timer names and TLC produces a
healthy pre-start wait killed under the wrong budget.

Bounded client retry backoff (PR #1634). A turn killed in the pre-response
phase tears the client session; ClientRetryAttempt repairs it and is fair,
but only while retryBackoff stays inside MaxRetryBackoff. The new liveness
property TearEventuallyRecovers states that a recoverable tear is eventually
recovered; weak-unbounded-backoff.cfg lets the backoff grow past every
deadline in the model, and TLC produces a behaviour where the client stays
torn forever - the 29-hour client sleep observed today.

check.sh gains the three mappings and a PROPERTY:<Name> expectation form: a
liveness control must violate exactly the temporal property its config
declares and must not violate any invariant.

bash spec/check.sh exits 0. Full model: 16696096 states generated, 3606740
distinct, depth 23, zero violations, deadlock checking enabled. All 13
weakenings produce their mapped counterexample.
@Komzpa
Komzpa force-pushed the bridge-eventless-semantics branch from 350c41e to 303742d Compare August 6, 2026 17:50
Komzpa added a commit that referenced this pull request Aug 6, 2026
Every new bug class extends the leg that should have caught it, so the three
classes proven on the live stack today each get model state, an invariant or
liveness property, and a negative control that reproduces the failure.

Anchor account ownership (PR #1638). A continuity anchor now carries the
account that owns it. Upstream accepts a request carrying a
previous_response_id owned by a different account and then never sends
response.created, so UpstreamRespondsTo gates StartStream, CompleteTurn and
ClaimCompletedDelivery: a foreign-anchored turn can only leave the
pre-response phase through a timer or a cancel. Inv10AnchorAccountOwnership
forbids dispatching with a foreign-owned anchor;
weak-cross-account-anchor.cfg reproduces the wedge.

Pre-response eventless phase (PR #1633). The "active" phase - dispatched
upstream, response.created not seen yet - now has its own bound instead of
sharing the request/stream-idle budget, and ExpireDeadline picks both its
bound and its budget label per phase. Inv11PreResponseBudget requires the
pre-response bound to be the minimum of the named gate-retire and stream-idle
budgets, at or above the keepalive cadence floor, strictly below the
post-start budget, and forbids reporting a kill under the post-start
stream-idle budget while the response had not started.
weak-conflated-timers.cfg collapses the two timer names and TLC produces a
healthy pre-start wait killed under the wrong budget.

Bounded client retry backoff (PR #1634). A turn killed in the pre-response
phase tears the client session; ClientRetryAttempt repairs it and is fair,
but only while retryBackoff stays inside MaxRetryBackoff. The new liveness
property TearEventuallyRecovers states that a recoverable tear is eventually
recovered; weak-unbounded-backoff.cfg lets the backoff grow past every
deadline in the model, and TLC produces a behaviour where the client stays
torn forever - the 29-hour client sleep observed today.

check.sh gains the three mappings and a PROPERTY:<Name> expectation form: a
liveness control must violate exactly the temporal property its config
declares and must not violate any invariant.

bash spec/check.sh exits 0. Full model: 16696096 states generated, 3606740
distinct, depth 23, zero violations, deadlock checking enabled. All 13
weakenings produce their mapped counterexample.
@Komzpa
Komzpa force-pushed the bridge-eventless-semantics branch from 303742d to 4a94c36 Compare August 6, 2026 20:08
@Komzpa
Komzpa changed the base branch from bridge-restart-recovery to main August 6, 2026 20:14
@Komzpa
Komzpa force-pushed the bridge-eventless-semantics branch from 5e3cb70 to 588ecf7 Compare August 6, 2026 22:27
Komzpa added a commit that referenced this pull request Aug 6, 2026
Every new bug class extends the leg that should have caught it, so the three
classes proven on the live stack today each get model state, an invariant or
liveness property, and a negative control that reproduces the failure.

Anchor account ownership (PR #1638). A continuity anchor now carries the
account that owns it. Upstream accepts a request carrying a
previous_response_id owned by a different account and then never sends
response.created, so UpstreamRespondsTo gates StartStream, CompleteTurn and
ClaimCompletedDelivery: a foreign-anchored turn can only leave the
pre-response phase through a timer or a cancel. Inv10AnchorAccountOwnership
forbids dispatching with a foreign-owned anchor;
weak-cross-account-anchor.cfg reproduces the wedge.

Pre-response eventless phase (PR #1633). The "active" phase - dispatched
upstream, response.created not seen yet - now has its own bound instead of
sharing the request/stream-idle budget, and ExpireDeadline picks both its
bound and its budget label per phase. Inv11PreResponseBudget requires the
pre-response bound to be the minimum of the named gate-retire and stream-idle
budgets, at or above the keepalive cadence floor, strictly below the
post-start budget, and forbids reporting a kill under the post-start
stream-idle budget while the response had not started.
weak-conflated-timers.cfg collapses the two timer names and TLC produces a
healthy pre-start wait killed under the wrong budget.

Bounded client retry backoff (PR #1634). A turn killed in the pre-response
phase tears the client session; ClientRetryAttempt repairs it and is fair,
but only while retryBackoff stays inside MaxRetryBackoff. The new liveness
property TearEventuallyRecovers states that a recoverable tear is eventually
recovered; weak-unbounded-backoff.cfg lets the backoff grow past every
deadline in the model, and TLC produces a behaviour where the client stays
torn forever - the 29-hour client sleep observed today.

check.sh gains the three mappings and a PROPERTY:<Name> expectation form: a
liveness control must violate exactly the temporal property its config
declares and must not violate any invariant.

bash spec/check.sh exits 0. Full model: 16696096 states generated, 3606740
distinct, depth 23, zero violations, deadlock checking enabled. All 13
weakenings produce their mapped counterexample.
@Komzpa
Komzpa force-pushed the bridge-eventless-semantics branch from d75844e to 350c41e Compare August 7, 2026 01:26
@Komzpa Komzpa closed this Aug 7, 2026
@Komzpa
Komzpa force-pushed the bridge-eventless-semantics branch from cceac47 to 410bfae Compare August 7, 2026 08:12
Komzpa and others added 7 commits August 7, 2026 12:14
…ream

The HTTP responses bridge killed pre-`response.created` streams under the
name `stream_idle_timeout`, whose configured budget
(`stream_idle_timeout_seconds`, 7200s) governs only post-start gaps. The kill
actually fired at the implicit `_STREAM_KEEPALIVE_MAX_COUNT (6) *
sse_keepalive_interval_seconds (10s)` product, i.e. ~60s, derived from no
configured timeout at all. It therefore raced and beat the owner-side
`missing_response_created_timeout` gate and relabelled local bridge handoff
wedges as upstream idle failures. Four local recovery paths compounded this by
settling their own resets with "Upstream websocket closed before
response.completed" when no upstream close happened.

- Pre-response-start terminals now use a distinct `bridge_eventless_timeout`
  code across logs, request-log `failure_detail`/`failure_phase`, retry-circuit
  `last_detail`, the Prometheus surface label, and the client error. The
  retry-circuit detail is deliberately not aliased onto `stream_idle_timeout`.
  `stream_idle_timeout` is now reserved for post-start silence.
- The client-visible shape stays retryable: 503 with a message stating no
  response was created upstream and the request is safe to retry.
- The pre-response budget becomes the named, settings-derived
  `_http_bridge_eventless_budget_seconds` = min(stuck gate, stream idle,
  bridge request budget), aligning it with the 300s owner-side stuck gate.
- Unmatched-but-live upstream frames emit an `unmatched_upstream_liveness`
  marker plus a per-session counter that the eventless timeout reports;
  locally injected `codex.keepalive` frames are excluded.
- The four local reset sites say local bridge reset and stay account-health
  neutral for anchored `stream_incomplete` settlements.
…heads

Merging origin/main brought
`20260806_020000_add_usage_history_bulk_covering_indexes` into this branch. It
revises the same parent as the already-shipped
`20260806_120000_add_http_bridge_owner_process_epoch`, so the chain forked and
the deploy preflight refused with
`alembic_head_count_invalid expected=1 actual=2`.

The owner-epoch revision id is already in production, so reparenting it is not
an option. Add an empty merge revision recording the convergence instead.

`ScriptDirectory.get_heads()` now returns exactly one head and
`codex-lb-db check` reports `migration_policy=ok schema_drift=none`.
The five new pre-response-start regressions were appended to the end of
tests/unit/test_proxy_http_bridge.py, which collides with PR #1630 appending
its quarantine tests at the same spot. Move them (and their local
_eventless_settings/_make_bridge_session helpers) into
tests/unit/test_http_bridge_eventless_semantics.py so both branches can land.

The two in-place assertion updates in test_proxy_http_bridge.py stay where
they are; that file's tail is now byte-identical to its pre-branch state, so
there is no append surface left to collide on.
@Komzpa Komzpa reopened this Aug 7, 2026
@Komzpa

Komzpa commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4354c3cfe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1251 to +1254
dead_owner_process_epoch_mismatch = (
durable_lookup.owner_process_epoch is not None
and durable_lookup.owner_process_epoch != current_process_epoch
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compare process epochs only for the same owner instance

In a multi-replica deployment, an active row owned by another instance normally has a process epoch different from this process, so this marks every remote owner as dead even when its lease and ring membership are current. For replay-safe full-resend requests, the branch at lines 1872–1889 consequently switches directly to account-neutral replay instead of forwarding to the live owner, potentially duplicating or forking a turn and changing its account; only compare owner_process_epoch when owner_instance_id == current_instance.

AGENTS.md reference: AGENTS.md:L105-L110

Useful? React with 👍 / 👎.

settings,
fallback_seconds=interval_seconds * max(1, floor_count),
)
return max(max(1, floor_count), math.ceil(budget_seconds / interval_seconds))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let short budgets override the legacy keepalive floor

When any configured eventless clamp is shorter than floor_count * keepalive_interval_seconds, this outer max makes the actual watchdog outlive the derived budget. For example, a 45-second idle/request budget with the default 10-second interval and floor 6 returns six ticks and waits about 60 seconds; a 20-second request budget is exceeded by roughly 40 seconds. Preserve the floor only when it does not violate the configured stuck-gate, idle, or request budget.

AGENTS.md reference: AGENTS.md:L129-L132

Useful? React with 👍 / 👎.

Comment on lines 3875 to 3878
await self._record_http_bridge_retry_circuit_failure(
session,
detail="stream_idle_timeout",
detail=_HTTP_BRIDGE_EVENTLESS_TIMEOUT_DETAIL,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply anchor poisoning to eventless stream timeouts

For the normal hard-bridge pre-response timeout, this records another bridge_eventless_timeout failure but discards the returned consecutive-failure count and never calls _abandon_durable_http_bridge_continuity. The new poison threshold is checked only in the separate reader-failure path when an admission waiter exists, so repeated timeouts through this primary downstream watchdog can exceed the configured threshold indefinitely while the poisoned durable anchor remains reusable. Check the threshold here as well and clear continuity before retiring the session.

AGENTS.md reference: AGENTS.md:L105-L110

Useful? React with 👍 / 👎.

@Komzpa Komzpa added the 🤖 codex: needs work [@codex review] raised an issue label Aug 8, 2026
@Soju06

Soju06 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Triage follow-up (2026-08-10): CI is fully green and the branch is mergeable, but the latest codex review (2026-08-08) left three unresolved P-threads, which are merge blockers under the repo gate. Round 1 was addressed promptly in 5e3cb70 — appreciated — but there have been no pushes since the second review. Summarizing what's outstanding after verifying each finding against the PR head:

  1. P1 — app/modules/proxy/_service/http_bridge/streaming.py:1251: dead_owner_process_epoch_mismatch compares durable_lookup.owner_process_epoch against the local process epoch unconditionally. In a multi-replica deployment every live remote owner has a different epoch, so the branch at ~1872-1889 diverts replay-safe full resends into account-neutral replay even while the remote owner's lease is active — risking a duplicated/forked turn on a different account. Epoch mismatch only proves a local restart; gate the comparison on owner_instance_id == current_instance and rely on lease liveness for remote owners.
  2. P1 — streaming.py eventless terminal (~3878): this path calls _record_http_bridge_retry_circuit_failure(...) but discards the consecutive-failure count and never checks http_responses_session_bridge_anchor_poison_failure_threshold / calls _abandon_durable_http_bridge_continuity. The poison check currently only runs in the reader-failure path when an admission waiter exists, so the PR's own new spec requirement ("Repeated zero-event idle failures poison dead anchors") doesn't hold on the primary downstream watchdog path.
  3. P2 — helpers.py _http_bridge_eventless_max_keepalive_count: the outer max(max(1, floor_count), ...) lets the legacy 6-tick floor outlive a shorter configured budget (e.g. a 45s budget at the default 10s interval still waits ~60s). Apply the floor only when it doesn't exceed the derived budget.

Two logistics notes: since this is stacked on #1625 (still open) and carries its two Alembic revisions (including the heads-merge migration), the maintainer will need to coordinate merge order — and the fix for (1) likely belongs in #1625's commits rather than this PR's. Once the three threads are addressed (or rebutted in-thread) and re-reviewed, this looks close to mergeable.

@Komzpa Komzpa added the needs rebase Needs rebase or conflict repair against current main label Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖 codex: needs work [@codex review] raised an issue db migration PR changes Alembic database migrations; maintainer must coordinate merge order needs rebase Needs rebase or conflict repair against current main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants