OpenProgram · 设计文档

# 报错与超时机制（现状梳理）

范围：一次 `runtime.exec()` 从发起到失败/超时的完整路径——重试、退避、连接/流超时、错误分类、外层 watchdog。 目的：先把现状写清楚再优化。优化项在文末（§8）单列并标注实现状态与对应 commit。配套：`docs/design/llm-fault-tolerance.md`（跨框架对比）、`error-retry.md`。

一句话结论。 provider 层的连接/流超时基础设施是成熟的——mid-stream stall（连上后不再吐 token）已被 SSE idle/data-stall/total 预算主动限住。真正会导致「卡很久没有新行」的是 三层重试循环相乘 + deadline 不穿透 + 外层 watchdog 被 except 吞掉。三者叠加，一次持续的 `ConnectError` 能跑数百秒到上千秒。

实现状态 — 已落地（2026-06-01）。 §8 的 #1–#4（框架）与 #5（GUI harness）已实施并通过验证；#6 评估后暂缓（见该节）。下文 §2–§7 描述的是修复前的机制（保留以解释问题根因）；§5/§6 标了「已修复」，§8 标了每项状态与对应符号 / env。
框架 OpenProgram `4e64ac95` — deadline 穿透 + `ExecInterrupt` + 不跨层重试 + `OPENPROGRAM_EXEC_TIMEOUT_S`
harness GUI-Agent-Harness `74bfd06` — `SampleTimeoutError(ExecInterrupt)` + `--exec-timeout-s` + 按 `LLMError.reason` 分类 + auth 中止 + `reraise_if_fatal`
文档更新提交于本仓后续 commit；上面两个是实现 commit。行号仍为撰写时快照。

## 1 三层超时职责

系统里有三种语义不同的「超时」，分属不同层、由不同代码负责。把它们混在一起是大多数困惑的来源。

#### ① call-level 已覆盖

责任方：provider transport

一次 HTTP 请求 / 一条 SSE 流最多等多久；chunk 之间最大间隔。由 httpx 超时 + TCP keepalive + codex SSE 解析预算共同保证。

#### ② exec-level 部分

责任方：`runtime.exec(timeout_s=)`

整个 exec（含全部 retry sleep）的 wall-clock 上限。当前是「attempt 之间的软 deadline」，不向下穿透到内层 stream 重试，且默认 `None`=无界。

#### ③ task-level 不可靠

责任方：调用方（runner / dispatcher）

一条 benchmark 样本 / 一个 agent turn 的总预算（OCR + 多轮 crop + gate + review + 重试）。只有调用方知道。ScreenSpot 现用 `signal.alarm`，会被框架吞掉。

## 2 完整调用栈

从 benchmark 一路到网络 I/O。每层右下角标注它自己持有的超时/重试控制。注意有 三层独立的重试循环（locator、exec、stream_retry），彼此不共享 wall-clock。

ScreenSpot runner · run_one()
外层 `signal.alarm(sample_timeout_s)` 软中断；locator 内部还有自己的多轮重试。benchmark 侧（不在本仓）
retry loop #1（locator 轮次）signal.alarm watchdog
Runtime.exec(content, …, timeout_s=None)
`for attempt in range(max_retries)` 包 `except Exception`；deadline 只在 attempt 之间检查，不打断正在跑的 `_call`。runtime.py:750 / :780 / :754
retry loop #2（max_retries=6）软 deadline（要调用方传 timeout_s）
Runtime._call(content, model, response_format)
同步方法；不接收任何 timeout 参数。委派给 `_call_via_providers` → `_run_async()`。runtime.py:771 / :981 / :1007
无 timeout 入参
_run_async(coro) · async→sync 桥
无运行中的 loop → `asyncio.run(coro)` 跑在当前线程（CLI/benchmark 即主线程）；已有 loop（web/worker）→ offload 到 线程池 worker 线程。runtime.py:1248–1261
主线程：signal.alarm 能落进来 worker 线程：信号根本落不到
openai_codex · _run() → retry_stream(_attempt)
codex provider 的一次完整调用，内部走 stream 重试编排。openai_codex.py:134 / :210
retry_stream(attempt_fn, max_attempts=3)
`for attempt in range(max_attempts)` 包 `except Exception`；`ConnectError` 判 `retryable=True`。纯按次数，完全不知道任何 deadline。stream_retry.py:230 / :238 / :51
retry loop #3（max_attempts=3）无 wall-clock 感知
httpx.AsyncClient.stream(...) · get_shared_async_client("openai-codex")
硬化过的共享 client：connect/write/pool=30s、TCP keepalive ~60s 探死连接、force-IPv4 逃生、proxy。http_client.py:107 / :48–104 / openai_codex.py:223
connect 30s keepalive ~60s
_parse_sse_stream(response) · SSE 逐行
`await asyncio.wait_for(line_iter.__anext__(), timeout=wait)`，`wait = min(idle_left, stall_left, total−now)`。mid-stream stall 在这里被主动限住。openai_codex.py:407 / :447 / :449 / :424
idle 1800s data-stall 900s total 7200s

## 3 已有的超时防御（已 armed）

这些都已经在生效，默认值全部可经 env 覆盖。集中定义在 `providers/utils/timeouts.py` 与 `http_client.py`。

防御 | 默认值 | env 覆盖 | 生效点 | 状态
--- | --- | --- | --- | ---
connect 超时 | 30s | `OPENPROGRAM_HTTPX_CONNECT_TIMEOUT_S` | timeouts.py:39 | on
write / pool 超时 | 30s | `…_WRITE_/_POOL_…` | timeouts.py:40–41 | on
TCP keepalive 探死连接 | ~60s (30+10×3) | `OPENPROGRAM_TCP_KEEPALIVE` | http_client.py:48–79 | on
SSE idle（无任何字节） | 1800s | `OPENPROGRAM_SSE_IDLE_TIMEOUT_S` | timeouts.py:45 / codex:449 | on
SSE data-stall（无真实 data） | 900s | `OPENPROGRAM_SSE_DATA_STALL_TIMEOUT_S` | timeouts.py:47 / codex:447 | on
SSE 单流 total 上限 | 7200s | `OPENPROGRAM_SSE_TOTAL_TIMEOUT_S` | timeouts.py:49 / codex:424 | on
httpx read backstop | 1860s (idle+60) | `OPENPROGRAM_HTTPX_READ_TIMEOUT_S` | timeouts.py:52–60 | on
大 prompt 预算缩放 | ×1.25–2.0 | 按 context_tokens 分档 | timeouts.py:65–89 | on
force-IPv4 / proxy 逃生 | off / 自动 | `OPENPROGRAM_FORCE_IPV4`, `HTTP(S)_PROXY` | http_client.py:92–101 | on

含义。「连上后服务器不再吐 token」这种典型卡死，已被 codex 的 `_parse_sse_stream` 用 `asyncio.wait_for` 主动限在 idle/data-stall/total 三个预算内——不是裸奔。所以「provider 缺 read-timeout」的判断对 codex 不成立。

## 4 exec 重试状态机

一次 `exec()` 内部的重试逻辑。关键：deadline 检查只发生在两次 `_call` 之间（虚线框），无法打断正在执行的 `_call`。

    # runtime.py:746–818（精简）
    _deadline = start + timeout_s   # timeout_s=None ⇒ _deadline=None ⇒ 整段失效
    for attempt in range(max_retries):          # 默认 6（_default_max_retries, :58）

        ┌── 软 deadline 检查（仅 attempt 之间）─────────────┐
        if _deadline and now() >= _deadline:           # :754
            raise LLMError(reason=TIMEOUT, retryable=False)
        └──────────────────────────────────────────────────┘

        try:
            reply = self._call(...)      # :771 同步阻塞，无 timeout 入参；卡这里时上面的检查到不了
            break
        except (TypeError, NotImplementedError):
            raise                       # 编程错误，不重试
        except Exception as e:           # :780 ← 这里吞掉一切，包括外来的 TimeoutError
            permanent = _is_permanent_error(e)   # :782 只匹配图片/auth marker
            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% 抖动；attempt5 ≈ 48s（:81）
            if _deadline and now()+sleep_s >= _deadline:  # :801 退避会越界就提前判 TIMEOUT
                raise LLMError(reason=TIMEOUT, retryable=False)
            time.sleep(sleep_s)          # :818

### `_is_permanent_error` 只认两类 runtime.py:104–120

marker 列表只有：`not a valid image` / `invalid image` / `image data is not` / `login expired` / `login failed` / `re-auth` / `unauthorized` / `invalid api key`。任何超时类异常都不命中 → `permanent=False` → 会被重试。这正是外层 `SampleTimeoutError` 被当 transient 吃掉的直接原因。

## 5 核心缺陷：三层重试相乘，无人封顶 wall-clock

持续 `ConnectError`（死路由 / VPN 黑洞 / proxy 不通，日志即 `openai-codex stream retry … ConnectError`）时，三层循环逐层相乘，没有任何一层用 wall-clock 封顶：

循环 | 位置 | 次数 | 单次代价 | 退避 | deadline 感知
--- | --- | --- | --- | --- | ---
#3 内层 stream | stream_retry.py:230 | max_attempts=3 | connect ≤30s | 1→2s（封顶30s） | 无
#2 中层 exec | runtime.py:750 | max_retries=6 | = 一整个内层循环 | 1.5→48s | 软 / 需传 timeout_s
#1 外层 locator | benchmark 侧 | 多轮 | = 一整个中层循环 | — | 无

connect 为超时（丢包、非 refused，每次吃满 30s）时的最坏 wall-clock： 内层 ≈ 3 × 30s（connect） + (1+2)s（退避） ≈ 93s 中层 ≈ 6 × 93s + (1.5+3+6+12+24)s（退避） ≈ 605s ← 单次 exec() 外层 ≈ locator 再乘 N 轮 ≈ >900s 传输层 connect 尝试总数 = 3 × 6 (× locator 轮) = 18+ 次
双层重复重试（设计味道）。 `ConnectError` 在内层已经被判 `retryable=True` 并重试满 3 次、抛出 `ProviderStreamError(retryable=True)`；交到 exec 后，`_is_permanent_error` 不命中 → exec 又用全新的 6× 预算重试同一个传输错误。同一个传输错误被两层各自重试，这才是把 ~90s 的问题放大成 600s+ 的根本。
已修复 `4e64ac95`。 exec 把单一 deadline 穿透进 `stream_retry`（每 attempt 前 + 退避前都检查），且 `stream_retry` 耗尽预算时给异常打 `transport_exhausted` 标志，exec 见到就不再用新预算重试 —— 18 次（3×6）塌回内层 3 次。`_parse_sse_stream` 的 SSE 等待也 clamp 到 `min(budget, 剩余 deadline)`，单条流不再越过预算。

## 6 watchdog 被吞掉的时序

ScreenSpot 用 `signal.alarm` 想在 sample 总预算到点时硬停，但它抛的异常正好撞进上面两层的 `except Exception`。

1
signal.alarm(900) 起一个一次性闹钟。benchmark run_one() 外层
2
进入 exec → _call → _run_async → asyncio.run（主线程），卡在持续 ConnectError 的嵌套重试里。
3
900s 到点，SIGALRM 触发，handler 在主线程下一个字节码边界 `raise SampleTimeoutError`（继承 `TimeoutError → Exception`）。
4
异常冒泡到 stream_retry 的 `except Exception`（:238）或 exec 的 `except Exception`（:780），被当成「一次普通调用失败」。
5
`_is_permanent_error` 不命中 → `permanent=False` → 继续 retry。sample 语义（「整条样本已超时、必须停」）丢失。
6
`signal.alarm` 是一次性的，已消耗；外层 `finally: alarm(0)` 要等 run_one() 返回才执行——但它正卡着。此后没有任何硬保护，继续跑。
web / worker 语境更糟。 当存在运行中的 event loop 时，`_run_async` 把 `asyncio.run` offload 到线程池 worker 线程（runtime.py:1260）。Python 只向主线程投递信号，所以 `signal.alarm` 在这种语境里根本落不到正在跑的 `_call` 上——软 watchdog 完全无效。
已修复 框架 `4e64ac95` + harness `74bfd06`。 `SampleTimeoutError` 改继承 `ExecInterrupt(BaseException)`，两层 `except Exception` 都吞不住它 —— 它穿过 OpenProgram 重试层、由 `run_one()` 在 sample 边界接住记 `sample_timeout`（步骤 4/5 不再发生）。worker 线程语境里信号本就落不到，改由 `--exec-timeout-s` → `OPENPROGRAM_EXEC_TIMEOUT_S` 的 call-level deadline 兜底（不依赖信号），且 `_run_async` 现在把 deadline contextvar 带进 worker 线程。

## 7 错误分类与 retryable 判定

exec 放弃时抛的是结构化 `LLMError`（errors.py:62），已经带够了上层精确计分所需的字段——上层不该一律记成 `wrong_format`。

#### `ErrorReason` 枚举 errors.py:31

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

#### `LLMError` 字段 errors.py:62–85

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

### `classify_error` 判定顺序 errors.py:191–258

优先级 | 依据 | 判定 | retryable
--- | --- | --- | ---
1 | HTTP 429 | RATE_LIMIT | 是
1 | HTTP 500/502/503/504 | PROVIDER_INTERNAL | 是
1 | HTTP 401 / 403 | AUTHENTICATION / AUTHORIZATION | 否
1 | HTTP 400（+context/policy marker） | INVALID_REQUEST / CONTEXT_LENGTH / CONTENT_POLICY | 否
2 | 异常类型名 ∈ 传输集合 ConnectError / ReadTimeout / …（stream_retry.py:72） | TRANSPORT | 是
3 | 消息子串兜底（auth / invalid / context / policy / rate-limit / 传输名） | 对应 reason | 视类
— | 都不命中 | UNKNOWN | 否

注意：`override_reason=TIMEOUT` 的 deadline 命中一律 `retryable=False`（runtime.py:170–176）；其余「预算耗尽但类型本可重试」保持 `retryable=True`，含义是「换个新预算可再试」，不是「立刻重试」。

## 8 优化项（实现状态）

两条线并行、职责不同：框架侧 #1–#4 让任何调用方（channels worker、web turn、benchmark）都不被嵌套重试拖死；harness 侧 #5 负责 sample-level 计分与可靠 watchdog。子进程不替代框架修复，框架修复也不免除 runner 的 watchdog。#1–#4 已在框架 `4e64ac95` 落地，#5 在 harness `74bfd06` 落地（子进程隔离暂缓）；#6 暂缓。

# | 修复点 | 为什么 | 价值 | 状态
--- | --- | --- | --- | ---
1 | 把单一 deadline 从 `exec` 穿透到 `_call` → codex `_run` → `stream_retry`（SSE wait 取 `min(budget, 剩余)`） | 现在 `exec(timeout_s)` 只管中层、且要调用方传；内层 stream_retry + SSE 预算都不知道它。穿透后才是真 end-to-end，溢出 ≤ 一次 connect。 | 最高 | 已实施 `4e64ac95` 新 `providers/utils/deadline.py`（ContextVar）；exec/async_exec set+reset；stream_retry & codex 读取
2 | 传输层错误不跨层重复重试 | stream_retry 已耗尽 3 次的 `ProviderStreamError` 到 exec 又被重试 6 次 = 18 次。exec 应识别「provider 自己的传输预算已耗尽」，几乎不再重试。 | 高 | 已实施 `4e64ac95` `stream_retry` 打 `transport_exhausted` 标志；exec 见到即不再重试（reason 仍诚实 TRANSPORT/retryable）
3 | 引入 `ExecInterrupt(BaseException)` 硬中断契约 | `exec` 与 `stream_retry` 都有 `except Exception`；`BaseException` 同时穿透两层，外部 watchdog 一抛即退。 | 高 | 已实施 `4e64ac95` `errors.ExecInterrupt`；两层显式 `except ExecInterrupt: raise`
4 | `exec(timeout_s)` 给默认值 / 入口必传 | 现默认 `None`=无界。dispatcher / worker / benchmark 任一未 arm 的调用方都裸奔。 | 中 | 已实施 `4e64ac95` `OPENPROGRAM_EXEC_TIMEOUT_S` 进程级默认；默认 0=无界（opt-in，不改既有行为）
5 | ScreenSpot：可靠 watchdog + 按 `LLMError.reason` 计分（+ 子进程隔离） | sample-level 总预算只有 runner 知道。auth 直接停 run、timeout 单列、传输失败单列，不污染 wrong_format。 | harness 侧 | 部分 `74bfd06` `SampleTimeoutError(ExecInterrupt)`、`--exec-timeout-s`、reason 分类 + `provider_auth`、auth 中止 run、`reraise_if_fatal` 已做；子进程硬隔离暂缓（watchdog 已可靠 + 有 call-level deadline，优先级降低）
6 | 同步 `_call` 线程 future 硬上限（兜底） | 仅防「单次 `_call` 真无限挂」。SSE 预算已限住 stall，故降级为 belt-and-suspenders。 | 兜底 | 暂缓 副作用见下；#1 已让 stall/connect 有界，单独 _call 无限挂的窗口已很小

关于线程 future 兜底的副作用（#6）。 Python 不能安全 kill 线程：超时后 caller 返回，但那个 `_call` 线程仍在后台跑，直到 socket 自己报错才回收；其间它可能仍去 append model-call node / 写 store/DAG，造成状态污染。所以 #6 只能当兜底，且必须依赖 #1 的 read-timeout 让泄漏窗口有界。理想路径是推动 provider 暴露 async，exec 主走 `asyncio.wait_for`（能真正取消到 await 点，无泄漏线程），同步线程方案仅作无 async 路径时的退路。
现状基于对以下文件的只读核查： `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`。 行号为撰写时快照，优化落地后需同步更新。配套对比见 `llm-fault-tolerance.md`。