Total response duration and interaction count

Default chat responses have neither a tool-interaction limit nor a total processing-duration limit. HTTP model streams that keep producing data are not interrupted merely for running a long time. OPENPROGRAM_SSE_TOTAL_TIMEOUT_S defaults to 0, meaning no total-duration limit; positive values remain explicit deployment deadlines for one stream. An unset limit uses a positive-infinity deadline. Existing HTTP URL, redirect and response-size checks remain unchanged.

Connection establishment, writes, pool waits, no-data and no-effective-data checks remain distinct from total response duration. User cancellation always applies. Explicit runtime.exec deadlines, program call-round parameters and declared budgets of standalone GUI/browser tools are not overridden by chat defaults.

Acceptance uses public boundaries: an actual AgentSession exceeding the former 200 interactions, a managed HTTP stream returning content beyond the former 7200 seconds, and continued termination under explicit deadlines. Tests neither wait for real long periods nor modify real sessions.

Implementation status

Chat interaction loops and individual HTTP streams now default to unlimited counts and total duration. Regressions cover more than 200 tool interactions, continuous responses beyond the former duration, Google synchronous/asynchronous transport, and explicit total-duration configuration.

OpenProgram · Design document

Error and timeout mechanisms (current-state inventory)

Scope: the complete path of one runtime.exec() from initiation to failure/timeout: retries, backoff, connection/stream timeouts, error classification and outer watchdog. Purpose: document current behavior before optimizing. Optimization items are listed separately in §8 with implementation state and corresponding commits. Companion documents: docs/design/llm-fault-tolerance.md(cross-framework comparison), error-retry.md.

Summary. The provider-layer connection/stream timeout infrastructure is mature: mid-stream stalls after connection are actively bounded by SSE idle/data-stall/total budgets. Long periods with no new output instead result from multiplication of three retry loops + deadlines not propagating + outer watchdog exceptions being caught. Together, one persistent ConnectError can run for hundreds to over a thousand seconds.

Implementation status — delivered (2026-06-01). Items #1–#4 (framework) and #5 (GUI harness) in §8 are implemented and verified; #6 was evaluated and deferred. Sections §2–§7 describepre-fixbehavior to explain the causes. Sections §5/§6 mark fixes, and §8 records each status with symbols/environment variables.
Framework OpenProgram 4e64ac95 — deadline propagation + ExecInterrupt + no cross-layer retries + OPENPROGRAM_EXEC_TIMEOUT_S
harness GUI-Agent-Harness 74bfd06 — SampleTimeoutError(ExecInterrupt) + --exec-timeout-s + classification by LLMError.reason + abort on auth + reraise_if_fatal
Documentation updates were committed later in this repository; the two above are implementation commits. Line numbers remain snapshots from authoring.

1 Three timeout responsibilities

The system has three semantically different timeouts, owned by different layers and code. Confusing them causes most misunderstandings.

① call-level Covered

Owner: provider transport

Maximum wait for one HTTP request/SSE stream and maximum interval between chunks, enforced jointly by httpx timeouts, TCP keepalive and codex SSE parsing budgets.

② exec-level Partial

Owner: runtime.exec(timeout_s=)

Wall-clock limit for an entire exec, including all retry sleeps. Currently a soft deadline between attempts; it does not propagate into inner stream retries, and defaults to None=unbounded.

③ task-level Unreliable

Owner: caller (runner / dispatcher)

Total budget for one benchmark sample/Agent turn: OCR, multiple crops, gates, review and retries. Only the caller knows it. ScreenSpot currently uses signal.alarm, which the framework catches.

2 Complete call stack

From benchmark to network I/O. Each layer’s lower-right corner lists its own timeout/retry controls. There are three independent retry loops(locator, exec, stream_retry), without a shared wall-clock budget.

ScreenSpot runner · run_one()
Outer signal.alarm(sample_timeout_s) soft interrupt; locator also has its own multiround retries.Benchmark side (outside this repository)
Retry loop #1 (locator rounds)signal.alarm watchdog
Runtime.exec(content, …, timeout_s=None)
for attempt in range(max_retries) wraps except Exception; the deadline is checked only between attemptsand does not interrupt an executing _call.runtime.py:750 / :780 / :754
retry loop #2 (max_retries=6)Soft deadline (caller must pass timeout_s)
Runtime._call(content, model, response_format)
Synchronous method;accepts no timeout parameter. Delegates to _call_via_providers → _run_async().runtime.py:771 / :981 / :1007
No timeout argument
_run_async(coro) · async-to-sync adapter
No running loop → asyncio.run(coro) runs on the current thread(main thread for CLI/benchmark); existing loop (web/worker) → offload to a thread-pool worker.runtime.py:1248–1261
Main thread: signal.alarm can arriveWorker thread: signals cannot arrive
openai_codex · _run() → retry_stream(_attempt)
One complete codex-provider call internally orchestrates stream retries.openai_codex.py:134 / :210
retry_stream(attempt_fn, max_attempts=3)
for attempt in range(max_attempts) wraps except Exception; ConnectError Checks retryable=True.Count-based only, with no knowledge of any deadline.stream_retry.py:230 / :238 / :51
retry loop #3 (max_attempts=3)No wall-clock awareness
httpx.AsyncClient.stream(...) · get_shared_async_client("openai-codex")
Hardened shared client: connect/write/pool=30s, TCP keepalive detecting dead connections at ~60s, force-IPv4 fallback and proxy.http_client.py:107 / :48–104 / openai_codex.py:223
connect 30skeepalive ~60s
_parse_sse_stream(response) · line-by-line SSE
await asyncio.wait_for(line_iter.__anext__(), timeout=wait), wait = min(idle_left, stall_left, total−now).Mid-stream stalls are actively bounded here.openai_codex.py:407 / :447 / :449 / :424
idle 1800sdata-stall 900stotal 7200s

3 Existing timeout defenses (active)

These are all already active; environment variables can override every default. Defined centrally in providers/utils/timeouts.py and http_client.py.

DefenseDefaultEnvironment overrideEnforcement pointStatus
Connect timeout30sOPENPROGRAM_HTTPX_CONNECT_TIMEOUT_Stimeouts.py:39on
Write / pool timeout30s…_WRITE_/_POOL_…timeouts.py:40–41on
TCP keepalive dead-connection detection~60s
(30+10×3)
OPENPROGRAM_TCP_KEEPALIVEhttp_client.py:48–79on
SSE idle (no bytes)1800sOPENPROGRAM_SSE_IDLE_TIMEOUT_Stimeouts.py:45 / codex:449on
SSE data-stall (no actual data)900sOPENPROGRAM_SSE_DATA_STALL_TIMEOUT_Stimeouts.py:47 / codex:447on
SSE single-stream total limit7200sOPENPROGRAM_SSE_TOTAL_TIMEOUT_Stimeouts.py:49 / codex:424on
httpx read backstop1860s
(idle+60)
OPENPROGRAM_HTTPX_READ_TIMEOUT_Stimeouts.py:52–60on
Large-prompt budget scaling×1.25–2.0Tiered by context_tokenstimeouts.py:65–89on
Force-IPv4 / proxy fallbackOff / automaticOPENPROGRAM_FORCE_IPV4, HTTP(S)_PROXYhttp_client.py:92–101on
Meaning. The typical stall where a connected server stops producing tokens is already constrained by codex _parse_sse_stream using asyncio.wait_for to idle/data-stall/total budgets:protection is present. Therefore claiming that the codex provider lacks a read timeout is incorrect.

4 Exec retry state machine

Retry logic within one exec() . Key point: the deadline is checked only between two _call between attempts(dashed box), so it cannot interrupt the currently running _call.

# runtime.py:746–818 (abridged)
_deadline = start + timeout_s   # timeout_s=None ⇒ _deadline=None ⇒ entire block inactive
for attempt in range(max_retries):          # default 6 (_default_max_retries, :58)

    ┌── Soft deadline check (between attempts only) ─────────────┐
    if _deadline and now() >= _deadline:           # :754
        raise LLMError(reason=TIMEOUT, retryable=False)
    └──────────────────────────────────────────────────┘

    try:
        reply = self._call(...)      # :771 synchronous blocking, no timeout argument; a stall here prevents the check above
        break
    except (TypeError, NotImplementedError):
        raise                       # Programming error: do not retry
    except Exception as e:           # :780 catches everything, including an external TimeoutError
        permanent = _is_permanent_error(e)   # :782 matches image/auth markers only
        if permanent or attempt == max_retries-1:
            raise _build_llm_error(...)   # → LLMError(reason, retryable, cause, …)
        sleep_s = _retry_sleep_seconds(attempt)  # 1.5·2^n, ±25% jitter; attempt5 ≈ 48s (:81)
        if _deadline and now()+sleep_s >= _deadline:  # :801 report TIMEOUT early if backoff would exceed the deadline
            raise LLMError(reason=TIMEOUT, retryable=False)
        time.sleep(sleep_s)          # :818

_is_permanent_error Recognizes only two classes runtime.py:104–120

The marker list contains only: not a valid image / invalid image / image data is not / login expired / login failed / re-auth / unauthorized / invalid api key. Any timeout-class exception misses these → permanent=False → it is retried. This directly explains why the outer SampleTimeoutError is treated as transient.

5 Core defect: three retry loops multiply with no wall-clock cap

With persistent ConnectError(dead route, VPN packet loss or unreachable proxy; logs show openai-codex stream retry … ConnectError), the three loops multiply, and no layer caps wall-clock time:

LoopLocationCountCost per attemptBackoffDeadline awareness
#3 Inner streamstream_retry.py:230max_attempts=3connect ≤30s1→2s (capped at 30s)None
#2 Middle execruntime.py:750max_retries=6= one complete inner loop1.5→48sSoft / requires timeout_s
#1 Outer locatorBenchmark sideMultiple rounds= one complete middle loop—None
When connect encounters timeouts(packet loss, not refusal, consuming 30s each), worst-case wall-clock time: Inner ≈ 3 × 30s (connect) + (1+2)s (backoff) ≈ 93s Middle ≈ 6 × 93s + (1.5+3+6+12+24)s (backoff) ≈ 605s ← one exec() Outer ≈ locator multiplies by N rounds ≈ >900s Total transport connect attempts = 3 × 6 (× locator rounds) = 18+ attempts
Duplicate retries at two layers (design issue). ConnectError The inner layer already classifies retryable=True and retries it three times before raising ProviderStreamError(retryable=True); once it reaches exec, _is_permanent_error does not match → exec again retries the same transport error with a fresh 6× budget.The same transport error is independently retried at two layers, turning a ~90s problem into 600s+.
Fixed 4e64ac95. Exec propagates one deadline into stream_retry(checked before each attempt and backoff), and stream_retry marks budget-exhausted exceptions with transport_exhausted ; exec thendoes not retry with a new budget , reducing 18 attempts (3×6) to the three inner attempts._parse_sse_stream SSE waits in min(budget, remaining deadline) are also clamped, so individual streams cannot exceed the budget.

6 Timeline of a swallowed watchdog

ScreenSpot uses signal.alarm to stop at the sample deadline, but its exception reaches the two layers’ except Exception.

1
signal.alarm(900) Starts a one-shot alarm.Outside benchmark run_one()
2
Enters exec → _call → _run_async → asyncio.run on the main thread and stalls in nested retries for persistent ConnectError.
3
At 900s, SIGALRM fires, and the handler at the next main-thread bytecode boundary raise SampleTimeoutError(inherits TimeoutError → Exception).
4
The exception propagates to stream_retry’s except Exception(:238), or exec’s except Exception(:780), and is treated as an ordinary call failure.
5
_is_permanent_error Does not match → permanent=False → retry continues. The sample meaning (“this entire sample timed out and must stop”) islost.
6
signal.alarm is one-shot and has been consumed; the outer finally: alarm(0) runs only after run_one() returns, but it is still blocked.No further hard protection remains, so execution continues.
Web/worker contexts are worse. With a running event loop, _run_async takes asyncio.run and offloads it to a thread-pool worker(runtime.py:1260). Python delivers signals only to the main thread, so signal.alarm in this contextcannot reachthe running _call : the soft watchdog is ineffective.
Fixed in framework 4e64ac95 + harness 74bfd06. SampleTimeoutError now inherits ExecInterrupt(BaseException), which neither except Exception can catch. It crosses the OpenProgram retry layers and is caught by run_one() at the sample boundary to record sample_timeout(steps 4/5 no longer occur). Signals never reach worker threads; instead, --exec-timeout-s → OPENPROGRAM_EXEC_TIMEOUT_S provides a signal-independent call-level deadline, and _run_async now propagates the deadline ContextVar into the worker thread.

7 Error classification and retryability

When exec gives up, it raises structured LLMError (errors.py:62), already containing enough fields for precise caller-side scoring; callers should not label everything wrong_format.

ErrorReason Enumeration errors.py:31

TRANSPORT · RATE_LIMIT · PROVIDER_INTERNAL · AUTHENTICATION · AUTHORIZATION · INVALID_REQUEST · CONTEXT_LENGTH · CONTENT_POLICY · TIMEOUT · UNKNOWN

LLMError Fields errors.py:62–85

reason · retryable · http_status · retry_after_s · attempts · elapsed_s · had_image · provider · model · last_error_type · cause

classify_error Classification order errors.py:191–258

PriorityBasisClassificationretryable
1HTTP 429RATE_LIMITYes
1HTTP 500/502/503/504PROVIDER_INTERNALYes
1HTTP 401 / 403AUTHENTICATION / AUTHORIZATIONNo
1HTTP 400 (+context/policy marker)INVALID_REQUEST / CONTEXT_LENGTH / CONTENT_POLICYNo
2Exception type name belongs to the transport set
ConnectError / ReadTimeout / … (stream_retry.py:72)
TRANSPORTYes
3Message-substring fallback (auth / invalid / context / policy / rate-limit / transport name)Corresponding reasonDepends on class
—No matchUNKNOWNNo

Note: override_reason=TIMEOUT deadline hits always use retryable=False (runtime.py:170–176); other exhausted budgets whose error type could be retried retain retryable=True, meaning “retry with a new budget”, not “retry immediately”.

8 Optimization items (implementation status)

Two parallel areas with different responsibilities: framework #1–#4 preventanycaller (channel worker, web turn, benchmark) from excessive nested retries; harness #5 handles sample-level scoring and a reliable watchdog. Subprocesses do not replace framework fixes, and framework fixes do not remove runner watchdog obligations.#1–#4 delivered in framework 4e64ac95 , #5 in harness 74bfd06 (subprocess isolation deferred); #6 deferred.

#FixReasonValueStatus
1Propagate a single deadline from exec to _call → codex _run → stream_retry(SSE waits use min(budget, remaining))Currently exec(timeout_s) controls only the middle layer and requires caller input; inner stream_retry and SSE budgets do not know it. Propagation makes the deadline end-to-end, with overshoot bounded by one connect attempt.HighestImplemented 4e64ac95
New providers/utils/deadline.py (ContextVar); exec/async_exec set+reset; stream_retry & Codex reads
2Do not repeat transport retries across layersAfter stream_retry exhausts three attempts for ProviderStreamError , exec retries six more times, totaling 18. Exec should recognize the provider’s exhausted transport budget and practically avoid retrying.HighImplemented 4e64ac95
stream_retry Set the transport_exhausted flag; exec stops retrying while honestly retaining TRANSPORT/retryable as the reason
3Introduce a ExecInterrupt(BaseException) hard-interrupt contractexec and stream_retry Both include except Exception; BaseException so it passes through both layers and immediately exits on an external watchdog interrupt.HighImplemented 4e64ac95
errors.ExecInterrupt; both layers explicitly except ExecInterrupt: raise
4exec(timeout_s) Provide a default / require it at entryCurrent default None=unbounded. Any dispatcher/worker/benchmark caller that does not configure it lacks this protection.MediumImplemented 4e64ac95
OPENPROGRAM_EXEC_TIMEOUT_S Process-level default; 0=unbounded (opt-in, preserving behavior)
5ScreenSpot: reliable watchdog + scoring by LLMError.reason (+ subprocess isolation)Only the runner knows the total sample budget. Authentication stops the run; timeout and transport failures have separate categories and do not contaminate wrong_format.Harness sidePartial 74bfd06
SampleTimeoutError(ExecInterrupt), --exec-timeout-s, reason classification + provider_auth, auth aborts the run, reraise_if_fatal implemented; hard subprocess isolation deferred(watchdog reliability and call-level deadlines reduce its priority)
6Synchronous _call Hard thread-future limit (last resort)Only protects against one _call hanging indefinitely. SSE budgets already bound stalls, making this an additional safeguard.Last resortDeferred
See side effects below; #1 bounds stalls/connects, leaving little scope for an individual _call to hang indefinitely
Side effects of thread-future fallback (#6). Python cannot safely kill a thread: the caller returns on timeout, but the _call thread keeps running in the backgrounduntil the socket itself fails; meanwhile it may append model-call nodes or write store/DAG state, causing contamination. Thus #6 is only a last resort and must rely on #1 read timeouts to bound the leak. Ideally providers expose async and exec uses asyncio.wait_for(cancellation reaches await points with no leaked threads); synchronous threads remain a fallback only when async is unavailable.
Current-state analysis is based on read-only inspection of these files: agentic_programming/runtime.py (exec/retry/bridge), providers/utils/timeouts.py, providers/utils/http_client.py, providers/utils/stream_retry.py, providers/utils/errors.py, providers/openai_codex/openai_codex.py. Line numbers are snapshots from authoring and must be updated after implementation. Companion comparison: llm-fault-tolerance.md.

Nested Codex authentication and terminal error contract

Codex Runtime uses the provider active account by default, matching direct chat credentials; an explicit profile pins its own account. CredentialProvider refresh remains authoritative. Both synchronous and asynchronous Runtime calls preserve the provider terminal reason, retry verdict and Retry-After through AgentSession. A rejected OAuth token or any explicit non-retryable provider failure makes exactly one provider request; transient failures retain bounded recovery. Public fake-provider acceptance covers active versus pinned accounts, HTTP status classification and terminal failure propagation without external credentials or requests.

An arbitrary exception without an explicit retry verdict can be classified UNKNOWN/non-retryable for display. AssistantMessage retains that classification and marks error_retryable_inferred. Runtime preserves its existing bounded fallback only for UNKNOWN/non-retryable messages with this inferred marker; an explicit provider verdict (both true and false), known taxonomy or exhausted transport budget remains authoritative. taxonomy_fields retains an original boolean retryable value; only an omitted verdict receives the inferred marker. The marker defaults to false for existing producers and serialized messages and survives message/event serialization.

DNS failures at the policy transport boundary

A DNS_ERROR caused by socket.gaierror is an operational connection failure. The shared HTTP transport records the policy diagnostic and raises httpx.ConnectError, allowing existing bounded provider transport recovery to apply before response commitment. Every attempt still resolves and validates every address and pins the connection to approved addresses. Empty or malformed resolver answers and denied origins or private addresses remain policy failures. Proxy resolution follows the same rule. No DNS fallback, private-address exception or unbounded retry is added.

Implementation status: static review and release build only for this change; automated regression suites are not run at the user's request.