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 74bfd06SampleTimeoutError(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 之间检查,不打断正在跑的 _callruntime.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 ExceptionConnectErrorretryable=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 30skeepalive ~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 1800sdata-stall 900stotal 7200s

3 已有的超时防御(已 armed)

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

防御默认值env 覆盖生效点状态
connect 超时30sOPENPROGRAM_HTTPX_CONNECT_TIMEOUT_Stimeouts.py:39on
write / pool 超时30s…_WRITE_/_POOL_…timeouts.py:40–41on
TCP keepalive 探死连接~60s
(30+10×3)
OPENPROGRAM_TCP_KEEPALIVEhttp_client.py:48–79on
SSE idle(无任何字节)1800sOPENPROGRAM_SSE_IDLE_TIMEOUT_Stimeouts.py:45 / codex:449on
SSE data-stall(无真实 data)900sOPENPROGRAM_SSE_DATA_STALL_TIMEOUT_Stimeouts.py:47 / codex:447on
SSE 单流 total 上限7200sOPENPROGRAM_SSE_TOTAL_TIMEOUT_Stimeouts.py:49 / codex:424on
httpx read backstop1860s
(idle+60)
OPENPROGRAM_HTTPX_READ_TIMEOUT_Stimeouts.py:52–60on
大 prompt 预算缩放×1.25–2.0按 context_tokens 分档timeouts.py:65–89on
force-IPv4 / proxy 逃生off / 自动OPENPROGRAM_FORCE_IPV4, HTTP(S)_PROXYhttp_client.py:92–101on
含义。「连上后服务器不再吐 token」这种典型卡死,已被 codex 的 _parse_sse_streamasyncio.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 内层 streamstream_retry.py:230max_attempts=3connect ≤30s1→2s(封顶30s)
#2 中层 execruntime.py:750max_retries=6= 一整个内层循环1.5→48s软 / 需传 timeout_s
#1 外层 locatorbenchmark 侧多轮= 一整个中层循环
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_asyncasyncio.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-sOPENPROGRAM_EXEC_TIMEOUT_S 的 call-level deadline 兜底(不依赖信号),且 _run_async 现在把 deadline contextvar 带进 worker 线程。

7 错误分类与 retryable 判定

exec 放弃时抛的是结构化 LLMErrorerrors.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
1HTTP 429RATE_LIMIT
1HTTP 500/502/503/504PROVIDER_INTERNAL
1HTTP 401 / 403AUTHENTICATION / AUTHORIZATION
1HTTP 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=Falseruntime.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 _runstream_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_retrytransport_exhausted 标志;exec 见到即不再重试(reason 仍诚实 TRANSPORT/retryable)
3引入 ExecInterrupt(BaseException) 硬中断契约execstream_retry except ExceptionBaseException 同时穿透两层,外部 watchdog 一抛即退。已实施 4e64ac95
errors.ExecInterrupt;两层显式 except ExecInterrupt: raise
4exec(timeout_s) 给默认值 / 入口必传现默认 None=无界。dispatcher / worker / benchmark 任一未 arm 的调用方都裸奔。已实施 4e64ac95
OPENPROGRAM_EXEC_TIMEOUT_S 进程级默认;默认 0=无界(opt-in,不改既有行为)
5ScreenSpot:可靠 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.pyproviders/utils/http_client.pyproviders/utils/stream_retry.pyproviders/utils/errors.pyproviders/openai_codex/openai_codex.py。 行号为撰写时快照,优化落地后需同步更新。配套对比见 llm-fault-tolerance.md