The notice builder and the kill loop each hand-computed `retry_started_ts or call_start` and the "stream open, no progress yet" predicate. Two copies that must agree or the notice countdown and the actual kill diverge. `_codex_watchdog_snapshot()` now returns the attempt origin under the same lock, and `_pre_progress()` is the single phase predicate for both sites. Also restores the base preference for `retry_started_ts` over `last_event_ts` as the non-pre-progress activity anchor. On head a reconnect marker only coexists with events during pre-progress (retries reset `last_event_ts`; first progress clears the marker), so the ordering is behaviour-neutral in production and keeps the existing `(0.0, 0.0, 1.0)` wait-notice case green.
320 lines
17 KiB
Python
320 lines
17 KiB
Python
"""Request-local worker lifecycle, watchdog polling, and wait status."""
|
|
|
|
from agent import chat_completion_helpers as h
|
|
from agent import chat_completion_wait_notice as wn
|
|
|
|
|
|
class _NonStreamRequest:
|
|
"""One non-streaming request on a worker thread, polled by the caller.
|
|
|
|
State shared between the worker (``_call``) and the poll loop lives on the
|
|
instance; ``_abort_request`` may run from the poll (stranger) thread.
|
|
"""
|
|
|
|
def __init__(self, agent, api_kwargs: dict):
|
|
self.agent = agent
|
|
self.api_kwargs = api_kwargs
|
|
self.result = {"response": None, "error": None}
|
|
self.clients = h._RequestClientRegistry(agent)
|
|
# Request-local cancel flag: agent._interrupt_requested is cleared at turn
|
|
# boundaries but this daemon worker can outlive the turn, so it must know THIS
|
|
# request was force-closed and not surface the transport error as a bug (#6600).
|
|
self.cancelled = False
|
|
# Codex retirement token: the worker checks ``agent._active_codex_stream_request_token``
|
|
# to know it still owns the turn; a watchdog kill clears it so a worker still
|
|
# draining SSE raises instead of returning partial output as "completed"
|
|
# (run_codex_stream._request_is_current). ``codex_retired`` mirrors it locally.
|
|
self.codex_token = object() if agent.api_mode == "codex_responses" else None
|
|
self.codex_retired = False
|
|
self.wd = h._resolve_nonstream_watchdogs(agent, api_kwargs)
|
|
self.codex_watchdog_state = (
|
|
h.SimpleNamespace(
|
|
token=self.codex_token,
|
|
lock=h.threading.Lock(),
|
|
last_event_ts=None,
|
|
last_progress_ts=None,
|
|
retry_started_ts=None,
|
|
phase_aware=self.wd.idle_requires_progress,
|
|
)
|
|
if self.codex_token is not None
|
|
else None
|
|
)
|
|
self.call_start = h.time.time()
|
|
self.wait_notice_started_ts = None
|
|
self.wait_notice = wn.WaitNoticeState()
|
|
self.thread = None
|
|
|
|
def _install_codex_request_token(self) -> None:
|
|
if self.codex_token is not None and not self.codex_retired: # retired before start: don't re-publish
|
|
self.agent._active_codex_stream_request_token = self.codex_token
|
|
|
|
def _retire_codex_request_token(self) -> None:
|
|
if self.codex_token is None:
|
|
return
|
|
self.codex_retired = True
|
|
if getattr(self.agent, "_active_codex_stream_request_token", None) is self.codex_token:
|
|
self.agent._active_codex_stream_request_token = None
|
|
|
|
def _make_client(self, reason: str, kind: str = "openai"):
|
|
# Per-request clients are registered with the abort machinery so the watchdogs
|
|
# force-close the worker's connection, never the shared client (#67142).
|
|
if kind == "anthropic_messages":
|
|
client = self.agent._create_request_anthropic_client(reason=reason)
|
|
else:
|
|
client = self.agent._create_request_openai_client(reason=reason, api_kwargs=self.api_kwargs)
|
|
return self.clients.set_client(client, kind=kind)
|
|
|
|
def _call(self):
|
|
watchdog_state_var = watchdog_context_token = None
|
|
try:
|
|
self._install_codex_request_token()
|
|
if self.codex_watchdog_state is not None:
|
|
from agent.codex_runtime import _codex_watchdog_state_var
|
|
|
|
watchdog_state_var = _codex_watchdog_state_var
|
|
watchdog_context_token = watchdog_state_var.set(self.codex_watchdog_state)
|
|
self.result["response"] = h._dispatch_nonstreaming_api_request(
|
|
self.agent, self.api_kwargs, make_client=self._make_client)
|
|
except Exception as e:
|
|
# Our own force-close caused this error: swallow it, the main
|
|
# thread raises InterruptedError (#6600). Retirement logs at info
|
|
# (a watchdog discarded output the provider already sent — what an
|
|
# operator debugging a truncated reply needs); cancellation at debug.
|
|
if self.codex_retired:
|
|
h.logger.info("Codex worker caught %s after request retirement — "
|
|
"discarding the stale partial instead of surfacing it as a completed response. %s",
|
|
type(e).__name__, self.agent._client_log_context())
|
|
return
|
|
if self.cancelled:
|
|
h.logger.debug("Non-streaming worker caught %s after request "
|
|
"cancellation — exiting without surfacing a network error.", type(e).__name__)
|
|
return
|
|
self.result["error"] = e
|
|
finally:
|
|
if watchdog_state_var is not None:
|
|
watchdog_state_var.reset(watchdog_context_token)
|
|
# Retire first: close_once can raise, and a leaked token would let
|
|
# a later worker mistake itself for the owning attempt.
|
|
self._retire_codex_request_token()
|
|
# Reuse reason only on a clean response; error or cancel-swallow
|
|
# really closes so the next attempt builds a fresh pool.
|
|
self.clients.close_once(
|
|
"request_complete" if self.result["response"] is not None else "request_error_cleanup")
|
|
|
|
def _abort_request(self, reason: str) -> None:
|
|
"""Watchdog/interrupt kill: abort the request client (kind-aware, #67142)
|
|
and retire the codex token; the worker sees its own forced close via
|
|
the cancel flags."""
|
|
with h.contextlib.suppress(Exception):
|
|
self.clients.close_once(reason)
|
|
self._retire_codex_request_token()
|
|
|
|
def _await_worker_after_kill(self, timeout_message: str) -> None:
|
|
# Wait briefly for the worker to notice the closed connection.
|
|
self.thread.join(timeout=2.0)
|
|
if self.result["error"] is None and self.result["response"] is None:
|
|
self.result["error"] = TimeoutError(timeout_message)
|
|
|
|
def _model(self) -> str:
|
|
return self.api_kwargs.get("model", "unknown")
|
|
|
|
def _codex_watchdog_snapshot(self):
|
|
"""``(last_event_ts, last_progress_ts, retry_started_ts, attempt_started_ts)`` under one lock.
|
|
|
|
``attempt_started_ts`` is the physical attempt's origin (the reconnect marker, else
|
|
``call_start``); the notice and the kill loop must anchor to the same value."""
|
|
state = self.codex_watchdog_state
|
|
if state is None: # non-codex request: no watchdog reads these
|
|
return (None, None, None, self.call_start)
|
|
with state.lock:
|
|
retry_started_ts = state.retry_started_ts
|
|
return (state.last_event_ts, state.last_progress_ts, retry_started_ts,
|
|
retry_started_ts if retry_started_ts is not None else self.call_start)
|
|
|
|
def _pre_progress(self, last_event_ts, last_progress_ts) -> bool:
|
|
"""Stream open on this attempt, but no substantive model progress yet."""
|
|
return self.wd.progress_timeout > 0 and last_event_ts is not None and last_progress_ts is None
|
|
|
|
def _emit_wait_notice(self, elapsed: float, *, heartbeat: bool = True) -> None:
|
|
wd = self.wd
|
|
try:
|
|
last_event_ts, last_progress_ts, retry_started_ts, attempt_started_ts = self._codex_watchdog_snapshot()
|
|
pre_progress = self._pre_progress(last_event_ts, last_progress_ts)
|
|
activity_ts = attempt_started_ts if pre_progress else (
|
|
retry_started_ts if retry_started_ts is not None else last_event_ts)
|
|
# Lifecycle chatter is not progress: once pre-progress begins it must not
|
|
# clear/reset this notice. Real progress changes phase and clears it.
|
|
if (self.wait_notice_started_ts is not None and activity_ts is not None
|
|
and activity_ts > self.wait_notice_started_ts):
|
|
self.agent._emit_wait_notice("")
|
|
self.wait_notice_started_ts = None
|
|
self.wait_notice.reset()
|
|
if not heartbeat:
|
|
return
|
|
silence = self.call_start + elapsed - (
|
|
activity_ts if activity_ts is not None else self.call_start)
|
|
if silence < 60.0:
|
|
self.agent._touch_activity(
|
|
"waiting for first stream event after reconnect"
|
|
if retry_started_ts is not None else "waiting for provider response")
|
|
return
|
|
phase = "first_event"
|
|
if pre_progress:
|
|
phase = "pre_progress"
|
|
elif retry_started_ts is not None:
|
|
phase = "reconnect"
|
|
elif last_event_ts is not None:
|
|
phase = "post_event"
|
|
watchdog = wn.codex_watchdog_deadline(stale_timeout=wd.stale_timeout,
|
|
ttfb_enabled=wd.ttfb_enabled, ttfb_timeout=wd.ttfb_timeout,
|
|
last_event_ts=last_event_ts, last_progress_ts=last_progress_ts,
|
|
retry_started_ts=retry_started_ts,
|
|
call_start=self.call_start, idle_enabled=wd.idle_enabled, idle_timeout=wd.idle_timeout,
|
|
idle_requires_progress=wd.idle_requires_progress, progress_timeout=wd.progress_timeout,
|
|
elapsed=elapsed)
|
|
# One neutral notice per silence; repeating it every heartbeat made
|
|
# healthy long calls read as provider trouble (#92550).
|
|
if not self.wait_notice.should_emit(phase, watchdog):
|
|
self.agent._touch_activity(f"waiting for provider response ({int(silence)}s, {phase})")
|
|
return
|
|
self.agent._emit_wait_notice(wn.wait_notice_text(
|
|
self.api_kwargs.get('model', 'the provider'), silence, phase, watchdog))
|
|
self.wait_notice_started_ts = self.call_start + elapsed
|
|
except Exception:
|
|
h.logger.debug("wait-notice construction failed", exc_info=True)
|
|
|
|
def _ttfb_kill(self, elapsed: float) -> None:
|
|
"""No parsed Codex event past the first-event cutoff — kill so the retry loop
|
|
reconnects instead of waiting out the stale timeout."""
|
|
agent, wd = self.agent, self.wd
|
|
silent_hint = h._codex_silent_hang_hint(agent, self.api_kwargs)
|
|
h.logger.warning("Codex stream produced no parsed stream event within TTFB cutoff "
|
|
"(%.0fs > %.0fs, model=%s). Backend accepted the connection "
|
|
"but sent no stream events. Killing connection so the retry loop can reconnect.", elapsed,
|
|
wd.ttfb_timeout, self._model())
|
|
agent._buffer_diagnostic_status(
|
|
f"⚠️ No first stream event from provider in {int(elapsed)}s (codex stream, model: {self._model()}). "
|
|
f"Reconnecting." + (f" {silent_hint}" if silent_hint else ""))
|
|
self._abort_request("codex_ttfb_kill")
|
|
agent._emit_diagnostic_wait(f"⚠ no response from provider in {int(elapsed)}s — reconnecting...")
|
|
agent._touch_activity(f"codex stream killed after {int(elapsed)}s with no first stream event")
|
|
self._await_worker_after_kill(
|
|
f"Codex stream produced no parsed stream event within {int(elapsed)}s "
|
|
f"(TTFB threshold: {int(wd.ttfb_timeout)}s)"
|
|
+ (f". {silent_hint}" if silent_hint else ""))
|
|
|
|
def _progress_kill(self, elapsed: float) -> None:
|
|
"""The stream opened, but this physical attempt never made model progress."""
|
|
agent, wd = self.agent, self.wd
|
|
h.logger.warning("Codex stream produced lifecycle events but no substantive model progress "
|
|
"for %.0fs (threshold %.0fs, model=%s, context=~%s tokens). Reconnecting.",
|
|
elapsed, wd.progress_timeout, self._model(), f"{wd.est_tokens:,}")
|
|
agent._buffer_diagnostic_status(
|
|
f"⚠️ Codex stream opened but made no model progress for {int(elapsed)}s "
|
|
f"(model: {self._model()}). Reconnecting.")
|
|
self._abort_request("codex_progress_kill")
|
|
agent._touch_activity(f"codex stream killed after {int(elapsed)}s without model progress")
|
|
self._await_worker_after_kill(
|
|
f"Codex stream produced no substantive model progress for {int(elapsed)}s "
|
|
f"(progress threshold: {int(wd.progress_timeout)}s)")
|
|
|
|
def _idle_kill(self, event_stale_elapsed: float) -> None:
|
|
"""SSE events stopped after the phase-specific idle arm point.
|
|
|
|
Only the implicit official OpenAI Codex policy arms on substantive model
|
|
progress; compatible providers and explicit operator timeouts arm on first
|
|
parsed event. Once armed, any parsed SSE event refreshes transport activity.
|
|
"""
|
|
agent, wd = self.agent, self.wd
|
|
arm_point = "model progress began" if wd.idle_requires_progress else "the first parsed event"
|
|
h.logger.warning("Codex stream produced no SSE events for %.0fs after %s "
|
|
"(threshold %.0fs, model=%s, context=~%s tokens). Killing "
|
|
"connection so the retry loop can reconnect.", event_stale_elapsed, arm_point, wd.idle_timeout,
|
|
self._model(), f"{wd.est_tokens:,}")
|
|
agent._buffer_diagnostic_status(
|
|
f"⚠️ Codex stream sent no events for {int(event_stale_elapsed)}s after {arm_point} "
|
|
f"(model: {self._model()}). Reconnecting.")
|
|
self._abort_request("codex_stream_idle_kill")
|
|
agent._touch_activity(f"codex stream killed after {int(event_stale_elapsed)}s with no SSE events")
|
|
self._await_worker_after_kill(
|
|
f"Codex stream produced no SSE events for {int(event_stale_elapsed)}s "
|
|
f"after {arm_point} (threshold: {int(wd.idle_timeout)}s)")
|
|
|
|
def _stale_kill(self, elapsed: float) -> None:
|
|
"""No response within the stale timeout: kill and count toward the
|
|
circuit breaker (#58962, see ``_stale_streak``)."""
|
|
agent, wd = self.agent, self.wd
|
|
silent_hint = h._codex_silent_hang_hint(agent, self.api_kwargs)
|
|
h._report_stale_nonstream_kill(agent, self.api_kwargs, elapsed, wd.stale_timeout, hint=silent_hint)
|
|
self._abort_request("stale_call_kill")
|
|
h._bump_stale_streak(agent)
|
|
h._touch_stale_kill_activity(agent, elapsed)
|
|
self._await_worker_after_kill(
|
|
f"Non-streaming API call timed out after {int(elapsed)}s with no response (threshold: {int(wd.stale_timeout)}s)"
|
|
+ (f". {silent_hint}" if silent_hint else ""))
|
|
|
|
def _interrupt(self, elapsed: float) -> None:
|
|
agent = self.agent
|
|
last_event_ts, _, _, _ = self._codex_watchdog_snapshot()
|
|
h._record_interrupted_provider_wait(agent, elapsed,
|
|
response_started=self.wd.codex and last_event_ts is not None
|
|
)
|
|
# Mark cancelled BEFORE force-closing so the worker treats the transport
|
|
# error as a cancel (#6600). Never close the shared client (releasing a
|
|
# TLS FD mid-SSL-BIO corrupted an unrelated SQLite DB, #67142). Then let
|
|
# the worker unwind Relay scopes before raising (#81521).
|
|
self.cancelled = True
|
|
h.logger.debug("Force-closing httpx client due to interrupt (not a network error).")
|
|
self._abort_request("interrupt_abort")
|
|
h._join_worker_for_relay_teardown(self.thread, label="Non-streaming")
|
|
raise InterruptedError("Agent interrupted during API call")
|
|
|
|
def run(self):
|
|
agent, wd = self.agent, self.wd
|
|
if wd.codex:
|
|
# Reset before the worker starts so a marker left over from a previous
|
|
# call on this agent can't be misread as the first event for this one.
|
|
with self.codex_watchdog_state.lock:
|
|
self.codex_watchdog_state.last_event_ts = None
|
|
self.codex_watchdog_state.last_progress_ts = None
|
|
self.codex_watchdog_state.retry_started_ts = None
|
|
agent._touch_activity("waiting for non-streaming API response")
|
|
|
|
self.thread = t = h.threading.Thread(target=h._context_thread_target(self._call), daemon=True)
|
|
t.start()
|
|
poll_count = 0
|
|
while t.is_alive():
|
|
t.join(timeout=0.3)
|
|
poll_count += 1
|
|
# Keep the quiet gateway heartbeat; only silence warrants a notice.
|
|
# Resumed events clear our notice on the next poll, not 30s later.
|
|
now = h.time.time()
|
|
elapsed = now - self.call_start
|
|
self._emit_wait_notice(elapsed, heartbeat=poll_count % 100 == 0)
|
|
last_event_ts, last_progress_ts, retry_started_ts, attempt_started_ts = self._codex_watchdog_snapshot()
|
|
attempt_elapsed = now - attempt_started_ts
|
|
if (wd.ttfb_enabled and last_event_ts is None and attempt_elapsed > wd.ttfb_timeout):
|
|
self._ttfb_kill(attempt_elapsed)
|
|
break
|
|
if (self._pre_progress(last_event_ts, last_progress_ts)
|
|
and attempt_elapsed > wd.progress_timeout):
|
|
self._progress_kill(attempt_elapsed)
|
|
break
|
|
idle_elapsed = now - last_event_ts if last_event_ts is not None else None
|
|
if (retry_started_ts is None and wd.idle_enabled and idle_elapsed is not None
|
|
and (not wd.idle_requires_progress or last_progress_ts is not None)
|
|
and idle_elapsed > wd.idle_timeout):
|
|
self._idle_kill(idle_elapsed)
|
|
break
|
|
if elapsed > wd.stale_timeout:
|
|
self._stale_kill(elapsed)
|
|
break
|
|
if agent._interrupt_requested:
|
|
self._interrupt(elapsed)
|
|
if self.result["error"] is not None:
|
|
raise self.result["error"]
|
|
# Success — the provider proved responsive: clear the breaker (#58962).
|
|
if self.result["response"] is not None:
|
|
h._reset_stale_streak(agent)
|
|
return self.result["response"]
|