codebee 0.1.65 → 0.1.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +520 -513
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/aiflavor.py +197 -197
  5. package/app/core/artifacts.py +148 -148
  6. package/app/core/attachments.py +532 -532
  7. package/app/core/automation.py +758 -758
  8. package/app/core/backup.py +470 -470
  9. package/app/core/beekeeper.py +183 -183
  10. package/app/core/board.py +209 -209
  11. package/app/core/bookmeta.py +796 -759
  12. package/app/core/bookmeta_catalog.py +180 -180
  13. package/app/core/branching.py +137 -137
  14. package/app/core/builtin_agent.py +1234 -1191
  15. package/app/core/capability.py +144 -144
  16. package/app/core/catalog.py +23 -24
  17. package/app/core/chaptersafe.py +32 -32
  18. package/app/core/cleanup.py +303 -303
  19. package/app/core/compaction.py +201 -186
  20. package/app/core/covergen.py +356 -356
  21. package/app/core/diagnostics.py +114 -114
  22. package/app/core/dispatch.py +173 -173
  23. package/app/core/dispatch_log.py +113 -113
  24. package/app/core/env_scrub.py +85 -85
  25. package/app/core/error_codes.py +220 -65
  26. package/app/core/errorlog.py +137 -137
  27. package/app/core/evaluation.py +174 -0
  28. package/app/core/failure_notify.py +290 -0
  29. package/app/core/flows.py +355 -355
  30. package/app/core/goal_service.py +167 -167
  31. package/app/core/health.py +374 -342
  32. package/app/core/history.py +38 -38
  33. package/app/core/hooks.py +162 -156
  34. package/app/core/jobs.py +1034 -1022
  35. package/app/core/knowledge.py +6 -2
  36. package/app/core/manager.py +2115 -1996
  37. package/app/core/market.py +362 -362
  38. package/app/core/market_remote.py +66 -66
  39. package/app/core/mocks.py +64 -64
  40. package/app/core/modelhub.py +3517 -3332
  41. package/app/core/notify.py +106 -106
  42. package/app/core/paihang.py +109 -109
  43. package/app/core/paths.py +2 -2
  44. package/app/core/pipeline.py +4614 -4494
  45. package/app/core/planner.py +33 -31
  46. package/app/core/portguard.py +111 -111
  47. package/app/core/preview.py +293 -293
  48. package/app/core/publish/__init__.py +7 -7
  49. package/app/core/publish/auto.py +367 -367
  50. package/app/core/publish/browser.py +533 -533
  51. package/app/core/publish/fanqie.py +128 -128
  52. package/app/core/publish/flow.py +432 -406
  53. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  54. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +623 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +129 -124
  61. package/app/core/router.py +119 -81
  62. package/app/core/runner.py +2050 -2042
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +114 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/skills.py +221 -72
  78. package/app/core/step_runner.py +132 -132
  79. package/app/core/store.py +23 -0
  80. package/app/core/task_compile.py +199 -199
  81. package/app/core/telemetry.py +292 -292
  82. package/app/core/tlsctx.py +64 -64
  83. package/app/core/token_meter.py +147 -147
  84. package/app/core/upstream.py +41 -0
  85. package/app/core/usage.py +114 -3
  86. package/app/core/volumes.py +355 -355
  87. package/app/core/wxdigest.py +858 -858
  88. package/app/core/zentao.py +2447 -2447
  89. package/app/main.py +2838 -2832
  90. package/app/pick_dialog.py +110 -110
  91. package/app/ui/app.js +13994 -13977
  92. package/app/ui/board.css +20 -41
  93. package/app/ui/board.html +162 -162
  94. package/app/ui/board.js +305 -305
  95. package/app/ui/i18n.js +2716 -2711
  96. package/app/ui/icons/bee.svg +79 -79
  97. package/app/ui/index.html +1 -1
  98. package/app/ui/manifest.json +16 -16
  99. package/app/ui/qrcode.js +2297 -2297
  100. package/app/ui/style.css +6107 -6125
  101. package/bin/tutti.js +147 -147
  102. package/package.json +46 -41
  103. package/tools/wx_reader.py +261 -261
  104. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  105. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  106. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  107. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  108. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  109. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  110. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -1,2042 +1,2050 @@
1
- # -*- coding: utf-8 -*-
2
- """运行器:把各家 CLI 的无头调用统一成一个接口。
3
-
4
- 实测验证过的接入方式(2026-09,本机):
5
- codex : codex exec --skip-git-repo-check --json -s <sandbox> [prompt从stdin]
6
- stdout 为 JSONL 事件流;item.completed(type=agent_message)=最终回答,
7
- turn.completed=token 用量。pnpm 的 .cmd 垫片需 cmd /c 包装。
8
- claude : claude -p --output-format json [prompt从stdin]
9
- stdout 为单个 JSON:result / total_cost_usd / usage。
10
- 需环境变量 CLAUDE_CODE_GIT_BASH_PATH(原生 exe 找不到 bash 会拒绝启动)
11
- 与 CLAUDE_CODE_MAX_OUTPUT_TOKENS(自定义网关模型常见 32768 上限)。
12
- """
13
- from __future__ import annotations
14
-
15
- import json
16
- import os
17
- import re
18
- import shutil
19
- import signal
20
- import subprocess
21
- import tempfile
22
- import threading
23
- import time
24
-
25
- from . import beekeeper
26
- from .env_scrub import scrub_env
27
- from .error_codes import ErrorCode
28
-
29
- # 非 Windows 必须置 0:POSIX 的 Popen 对非零 creationflags 直接抛 ValueError,
30
- # 置 0 则两边通用(remote.py 同款守卫)。
31
- CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
32
- DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
33
- DEFAULT_STREAM_ACTIVITY_TIMEOUT_S = 180 # 流式 CLI 的活动延长窗口
34
-
35
- _BASH_CANDIDATES = [
36
- r"D:\Git\usr\bin\bash.exe",
37
- r"C:\Program Files\Git\usr\bin\bash.exe",
38
- r"C:\Program Files (x86)\Git\usr\bin\bash.exe",
39
- ]
40
- _bash_cache = {"path": None, "done": False}
41
-
42
-
43
- def find_git_bash():
44
- """Windows 专供:claude 原生 exe 找不到 bash 会拒绝启动,指给 Git Bash。
45
- macOS/Linux 有系统 bash,claude 自会找到;强行设 CLAUDE_CODE_GIT_BASH_PATH
46
- 反而可能指错(/bin/bash 与 Git Bash 行为有差异),故非 Windows 一律 None。"""
47
- if os.name != "nt":
48
- return None
49
- if _bash_cache["done"]:
50
- return _bash_cache["path"]
51
- _bash_cache["done"] = True
52
- p = shutil.which("bash.exe") or shutil.which("bash")
53
- if p:
54
- _bash_cache["path"] = p
55
- return p
56
- for cand in _BASH_CANDIDATES:
57
- if os.path.isfile(cand):
58
- _bash_cache["path"] = cand
59
- return cand
60
- return None
61
-
62
-
63
- def resolve_command(command):
64
- """命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
65
- path = shutil.which(command) or command
66
- low = path.lower()
67
- if low.endswith(".cmd") or low.endswith(".bat"):
68
- return ["cmd", "/c", path]
69
- return [path]
70
-
71
-
72
- def _orphan_pack_files(workdir):
73
- """列出缺少同名 .idx 的 pack 文件名(无则空列表)。
74
-
75
- Git 本体扫目录时发现 pack 没有对应 .idx,只打一行 warning 就跳过该包,
76
- 所以 git status/fsck 全都正常;aider 走的 gitpython/gitdb 却按
77
- pack-*.pack 逐个打开同名 .idx,缺一次就抛 FileNotFoundError,被它记成
78
- 「is your git repo corrupted?」——2026-09-22 mo-so 实案:pack-a52e675e
79
- .idx 缺失,aider 换将后跑满 14 分钟才失败,报错头还是 prompt toolkit 噪声。
80
- """
81
- try:
82
- probe = subprocess.run(
83
- ["git", "-C", workdir, "rev-parse", "--git-path", "objects/pack"],
84
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
85
- text=True, timeout=5, check=False,
86
- )
87
- except (OSError, subprocess.SubprocessError):
88
- return []
89
- rel = (probe.stdout or "").strip()
90
- if probe.returncode != 0 or not rel:
91
- return []
92
- pack_dir = rel if os.path.isabs(rel) else os.path.join(workdir, rel)
93
- try:
94
- names = set(os.listdir(pack_dir))
95
- except OSError:
96
- return []
97
- return sorted(n for n in names
98
- if n.startswith("pack-") and n.endswith(".pack")
99
- and (n[:-len(".pack")] + ".idx") not in names)
100
-
101
-
102
- def _git_repo_issue(workdir):
103
- """返回工作目录 Git 仓库的可读性问题;无 Git 仓库时返回空串。
104
-
105
- Aider 会在启动后自行读取索引。索引包缺失时它通常先写入 .gitignore,
106
- 再在深层调用中才报错,用户会误以为任务卡住;提前做一次廉价预检能把
107
- 错误变成可操作的任务失败信息。
108
- """
109
- if not workdir or not os.path.isdir(workdir):
110
- return ""
111
- git_dir = os.path.join(workdir, ".git")
112
- if not os.path.exists(git_dir):
113
- return ""
114
- try:
115
- probe = subprocess.run(
116
- ["git", "-C", workdir, "rev-parse", "--git-dir"],
117
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
118
- text=True, timeout=5, check=False,
119
- )
120
- except (OSError, subprocess.SubprocessError):
121
- return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
122
- if probe.returncode != 0:
123
- detail = (probe.stderr or probe.stdout or "仓库元数据不可读").strip()
124
- return "Git 仓库不可读:%s;请先执行 git fsck/恢复 .git 对象后重试" % detail[-300:]
125
- try:
126
- probe = subprocess.run(
127
- ["git", "-C", workdir, "status", "--porcelain", "--untracked-files=no"],
128
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
129
- text=True, timeout=8, check=False,
130
- )
131
- except subprocess.TimeoutExpired:
132
- return "Git 仓库检查超时;请先执行 git status/git fsck 修复后重试"
133
- except OSError:
134
- return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
135
- if probe.returncode != 0:
136
- detail = (probe.stderr or probe.stdout or "仓库对象或索引不可读").strip()
137
- return "Git 仓库损坏或对象缺失:%s;请先执行 git fsck 并恢复仓库后重试" % detail[-300:]
138
- # git status 对「pack 缺 .idx」不敏感(只 warning 跳过该包),必须单独查:
139
- # aider 的 gitdb 按 pack-*.pack 逐个开同名 .idx,缺一个就整步失败。
140
- orphans = _orphan_pack_files(workdir)
141
- if orphans:
142
- return ("Git 仓库 pack 索引缺失:%s 没有同名 .idx(git 本体只警告跳过,"
143
- "Aider 的 gitdb 会直接报「Unable to list files in git repo」)。"
144
- "修复:在 %s 目录对每个 pack 执行 `git index-pack <pack文件>` "
145
- "重建索引,或临时改用 Codex/Claude 实现器。"
146
- % (", ".join(orphans[:3]), os.path.join(".git", "objects", "pack")))
147
- return ""
148
-
149
-
150
- def _npm_shim_bypass(argv):
151
- """generic 类 CLI 把提示词嵌进 argv;经 cmd /c 重解析时引号/特殊字符会把
152
- 提示词截烂(kimi 实测:模型只看到 UTF-8 须知、任务本体整段丢失)。识别
153
- 标准 npm .cmd 垫片(%_prog% + %dp0% 目标脚本),改 node 直启绕开 cmd。
154
- 识别不出标准形态原样返回。"""
155
- if len(argv) >= 3 and argv[0] == "cmd" and argv[1] == "/c" \
156
- and str(argv[2]).lower().endswith((".cmd", ".bat")):
157
- shim = argv[2]
158
- try:
159
- with open(shim, encoding="utf-8", errors="replace") as fh:
160
- text = fh.read()
161
- except Exception:
162
- return argv
163
- m = re.search(r'%_prog%"\s+"?%dp0%(\\[^"\n]+?\.(?:mjs|js))"?', text)
164
- if not m:
165
- return argv
166
- script = os.path.normpath(
167
- os.path.join(os.path.dirname(shim), m.group(1).lstrip("\\")))
168
- node = os.path.join(os.path.dirname(shim), "node.exe")
169
- return [node if os.path.isfile(node) else "node", script] + list(argv[3:])
170
- return argv
171
-
172
-
173
- def _kill_tree(pid):
174
- """杀整棵进程树。Windows:先 TerminateProcess 直接杀根进程(毫秒级,绝不
175
- 挂),再 taskkill /T 兜底扫子孙(短等待,卡死即放弃——真实案例 2026-09-19:
176
- 某些机器状态下 taskkill /F /T 对普通进程也挂死 30s+,等满 15s 超时会让每次
177
- 超时杀进程都卡 20s,看门狗/超时全被拖死;漏杀的孙进程由启动孤儿清扫兜底)。
178
- POSIX 靠 spawn 时的 start_new_session(子进程自成一个进程组,pgid==pid)
179
- 用 killpg 连孙带杀。"""
180
- if os.name != "nt":
181
- try:
182
- os.killpg(pid, signal.SIGKILL)
183
- return
184
- except Exception:
185
- pass
186
- try:
187
- os.kill(pid, signal.SIGKILL)
188
- except Exception:
189
- pass
190
- return
191
- # Windows 没有 signal.SIGKILL(直接引用会 AttributeError);os.kill 带任意
192
- # 信号值都走 TerminateProcess,用 SIGTERM
193
- try:
194
- os.kill(pid, signal.SIGTERM) # Windows 语义 = TerminateProcess,只杀根进程
195
- except OSError:
196
- pass # 根进程可能已被 taskkill 抢先杀掉
197
- except Exception:
198
- pass
199
- try:
200
- tk = subprocess.Popen(
201
- ["taskkill", "/F", "/T", "/PID", str(pid)],
202
- stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
203
- stderr=subprocess.DEVNULL, close_fds=True, # 不继承被杀进程的管道句柄,否则 EOF 永不来、drain 烧满超时
204
- creationflags=CREATE_NO_WINDOW)
205
- tk.wait(timeout=1.2) # 健康机器 <1s;卡死就不再陪等(后台自行结束)
206
- except Exception:
207
- pass
208
-
209
-
210
- def _drain_streams(proc, t_out, t_err, *, timeout=10):
211
- """kill_tree 后等管道线程读完剩余字节。
212
-
213
- daemon=True 线程 join 超时即结束(不会卡死主流程)。
214
- 设计稿:docs/migration/01-defense-patterns.md §5B。
215
- 参考 dsh docs/defensive-patterns.zh.md 第 21 行("dispose 必须达到完全停稳")。
216
- """
217
- deadline = time.time() + timeout
218
- for t in (t_out, t_err):
219
- remaining = max(0.0, deadline - time.time())
220
- if remaining > 0:
221
- t.join(timeout=remaining)
222
- # Killing the tree does not necessarily reap the root process on Windows.
223
- # Reap it with the remaining bounded budget before callers inspect
224
- # ``returncode``; otherwise long-running timeout tests can leak a live
225
- # Popen handle and emit ResourceWarning during interpreter shutdown.
226
- remaining = max(0.0, deadline - time.time())
227
- # proc 可为 None(旧契约:只收线程不收割进程的调用方,tests/test_runner_drain)
228
- if proc is not None and remaining > 0 and proc.poll() is None:
229
- try:
230
- proc.wait(timeout=remaining)
231
- except (subprocess.TimeoutExpired, OSError):
232
- pass
233
-
234
-
235
- def _pipe_reader(stream, chunks, log_fh, stamp=None):
236
- """持续读子进程管道并实时落盘。
237
-
238
- 必须用 read1():BufferedReader.read(n) 会阻塞到凑满 n 字节或 EOF,
239
- 在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
240
- read1() 只要有数据就返回,日志才能真正边跑边看。
241
- stamp:共享 [最后输出时刻],停滞看门狗据此判定进程是否卡死。
242
- 关闭也归本线程:EOF(或杀树后的异常)后自关。主线程绝不能 close()——
243
- close 要抢 BufferedReader 的锁,而锁被还在 read1 等 EOF 的本线程攥着,
244
- 孙进程漏杀时它攥着管道到自然死,主线程就陪锁到天荒地老(2026-09-22
245
- run_command 超时杀 ping 实测:taskkill /T 失手 → close 卡满命令时长)。
246
- """
247
- try:
248
- while True:
249
- b = stream.read1(65536)
250
- if not b:
251
- break
252
- chunks.append(b)
253
- if stamp is not None:
254
- stamp[0] = time.time()
255
- if len(stamp) > 1:
256
- stamp[1] = True
257
- if len(stamp) > 2:
258
- stamp[2] = time.monotonic()
259
- if log_fh:
260
- try:
261
- log_fh.write(b)
262
- log_fh.flush()
263
- except Exception:
264
- pass
265
- except Exception:
266
- pass # 杀树瞬间句柄作废:ValueError/OSError 都算正常收场
267
- finally:
268
- try:
269
- stream.close()
270
- except Exception:
271
- pass
272
-
273
-
274
- def _decode_line(blob):
275
- """单行解码:UTF-8 → GBK → replace(与 decode_output 同一编码纪律)。"""
276
- try:
277
- return blob.decode("utf-8")
278
- except UnicodeDecodeError:
279
- pass
280
- try:
281
- return blob.decode("gbk")
282
- except UnicodeDecodeError:
283
- return blob.decode("utf-8", "replace")
284
-
285
-
286
- def decode_output(data):
287
- """子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。
288
-
289
- 整块 UTF-8 与整块 GBK 都失败时逐行兜底——日志文件是混合编码(我方 UTF-8
290
- 审计头 + CLI 自家 GBK 输出),整块回退会把能读的部分一起牺牲:要么头部中文
291
- 变乱码,要么整篇落进 replace 分支铺成 U+FFFD 墙(2026-09-20 aider 日志实证,
292
- 详情页卡片显示成一片 ◆)。逐行解码两边都保住(GBK/UTF-8 的多字节序列都不
293
- 含 0x0A,按行切不会切坏字符)。"""
294
- if not data:
295
- return ""
296
- try:
297
- return data.decode("utf-8")
298
- except UnicodeDecodeError:
299
- pass
300
- try:
301
- return data.decode("gbk")
302
- except UnicodeDecodeError:
303
- pass
304
- return "\n".join(_decode_line(ln) for ln in data.split(b"\n"))
305
-
306
-
307
- def read_text_any_enc(path):
308
- """读文本文件,编码纪律同 decode_output(UTF-8 严格 → GBK 回退 → replace 兜底)。
309
-
310
- 工作区文件由 CLI 子代理落盘,中文 Windows 上 PowerShell Set-Content 缺省写
311
- GBK;消费侧(章节/diff/故事圣经)统一走本函数,不再因编码混编出 U+FFFD。
312
- 文件不存在/读失败抛 OSError,由调用方兜底(与 open 语义一致)。"""
313
- with open(path, "rb") as f:
314
- data = f.read()
315
- return decode_output(data)
316
-
317
-
318
- def tail_decoded(data, tail):
319
- """按字节取尾部再解码;切片可能落在 UTF-8 多字节字符中间,先丢弃开头的
320
- continuation 字节(10xxxxxx,至多 3 个)对齐字符边界,否则残缺字节会被
321
- GBK 回退解码成乱码字符。"""
322
- if not data:
323
- return ""
324
- chunk = data[-tail:] if tail and len(data) > tail else data
325
- i = 0
326
- while i < len(chunk) and i < 3 and (chunk[i] & 0xC0) == 0x80:
327
- i += 1
328
- return decode_output(chunk[i:])
329
-
330
-
331
- # 终端控制序列:CSI(颜色/光标/清行,`\x1b[91m`)、OSC(窗口标题)、其余两字符转义
332
- _ANSI_RE = re.compile(
333
- r"\x1b\[[0-9;?]*[ -/]*[@-~]"
334
- r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?"
335
- r"|\x1b[@-Z\\-_]"
336
- )
337
- # 被切片/前端截断掉 ESC 的「裸序列」残尾(`[91m`、`[0m`):只在行首止血,
338
- # 正文里合法的 [数字+字母](如 [1m] 引用)不动——后面紧跟 `]` 的不算残尾
339
- _ANSI_HEAD_RE = re.compile(r"^\[[0-9;?]{1,6}[A-Za-z](?!\])")
340
- # CLI 拿来做进度条/画框/旋转动画的图元,成串出现时人读不出任何信息
341
- _NOISE_RUN_RE = re.compile(r"([\u2500-\u259f\u25a0-\u25ff\u2800-\u28ff])\1{7,}")
342
- # 解不出来的字节(replace 兜底)成串即「乱码墙」——多数等宽字体把 U+FFFD 画成
343
- # 带问号的菱形,一屏看起来就是 ◆◆◆◆
344
- _FFFD_RUN_RE = re.compile(r"\ufffd{3,}")
345
- _CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
346
-
347
-
348
- def strip_ansi(text):
349
- """剥掉 ANSI 转义序列(颜色/光标/进度),并清掉行首被截断的裸序列残尾。"""
350
- if not text:
351
- return ""
352
- out = _ANSI_RE.sub("", text)
353
- # 残尾可能连着几个(`[91m[0mError:`),一次替换只够消掉最前面那个
354
- while True:
355
- nxt = "\n".join(_ANSI_HEAD_RE.sub("", ln) for ln in out.split("\n"))
356
- if nxt == out:
357
- return out
358
- out = nxt
359
-
360
-
361
- def clean_cli_text(text):
362
- """CLI 原始输出 → 人类可读文本(只用于错误摘要/日志展示,不碰模型正文)。
363
-
364
- 终端噪声四种,都是实测踩过的:
365
- 1. ANSI 转义:`\\x1b[91m\\x1b[1mError:` 在浏览器里渲染成 `[91m[1mError:`;
366
- 2. 裸 `\\r` 覆写:进度条/旋转动画反复回退改写同一行,原样展示会叠成一片;
367
- `\\r\\n` 是行结束符不是覆写,先归一化再按覆写取「该行最终形态」;
368
- 3. 画线/进度图元成串(`────…`、`████…`)与 U+FFFD 乱码墙(编码不可解);
369
- 4. 剩余控制字符。
370
- """
371
- if not text:
372
- return ""
373
- out = strip_ansi(text).replace("\r\n", "\n")
374
- if "\r" in out:
375
- lines = []
376
- for ln in out.split("\n"):
377
- if "\r" in ln:
378
- parts = ln.split("\r")
379
- ln = next((p for p in reversed(parts) if p.strip()), parts[-1])
380
- lines.append(ln)
381
- out = "\n".join(lines)
382
- out = _CTRL_RE.sub("", out)
383
- out = _FFFD_RUN_RE.sub(
384
- lambda m: "…(%d 个字符无法解码:CLI 输出不是 UTF-8/GBK)" % len(m.group(0)), out)
385
- out = _NOISE_RUN_RE.sub(lambda m: m.group(1) * 3 + "…", out)
386
- return re.sub(r"\n{4,}", "\n\n\n", out)
387
-
388
-
389
- _TS_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.,]+Z?\s*")
390
-
391
-
392
- def collapse_dup_lines(text, max_group=4):
393
- """折叠「归一化后重复」的邻近行,保留每种首行原文并标注折叠数。
394
-
395
- 真实案例:codex_otel 遥测对网关自定义模型名(如 [opencode]xxx,方括号是
396
- 非法 OTel tag 字符)每个 SSE 事件刷一对 WARN——counter/duration 两种文案
397
- 交替出现,且每条带微秒时间戳:剥掉时间戳前缀后相邻行仍不相等,必须按
398
- 「邻近组」折叠——组内至多 max_group 个键,新键只有组满才结算上一组,
399
- 这样交替刷屏的各个文案都归入同一组(计数各自累计)。空行先结算所在组
400
- 再原样保留。超过组宽的零散重复(中间隔着足量新行)不受影响。
401
- """
402
- if not text:
403
- return ""
404
- out = []
405
- keys, first, cnt = [], {}, {}
406
-
407
- def _flush():
408
- for k in keys:
409
- out.append(first[k])
410
- if cnt[k] > 1:
411
- out.append("⋯(上行重复 ×%d 已折叠)" % (cnt[k] - 1))
412
- keys.clear()
413
- first.clear()
414
- cnt.clear()
415
-
416
- for ln in text.splitlines():
417
- k = _TS_PREFIX.sub("", ln).strip()
418
- if not k:
419
- _flush() # 先结算扣住的组,保证空行前后顺序不失真
420
- out.append(ln)
421
- continue
422
- if k in cnt:
423
- cnt[k] += 1
424
- elif len(keys) >= max_group:
425
- _flush()
426
- keys.append(k)
427
- first[k] = ln
428
- cnt[k] = 1
429
- else:
430
- keys.append(k)
431
- first[k] = ln
432
- cnt[k] = 1
433
- _flush()
434
- if text.endswith("\n"):
435
- out.append("")
436
- return "\n".join(out)
437
-
438
-
439
- def _fmt_codex_item(it, cap):
440
- t = it.get("type") or ""
441
- if t == "agent_message":
442
- return "【消息】" + str(it.get("text") or "")[:cap]
443
- if t == "reasoning":
444
- return "【思考】" + str(it.get("text") or "")[:cap]
445
- if t == "command_execution":
446
- line = "【命令】%s(退出码 %s)" % (it.get("command") or "?", it.get("exit_code", "?"))
447
- outp = str(it.get("aggregated_output") or "").strip()
448
- if outp:
449
- line += "\n | " + outp[:600].replace("\n", "\n | ")
450
- return line
451
- if t == "file_change":
452
- names = ", ".join(str(c.get("path") or "?") for c in (it.get("changes") or [])
453
- if isinstance(c, dict))
454
- return "【文件改动】" + (names or "?")
455
- if t == "mcp_tool_call":
456
- return "【工具】%s %s" % (it.get("tool") or "?",
457
- json.dumps(it.get("arguments") or "", ensure_ascii=False)[:200])
458
- if t == "web_search":
459
- return "【搜索】" + str(it.get("query") or "")
460
- if t == "error":
461
- return "【错误】" + str(it.get("message") or "")
462
- return None
463
-
464
-
465
- def pretty_cli_log(text, max_event_chars=4000):
466
- """codex --json 的 JSONL 事件流 → 人类可读行;非 JSONL 行原样保留。
467
-
468
- --json 模式下 stdout 全是机器事件(thread/turn/item…),智能体真正在说的
469
- 话被埋在转义 JSON 里;这里逐行翻译成【消息】【思考】【命令】,让日志抽屉
470
- 读到的是「蜂在干什么」。解析失败或未识别的事件类型原样保留,不吞内容。
471
-
472
- claude stream-json 同场翻译(2026-09-22 真实案:3.2MB 步骤日志 15828 条
473
- thinking_tokens 心跳,每 SSE 分片一条、数值只涨 1~2):思考心跳按邻近组
474
- 折成一行;init/api_retry/assistant/result 翻译成可读行——api_retry 是
475
- 429/断连诊断金矿,逐条保留不折叠。只影响抽屉显示,原始日志与解析不动。
476
- """
477
- out = []
478
- hb = [0, 0] # claude 思考心跳邻近组:[条数, 最大估算 tokens]
479
- msg_seen = [False] # 是否已出过【消息】(防 result 再重复一遍正文)
480
-
481
- def _flush_thinking():
482
- if hb[0]:
483
- out.append("— 思考中(心跳 ×%d 已折叠,估算 ~%d tokens)—" % (hb[0], hb[1]))
484
- hb[0] = hb[1] = 0
485
-
486
- for ln in (text or "").splitlines():
487
- s = ln.strip()
488
- ev = None
489
- if s.startswith("{") and s.endswith("}"):
490
- try:
491
- ev = json.loads(s)
492
- except Exception:
493
- ev = None
494
- if isinstance(ev, dict) and ev.get("type") == "system" \
495
- and (ev.get("subtype") or "") == "thinking_tokens":
496
- try:
497
- n = int(ev.get("estimated_tokens") or 0)
498
- except Exception:
499
- n = 0
500
- hb[0] += 1
501
- if n > hb[1]:
502
- hb[1] = n
503
- continue
504
- _flush_thinking()
505
- if not isinstance(ev, dict):
506
- out.append(ln)
507
- continue
508
- typ = ev.get("type") or ""
509
- if typ == "system": # claude 其余系统事件
510
- sub = ev.get("subtype") or ""
511
- if sub == "init":
512
- out.append("— 会话启动(model=%s)—" % (ev.get("model") or "?"))
513
- elif sub == "api_retry":
514
- out.append("— API 重试 %s/%s(等 %sms):%s —" % (
515
- ev.get("attempt", "?"), ev.get("max_retries", "?"),
516
- ev.get("retry_delay_ms", "?"), ev.get("error") or "?"))
517
- else:
518
- out.append(ln) # 未知 subtype 原样保留,不吞内容
519
- continue
520
- if typ == "assistant": # claude 正文/思考/工具调用都在 content 块里
521
- for blk in ((ev.get("message") or {}).get("content") or []):
522
- if not isinstance(blk, dict):
523
- continue
524
- bt = blk.get("type") or ""
525
- if bt == "thinking":
526
- th = " ".join(str(blk.get("thinking") or "").split())
527
- if th:
528
- out.append("【思考】" + th[:200])
529
- elif bt == "text":
530
- tx = str(blk.get("text") or "")
531
- if tx:
532
- out.append("【消息】" + tx[:max_event_chars])
533
- msg_seen[0] = True
534
- elif bt == "tool_use":
535
- out.append("【命令】%s %s" % (
536
- blk.get("name") or "?",
537
- json.dumps(blk.get("input") or "", ensure_ascii=False)[:200]))
538
- continue
539
- if typ in ("user", "stream_event"):
540
- continue # claude 工具回包/流片段是过程噪音
541
- if typ == "result": # claude 收尾:统计一行;报错带原因
542
- u = ev.get("usage") or {}
543
- if ev.get("is_error"):
544
- out.append("— 完成(报错):" + str(ev.get("result") or "")[:300] + " —")
545
- else:
546
- cost = ev.get("total_cost_usd")
547
- if not msg_seen[0]:
548
- tx = str(ev.get("result") or "")
549
- if tx: # 旧单 JSON 模式没有 assistant 事件,正文在这补上
550
- out.append("【消息】" + tx[:max_event_chars])
551
- msg_seen[0] = True
552
- out.append("— 完成(tokens 入 %s / 出 %s,%s)—" % (
553
- u.get("input_tokens", "?"), u.get("output_tokens", "?"),
554
- ("$%.2f" % cost) if isinstance(cost, (int, float)) else "?"))
555
- continue
556
- if typ in ("thread.started", "turn.started"):
557
- continue
558
- if typ == "turn.completed":
559
- u = ev.get("usage") or {}
560
- out.append("— 一轮完成(tokens 入 %s / 出 %s)—" % (
561
- u.get("input_tokens", "?"), u.get("output_tokens", "?")))
562
- continue
563
- if typ == "turn.failed":
564
- e = ev.get("error")
565
- out.append("— 一轮失败:%s —" % (e.get("message") if isinstance(e, dict) else e))
566
- continue
567
- if typ in ("item.completed", "item.started", "item.updated"):
568
- if typ != "item.completed" or not isinstance(ev.get("item"), dict):
569
- continue # started/updated 是过程噪音,completed 才有内容
570
- line = _fmt_codex_item(ev["item"], max_event_chars)
571
- if line:
572
- out.append(line)
573
- continue
574
- out.append(ln)
575
- return "\n".join(out)
576
-
577
-
578
- def _normalize_abort_markers(value):
579
- """把重复错误配置规范成 ``[(marker, count), ...]``。
580
-
581
- 旧调用方传的是单个 ``(marker, count)`` 元组,必须继续支持;新的
582
- 调用方可以传字典或元组列表,以便同一 CLI 同时对多种断流文案止损。
583
- """
584
- if not value:
585
- return []
586
- if isinstance(value, str):
587
- return [(value, 1)]
588
- if isinstance(value, dict):
589
- raw = list(value.items())
590
- elif (isinstance(value, (tuple, list)) and len(value) == 2
591
- and isinstance(value[0], str)):
592
- raw = [value]
593
- else:
594
- try:
595
- raw = list(value)
596
- except TypeError:
597
- return []
598
- out = []
599
- for item in raw:
600
- if isinstance(item, str):
601
- marker, limit = item, 1
602
- else:
603
- try:
604
- marker, limit = item
605
- except (TypeError, ValueError):
606
- continue
607
- marker = str(marker or "")
608
- try:
609
- limit = max(1, int(limit))
610
- except (TypeError, ValueError):
611
- continue
612
- if marker:
613
- out.append((marker, limit))
614
- return out
615
-
616
-
617
- def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
618
- timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
619
- stall_timeout=0, repeat_abort=None, deadline=None,
620
- abort_markers=None, activity_timeout=0):
621
- """通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
622
-
623
- stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
624
- 新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
625
- 持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
626
- 开了会把正常长任务误杀。
627
-
628
- activity_timeout:基础 timeout 到期后,最近仍有真实输出的流式 CLI 允许继续
629
- 运行的最长静默窗口(秒,0=关闭)。它独立于 stall_timeout,避免环境把
630
- 停滞看门狗设为 0 后,Claude/Codex 又在基础超时处被硬杀;总延长仍有上限。
631
-
632
- repeat_abort:可选 (marker, count),也接受多个 ``(marker, count)``;同一
633
- 致命错误达到次数即提前杀树,处理 CLI 自身不断打印重连消息、因此永远
634
- 触发不了静默看门狗的假运行。abort_markers 是兼容扩展,用于不改变旧
635
- repeat_abort 参数形状的情况下追加多种一次性断流标记。
636
-
637
- deadline:可选的 ``time.monotonic()`` 绝对截止时刻。它比 timeout 优先,
638
- 到点会杀掉进程树并返回 deadline_exceeded=True,调用方可把整个任务收口
639
- 为 timeout,而不是把它误记成普通供应商失败。
640
-
641
- 返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled,
642
- deadline_exceeded, abort_marker}。
643
- """
644
- if shell_cmd:
645
- # shell 串的解析器随平台:重定向/引号语法两边通用,只是解释器不同
646
- argv = ["cmd", "/c", shell_cmd] if os.name == "nt" else ["/bin/sh", "-c", shell_cmd]
647
- if argv is None:
648
- return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
649
- "duration": 0.0, "cancelled": False, "timed_out": False,
650
- "stalled": False, "deadline_exceeded": False, "abort_marker": None}
651
- try:
652
- deadline = float(deadline) if deadline is not None else None
653
- except (TypeError, ValueError):
654
- deadline = None
655
- try:
656
- timeout = max(0.0, float(timeout))
657
- except (TypeError, ValueError):
658
- timeout = float(DEFAULT_TIMEOUT)
659
- try:
660
- activity_timeout = max(0.0, float(activity_timeout))
661
- except (TypeError, ValueError):
662
- activity_timeout = 0.0
663
- if deadline is not None and time.monotonic() >= deadline:
664
- return {"ok": False, "exit_code": None, "stdout": "",
665
- "stderr": "[任务总时限已到,未启动子进程]", "duration": 0.0,
666
- "cancelled": False, "timed_out": True, "stalled": False,
667
- "deadline_exceeded": True, "abort_marker": None}
668
- aborts = _normalize_abort_markers(repeat_abort)
669
- aborts.extend(_normalize_abort_markers(abort_markers))
670
- full_env = scrub_env(os.environ.copy(), mode="drop")
671
- if env:
672
- # 5A:env 关键字环境变量注入用户传入的 env(属于有意注入,例如模型 API key)
673
- full_env.update({str(k): str(v) for k, v in env.items()})
674
- log_fh = open(log_path, "ab") if log_path else None
675
- # 审计:调用下达前先把「执行的命令 + 发给智能体的指令原文」写进日志,
676
- # 运行中点开步骤就能看到"编排者下了什么令",不用等结束猜。
677
- # env 绝不写(含 API key);argv 里只有 base_url/env_key 名,无密钥值。
678
- # 指令超 12000 字符截断(章节正文可能很长),标注原始长度防误读。
679
- if log_fh:
680
- try:
681
- head = ["===== 下达 %s =====" % time.strftime("%Y-%m-%d %H:%M:%S"),
682
- "$ " + " ".join(str(a) for a in argv)]
683
- if stdin_text:
684
- capped = stdin_text[:12000]
685
- head.append("--- 指令(%d 字符%s)---" % (
686
- len(stdin_text), ",已截断" if len(stdin_text) > 12000 else ""))
687
- head.append(capped)
688
- log_fh.write(("\n".join(head) + "\n--- 输出 ---\n").encode("utf-8", "replace"))
689
- log_fh.flush()
690
- except Exception:
691
- pass
692
- try:
693
- try:
694
- proc = subprocess.Popen(
695
- [str(a) for a in argv], cwd=cwd, env=full_env,
696
- stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
697
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
698
- creationflags=CREATE_NO_WINDOW,
699
- start_new_session=(os.name != "nt")) # POSIX 需独立进程组供 killpg 杀树;Windows 忽略该参数
700
- except Exception as e:
701
- return {"ok": False, "exit_code": None, "stdout": "",
702
- "stderr": "启动失败: %r" % e, "duration": 0.0,
703
- "cancelled": False, "timed_out": False, "stalled": False,
704
- "deadline_exceeded": False, "abort_marker": None}
705
- # 挂巢(beekeeper):服务无论以何种方式退出(含被硬杀),蜜蜂 CLI
706
- # 一起带走。挂巢失败只降级,绝不影响本步起跑。
707
- beekeeper.adopt(proc)
708
- out_chunks, err_chunks = [], []
709
- stamp = [time.time(), False, time.monotonic()] # [墙钟、是否有输出、单调时钟]
710
- t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh, stamp), daemon=True)
711
- t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh, stamp), daemon=True)
712
- t_out.start()
713
- t_err.start()
714
- if stdin_text is not None:
715
- def _feed():
716
- try:
717
- proc.stdin.write(stdin_text.encode("utf-8"))
718
- except Exception:
719
- pass
720
- finally:
721
- try:
722
- proc.stdin.close()
723
- except Exception:
724
- pass
725
- threading.Thread(target=_feed, daemon=True).start()
726
- start = time.time()
727
- start_mono = time.monotonic()
728
- cancelled = timed_out = stalled = repeat_aborted = False
729
- deadline_exceeded = False
730
- abort_marker = None
731
- while True:
732
- try:
733
- proc.wait(timeout=0.4)
734
- break
735
- except subprocess.TimeoutExpired:
736
- pass
737
- if cancel_event is not None and cancel_event.is_set():
738
- cancelled = True
739
- _kill_tree(proc.pid)
740
- _drain_streams(proc, t_out, t_err, timeout=10)
741
- break
742
- if deadline is not None and time.monotonic() >= deadline:
743
- deadline_exceeded = True
744
- timed_out = True
745
- _kill_tree(proc.pid)
746
- _drain_streams(proc, t_out, t_err, timeout=5)
747
- break
748
- elapsed = time.monotonic() - start_mono
749
- if elapsed > timeout:
750
- # Claude/Codex 的工具循环可能持续吐 stream-json 事件而迟迟
751
- # 不给最终 result。活动延长与 stall 看门狗分离:无输出的挂死
752
- # 仍按原单模型 timeout 快速换将,收到过输出则最多再给一个活动窗口。
753
- active = (activity_timeout > 0 and stamp[1]
754
- and time.monotonic() - stamp[2] < activity_timeout
755
- and elapsed < timeout + activity_timeout)
756
- if not active:
757
- timed_out = True
758
- _kill_tree(proc.pid)
759
- _drain_streams(proc, t_out, t_err, timeout=5)
760
- break
761
- if stall_timeout and time.time() - stamp[0] > stall_timeout:
762
- # 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
763
- # 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
764
- stalled = True
765
- timed_out = True
766
- _kill_tree(proc.pid)
767
- _drain_streams(proc, t_out, t_err, timeout=5)
768
- break
769
- if aborts:
770
- try:
771
- recent = decode_output(b"".join(out_chunks)[-8000:] +
772
- b"\n" + b"".join(err_chunks)[-8000:])
773
- for marker, limit in aborts:
774
- if recent.count(marker) >= limit:
775
- repeat_aborted = True
776
- abort_marker = marker
777
- timed_out = True
778
- _kill_tree(proc.pid)
779
- _drain_streams(proc, t_out, t_err, timeout=5)
780
- break
781
- if repeat_aborted:
782
- break
783
- except (TypeError, ValueError):
784
- pass
785
- duration = round(time.time() - start, 1)
786
- t_out.join(timeout=5)
787
- t_err.join(timeout=5)
788
- exit_code = proc.returncode
789
- stdout = decode_output(b"".join(out_chunks))
790
- stderr = decode_output(b"".join(err_chunks))
791
- # stdout/stderr 的 close 已归 _pipe_reader 自线程(EOF 后自关):这里再
792
- # close 会抢 read1 手里的锁,孙进程漏杀攥着管道时就死等(见其 docstring)。
793
- # stdin 无读线程,_feed 写完自关;这里兜底一次(DEVNULL 时本就无事)。
794
- try:
795
- if proc.stdin:
796
- proc.stdin.close()
797
- except Exception:
798
- pass
799
- if cancelled:
800
- stderr += "\n[已被用户取消]"
801
- elif repeat_aborted:
802
- limit = next((n for m, n in aborts if m == abort_marker), "?")
803
- if len(aborts) == 1 and abort_markers is None:
804
- # 保留旧版 UI/测试已经依赖的文案;多标记配置使用更具体的
805
- # 标记名,方便定位究竟是哪一种断流触发了止损。
806
- stderr += "\n[同一网络错误重复 %s 次,已提前终止进程树]" % limit
807
- else:
808
- stderr += "\n[错误标记 %s 重复 %s 次,已提前终止进程树]" % (
809
- abort_marker, limit)
810
- elif deadline_exceeded:
811
- stderr += "\n[任务总时限已到,已终止进程树]"
812
- elif stalled:
813
- stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
814
- elif timed_out:
815
- stderr += "\n[超时 %ss,已终止进程树]" % timeout
816
- return {
817
- "ok": exit_code == 0 and not cancelled and not timed_out,
818
- "exit_code": exit_code, "stdout": stdout, "stderr": stderr,
819
- "duration": duration, "cancelled": cancelled, "timed_out": timed_out,
820
- "stalled": stalled, "deadline_exceeded": deadline_exceeded,
821
- "abort_marker": abort_marker,
822
- "activity_extended": bool(activity_timeout and stamp[1]
823
- and duration > timeout),
824
- "activity_timeout": activity_timeout,
825
- "last_output_age": max(0.0, time.time() - stamp[0]),
826
- }
827
- finally:
828
- if log_fh:
829
- try:
830
- log_fh.close()
831
- except Exception:
832
- pass
833
-
834
-
835
- # ---------------------------------------------------------------- 各家适配
836
-
837
- def _parse_codex_jsonl(stdout):
838
- """解析 codex exec JSONL 事件流。返回 (text, usage, sid)。
839
-
840
- usage 细分来自 turn.completed:input_tokens(含 cached)、cached_input_tokens、
841
- output_tokens、reasoning_output_tokens;多 turn 累加。
842
- sid 来自 thread.started 的 thread_id(§07 T1.1:供后续 revise/fix 复用会话,
843
- 会话内前缀按供应商缓存读计价)。
844
- """
845
- text = ""
846
- sid = ""
847
- usage = {"input": 0, "output": 0, "cached": 0, "reasoning": 0, "total": 0}
848
- for line in stdout.splitlines():
849
- line = line.strip()
850
- if not line.startswith("{"):
851
- continue
852
- try:
853
- ev = json.loads(line)
854
- except Exception:
855
- continue
856
- if ev.get("type") == "thread.started":
857
- sid = str(ev.get("thread_id") or sid)
858
- elif ev.get("type") == "item.completed":
859
- item = ev.get("item") or {}
860
- if item.get("type") == "agent_message" and item.get("text"):
861
- text = item["text"]
862
- elif ev.get("type") == "turn.completed":
863
- u = ev.get("usage") or {}
864
- inp = int(u.get("input_tokens") or 0)
865
- out = int(u.get("output_tokens") or 0)
866
- usage["input"] += inp
867
- usage["output"] += out
868
- usage["cached"] += int(u.get("cached_input_tokens") or 0)
869
- usage["reasoning"] += int(u.get("reasoning_output_tokens") or 0)
870
- usage["total"] += (u.get("total_tokens") if u.get("total_tokens") is not None
871
- else inp + out)
872
- return text, usage, sid
873
-
874
-
875
- def _codex_work_events(stdout):
876
- """统计本轮事件流里真实「动了手」的事件数(命令执行/文件改动/MCP 工具)。
877
-
878
- 实现步空转闸的客观判据:2026-09-17 mo-so 实现步模型一条命令都没发,纯口头
879
- 谎报「进程执行被策略拦截」却 exit 0 + 一段像模像样的总结——text 看不出敷衍,
880
- command_execution 计数为 0 才是硬证据。"""
881
- n = 0
882
- for line in stdout.splitlines():
883
- line = line.strip()
884
- if not line.startswith("{"):
885
- continue
886
- try:
887
- ev = json.loads(line)
888
- except Exception:
889
- continue
890
- if ev.get("type") == "item.completed" and (
891
- ev.get("item") or {}).get("type") in (
892
- "command_execution", "file_change", "mcp_tool_call"):
893
- n += 1
894
- return n
895
-
896
-
897
- def _codex_fail_msg(stdout):
898
- """从 JSONL 事件流提取终态失败消息;无失败返回 ""。
899
-
900
- 2026-09-16 实测:配额/限流只出现在 error 与 turn.failed 事件里,旧解析器
901
- 两者都丢——进程退出码非 0 时错误串里只剩 "Reading prompt from stdin...",
902
- _quota_error/_transient_error 判不出可降级,健康后继模型从未被尝试。
903
- Reconnecting... 是 CLI 内部重试噪音(可能自愈),不取作终态错误;重复
904
- 重连由进程看门狗提前中断。turn.failed 优先于裸 error(后者取最后一条兜底)。
905
- """
906
- terminal, last_err = "", ""
907
- for line in (stdout or "").splitlines():
908
- line = line.strip()
909
- if not line.startswith("{"):
910
- continue
911
- try:
912
- ev = json.loads(line)
913
- except Exception:
914
- continue
915
- t = ev.get("type")
916
- if t == "turn.failed":
917
- e = ev.get("error")
918
- terminal = e.get("message") if isinstance(e, dict) else str(e or "")
919
- elif t == "error":
920
- m = str(ev.get("message") or "")
921
- if m and "reconnecting" not in m.lower():
922
- last_err = m
923
- return terminal or last_err
924
-
925
-
926
- def _parse_claude_json(stdout):
927
- data = None
928
- try:
929
- data = json.loads(stdout)
930
- except Exception:
931
- # stream-json:逐行事件取最后一条 type=="result"(与旧单 JSON 同构)
932
- for line in stdout.splitlines():
933
- line = line.strip()
934
- if not line.startswith("{"):
935
- continue
936
- try:
937
- ev = json.loads(line)
938
- except Exception:
939
- continue
940
- if isinstance(ev, dict) and ev.get("type") == "result":
941
- data = ev
942
- if not isinstance(data, dict):
943
- return None
944
- u = data.get("usage") or {}
945
- inp = int(u.get("input_tokens") or 0)
946
- out = int(u.get("output_tokens") or 0)
947
- # 缓存读 + 缓存写都计入 cached(读是省钱的部分,写是额外消耗的部分)
948
- cached = int(u.get("cache_read_input_tokens") or 0) + \
949
- int(u.get("cache_creation_input_tokens") or 0)
950
- usage = {"input": inp, "output": out, "cached": cached, "reasoning": 0,
951
- "total": inp + out + cached}
952
- # 网关实服模型:请求 deepseek-v4.1-flash 却被网关映射成 gemini-3.8-flash
953
- # 之类(2026-09-23 夜班实案)——用量统计记错名、失败排查看错病根,都
954
- # 靠 result 行的 modelUsage 对出来
955
- served = sorted({str(k) for k in ((data.get("modelUsage") or {}) or {})})
956
- return {
957
- "text": data.get("result") or "",
958
- "cost_usd": data.get("total_cost_usd") or 0.0,
959
- "usage": usage,
960
- "tokens": usage["total"],
961
- "is_error": bool(data.get("is_error")),
962
- # §07 T1.1:claude -p 返回本次会话 id,供 --resume 复用
963
- "sid": str(data.get("session_id") or ""),
964
- "served_models": served,
965
- }
966
-
967
-
968
- def _codex_provider_args(cp):
969
- """把外部供应商注入为一次性的 codex model_provider(-c 覆盖,不改配置文件)。"""
970
- name = cp.get("name", "orch")
971
- return ["-c", 'model_provider="%s"' % name,
972
- "-c", 'model_providers.%s.name="%s"' % (name, name),
973
- "-c", 'model_providers.%s.base_url="%s"' % (name, cp.get("base_url", "")),
974
- "-c", 'model_providers.%s.env_key="%s"' % (name, cp.get("env_key", "ORCH_API_KEY")),
975
- "-c", 'model_providers.%s.wire_api="%s"' % (name, cp.get("wire_api", "responses"))]
976
-
977
-
978
- def _model_flag(kind, model):
979
- if kind in ("codex", "qwen"):
980
- return ["-m", model]
981
- return ["--model", model] # claude / opencode / aider
982
-
983
-
984
- _TRANSIENT_DEFAULT = ("503", "502", "529", "429", "no available channel", "temporarily",
985
- "unavailable", "overloaded", "rate limit", "timeout", "timed out",
986
- "输出停滞",
987
- # 2026-09-15 连载验收实测:网关故障形态远不止 HTTP 5xx——
988
- # Z.ai 报 "400 [1211] Unknown Model"(模型临时下架)、qwen 连本地
989
- # 端点 ECONNREFUSED、codex initialize 空响应,这些都被旧表判成
990
- # 「非瞬态不降级」,导致跨厂商链上健康的后继模型从未被尝试。
991
- "unknown model", "1211", "connection error", "econnrefused",
992
- "connection aborted", "initialize", "reset by peer",
993
- "channel is closed", "no route to host",
994
- # 2026-09-20 连载评审实测:codex 网关断流(stream disconnected)
995
- # 与 opencode 服务端 500(Unexpected server error)都是「重试/换将
996
- # 就可能活」的瞬态病,旧表判成终态导致整链早死
997
- "stream disconnected", "stream closed before response.completed",
998
- "unexpected server error",
999
- # Grok CLI wraps transport failures in reqwest text and then
1000
- # retries the same request internally. Treat that as a transient
1001
- # upstream failure so the orchestration chain can move on.
1002
- "reqwest error stream", "error sending request for url",
1003
- "internal error",
1004
- # 2026-09-22 mo-so 实案:claude 报 "Can't reach the API server —
1005
- # check your internet or DNS (ENOTFOUND)",一家端点解析不到不代表
1006
- # 跨厂商链上别家也不通,旧表判成终态直接跳去换将别的 CLI
1007
- "enotfound", "getaddrinfo", "name or service not known",
1008
- "name resolution")
1009
-
1010
- # 保留兼容别名:旧 worker 热重载时可能短暂丢失模块级变量,判断函数不能因此
1011
- # 把一次普通上游故障升级成 NameError。
1012
- _TRANSIENT = _TRANSIENT_DEFAULT
1013
-
1014
-
1015
- def _transient_error(err, _default_markers=_TRANSIENT_DEFAULT):
1016
- err = (err or "").lower()
1017
- markers = globals().get("_TRANSIENT") or _default_markers
1018
- return any(k in err for k in markers)
1019
-
1020
-
1021
- # 欠费/配额/限流耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
1022
- # 与 modelhub._QUOTA_HINTS 同源,这里独立一份避免 core 模块间循环依赖。
1023
- _QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
1024
- "payment required", "402", "欠费", "余额", "额度", "exceeded",
1025
- "too many requests", "rate limit", "rate_limit", "http 429",
1026
- "并发", "超过限")
1027
-
1028
-
1029
- def _quota_error(err):
1030
- err = (err or "").lower()
1031
- return any(k in err for k in _QUOTA)
1032
-
1033
-
1034
- _AUTH = ("invalid api key", "invalid_api_key", "api key is invalid",
1035
- "incorrect api key", "unauthorized", "unauthorised",
1036
- "authentication failed", "authentication error", "authentication_error",
1037
- "invalid token", "invalid_token", "http 401", "status code 401")
1038
-
1039
-
1040
- def _auth_error(err):
1041
- """只识别明确的凭据拒绝,不把一般模型权限错误(403)当成 KEY 错。"""
1042
- err = (err or "").lower()
1043
- return any(k in err for k in _AUTH)
1044
-
1045
-
1046
- _FORBIDDEN_HINTS = ("forbidden", "request not allowed", "permission denied",
1047
- "access denied")
1048
-
1049
-
1050
- def _permission_error(err):
1051
- """识别明确的 HTTP 403 权限拒绝;它是上游故障,不等同于无效 API Key。"""
1052
- err = (err or "").lower()
1053
- return bool(re.search(r"(?<!\d)403(?!\d)", err) and
1054
- any(marker in err for marker in _FORBIDDEN_HINTS))
1055
-
1056
-
1057
- def _attempt_credential(att):
1058
- """绑定链中凭据的非敏感身份;不把密钥值写入日志或尝试记录。"""
1059
- return (str((att or {}).get("provider_id") or ""),
1060
- str((att or {}).get("key_id") or ""))
1061
-
1062
-
1063
- # 限流(429)专项:与一般瞬态不同,它是「等一个窗口就常能自愈」的病——
1064
- # 2026-09-22 四连败实测:claude ENOTFOUND 换将 codex 后仍与同一上游撞 429,
1065
- # 链上无第三条路时整步立刻判死;而限流窗口通常按分钟计,原地等一个窗口
1066
- # 再试一次,好过把整轮 run 直接烧成 failed(欠费不同源,不适用宽限)。
1067
- _RATE_LIMIT = ("429", "rate limit", "too many requests", "限流")
1068
- RATE_LIMIT_GRACE_S = 45 # 宽限等待时长:限流窗口通常按分钟计,等一个再试
1069
- RATE_LIMIT_GRACE_N = 1 # 每步宽限次数:只兜一次,防限流长拖整轮时间
1070
-
1071
-
1072
- # 上游内容拒答(refusal):claude CLI 把 refusal stop_reason 渲染成
1073
- # "API Error: <model> can't help with this. Start a new session to continue."
1074
- # 这是网关后面那个模型对本次会话内容的过滤决定,不是任务终态——换一家
1075
- # 异上游候选常常就能跑通。2026-09-23 夜班实案:doc 起草被 gemini 拒答后
1076
- # 判成非瞬态整链早死,链上云知声候选根本没被尝试。
1077
- _REFUSAL = ("can't help with this", "couldn't help with this",
1078
- 'stop_reason":"refusal"', "content policy", "content filtering")
1079
-
1080
-
1081
- def _refusal_error(err):
1082
- err = (err or "").lower()
1083
- return any(k in err for k in _REFUSAL)
1084
-
1085
-
1086
- def _attempt_upstream(att):
1087
- """返回尝试项的上游身份;同一 host 换模型/供应商别名不重复烧超时。"""
1088
- provider = (att or {}).get("provider") or {}
1089
- base_url = str(provider.get("base_url") or "").strip()
1090
- match = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)", base_url)
1091
- if match:
1092
- return match.group(1).lower()
1093
- provider_id = str((att or {}).get("provider_id") or "").strip()
1094
- return "provider:" + provider_id if provider_id else "unknown"
1095
-
1096
-
1097
- def _claude_drift_note(att_model, parsed):
1098
- """请求模型 vs 网关实服模型不一致时的注记;一致或无从判断返回空。"""
1099
- served = (parsed or {}).get("served_models") or []
1100
- if not served or not att_model or served == [att_model]:
1101
- return ""
1102
- return "[模型漂移] 请求 %s,上游实服 %s" % (att_model, ",".join(served))
1103
-
1104
-
1105
- def _log_note(log_path, text):
1106
- if not log_path or not text:
1107
- return
1108
- try:
1109
- with open(log_path, "a", encoding="utf-8") as fh:
1110
- fh.write("\n[%s]\n" % text)
1111
- except OSError:
1112
- pass
1113
-
1114
-
1115
- def _rate_limited(err):
1116
- err = (err or "").lower()
1117
- return any(k in err for k in _RATE_LIMIT)
1118
-
1119
-
1120
- # 审计日志错误行探针:只认强信号,避免把回显提示词里的普通词当错误
1121
- _LOG_ERR_HINT = re.compile(
1122
- r"(429|402|rate[_ ]?limit|quota|余额|欠费|insufficient|billing|arrears"
1123
- r"|401|unauthorized|unauthorised|invalid[_ ]api[_ ]key|authentication[_ ]error"
1124
- r"|ENOTFOUND|ECONNREFUSED|failed to run prompt|api_error|overloaded)",
1125
- re.I)
1126
-
1127
-
1128
- def _augment_error_from_log(out, log_path, limit=300):
1129
- """stdout/stderr 尾段没有错误信号时,从流式审计日志里补最后一条错误行。
1130
- 真错误有时只走流式管道(kimi 撞 429 时捕获输出只剩启动横幅),不补的
1131
- 话路由层与用户都只看到「退出码 1;stderr/stdout: kimi version 2.0.2」。"""
1132
- if not log_path or not out.get("error"):
1133
- return
1134
- try:
1135
- with open(log_path, "rb") as fh:
1136
- fh.seek(0, 2)
1137
- size = fh.tell()
1138
- fh.seek(max(0, size - 8192))
1139
- blob = fh.read().decode("utf-8", "ignore")
1140
- except OSError:
1141
- return
1142
- hits = [ln.strip() for ln in blob.splitlines() if _LOG_ERR_HINT.search(ln)]
1143
- if not hits:
1144
- return
1145
- out["error"] = (out["error"] + ";日志错误行: " + hits[-1][:limit])[-800:]
1146
-
1147
-
1148
- def _grace_wait(cancel_event, seconds, log_path=None, why=""):
1149
- """链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
1150
- if log_path:
1151
- try:
1152
- with open(log_path, "a", encoding="utf-8") as fh:
1153
- fh.write("\n[限流宽限] %s:等待 %ds 后原地重试一次\n"
1154
- % (why or "上游限流(429)", seconds))
1155
- except Exception:
1156
- pass
1157
- end = time.monotonic() + seconds
1158
- while time.monotonic() < end:
1159
- if cancel_event is not None and cancel_event.is_set():
1160
- return False
1161
- time.sleep(min(3, max(0.5, end - time.monotonic())))
1162
- return cancel_event is None or not cancel_event.is_set()
1163
-
1164
-
1165
- def _report_key(att, out):
1166
- """把这次尝试的结果回写到 KEY 账本:欠费/失败 → 冷却,成功 → 清错误。
1167
-
1168
- 回写失败绝不能影响主流程(账本是旁路),所以整体吞异常。
1169
- """
1170
- pid, kid = att.get("provider_id") or "", att.get("key_id") or ""
1171
- if not (pid and kid):
1172
- return
1173
- try:
1174
- from . import modelhub
1175
- if out.get("ok"):
1176
- modelhub.note_key_ok(pid, kid)
1177
- elif out.get("error"):
1178
- modelhub.note_key_error(pid, kid, out["error"])
1179
- except Exception:
1180
- pass
1181
-
1182
-
1183
- def _classify_failure(res, *, parsed=None, kind="", attempt_done=False, empty_output=False):
1184
- """根据 run_process 结果 + 解析结果,返回 ErrorCode 字符串。
1185
-
1186
- 设计稿:docs/migration/01-defense-patterns.md §5D + §2D。
1187
-
1188
- Args:
1189
- res: run_process 返回的 dict(含 ok/cancelled/timed_out/exit_code/stdout/stderr)
1190
- parsed: claude 解析后的 dict(或 None);codex 由 caller 处理
1191
- kind: "claude" | "codex" | "generic" | ...
1192
- attempt_done: claude 空响应是否已重试一次(True=已是第二次)
1193
- empty_output: 调用方已确认 stdout 为空(用于 generic/codex 的空响应)
1194
-
1195
- Returns:
1196
- ErrorCode 枚举值字符串;成功返回 ""。
1197
- """
1198
- if res.get("cancelled"):
1199
- return ErrorCode.CANCELLED
1200
- if res.get("timed_out"):
1201
- return ErrorCode.TIMEOUT
1202
- # claude 解析失败(进程 ok 但 JSON 不可解析)
1203
- if kind == "claude" and parsed is None:
1204
- return ErrorCode.PARSE_FAIL
1205
- # claude 明确 is_error
1206
- if parsed and parsed.get("is_error"):
1207
- return ErrorCode.VENDOR_REFUSAL
1208
- # claude 两次都空
1209
- if kind == "claude" and attempt_done and not (parsed or {}).get("text", "").strip():
1210
- return ErrorCode.EMPTY
1211
- # generic/codex 空输出(caller 已确认)
1212
- if empty_output and not parsed:
1213
- return ErrorCode.EMPTY
1214
- # 退出码非 0(vendor 内部崩溃)
1215
- ec = res.get("exit_code")
1216
- if ec is not None and ec != 0:
1217
- return ErrorCode.VENDOR_ERROR
1218
- return ""
1219
-
1220
-
1221
- def _resolve_attempts(agent):
1222
- """把 agent 的模型配置展开为逐次尝试列表。
1223
-
1224
- 优先用跨厂商链 call_chain(每条自带 env / codex_provider,来自不同供应商);
1225
- 无链时退回 model + model_fallbacks(同一 CLI 进程内换 -m,沿用 agent 级注入)。
1226
-
1227
- 链条目可能带 key_id/provider_id(同一厂商多把 KEY 展开成的多条)——失败时
1228
- 据此把「哪把 KEY 不行」回写冷却,后续解析自动切备用。
1229
- """
1230
- chain = agent.get("call_chain") or []
1231
- if chain:
1232
- return [{"model": (e.get("model") or "").strip() or None,
1233
- "env": dict(e.get("env") or {}),
1234
- "provider": e.get("provider") or {},
1235
- "from_chain": True,
1236
- "own_cp": "codex_provider" in e,
1237
- "codex_provider": e.get("codex_provider"),
1238
- "provider_id": e.get("provider_id") or "",
1239
- "key_id": e.get("key_id") or ""} for e in chain]
1240
- base_model = agent.get("model")
1241
- fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
1242
- models_to_try = ([base_model] if base_model else []) + fb
1243
- provider = agent.get("provider") or {}
1244
- return [{"model": m or None, "env": {}, "provider": provider,
1245
- "from_chain": False, "own_cp": False,
1246
- "codex_provider": None,
1247
- "provider_id": provider.get("id") if isinstance(provider, dict) else "",
1248
- "key_id": ""}
1249
- for m in (models_to_try or [None])[:3]]
1250
-
1251
-
1252
- def _codex_sandbox(readonly):
1253
- """codex 沙箱档位。写步默认 danger-full-access(2026-09-17 用户拍板「默认给全部
1254
- 权限」):workspace-write 禁网+禁盘外写,模型偶发还会误判沙箱受限、谎报
1255
- 「进程执行被策略拦截」直接躺平(mo-so BUG#27596 实测,沙箱探针证明命令本可跑)。
1256
- 只读步(评审/规划)仍 read-only——评审者可写会污染 git diff 裁决。
1257
- env TUTTI_CODEX_SANDBOX 可钉死某档(workspace-write / read-only / danger-full-access)。"""
1258
- if readonly:
1259
- return "read-only"
1260
- env = os.environ.get("TUTTI_CODEX_SANDBOX", "").strip().lower()
1261
- if env in ("workspace-write", "read-only", "danger-full-access"):
1262
- return env
1263
- return "danger-full-access"
1264
-
1265
-
1266
- def _prompt_to_file(prompt, workdir):
1267
- """超长提示词落盘(工作目录优先,保证评审 CLI 沙箱内可读),返回绝对路径。"""
1268
- try:
1269
- d = workdir if workdir and os.path.isdir(workdir) else None
1270
- fd, path = tempfile.mkstemp(prefix="tutti_prompt_", suffix=".md", dir=d)
1271
- with os.fdopen(fd, "w", encoding="utf-8") as f:
1272
- f.write(prompt)
1273
- return path
1274
- except Exception:
1275
- return None
1276
-
1277
-
1278
- # Windows 上不少 CLI 先经过 cmd.exe/.cmd 垫片,实际安全上限接近 8191,
1279
- # 而不是 CreateProcess 的理论 32767。留出命令、路径和环境参数余量,超过
1280
- # 该值统一落盘,避免 MiMo/Kimi 评审在启动前直接报「命令行太长」。
1281
- _ARGV_PROMPT_SAFE = 20000
1282
- _ARGV_PROMPT_SHIM_SAFE = 6000
1283
-
1284
-
1285
- def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=None):
1286
- """构建一次 CLI 调用的 (argv, stdin_text, prompt, tmp_files)。model 可为 None=CLI 默认。
1287
- images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。
1288
- tmp_files:为绕开命令行长度限制落盘的指令临时文件,调用方用完(进程结束后)删除。"""
1289
- env = {}
1290
- argv = None
1291
- stdin_text = None
1292
- tmp_files = []
1293
- imgs = [str(p) for p in (images or []) if p]
1294
- if kind == "codex":
1295
- cp = agent.get("codex_provider")
1296
- reasoning = str(agent.get("reasoning_effort") or "").strip().lower()
1297
- if sid:
1298
- # resume 子命令不支持 -s 也不支持 --full-auto(0.154 实测:
1299
- # "unexpected argument '--full-auto'")——读/写模式都用 -c sandbox_mode
1300
- argv = resolve_command(agent["command"]) + [
1301
- "exec", "resume", sid, "-",
1302
- "--skip-git-repo-check", "--json"]
1303
- if model:
1304
- argv += ["-m", model]
1305
- argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
1306
- if cp:
1307
- argv += _codex_provider_args(cp)
1308
- else:
1309
- argv = resolve_command(agent["command"]) + [
1310
- "exec", "--skip-git-repo-check", "--json",
1311
- "-s", _codex_sandbox(readonly)]
1312
- if model:
1313
- argv += ["-m", model]
1314
- if cp:
1315
- argv += _codex_provider_args(cp)
1316
- if reasoning in ("low", "medium", "high", "xhigh"):
1317
- argv += ["-c", 'model_reasoning_effort="%s"' % reasoning]
1318
- # codex exec 与 exec resume 都支持 -i:图片直接附到 prompt(exec resume 的
1319
- # -i 附在恢复后发送的首条消息上,即本次 stdin prompt)
1320
- for p in imgs:
1321
- argv += ["-i", p]
1322
- stdin_text = prompt
1323
- elif kind == "claude":
1324
- # stream-json(2026-09-17 起):逐行事件边跑边吐——停滞看门狗有得看,
1325
- # 步骤日志实时可读;result 末行与旧单 JSON 同构,解析器双形态兼容
1326
- argv = resolve_command(agent["command"]) + [
1327
- "-p", "--output-format", "stream-json", "--verbose"]
1328
- if sid:
1329
- argv += ["--resume", sid]
1330
- if model:
1331
- argv += ["--model", model]
1332
- if readonly:
1333
- # 实测本机自定义网关在 -p 模式下工具续接会丢最终结果(用工具必空)。
1334
- # 评审/规划所需的上下文已内嵌在提示词中,显式禁用工具最稳。
1335
- prompt = "(请勿使用任何工具,直接依据下方内容回答。)\n\n" + prompt
1336
- elif os.environ.get("TUTTI_CLAUDE_PERMS", "").strip() == "acceptEdits":
1337
- argv += ["--permission-mode", "acceptEdits"]
1338
- else:
1339
- # 无人值守 -p 下 acceptEdits 只自动放行改文件,Bash 一律权限拒绝 →
1340
- # 模型以为环境受限谎报「被策略拦截」躺平(同 codex 沙箱误判案)。
1341
- # 默认跳过全部权限检查(2026-09-17 用户拍板),env 可收回旧行为。
1342
- argv += ["--dangerously-skip-permissions"]
1343
- # 绑定链 env 落一次性 --settings:claude CLI 里用户 settings.json 的 env
1344
- # 会覆盖进程 env(2026-09-22 双监听器实测),光靠 run_process 的 env 注入,
1345
- # 链首供应商会被用户配置顶掉(a.test 毒配置案的放大器)。--settings 的
1346
- # 优先级高于用户 settings.json(同日实测),临时文件进程结束随 tmp_files 删。
1347
- ant = {k: v for k, v in (agent.get("env") or {}).items()
1348
- if k.startswith("ANTHROPIC_")}
1349
- if ant:
1350
- try:
1351
- fd, sp = tempfile.mkstemp(prefix="tutti_claude_settings_", suffix=".json")
1352
- with os.fdopen(fd, "w", encoding="utf-8") as f:
1353
- json.dump({"env": ant}, f)
1354
- argv += ["--settings", sp]
1355
- tmp_files.append(sp)
1356
- except Exception:
1357
- pass # 落盘失败退回纯 env 注入(旧路径),不拦运行
1358
- stdin_text = prompt
1359
- elif kind == "opencode":
1360
- argv = resolve_command(agent["command"]) + ["run"]
1361
- if workdir:
1362
- # opencode 的 shell 不总跟进程 cwd 走(服务端复用时会落在旧项目根,
1363
- # 2026-09-17 mo-so 实测 agent 在 Temp 里全盘找代码)——显式钉死
1364
- argv += ["--dir", str(workdir)]
1365
- if sid:
1366
- argv += ["-s", sid] # 无头续会话:-s 指定会话 id(-c 只能接最近一次)
1367
- if model:
1368
- # opencode 只认 provider/model 全名(裸名直接 UnknownError,实测);
1369
- # CodeBee 同步进其配置的 provider id 固定为 orch,故裸名补前缀。
1370
- # 绑定里已写全名(含 /)的尊重原值。
1371
- argv += ["--model", model if "/" in model else "orch/" + model]
1372
- stdin_text = prompt # 无位置参数且 stdin 有内容时读 stdin
1373
- elif kind == "qwen": # gemini-cli 系:无参数且 stdin 有内容时读 stdin
1374
- argv = resolve_command(agent["command"])
1375
- if sid:
1376
- argv += ["-r", sid] # 恢复指定会话(~/.qwen/projects/*/chats/<sid>.jsonl)
1377
- if model:
1378
- argv += ["-m", model]
1379
- stdin_text = prompt
1380
- elif kind == "aider":
1381
- argv = resolve_command(agent["command"]) + [
1382
- "--yes-always", "--no-auto-commits", "--no-check-update",
1383
- "--no-gitignore", "--no-pretty", "--no-stream",
1384
- # 无头执行没有 Windows 控制台,aider 建 PromptSession 抛错并打一行
1385
- # "Can't initialize prompt toolkit: No Windows console found..."
1386
- # (非致命,但它排在输出最前,UI 失败摘要只取头部 → 真错误被顶掉,
1387
- # 2026-09-22 mo-so 实案整条失败原因显示成这句噪声)。关掉花式输入
1388
- # 就不再建 PromptSession,噪声从源头消失。
1389
- "--no-fancy-input",
1390
- # 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
1391
- # 纯属刷屏(2026-09-20 实测占满步骤日志头部)
1392
- "--no-show-model-warnings", "--message", prompt]
1393
- if model:
1394
- # litellm 靠 provider 前缀路由,裸模型名直接报
1395
- # "LLM Provider NOT provided"。前缀按本条尝试实际注入的端点协议定:
1396
- # ANTHROPIC_BASE_URL=anthropic 面(Bigmodel 等)、OPENAI_API_BASE=
1397
- # openai 兼容面;都没有=CLI 本机默认场景,模型名保持用户原样
1398
- envd = agent.get("env") or {}
1399
- if "/" not in model:
1400
- if envd.get("ANTHROPIC_BASE_URL"):
1401
- model = "anthropic/" + model
1402
- elif envd.get("OPENAI_API_BASE"):
1403
- model = "openai/" + model
1404
- argv += ["--model", model]
1405
- else: # generic:模板把 {prompt}/{session} 嵌进参数(注意 cmd 行长度限制)
1406
- tmpl = agent.get("argv_template") or ["-p", "{prompt}"]
1407
- if sid and agent.get("resume_argv_template"):
1408
- tmpl = agent["resume_argv_template"]
1409
- argv = resolve_command(agent["command"]) + [
1410
- str(a).replace("{prompt}", prompt).replace("{session}", sid) for a in tmpl]
1411
- # 提示词在 argv 里 → 必须绕开 cmd 重解析(见 _npm_shim_bypass)
1412
- argv = _npm_shim_bypass(argv)
1413
- if "{prompt}" not in tmpl:
1414
- stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
1415
- # Windows CreateProcess 命令行上限 32767 字符:全局评审会把全书文本(约 6 万
1416
- # 字)嵌进 argv,直接 WinError 206 启动失败(2026-09-18 七猫甜宠案,kimi 实证)。
1417
- # 超长时落盘临时文件、参数位换成读文件指令——评审/作者 CLI 非交互模式均带
1418
- # 读文件工具(kimi 0.43 实测可读),读不到的调用输出不可解析,由评审全挂
1419
- # 防线兜底判失败,绝不静默降级。
1420
- prompt_safe = _ARGV_PROMPT_SAFE
1421
- if argv:
1422
- # 垫片检测扫前三个 token:cmd /c xxx.CMD 形态下 argv[0] 是 cmd.exe,
1423
- # 真正的 .cmd 垫片在 argv[2]——只看 argv[0] 会漏判,中等长度提示词
1424
- # (6000-20000 字)留在指令行直接撞 cmd.exe 8191 上限
1425
- # (2026-09-24 评审「命令行太长」实案)
1426
- heads = [str(a).lower() for a in argv[:3]]
1427
- if any(a.endswith((".cmd", ".bat")) or
1428
- os.path.basename(a) in ("npm", "npx", "pnpm", "yarn", "bun")
1429
- for a in heads):
1430
- prompt_safe = _ARGV_PROMPT_SHIM_SAFE
1431
- elif os.name == "nt" and kind == "generic":
1432
- # Vendor wrappers are not always recognisable as npm shims. Keep
1433
- # generic prompts below the cmd.exe limit in that case too.
1434
- prompt_safe = _ARGV_PROMPT_SHIM_SAFE
1435
- if argv is not None and stdin_text is None and len(prompt) >= prompt_safe \
1436
- and argv.count(prompt) == 1:
1437
- pf = _prompt_to_file(prompt, workdir)
1438
- if pf:
1439
- argv[argv.index(prompt)] = (
1440
- "[系统] 本次完整指令因命令行长度限制已写入文件:%s\n"
1441
- "请先用读文件工具完整读取该文件,然后把文件内容当作你的任务指令执行。" % pf)
1442
- # Keep the original payload on stdin as a compatibility path for
1443
- # simple generic adapters and test shims that read stdin. CLI tools
1444
- # that intentionally use argv still receive the bounded file hint.
1445
- if kind == "generic" and stdin_text is None and len(tmpl) > 1:
1446
- # 模板明确将提示词放进 argv 时,原文只保存在文件里;同时再
1447
- # 写入 stdin 会让部分包装器重复读取,也无法解决 Windows 命令行上限。
1448
- stdin_text = None
1449
- tmp_files.append(pf)
1450
- return argv, stdin_text, prompt, tmp_files
1451
-
1452
-
1453
- def _check_approval(agent):
1454
- """5G:catalog entry sensitive + policy=NEVER → 拒绝(无人值守不静默降级)。
1455
-
1456
- Policy 来源:环境变量 TUTTI_APPROVAL_POLICY(默认 "never")。
1457
- sensitive 字段从 catalog orch.sensitive 读取。
1458
-
1459
- Returns:
1460
- (True, "") 表示放行;
1461
- (False, reason) 表示拒绝(error_code=ENV_BLOCK)。
1462
- """
1463
- policy = os.environ.get("TUTTI_APPROVAL_POLICY", "never").strip().lower()
1464
- sensitive = bool((agent.get("orch") or {}).get("sensitive"))
1465
- if not sensitive:
1466
- return True, ""
1467
- if policy == "never":
1468
- return False, "sensitive step blocked by policy=never (set TUTTI_APPROVAL_POLICY=ask to require explicit approval)"
1469
- # policy=ask 留 Phase 5 做完整 UI 弹窗流程;当前等价 never
1470
- return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
1471
-
1472
-
1473
- def _stall_timeout(env_name, default):
1474
- """停滞看门狗秒数:stdout/stderr 持续无新输出超过该时长 → 判卡死提前杀,
1475
- 错误按超时归类走既有换模型/换将链路。只对事件流持续流动的 CLI 启用
1476
- (codex JSONL / claude stream-json);qwen/opencode/generic 结束才一次性
1477
- 输出,开了会误杀正常长任务,仍靠总超时兜底。env 可调,0=关闭。"""
1478
- try:
1479
- return max(0, int(os.environ.get(env_name, str(default))))
1480
- except Exception:
1481
- return default
1482
-
1483
-
1484
- def _activity_timeout(env_name, default=DEFAULT_STREAM_ACTIVITY_TIMEOUT_S):
1485
- """流式 CLI 的活动延长窗口,独立于停滞看门狗配置。"""
1486
- try:
1487
- return max(0, int(os.environ.get(env_name, str(default))))
1488
- except Exception:
1489
- return default
1490
-
1491
-
1492
- def _max_model_attempts(has_deadline=False):
1493
- """返回一次 runner 调用允许消耗的候选数。
1494
-
1495
- 生产任务会传入统一 deadline,默认收敛到 3 个候选;没有 deadline 的
1496
- 旧式直接调用保留最多 4 个候选,以兼容已有的「同一上游两模型后换
1497
- 供应商」策略。运维可用环境变量进一步收紧/放宽,最少 1 个。
1498
- """
1499
- default = 3 if has_deadline else 4
1500
- try:
1501
- value = int(os.environ.get("TUTTI_MAX_MODEL_ATTEMPTS", str(default)))
1502
- return max(1, min(12, value))
1503
- except (TypeError, ValueError):
1504
- return default
1505
-
1506
-
1507
- _INVALID_MODEL_NAMES = frozenset(("auto", "default", "none"))
1508
-
1509
-
1510
- def _invalid_model(model):
1511
- return str(model or "").strip().lower() in _INVALID_MODEL_NAMES
1512
-
1513
-
1514
- def run_agent(agent, prompt, workdir=None, readonly=True,
1515
- timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
1516
- images=None, require_tools=False, deadline=None):
1517
- """执行一次智能体调用,返回统一结构
1518
- {ok, text, json, cost_usd, tokens, error, error_code, raw}。
1519
- agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
1520
- (codex: exec resume;claude: --resume;opencode/mimo: run -s;qwen: -r;
1521
- generic: catalog orch.resume_argv_template)。
1522
- images:任务图片附件的绝对路径,仅 codex 原生支持(-i);其余智能体靠
1523
- 提示词里的 _attachments/ 路径 + 自身读文件能力获取,无读图工具时静默忽略。
1524
- require_tools:实现步空转闸(仅 codex 事件流可判)——True 时本轮零
1525
- command_execution/file_change/mcp_tool_call 事件即判 VENDOR_REFUSAL,
1526
- 防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
1527
-
1528
- 模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
1529
- 主模型 + 降级备选;瞬态/配额/超时/上游拒答换下一条,取消与解析
1530
- 失败不降级;同一上游超时两次即跳过该上游剩余模型。
1531
-
1532
- 5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout;
1533
- 单模型时限由该配置或 caller timeout 决定,任务 deadline 可进一步收紧;
1534
- 持续 stream 活动交由 stall_timeout 看门狗收口。deadline 是
1535
- 任务共享的 ``time.monotonic()`` 绝对截止时刻;传入后所有模型、空响应
1536
- 重试和限流宽限共用同一剩余预算。
1537
- """
1538
- # 5E:catalog orch.timeout_ms 优先
1539
- orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
1540
- if orch_timeout_ms:
1541
- timeout = float(orch_timeout_ms) / 1000.0
1542
- try:
1543
- timeout = float(timeout)
1544
- except (TypeError, ValueError):
1545
- timeout = float(DEFAULT_TIMEOUT)
1546
- timeout = max(0.0, timeout)
1547
- try:
1548
- deadline = float(deadline) if deadline is not None else None
1549
- except (TypeError, ValueError):
1550
- deadline = None
1551
- if deadline is not None and time.monotonic() >= deadline:
1552
- return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1553
- "tokens": 0, "usage": None, "error": "任务总时限已到,未启动模型",
1554
- "error_code": ErrorCode.TIMEOUT,
1555
- "raw": {"exit_code": None, "timed_out": True,
1556
- "deadline_exceeded": True, "duration": 0.0},
1557
- "kind": agent.get("kind", "generic"), "model": None,
1558
- "attempts": []}
1559
- kind = agent.get("kind", "generic")
1560
- if kind == "aider":
1561
- repo_issue = _git_repo_issue(workdir)
1562
- if repo_issue:
1563
- return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1564
- "tokens": 0, "usage": None, "error": repo_issue,
1565
- "error_code": ErrorCode.VENDOR_ERROR, "raw": None,
1566
- "kind": kind, "model": agent.get("model"), "attempts": []}
1567
- if kind == "codex":
1568
- stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
1569
- activity_t = _activity_timeout("TUTTI_CODEX_ACTIVITY_TIMEOUT")
1570
- elif kind == "claude":
1571
- stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
1572
- activity_t = _activity_timeout("TUTTI_CLAUDE_ACTIVITY_TIMEOUT")
1573
- else:
1574
- # 数据驱动:catalog orch.stall_timeout_s——给 kimi/qwen 这类评审用
1575
- # CLI 配置后,静默挂死即杀(qwen 900s:2026-09-22 MCP 收尾死锁案);
1576
- # mimo 等结束才一次性输出的仍留 0(开了必误杀),靠总超时兜底
1577
- try:
1578
- stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
1579
- except Exception:
1580
- stall_t = 0
1581
- activity_t = 0
1582
- # 5G:approval NEVER 一线(无人值守不静默降级)
1583
- ok, reason = _check_approval(agent)
1584
- if not ok:
1585
- return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
1586
- "usage": None, "error": reason,
1587
- "error_code": ErrorCode.ENV_BLOCK,
1588
- "raw": None, "kind": kind, "model": None, "attempts": []}
1589
- base_env = dict(agent.get("env") or {})
1590
- if kind == "claude":
1591
- bash = find_git_bash()
1592
- if bash:
1593
- base_env.setdefault("CLAUDE_CODE_GIT_BASH_PATH", bash)
1594
- base_env.setdefault("CLAUDE_CODE_MAX_OUTPUT_TOKENS", "16000")
1595
- sid = (resume or "").strip() if agent.get("mode") == "real" else ""
1596
- if sid and kind == "generic" and not agent.get("resume_argv_template"):
1597
- return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
1598
- "usage": None,
1599
- "error": "该 CLI 未配置会话恢复(catalog orch.resume_argv_template),"
1600
- "无法在已有会话上继续",
1601
- "error_code": ErrorCode.VENDOR_ERROR,
1602
- "sid": "", "raw": None, "kind": kind, "model": None, "attempts": []}
1603
-
1604
- attempts = _resolve_attempts(agent)
1605
- # 启动前过滤明显的占位模型名。它们常由某个 CLI 的会话标题/默认配置
1606
- # 泄漏进编排链;让子进程自己等待网关返回会把一个确定性配置错拖满
1607
- # 多轮超时。非法项不消耗模型调用预算,后面的真实候选仍可接手。
1608
- filtered_attempts = []
1609
- preflight_failures = []
1610
- for att in attempts:
1611
- if _invalid_model(att.get("model")):
1612
- preflight_failures.append({
1613
- "model": att.get("model"),
1614
- "provider_id": att.get("provider_id") or "",
1615
- "provider": (att.get("provider") or {}).get("name", "")
1616
- if isinstance(att.get("provider"), dict) else "",
1617
- "ok": False, "skipped": True, "error":
1618
- "无效模型名 %r,启动前跳过" % att.get("model"),
1619
- })
1620
- continue
1621
- filtered_attempts.append(att)
1622
- attempts = filtered_attempts[:_max_model_attempts(deadline is not None)]
1623
- out = None
1624
- attempt_history = list(preflight_failures)
1625
- if not attempts:
1626
- return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1627
- "tokens": 0, "usage": None,
1628
- "error": (preflight_failures[-1].get("error")
1629
- if preflight_failures else "没有可尝试的模型"),
1630
- "error_code": ErrorCode.VENDOR_ERROR, "raw": None,
1631
- "kind": kind, "model": None, "attempts": attempt_history}
1632
- grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
1633
- timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
1634
- skipped_upstreams = set()
1635
- forbidden_upstreams = set()
1636
- auth_failed_credentials = set()
1637
- for ai, att in enumerate(attempts):
1638
- credential = _attempt_credential(att)
1639
- if credential in auth_failed_credentials:
1640
- attempt_history.append({
1641
- "model": att.get("model") or "",
1642
- "provider_id": att.get("provider_id") or "",
1643
- "provider": ((att.get("provider") or {}).get("name", "")
1644
- if isinstance(att.get("provider"), dict) else ""),
1645
- "kind": kind, "ok": False, "skipped": True,
1646
- "error_code": ErrorCode.VENDOR_ERROR,
1647
- "error": "同一凭据已被认证拒绝,跳过该凭据的其他模型",
1648
- })
1649
- if out is not None:
1650
- out["attempts"] = list(attempt_history)
1651
- continue
1652
- upstream = _attempt_upstream(att)
1653
- if upstream in skipped_upstreams or upstream in forbidden_upstreams:
1654
- continue
1655
- # 有任务 deadline 时,所有候选共享同一个截止时刻;否则每个候选
1656
- # 使用 catalog 配置或 caller 提供的单模型时限。
1657
- model_deadline = (att.get("_deadline") or deadline or
1658
- (time.monotonic() + timeout))
1659
- env = dict(base_env)
1660
- env.update(att["env"])
1661
- if kind == "codex":
1662
- # 网关自定义模型名(如 [opencode]xxx)是合法 API 模型名不能改,但方括号
1663
- # 是非法 OTel tag 值 → codex_otel 每个 SSE 事件刷 2 条 WARN 淹没真输出。
1664
- # 只静音遥测模块(codex 不认 RUST_LOG 也无害),其余 WARN 全保留。
1665
- env.setdefault("RUST_LOG", "codex_otel=off")
1666
- eff_agent = dict(agent)
1667
- eff_agent["env"] = env
1668
- if att["own_cp"]:
1669
- eff_agent["codex_provider"] = att["codex_provider"]
1670
- elif att["from_chain"] and "codex_provider" in eff_agent:
1671
- # 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
1672
- del eff_agent["codex_provider"]
1673
- for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
1674
- remaining = model_deadline - time.monotonic()
1675
- if remaining <= 0:
1676
- res = {"ok": False, "exit_code": None, "stdout": "", "stderr": "",
1677
- "duration": timeout, "cancelled": False, "timed_out": True,
1678
- "stalled": False,
1679
- "deadline_exceeded": deadline is not None}
1680
- else:
1681
- argv, stdin_text, prompt_eff, tmp_files = _build_call(
1682
- eff_agent, kind, sid, readonly,
1683
- att["model"], prompt,
1684
- images=images if kind == "codex" else None,
1685
- workdir=workdir)
1686
- try:
1687
- repeat_guard = (("Reconnecting...", 2)
1688
- if kind == "codex" else None)
1689
- stream_abort_markers = (
1690
- (("stream disconnected", 1),
1691
- ("stream closed before response.completed", 1))
1692
- if kind == "codex" else None)
1693
- # Grok's CLI can keep a dead HTTP connection alive for the
1694
- # full 20-minute generic timeout. Two identical transport
1695
- # errors are enough evidence to stop this process and let
1696
- # the next bound agent try.
1697
- if agent.get("id") == "grok-build":
1698
- stream_abort_markers = (
1699
- ("reqwest error stream", 2),
1700
- ("error sending request for url", 2),
1701
- )
1702
- res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1703
- timeout=min(timeout, remaining), deadline=deadline,
1704
- cancel_event=cancel_event,
1705
- log_path=log_path, stall_timeout=stall_t,
1706
- activity_timeout=activity_t,
1707
- repeat_abort=repeat_guard,
1708
- abort_markers=stream_abort_markers)
1709
- finally:
1710
- # 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
1711
- for tf in tmp_files:
1712
- try:
1713
- os.remove(tf)
1714
- except OSError:
1715
- pass
1716
- out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1717
- "tokens": 0, "usage": None, "error": "", "error_code": "",
1718
- "sid": "", "raw": res, "kind": kind, "model": att["model"],
1719
- "provider_id": att.get("provider_id") or "",
1720
- "provider": att.get("provider") or {}}
1721
- if not res["ok"]:
1722
- # stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
1723
- # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
1724
- # 里——只取其一会让 _quota_error/_transient_error 判空。
1725
- # 先洗后切:切片会割断 ANSI 序列,留下 `[91m` 这种裸残尾直接进 UI
1726
- tail = clean_cli_text(
1727
- ((res["stderr"] or "") + "\n" + (res["stdout"] or ""))[-4000:]).strip()[-600:]
1728
- head = ("输出停滞 %ss(stall timed out,疑似卡死已提前终止)" % stall_t
1729
- if res.get("stalled")
1730
- else "超时" if res["timed_out"]
1731
- else "取消" if res["cancelled"]
1732
- else "退出码 %s" % res["exit_code"])
1733
- if res.get("deadline_exceeded"):
1734
- head = "任务总时限已到"
1735
- out["error"] = head + (";stderr/stdout: " + tail if tail else "")
1736
- if kind == "codex":
1737
- fm = _codex_fail_msg(res["stdout"])
1738
- if fm:
1739
- # 事件流终态错误比原始 JSONL 尾部可读,也是链降级判定依据
1740
- out["error"] = "codex: %s(退出码 %s)" % (fm, res["exit_code"])
1741
- # 5D+2D:错误码归一
1742
- if kind == "claude":
1743
- parsed_err = _parse_claude_json(res["stdout"] or "")
1744
- if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
1745
- out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
1746
- drift = _claude_drift_note(att.get("model"), parsed_err)
1747
- if drift:
1748
- out["error"] += ";" + drift
1749
- _log_note(log_path, drift)
1750
- if kind == "codex" and att.get("provider_id"):
1751
- el = out["error"].lower()
1752
- if (("wire_api" in el and "no longer supported" in el)
1753
- or "no route matched" in el
1754
- or "同一网络错误重复" in out["error"]):
1755
- # codex 撞 chat-only 供应商(0.154 只讲 responses):
1756
- # 自动冷却该供应商 30 分钟,链展开/路由绑定分随之
1757
- # 自动绕开——不用人工改绑定(2026-09-17 mo-so 实测)
1758
- try:
1759
- from . import modelhub as _mh
1760
- _mh.note_codex_wire_dead(att["provider_id"])
1761
- except Exception:
1762
- pass
1763
- elif "no model configured" in out["error"].lower():
1764
- # CLI 本体没配置(kimi 实测)→ 运行期自愈:把绑定注入其
1765
- # 自家配置,本次换将之后的下一轮即可用
1766
- try:
1767
- from . import manager as _mg
1768
- note = _mg.sync_cli_config_now(agent.get("id", ""))
1769
- if note and log_path:
1770
- try:
1771
- with open(log_path, "a", encoding="utf-8") as fh:
1772
- fh.write("\n[自愈] %s\n" % note)
1773
- except Exception:
1774
- pass
1775
- except Exception:
1776
- pass
1777
- if not (_quota_error(out["error"]) or _transient_error(out["error"])
1778
- or _rate_limited(out["error"])
1779
- or _permission_error(out["error"])):
1780
- # 尾段清洗后没有错误信号而流式审计日志里有:补日志错误行。
1781
- # 2026-09-22 实测 kimi 撞 429 时 stderr/stdout 只剩启动横幅
1782
- # (真错误只走了流式管道),路由层看不到配额关键词,换将
1783
- # 判死因全靠猜。
1784
- _augment_error_from_log(out, log_path)
1785
- out["error_code"] = _classify_failure(res, kind=kind)
1786
- break
1787
- if kind == "codex":
1788
- out["text"], out["usage"], out_sid = _parse_codex_jsonl(res["stdout"])
1789
- out["sid"] = out_sid # §07 T1.1:会话 id 供 revise/fix 复用
1790
- out["tokens"] = out["usage"]["total"]
1791
- # 退出码 0 不代表成功:配额耗尽时 turn.failed 收尾、进程仍正常退出,
1792
- # 不判失败的话编排者会把错误信息当成果往下传
1793
- fm = _codex_fail_msg(res["stdout"])
1794
- if fm:
1795
- out["ok"] = False
1796
- out["error"] = "codex: %s" % fm
1797
- out["error_code"] = ErrorCode.VENDOR_ERROR
1798
- elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
1799
- out["text"] = clean_cli_text(res["stdout"][-2000:])
1800
- if require_tools and out["ok"] and _codex_work_events(res["stdout"]) == 0:
1801
- # 实现步空转闸:exit 0 + 有话但零动手 → 判拒绝(fatal 语义正确,
1802
- # 不把谎报的「已改完」静默传给下游;auto 模式换将逻辑按 ok 触发,
1803
- # 仍可换别的 CLI 再试)。原文尾段留进错误供人核对。
1804
- out["ok"] = False
1805
- out["error"] = ("实现步零工具调用:模型未执行任何命令/文件改动,"
1806
- "仅口头汇报(典型如谎报环境受限)。回答尾段: %s"
1807
- % (out["text"] or "")[-300:])
1808
- out["error_code"] = ErrorCode.VENDOR_REFUSAL
1809
- if not out["text"] and out["ok"]:
1810
- out["error_code"] = _classify_failure(res, kind="codex", empty_output=True)
1811
- elif kind == "claude":
1812
- parsed = _parse_claude_json(res["stdout"])
1813
- if parsed is None:
1814
- out["ok"] = False
1815
- out["error"] = "claude 输出无法解析为 JSON;stdout 尾部: " + res["stdout"][-500:]
1816
- out["error_code"] = _classify_failure(res, kind="claude", parsed=None)
1817
- break
1818
- out["text"] = parsed["text"]
1819
- out["cost_usd"] = parsed["cost_usd"]
1820
- out["tokens"] = parsed["tokens"]
1821
- out["usage"] = parsed["usage"]
1822
- out["sid"] = parsed.get("sid") or "" # §07 T1.1
1823
- drift = _claude_drift_note(att.get("model"), parsed)
1824
- if drift:
1825
- _log_note(log_path, drift)
1826
- if parsed["is_error"]:
1827
- out["ok"] = False
1828
- out["error"] = "claude 返回 is_error: " + parsed["text"][:500]
1829
- if drift:
1830
- out["error"] += ";" + drift
1831
- out["error_code"] = _classify_failure(res, kind="claude", parsed=parsed)
1832
- break
1833
- if not out["text"] and attempt == 0:
1834
- continue # 空响应,同模型重试
1835
- if not out["text"] and attempt == 1:
1836
- out["error_code"] = _classify_failure(
1837
- res, kind="claude", parsed=parsed, attempt_done=True)
1838
- else:
1839
- # generic(aider/opencode/kimi…)的 stdout 就是终端转录:ANSI 颜色、
1840
- # \r 进度条、画线框全在里面。不清洗的话它同时污染两处——步骤摘要
1841
- # (详情页卡片显示成 ◆/─ 墙)与评审 JSON 解析(转义混进正文)
1842
- out["text"] = clean_cli_text(res["stdout"]).strip()
1843
- if not out["text"]:
1844
- out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
1845
- break
1846
- _report_key(att, out)
1847
- raw = out.get("raw") or {}
1848
- attempt_history.append({
1849
- "model": out.get("model") or att.get("model") or "",
1850
- "provider_id": out.get("provider_id") or att.get("provider_id") or "",
1851
- "provider": ((out.get("provider") or {}).get("name", "")
1852
- if isinstance(out.get("provider"), dict) else ""),
1853
- "kind": kind,
1854
- "ok": bool(out.get("ok")),
1855
- "duration": raw.get("duration", 0.0),
1856
- "timed_out": bool(raw.get("timed_out")),
1857
- "deadline_exceeded": bool(raw.get("deadline_exceeded")),
1858
- "abort_marker": raw.get("abort_marker"),
1859
- "error_code": str(out.get("error_code") or ""),
1860
- "error": (out.get("error") or "")[:500],
1861
- })
1862
- out["attempts"] = list(attempt_history)
1863
- if out["ok"] or ai == len(attempts) - 1:
1864
- remaining = model_deadline - time.monotonic()
1865
- if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
1866
- and remaining > 2
1867
- and not (cancel_event is not None and cancel_event.is_set())):
1868
- # 链尾限流宽限:enumerate 活列表——把链尾这条 append 回去,
1869
- # 下一轮迭代就是「原地重试一次」;再撞限流时预算已耗尽照常判死。
1870
- grace_left -= 1
1871
- grace_s = min(RATE_LIMIT_GRACE_S, remaining - 1)
1872
- if _grace_wait(cancel_event, grace_s, log_path=log_path):
1873
- retry = dict(att, _deadline=model_deadline)
1874
- attempts.append(retry)
1875
- continue
1876
- return out
1877
- if _auth_error(out.get("error")):
1878
- auth_failed_credentials.add(credential)
1879
- if not any(_attempt_credential(candidate) not in auth_failed_credentials
1880
- for candidate in attempts[ai + 1:]):
1881
- return out
1882
- continue
1883
- if _permission_error(out.get("error")):
1884
- # 403 通常是该网关/上游拒绝当前请求;同一 host 换模型只会重撞,
1885
- # 但不同 host 的候选仍可能有权限,应继续尝试异上游链项。
1886
- if upstream != "unknown":
1887
- forbidden_upstreams.add(upstream)
1888
- if not any(_attempt_upstream(candidate) not in forbidden_upstreams
1889
- for candidate in attempts[ai + 1:]):
1890
- return out
1891
- continue
1892
- # 换将闸门:什么失败值得烧链上下一个候选。
1893
- # · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
1894
- # · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
1895
- # 2026-09-23 夜班实案:旧逻辑里超时能否换将全赌输出尾部碰巧含有
1896
- # "timeout" 字样(当时是步骤里读到的代码 timeout=900 撞的表)。
1897
- # · 上游内容拒答(refusal):是那家模型的过滤决定,不是任务终态,
1898
- # 换异上游候选常常就活;判死则整 run 白烧(同案:gemini 拒答后
1899
- # 链上云知声候选从未被尝试)。
1900
- raw = out.get("raw") or {}
1901
- if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
1902
- or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
1903
- or _refusal_error(out.get("error"))
1904
- or _permission_error(out.get("error"))
1905
- or raw.get("timed_out"))):
1906
- return out # 取消/解析失败等真终态不降级
1907
- # 同一上游的模型连续两次超时就收手——
1908
- # 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
1909
- # 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最多
1910
- # 两个模型预算后转向其他上游。其余瞬态/拒答仍按链长全部尝试:各家病根
1911
- # 不同,每个候选都值得一次机会。
1912
- if raw.get("timed_out"):
1913
- timed_out_upstreams[upstream] = timed_out_upstreams.get(upstream, 0) + 1
1914
- if timed_out_upstreams[upstream] >= 2:
1915
- skipped_upstreams.add(upstream)
1916
- return out
1917
-
1918
-
1919
- def _loads_lenient(text):
1920
- """容错 json.loads:修复字符串内未转义引号后再解析。
1921
-
1922
- 模型高频病:评审 note 里带英文直引号("第5章"冷战三天"使用…"),
1923
- 严格 JSON 被打碎。判据:字符串内遇到 `"` 时向后看第一个非空白字符,
1924
- 是 , } ] : 视为结构性收尾引号,否则按字面引号转义。"""
1925
- out = []
1926
- i, n = 0, len(text)
1927
- in_str = False
1928
- while i < n:
1929
- c = text[i]
1930
- if in_str:
1931
- if c == "\\" and i + 1 < n:
1932
- out.append(text[i:i + 2])
1933
- i += 2
1934
- continue
1935
- if c == '"':
1936
- j = i + 1
1937
- while j < n and text[j] in " \t\r\n":
1938
- j += 1
1939
- nxt = text[j] if j < n else ""
1940
- if nxt in ",}]:":
1941
- in_str = False
1942
- out.append(c)
1943
- else:
1944
- out.append('\\"')
1945
- i += 1
1946
- continue
1947
- out.append(c)
1948
- i += 1
1949
- continue
1950
- if c == '"':
1951
- in_str = True
1952
- out.append(c)
1953
- i += 1
1954
- return json.loads("".join(out))
1955
-
1956
-
1957
- def extract_json(text):
1958
- """从模型回复中提取 JSON:直接解析 → ```json 围栏 → 平衡花括号扫描。"""
1959
- if not text:
1960
- return None
1961
- text = text.strip()
1962
- try:
1963
- return json.loads(text)
1964
- except Exception:
1965
- pass
1966
- m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
1967
- if m:
1968
- try:
1969
- return json.loads(m.group(1))
1970
- except Exception:
1971
- pass
1972
- try:
1973
- return _loads_lenient(m.group(1))
1974
- except Exception:
1975
- pass
1976
- start = text.find("{")
1977
- while start != -1:
1978
- depth = 0
1979
- for i in range(start, len(text)):
1980
- c = text[i]
1981
- if c == "{":
1982
- depth += 1
1983
- elif c == "}":
1984
- depth -= 1
1985
- if depth == 0:
1986
- try:
1987
- return json.loads(text[start:i + 1])
1988
- except Exception:
1989
- break
1990
- start = text.find("{", start + 1)
1991
- return None
1992
-
1993
-
1994
- def as_scores(gj):
1995
- """评审解析第二道网:花括号兜底扫描会掉进第一个可平衡的子对象——
1996
- 外层 JSON 病得重时返回的是「维度→分数」本体(无 scores 键),这里包回
1997
- 评审形状,否则良评审被误判成「输出不可解析」(2026-09-18 七猫案:
1998
- kimi 正常出分却因内嵌引号整轮判评审全挂,连环白烧自动续跑)。"""
1999
- if isinstance(gj, dict) and not isinstance(gj.get("scores"), dict):
2000
- vals = {k: v for k, v in gj.items()
2001
- if isinstance(v, (int, float)) and not isinstance(v, bool)}
2002
- if vals and len(vals) == len(gj) and len(vals) >= 3:
2003
- return {"scores": vals, "issues": [], "summary": ""}
2004
- return gj if isinstance(gj, dict) else None
2005
-
2006
-
2007
- def extract_scores_from_text(text):
2008
- """评审解析第四道网(借鉴 BAML 的宽容提取):模型把分数写成散文键值对
2009
- 完全不出 JSON 时(2026-09-18 真实案例:kimi 正文提分),从文本直接抓
2010
- 「维度:N 分」模式。返回 scores dict 或 {}。"""
2011
- if not text:
2012
- return {}
2013
- scores = {}
2014
- for m in re.finditer(
2015
- r"[\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9 ]{0,11}"
2016
- r"[::]\s*(\d{1,2}(?:\.\d)?)\s*分?", text):
2017
- dim = m.group(0).rsplit(":", 1)[0].rsplit(":", 1)[0].strip()
2018
- try:
2019
- val = float(m.group(1))
2020
- except ValueError:
2021
- continue
2022
- if not dim or dim in scores or not (1.0 <= val <= 10.0):
2023
- continue
2024
- scores[dim] = round(val, 1)
2025
- return scores if len(scores) >= 3 else {}
2026
-
2027
-
2028
- def scores_from_prose(text, dims):
2029
- """评审解析第三道网:agentic CLI 有时把 JSON 写进文件、stdout 只留中文
2030
- 总结(「情节 8 / 人物 8 / …」)。JSON 全灭后按维度名从正文提分;
2031
- 至少命中 3 个维度才认(防普通行文里的巧合数字)。"""
2032
- if not text or not dims:
2033
- return {}
2034
- found = {}
2035
- for d in dims:
2036
- m = re.search(re.escape(d) + r"\s*[::/是]?\s*([0-9]+(?:\.[0-9]+)?)", text)
2037
- if m:
2038
- try:
2039
- found[d] = float(m.group(1))
2040
- except (TypeError, ValueError):
2041
- pass
2042
- return found if len(found) >= 3 else {}
1
+ # -*- coding: utf-8 -*-
2
+ """运行器:把各家 CLI 的无头调用统一成一个接口。
3
+
4
+ 实测验证过的接入方式(2026-09,本机):
5
+ codex : codex exec --skip-git-repo-check --json -s <sandbox> [prompt从stdin]
6
+ stdout 为 JSONL 事件流;item.completed(type=agent_message)=最终回答,
7
+ turn.completed=token 用量。pnpm 的 .cmd 垫片需 cmd /c 包装。
8
+ claude : claude -p --output-format json [prompt从stdin]
9
+ stdout 为单个 JSON:result / total_cost_usd / usage。
10
+ 需环境变量 CLAUDE_CODE_GIT_BASH_PATH(原生 exe 找不到 bash 会拒绝启动)
11
+ 与 CLAUDE_CODE_MAX_OUTPUT_TOKENS(自定义网关模型常见 32768 上限)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import signal
20
+ import subprocess
21
+ import tempfile
22
+ import threading
23
+ import time
24
+
25
+ from . import beekeeper
26
+ from . import upstream
27
+ from .env_scrub import scrub_env
28
+ from .error_codes import (
29
+ ErrorCode, classify_error_text, error_code_value,
30
+ is_auth_error as _shared_auth_error,
31
+ is_forbidden_error as _shared_forbidden_error,
32
+ is_quota_error as _shared_quota_error,
33
+ is_rate_limited_error as _shared_rate_limited_error,
34
+ is_transient_error as _shared_transient_error,
35
+ )
36
+
37
+ # 非 Windows 必须置 0:POSIX 的 Popen 对非零 creationflags 直接抛 ValueError,
38
+ # 置 0 则两边通用(remote.py 同款守卫)。
39
+ CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
40
+ DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
41
+ DEFAULT_STREAM_ACTIVITY_TIMEOUT_S = 180 # 流式 CLI 的活动延长窗口
42
+
43
+ _BASH_CANDIDATES = [
44
+ r"D:\Git\usr\bin\bash.exe",
45
+ r"C:\Program Files\Git\usr\bin\bash.exe",
46
+ r"C:\Program Files (x86)\Git\usr\bin\bash.exe",
47
+ ]
48
+ _bash_cache = {"path": None, "done": False}
49
+
50
+
51
+ def find_git_bash():
52
+ """Windows 专供:claude 原生 exe 找不到 bash 会拒绝启动,指给 Git Bash。
53
+ macOS/Linux 有系统 bash,claude 自会找到;强行设 CLAUDE_CODE_GIT_BASH_PATH
54
+ 反而可能指错(/bin/bash 与 Git Bash 行为有差异),故非 Windows 一律 None。"""
55
+ if os.name != "nt":
56
+ return None
57
+ if _bash_cache["done"]:
58
+ return _bash_cache["path"]
59
+ _bash_cache["done"] = True
60
+ p = shutil.which("bash.exe") or shutil.which("bash")
61
+ if p:
62
+ _bash_cache["path"] = p
63
+ return p
64
+ for cand in _BASH_CANDIDATES:
65
+ if os.path.isfile(cand):
66
+ _bash_cache["path"] = cand
67
+ return cand
68
+ return None
69
+
70
+
71
+ def resolve_command(command):
72
+ """命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
73
+ path = shutil.which(command) or command
74
+ low = path.lower()
75
+ if low.endswith(".cmd") or low.endswith(".bat"):
76
+ return ["cmd", "/c", path]
77
+ return [path]
78
+
79
+
80
+ def _orphan_pack_files(workdir):
81
+ """列出缺少同名 .idx 的 pack 文件名(无则空列表)。
82
+
83
+ Git 本体扫目录时发现 pack 没有对应 .idx,只打一行 warning 就跳过该包,
84
+ 所以 git status/fsck 全都正常;aider 走的 gitpython/gitdb 却按
85
+ pack-*.pack 逐个打开同名 .idx,缺一次就抛 FileNotFoundError,被它记成
86
+ 「is your git repo corrupted?」——2026-09-22 mo-so 实案:pack-a52e675e
87
+ .idx 缺失,aider 换将后跑满 14 分钟才失败,报错头还是 prompt toolkit 噪声。
88
+ """
89
+ try:
90
+ probe = subprocess.run(
91
+ ["git", "-C", workdir, "rev-parse", "--git-path", "objects/pack"],
92
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
93
+ text=True, timeout=5, check=False,
94
+ )
95
+ except (OSError, subprocess.SubprocessError):
96
+ return []
97
+ rel = (probe.stdout or "").strip()
98
+ if probe.returncode != 0 or not rel:
99
+ return []
100
+ pack_dir = rel if os.path.isabs(rel) else os.path.join(workdir, rel)
101
+ try:
102
+ names = set(os.listdir(pack_dir))
103
+ except OSError:
104
+ return []
105
+ return sorted(n for n in names
106
+ if n.startswith("pack-") and n.endswith(".pack")
107
+ and (n[:-len(".pack")] + ".idx") not in names)
108
+
109
+
110
+ def _git_repo_issue(workdir):
111
+ """返回工作目录 Git 仓库的可读性问题;无 Git 仓库时返回空串。
112
+
113
+ Aider 会在启动后自行读取索引。索引包缺失时它通常先写入 .gitignore,
114
+ 再在深层调用中才报错,用户会误以为任务卡住;提前做一次廉价预检能把
115
+ 错误变成可操作的任务失败信息。
116
+ """
117
+ if not workdir or not os.path.isdir(workdir):
118
+ return ""
119
+ git_dir = os.path.join(workdir, ".git")
120
+ if not os.path.exists(git_dir):
121
+ return ""
122
+ try:
123
+ probe = subprocess.run(
124
+ ["git", "-C", workdir, "rev-parse", "--git-dir"],
125
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
126
+ text=True, timeout=5, check=False,
127
+ )
128
+ except (OSError, subprocess.SubprocessError):
129
+ return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
130
+ if probe.returncode != 0:
131
+ detail = (probe.stderr or probe.stdout or "仓库元数据不可读").strip()
132
+ return "Git 仓库不可读:%s;请先执行 git fsck/恢复 .git 对象后重试" % detail[-300:]
133
+ try:
134
+ probe = subprocess.run(
135
+ ["git", "-C", workdir, "status", "--porcelain", "--untracked-files=no"],
136
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
137
+ text=True, timeout=8, check=False,
138
+ )
139
+ except subprocess.TimeoutExpired:
140
+ return "Git 仓库检查超时;请先执行 git status/git fsck 修复后重试"
141
+ except OSError:
142
+ return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
143
+ if probe.returncode != 0:
144
+ detail = (probe.stderr or probe.stdout or "仓库对象或索引不可读").strip()
145
+ return "Git 仓库损坏或对象缺失:%s;请先执行 git fsck 并恢复仓库后重试" % detail[-300:]
146
+ # git status 对「pack 缺 .idx」不敏感(只 warning 跳过该包),必须单独查:
147
+ # aider 的 gitdb 按 pack-*.pack 逐个开同名 .idx,缺一个就整步失败。
148
+ orphans = _orphan_pack_files(workdir)
149
+ if orphans:
150
+ return ("Git 仓库 pack 索引缺失:%s 没有同名 .idx(git 本体只警告跳过,"
151
+ "Aider 的 gitdb 会直接报「Unable to list files in git repo」)。"
152
+ "修复:在 %s 目录对每个 pack 执行 `git index-pack <pack文件>` "
153
+ "重建索引,或临时改用 Codex/Claude 实现器。"
154
+ % (", ".join(orphans[:3]), os.path.join(".git", "objects", "pack")))
155
+ return ""
156
+
157
+
158
+ def _npm_shim_bypass(argv):
159
+ """generic 类 CLI 把提示词嵌进 argv;经 cmd /c 重解析时引号/特殊字符会把
160
+ 提示词截烂(kimi 实测:模型只看到 UTF-8 须知、任务本体整段丢失)。识别
161
+ 标准 npm .cmd 垫片(%_prog% + %dp0% 目标脚本),改 node 直启绕开 cmd。
162
+ 识别不出标准形态原样返回。"""
163
+ if len(argv) >= 3 and argv[0] == "cmd" and argv[1] == "/c" \
164
+ and str(argv[2]).lower().endswith((".cmd", ".bat")):
165
+ shim = argv[2]
166
+ try:
167
+ with open(shim, encoding="utf-8", errors="replace") as fh:
168
+ text = fh.read()
169
+ except Exception:
170
+ return argv
171
+ m = re.search(r'%_prog%"\s+"?%dp0%(\\[^"\n]+?\.(?:mjs|js))"?', text)
172
+ if not m:
173
+ return argv
174
+ script = os.path.normpath(
175
+ os.path.join(os.path.dirname(shim), m.group(1).lstrip("\\")))
176
+ node = os.path.join(os.path.dirname(shim), "node.exe")
177
+ return [node if os.path.isfile(node) else "node", script] + list(argv[3:])
178
+ return argv
179
+
180
+
181
+ def _kill_tree(pid):
182
+ """杀整棵进程树。Windows:先 TerminateProcess 直接杀根进程(毫秒级,绝不
183
+ 挂),再 taskkill /T 兜底扫子孙(短等待,卡死即放弃——真实案例 2026-09-19:
184
+ 某些机器状态下 taskkill /F /T 对普通进程也挂死 30s+,等满 15s 超时会让每次
185
+ 超时杀进程都卡 20s,看门狗/超时全被拖死;漏杀的孙进程由启动孤儿清扫兜底)。
186
+ POSIX 靠 spawn 时的 start_new_session(子进程自成一个进程组,pgid==pid)
187
+ 用 killpg 连孙带杀。"""
188
+ if os.name != "nt":
189
+ try:
190
+ os.killpg(pid, signal.SIGKILL)
191
+ return
192
+ except Exception:
193
+ pass
194
+ try:
195
+ os.kill(pid, signal.SIGKILL)
196
+ except Exception:
197
+ pass
198
+ return
199
+ # Windows 没有 signal.SIGKILL(直接引用会 AttributeError);os.kill 带任意
200
+ # 信号值都走 TerminateProcess,用 SIGTERM
201
+ try:
202
+ os.kill(pid, signal.SIGTERM) # Windows 语义 = TerminateProcess,只杀根进程
203
+ except OSError:
204
+ pass # 根进程可能已被 taskkill 抢先杀掉
205
+ except Exception:
206
+ pass
207
+ try:
208
+ tk = subprocess.Popen(
209
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
210
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
211
+ stderr=subprocess.DEVNULL, close_fds=True, # 不继承被杀进程的管道句柄,否则 EOF 永不来、drain 烧满超时
212
+ creationflags=CREATE_NO_WINDOW)
213
+ tk.wait(timeout=1.2) # 健康机器 <1s;卡死就不再陪等(后台自行结束)
214
+ except Exception:
215
+ pass
216
+
217
+
218
+ def _drain_streams(proc, t_out, t_err, *, timeout=10):
219
+ """kill_tree 后等管道线程读完剩余字节。
220
+
221
+ daemon=True 线程 join 超时即结束(不会卡死主流程)。
222
+ 设计稿:docs/migration/01-defense-patterns.md §5B。
223
+ 参考 dsh docs/defensive-patterns.zh.md 第 21 行("dispose 必须达到完全停稳")。
224
+ """
225
+ deadline = time.time() + timeout
226
+ for t in (t_out, t_err):
227
+ remaining = max(0.0, deadline - time.time())
228
+ if remaining > 0:
229
+ t.join(timeout=remaining)
230
+ # Killing the tree does not necessarily reap the root process on Windows.
231
+ # Reap it with the remaining bounded budget before callers inspect
232
+ # ``returncode``; otherwise long-running timeout tests can leak a live
233
+ # Popen handle and emit ResourceWarning during interpreter shutdown.
234
+ remaining = max(0.0, deadline - time.time())
235
+ # proc 可为 None(旧契约:只收线程不收割进程的调用方,tests/test_runner_drain)
236
+ if proc is not None and remaining > 0 and proc.poll() is None:
237
+ try:
238
+ proc.wait(timeout=remaining)
239
+ except (subprocess.TimeoutExpired, OSError):
240
+ pass
241
+
242
+
243
+ def _pipe_reader(stream, chunks, log_fh, stamp=None):
244
+ """持续读子进程管道并实时落盘。
245
+
246
+ 必须用 read1():BufferedReader.read(n) 会阻塞到凑满 n 字节或 EOF,
247
+ 在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
248
+ read1() 只要有数据就返回,日志才能真正边跑边看。
249
+ stamp:共享 [最后输出时刻],停滞看门狗据此判定进程是否卡死。
250
+ 关闭也归本线程:EOF(或杀树后的异常)后自关。主线程绝不能 close()——
251
+ close 要抢 BufferedReader 的锁,而锁被还在 read1 等 EOF 的本线程攥着,
252
+ 孙进程漏杀时它攥着管道到自然死,主线程就陪锁到天荒地老(2026-09-22
253
+ run_command 超时杀 ping 实测:taskkill /T 失手 → close 卡满命令时长)。
254
+ """
255
+ try:
256
+ while True:
257
+ b = stream.read1(65536)
258
+ if not b:
259
+ break
260
+ chunks.append(b)
261
+ if stamp is not None:
262
+ stamp[0] = time.time()
263
+ if len(stamp) > 1:
264
+ stamp[1] = True
265
+ if len(stamp) > 2:
266
+ stamp[2] = time.monotonic()
267
+ if log_fh:
268
+ try:
269
+ log_fh.write(b)
270
+ log_fh.flush()
271
+ except Exception:
272
+ pass
273
+ except Exception:
274
+ pass # 杀树瞬间句柄作废:ValueError/OSError 都算正常收场
275
+ finally:
276
+ try:
277
+ stream.close()
278
+ except Exception:
279
+ pass
280
+
281
+
282
+ def _decode_line(blob):
283
+ """单行解码:UTF-8 → GBK → replace(与 decode_output 同一编码纪律)。"""
284
+ try:
285
+ return blob.decode("utf-8")
286
+ except UnicodeDecodeError:
287
+ pass
288
+ try:
289
+ return blob.decode("gbk")
290
+ except UnicodeDecodeError:
291
+ return blob.decode("utf-8", "replace")
292
+
293
+
294
+ def decode_output(data):
295
+ """子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。
296
+
297
+ 整块 UTF-8 与整块 GBK 都失败时逐行兜底——日志文件是混合编码(我方 UTF-8
298
+ 审计头 + CLI 自家 GBK 输出),整块回退会把能读的部分一起牺牲:要么头部中文
299
+ 变乱码,要么整篇落进 replace 分支铺成 U+FFFD 墙(2026-09-20 aider 日志实证,
300
+ 详情页卡片显示成一片 ◆)。逐行解码两边都保住(GBK/UTF-8 的多字节序列都不
301
+ 含 0x0A,按行切不会切坏字符)。"""
302
+ if not data:
303
+ return ""
304
+ try:
305
+ return data.decode("utf-8")
306
+ except UnicodeDecodeError:
307
+ pass
308
+ try:
309
+ return data.decode("gbk")
310
+ except UnicodeDecodeError:
311
+ pass
312
+ return "\n".join(_decode_line(ln) for ln in data.split(b"\n"))
313
+
314
+
315
+ def read_text_any_enc(path):
316
+ """读文本文件,编码纪律同 decode_output(UTF-8 严格 → GBK 回退 → replace 兜底)。
317
+
318
+ 工作区文件由 CLI 子代理落盘,中文 Windows 上 PowerShell Set-Content 缺省写
319
+ GBK;消费侧(章节/diff/故事圣经)统一走本函数,不再因编码混编出 U+FFFD。
320
+ 文件不存在/读失败抛 OSError,由调用方兜底(与 open 语义一致)。"""
321
+ with open(path, "rb") as f:
322
+ data = f.read()
323
+ return decode_output(data)
324
+
325
+
326
+ def tail_decoded(data, tail):
327
+ """按字节取尾部再解码;切片可能落在 UTF-8 多字节字符中间,先丢弃开头的
328
+ continuation 字节(10xxxxxx,至多 3 个)对齐字符边界,否则残缺字节会被
329
+ GBK 回退解码成乱码字符。"""
330
+ if not data:
331
+ return ""
332
+ chunk = data[-tail:] if tail and len(data) > tail else data
333
+ i = 0
334
+ while i < len(chunk) and i < 3 and (chunk[i] & 0xC0) == 0x80:
335
+ i += 1
336
+ return decode_output(chunk[i:])
337
+
338
+
339
+ # 终端控制序列:CSI(颜色/光标/清行,`\x1b[91m`)、OSC(窗口标题)、其余两字符转义
340
+ _ANSI_RE = re.compile(
341
+ r"\x1b\[[0-9;?]*[ -/]*[@-~]"
342
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?"
343
+ r"|\x1b[@-Z\\-_]"
344
+ )
345
+ # 被切片/前端截断掉 ESC 的「裸序列」残尾(`[91m`、`[0m`):只在行首止血,
346
+ # 正文里合法的 [数字+字母](如 [1m] 引用)不动——后面紧跟 `]` 的不算残尾
347
+ _ANSI_HEAD_RE = re.compile(r"^\[[0-9;?]{1,6}[A-Za-z](?!\])")
348
+ # CLI 拿来做进度条/画框/旋转动画的图元,成串出现时人读不出任何信息
349
+ _NOISE_RUN_RE = re.compile(r"([\u2500-\u259f\u25a0-\u25ff\u2800-\u28ff])\1{7,}")
350
+ # 解不出来的字节(replace 兜底)成串即「乱码墙」——多数等宽字体把 U+FFFD 画成
351
+ # 带问号的菱形,一屏看起来就是 ◆◆◆◆
352
+ _FFFD_RUN_RE = re.compile(r"\ufffd{3,}")
353
+ _CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
354
+
355
+
356
+ def strip_ansi(text):
357
+ """剥掉 ANSI 转义序列(颜色/光标/进度),并清掉行首被截断的裸序列残尾。"""
358
+ if not text:
359
+ return ""
360
+ out = _ANSI_RE.sub("", text)
361
+ # 残尾可能连着几个(`[91m[0mError:`),一次替换只够消掉最前面那个
362
+ while True:
363
+ nxt = "\n".join(_ANSI_HEAD_RE.sub("", ln) for ln in out.split("\n"))
364
+ if nxt == out:
365
+ return out
366
+ out = nxt
367
+
368
+
369
+ def clean_cli_text(text):
370
+ """CLI 原始输出 → 人类可读文本(只用于错误摘要/日志展示,不碰模型正文)。
371
+
372
+ 终端噪声四种,都是实测踩过的:
373
+ 1. ANSI 转义:`\\x1b[91m\\x1b[1mError:` 在浏览器里渲染成 `[91m[1mError:`;
374
+ 2. 裸 `\\r` 覆写:进度条/旋转动画反复回退改写同一行,原样展示会叠成一片;
375
+ `\\r\\n` 是行结束符不是覆写,先归一化再按覆写取「该行最终形态」;
376
+ 3. 画线/进度图元成串(`────…`、`████…`)与 U+FFFD 乱码墙(编码不可解);
377
+ 4. 剩余控制字符。
378
+ """
379
+ if not text:
380
+ return ""
381
+ out = strip_ansi(text).replace("\r\n", "\n")
382
+ if "\r" in out:
383
+ lines = []
384
+ for ln in out.split("\n"):
385
+ if "\r" in ln:
386
+ parts = ln.split("\r")
387
+ ln = next((p for p in reversed(parts) if p.strip()), parts[-1])
388
+ lines.append(ln)
389
+ out = "\n".join(lines)
390
+ out = _CTRL_RE.sub("", out)
391
+ out = _FFFD_RUN_RE.sub(
392
+ lambda m: "…(%d 个字符无法解码:CLI 输出不是 UTF-8/GBK)" % len(m.group(0)), out)
393
+ out = _NOISE_RUN_RE.sub(lambda m: m.group(1) * 3 + "…", out)
394
+ return re.sub(r"\n{4,}", "\n\n\n", out)
395
+
396
+
397
+ _TS_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.,]+Z?\s*")
398
+
399
+
400
+ def collapse_dup_lines(text, max_group=4):
401
+ """折叠「归一化后重复」的邻近行,保留每种首行原文并标注折叠数。
402
+
403
+ 真实案例:codex_otel 遥测对网关自定义模型名(如 [opencode]xxx,方括号是
404
+ 非法 OTel tag 字符)每个 SSE 事件刷一对 WARN——counter/duration 两种文案
405
+ 交替出现,且每条带微秒时间戳:剥掉时间戳前缀后相邻行仍不相等,必须按
406
+ 「邻近组」折叠——组内至多 max_group 个键,新键只有组满才结算上一组,
407
+ 这样交替刷屏的各个文案都归入同一组(计数各自累计)。空行先结算所在组
408
+ 再原样保留。超过组宽的零散重复(中间隔着足量新行)不受影响。
409
+ """
410
+ if not text:
411
+ return ""
412
+ out = []
413
+ keys, first, cnt = [], {}, {}
414
+
415
+ def _flush():
416
+ for k in keys:
417
+ out.append(first[k])
418
+ if cnt[k] > 1:
419
+ out.append("⋯(上行重复 ×%d 已折叠)" % (cnt[k] - 1))
420
+ keys.clear()
421
+ first.clear()
422
+ cnt.clear()
423
+
424
+ for ln in text.splitlines():
425
+ k = _TS_PREFIX.sub("", ln).strip()
426
+ if not k:
427
+ _flush() # 先结算扣住的组,保证空行前后顺序不失真
428
+ out.append(ln)
429
+ continue
430
+ if k in cnt:
431
+ cnt[k] += 1
432
+ elif len(keys) >= max_group:
433
+ _flush()
434
+ keys.append(k)
435
+ first[k] = ln
436
+ cnt[k] = 1
437
+ else:
438
+ keys.append(k)
439
+ first[k] = ln
440
+ cnt[k] = 1
441
+ _flush()
442
+ if text.endswith("\n"):
443
+ out.append("")
444
+ return "\n".join(out)
445
+
446
+
447
+ def _fmt_codex_item(it, cap):
448
+ t = it.get("type") or ""
449
+ if t == "agent_message":
450
+ return "【消息】" + str(it.get("text") or "")[:cap]
451
+ if t == "reasoning":
452
+ return "【思考】" + str(it.get("text") or "")[:cap]
453
+ if t == "command_execution":
454
+ line = "【命令】%s(退出码 %s)" % (it.get("command") or "?", it.get("exit_code", "?"))
455
+ outp = str(it.get("aggregated_output") or "").strip()
456
+ if outp:
457
+ line += "\n | " + outp[:600].replace("\n", "\n | ")
458
+ return line
459
+ if t == "file_change":
460
+ names = ", ".join(str(c.get("path") or "?") for c in (it.get("changes") or [])
461
+ if isinstance(c, dict))
462
+ return "【文件改动】" + (names or "?")
463
+ if t == "mcp_tool_call":
464
+ return "【工具】%s %s" % (it.get("tool") or "?",
465
+ json.dumps(it.get("arguments") or "", ensure_ascii=False)[:200])
466
+ if t == "web_search":
467
+ return "【搜索】" + str(it.get("query") or "")
468
+ if t == "error":
469
+ return "【错误】" + str(it.get("message") or "")
470
+ return None
471
+
472
+
473
+ def pretty_cli_log(text, max_event_chars=4000):
474
+ """codex --json 的 JSONL 事件流 → 人类可读行;非 JSONL 行原样保留。
475
+
476
+ --json 模式下 stdout 全是机器事件(thread/turn/item…),智能体真正在说的
477
+ 话被埋在转义 JSON 里;这里逐行翻译成【消息】【思考】【命令】,让日志抽屉
478
+ 读到的是「蜂在干什么」。解析失败或未识别的事件类型原样保留,不吞内容。
479
+
480
+ claude stream-json 同场翻译(2026-09-22 真实案:3.2MB 步骤日志 15828 条
481
+ thinking_tokens 心跳,每 SSE 分片一条、数值只涨 1~2):思考心跳按邻近组
482
+ 折成一行;init/api_retry/assistant/result 翻译成可读行——api_retry 是
483
+ 429/断连诊断金矿,逐条保留不折叠。只影响抽屉显示,原始日志与解析不动。
484
+ """
485
+ out = []
486
+ hb = [0, 0] # claude 思考心跳邻近组:[条数, 最大估算 tokens]
487
+ msg_seen = [False] # 是否已出过【消息】(防 result 再重复一遍正文)
488
+
489
+ def _flush_thinking():
490
+ if hb[0]:
491
+ out.append("— 思考中(心跳 ×%d 已折叠,估算 ~%d tokens)—" % (hb[0], hb[1]))
492
+ hb[0] = hb[1] = 0
493
+
494
+ for ln in (text or "").splitlines():
495
+ s = ln.strip()
496
+ ev = None
497
+ if s.startswith("{") and s.endswith("}"):
498
+ try:
499
+ ev = json.loads(s)
500
+ except Exception:
501
+ ev = None
502
+ if isinstance(ev, dict) and ev.get("type") == "system" \
503
+ and (ev.get("subtype") or "") == "thinking_tokens":
504
+ try:
505
+ n = int(ev.get("estimated_tokens") or 0)
506
+ except Exception:
507
+ n = 0
508
+ hb[0] += 1
509
+ if n > hb[1]:
510
+ hb[1] = n
511
+ continue
512
+ _flush_thinking()
513
+ if not isinstance(ev, dict):
514
+ out.append(ln)
515
+ continue
516
+ typ = ev.get("type") or ""
517
+ if typ == "system": # claude 其余系统事件
518
+ sub = ev.get("subtype") or ""
519
+ if sub == "init":
520
+ out.append("— 会话启动(model=%s)—" % (ev.get("model") or "?"))
521
+ elif sub == "api_retry":
522
+ out.append("— API 重试 %s/%s(等 %sms):%s —" % (
523
+ ev.get("attempt", "?"), ev.get("max_retries", "?"),
524
+ ev.get("retry_delay_ms", "?"), ev.get("error") or "?"))
525
+ else:
526
+ out.append(ln) # 未知 subtype 原样保留,不吞内容
527
+ continue
528
+ if typ == "assistant": # claude 正文/思考/工具调用都在 content 块里
529
+ for blk in ((ev.get("message") or {}).get("content") or []):
530
+ if not isinstance(blk, dict):
531
+ continue
532
+ bt = blk.get("type") or ""
533
+ if bt == "thinking":
534
+ th = " ".join(str(blk.get("thinking") or "").split())
535
+ if th:
536
+ out.append("【思考】" + th[:200])
537
+ elif bt == "text":
538
+ tx = str(blk.get("text") or "")
539
+ if tx:
540
+ out.append("【消息】" + tx[:max_event_chars])
541
+ msg_seen[0] = True
542
+ elif bt == "tool_use":
543
+ out.append("【命令】%s %s" % (
544
+ blk.get("name") or "?",
545
+ json.dumps(blk.get("input") or "", ensure_ascii=False)[:200]))
546
+ continue
547
+ if typ in ("user", "stream_event"):
548
+ continue # claude 工具回包/流片段是过程噪音
549
+ if typ == "result": # claude 收尾:统计一行;报错带原因
550
+ u = ev.get("usage") or {}
551
+ if ev.get("is_error"):
552
+ out.append("— 完成(报错):" + str(ev.get("result") or "")[:300] + " —")
553
+ else:
554
+ cost = ev.get("total_cost_usd")
555
+ if not msg_seen[0]:
556
+ tx = str(ev.get("result") or "")
557
+ if tx: # 旧单 JSON 模式没有 assistant 事件,正文在这补上
558
+ out.append("【消息】" + tx[:max_event_chars])
559
+ msg_seen[0] = True
560
+ out.append("— 完成(tokens 入 %s / 出 %s,%s)—" % (
561
+ u.get("input_tokens", "?"), u.get("output_tokens", "?"),
562
+ ("$%.2f" % cost) if isinstance(cost, (int, float)) else "?"))
563
+ continue
564
+ if typ in ("thread.started", "turn.started"):
565
+ continue
566
+ if typ == "turn.completed":
567
+ u = ev.get("usage") or {}
568
+ out.append("— 一轮完成(tokens 入 %s / 出 %s)—" % (
569
+ u.get("input_tokens", "?"), u.get("output_tokens", "?")))
570
+ continue
571
+ if typ == "turn.failed":
572
+ e = ev.get("error")
573
+ out.append("— 一轮失败:%s —" % (e.get("message") if isinstance(e, dict) else e))
574
+ continue
575
+ if typ in ("item.completed", "item.started", "item.updated"):
576
+ if typ != "item.completed" or not isinstance(ev.get("item"), dict):
577
+ continue # started/updated 是过程噪音,completed 才有内容
578
+ line = _fmt_codex_item(ev["item"], max_event_chars)
579
+ if line:
580
+ out.append(line)
581
+ continue
582
+ out.append(ln)
583
+ return "\n".join(out)
584
+
585
+
586
+ def _normalize_abort_markers(value):
587
+ """把重复错误配置规范成 ``[(marker, count), ...]``。
588
+
589
+ 旧调用方传的是单个 ``(marker, count)`` 元组,必须继续支持;新的
590
+ 调用方可以传字典或元组列表,以便同一 CLI 同时对多种断流文案止损。
591
+ """
592
+ if not value:
593
+ return []
594
+ if isinstance(value, str):
595
+ return [(value, 1)]
596
+ if isinstance(value, dict):
597
+ raw = list(value.items())
598
+ elif (isinstance(value, (tuple, list)) and len(value) == 2
599
+ and isinstance(value[0], str)):
600
+ raw = [value]
601
+ else:
602
+ try:
603
+ raw = list(value)
604
+ except TypeError:
605
+ return []
606
+ out = []
607
+ for item in raw:
608
+ if isinstance(item, str):
609
+ marker, limit = item, 1
610
+ else:
611
+ try:
612
+ marker, limit = item
613
+ except (TypeError, ValueError):
614
+ continue
615
+ marker = str(marker or "")
616
+ try:
617
+ limit = max(1, int(limit))
618
+ except (TypeError, ValueError):
619
+ continue
620
+ if marker:
621
+ out.append((marker, limit))
622
+ return out
623
+
624
+
625
+ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
626
+ timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
627
+ stall_timeout=0, repeat_abort=None, deadline=None,
628
+ abort_markers=None, activity_timeout=0):
629
+ """通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
630
+
631
+ stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
632
+ 新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
633
+ 持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
634
+ 开了会把正常长任务误杀。
635
+
636
+ activity_timeout:基础 timeout 到期后,最近仍有真实输出的流式 CLI 允许继续
637
+ 运行的最长静默窗口(秒,0=关闭)。它独立于 stall_timeout,避免环境把
638
+ 停滞看门狗设为 0 后,Claude/Codex 又在基础超时处被硬杀;总延长仍有上限。
639
+
640
+ repeat_abort:可选 (marker, count),也接受多个 ``(marker, count)``;同一
641
+ 致命错误达到次数即提前杀树,处理 CLI 自身不断打印重连消息、因此永远
642
+ 触发不了静默看门狗的假运行。abort_markers 是兼容扩展,用于不改变旧
643
+ repeat_abort 参数形状的情况下追加多种一次性断流标记。
644
+
645
+ deadline:可选的 ``time.monotonic()`` 绝对截止时刻。它比 timeout 优先,
646
+ 到点会杀掉进程树并返回 deadline_exceeded=True,调用方可把整个任务收口
647
+ 为 timeout,而不是把它误记成普通供应商失败。
648
+
649
+ 返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled,
650
+ deadline_exceeded, abort_marker}。
651
+ """
652
+ if shell_cmd:
653
+ # shell 串的解析器随平台:重定向/引号语法两边通用,只是解释器不同
654
+ argv = ["cmd", "/c", shell_cmd] if os.name == "nt" else ["/bin/sh", "-c", shell_cmd]
655
+ if argv is None:
656
+ return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
657
+ "duration": 0.0, "cancelled": False, "timed_out": False,
658
+ "stalled": False, "deadline_exceeded": False, "abort_marker": None}
659
+ try:
660
+ deadline = float(deadline) if deadline is not None else None
661
+ except (TypeError, ValueError):
662
+ deadline = None
663
+ try:
664
+ timeout = max(0.0, float(timeout))
665
+ except (TypeError, ValueError):
666
+ timeout = float(DEFAULT_TIMEOUT)
667
+ try:
668
+ activity_timeout = max(0.0, float(activity_timeout))
669
+ except (TypeError, ValueError):
670
+ activity_timeout = 0.0
671
+ if deadline is not None and time.monotonic() >= deadline:
672
+ return {"ok": False, "exit_code": None, "stdout": "",
673
+ "stderr": "[任务总时限已到,未启动子进程]", "duration": 0.0,
674
+ "cancelled": False, "timed_out": True, "stalled": False,
675
+ "deadline_exceeded": True, "abort_marker": None}
676
+ aborts = _normalize_abort_markers(repeat_abort)
677
+ aborts.extend(_normalize_abort_markers(abort_markers))
678
+ full_env = scrub_env(os.environ.copy(), mode="drop")
679
+ if env:
680
+ # 5A:env 关键字环境变量注入用户传入的 env(属于有意注入,例如模型 API key)
681
+ full_env.update({str(k): str(v) for k, v in env.items()})
682
+ log_fh = open(log_path, "ab") if log_path else None
683
+ # 审计:调用下达前先把「执行的命令 + 发给智能体的指令原文」写进日志,
684
+ # 运行中点开步骤就能看到"编排者下了什么令",不用等结束猜。
685
+ # env 绝不写(含 API key);argv 里只有 base_url/env_key 名,无密钥值。
686
+ # 指令超 12000 字符截断(章节正文可能很长),标注原始长度防误读。
687
+ if log_fh:
688
+ try:
689
+ head = ["===== 下达 %s =====" % time.strftime("%Y-%m-%d %H:%M:%S"),
690
+ "$ " + " ".join(str(a) for a in argv)]
691
+ if stdin_text:
692
+ capped = stdin_text[:12000]
693
+ head.append("--- 指令(%d 字符%s)---" % (
694
+ len(stdin_text), ",已截断" if len(stdin_text) > 12000 else ""))
695
+ head.append(capped)
696
+ log_fh.write(("\n".join(head) + "\n--- 输出 ---\n").encode("utf-8", "replace"))
697
+ log_fh.flush()
698
+ except Exception:
699
+ pass
700
+ try:
701
+ try:
702
+ proc = subprocess.Popen(
703
+ [str(a) for a in argv], cwd=cwd, env=full_env,
704
+ stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
705
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
706
+ creationflags=CREATE_NO_WINDOW,
707
+ start_new_session=(os.name != "nt")) # POSIX 需独立进程组供 killpg 杀树;Windows 忽略该参数
708
+ except Exception as e:
709
+ return {"ok": False, "exit_code": None, "stdout": "",
710
+ "stderr": "启动失败: %r" % e, "duration": 0.0,
711
+ "cancelled": False, "timed_out": False, "stalled": False,
712
+ "deadline_exceeded": False, "abort_marker": None}
713
+ # 挂巢(beekeeper):服务无论以何种方式退出(含被硬杀),蜜蜂 CLI
714
+ # 一起带走。挂巢失败只降级,绝不影响本步起跑。
715
+ beekeeper.adopt(proc)
716
+ out_chunks, err_chunks = [], []
717
+ stamp = [time.time(), False, time.monotonic()] # [墙钟、是否有输出、单调时钟]
718
+ t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh, stamp), daemon=True)
719
+ t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh, stamp), daemon=True)
720
+ t_out.start()
721
+ t_err.start()
722
+ if stdin_text is not None:
723
+ def _feed():
724
+ try:
725
+ proc.stdin.write(stdin_text.encode("utf-8"))
726
+ except Exception:
727
+ pass
728
+ finally:
729
+ try:
730
+ proc.stdin.close()
731
+ except Exception:
732
+ pass
733
+ threading.Thread(target=_feed, daemon=True).start()
734
+ start = time.time()
735
+ start_mono = time.monotonic()
736
+ cancelled = timed_out = stalled = repeat_aborted = False
737
+ deadline_exceeded = False
738
+ abort_marker = None
739
+ while True:
740
+ try:
741
+ proc.wait(timeout=0.4)
742
+ break
743
+ except subprocess.TimeoutExpired:
744
+ pass
745
+ if cancel_event is not None and cancel_event.is_set():
746
+ cancelled = True
747
+ _kill_tree(proc.pid)
748
+ _drain_streams(proc, t_out, t_err, timeout=10)
749
+ break
750
+ if deadline is not None and time.monotonic() >= deadline:
751
+ deadline_exceeded = True
752
+ timed_out = True
753
+ _kill_tree(proc.pid)
754
+ _drain_streams(proc, t_out, t_err, timeout=5)
755
+ break
756
+ elapsed = time.monotonic() - start_mono
757
+ if elapsed > timeout:
758
+ # Claude/Codex 的工具循环可能持续吐 stream-json 事件而迟迟
759
+ # 不给最终 result。活动延长与 stall 看门狗分离:无输出的挂死
760
+ # 仍按原单模型 timeout 快速换将,收到过输出则最多再给一个活动窗口。
761
+ active = (activity_timeout > 0 and stamp[1]
762
+ and time.monotonic() - stamp[2] < activity_timeout
763
+ and elapsed < timeout + activity_timeout)
764
+ if not active:
765
+ timed_out = True
766
+ _kill_tree(proc.pid)
767
+ _drain_streams(proc, t_out, t_err, timeout=5)
768
+ break
769
+ if stall_timeout and time.time() - stamp[0] > stall_timeout:
770
+ # 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
771
+ # 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
772
+ stalled = True
773
+ timed_out = True
774
+ _kill_tree(proc.pid)
775
+ _drain_streams(proc, t_out, t_err, timeout=5)
776
+ break
777
+ if aborts:
778
+ try:
779
+ recent = decode_output(b"".join(out_chunks)[-8000:] +
780
+ b"\n" + b"".join(err_chunks)[-8000:])
781
+ for marker, limit in aborts:
782
+ if recent.count(marker) >= limit:
783
+ repeat_aborted = True
784
+ abort_marker = marker
785
+ timed_out = True
786
+ _kill_tree(proc.pid)
787
+ _drain_streams(proc, t_out, t_err, timeout=5)
788
+ break
789
+ if repeat_aborted:
790
+ break
791
+ except (TypeError, ValueError):
792
+ pass
793
+ duration = round(time.time() - start, 1)
794
+ t_out.join(timeout=5)
795
+ t_err.join(timeout=5)
796
+ exit_code = proc.returncode
797
+ stdout = decode_output(b"".join(out_chunks))
798
+ stderr = decode_output(b"".join(err_chunks))
799
+ # stdout/stderr 的 close 已归 _pipe_reader 自线程(EOF 后自关):这里再
800
+ # close 会抢 read1 手里的锁,孙进程漏杀攥着管道时就死等(见其 docstring)。
801
+ # stdin 无读线程,_feed 写完自关;这里兜底一次(DEVNULL 时本就无事)。
802
+ try:
803
+ if proc.stdin:
804
+ proc.stdin.close()
805
+ except Exception:
806
+ pass
807
+ if cancelled:
808
+ stderr += "\n[已被用户取消]"
809
+ elif repeat_aborted:
810
+ limit = next((n for m, n in aborts if m == abort_marker), "?")
811
+ if len(aborts) == 1 and abort_markers is None:
812
+ # 保留旧版 UI/测试已经依赖的文案;多标记配置使用更具体的
813
+ # 标记名,方便定位究竟是哪一种断流触发了止损。
814
+ stderr += "\n[同一网络错误重复 %s 次,已提前终止进程树]" % limit
815
+ else:
816
+ stderr += "\n[错误标记 %s 重复 %s 次,已提前终止进程树]" % (
817
+ abort_marker, limit)
818
+ elif deadline_exceeded:
819
+ stderr += "\n[任务总时限已到,已终止进程树]"
820
+ elif stalled:
821
+ stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
822
+ elif timed_out:
823
+ stderr += "\n[超时 %ss,已终止进程树]" % timeout
824
+ return {
825
+ "ok": exit_code == 0 and not cancelled and not timed_out,
826
+ "exit_code": exit_code, "stdout": stdout, "stderr": stderr,
827
+ "duration": duration, "cancelled": cancelled, "timed_out": timed_out,
828
+ "stalled": stalled, "deadline_exceeded": deadline_exceeded,
829
+ "abort_marker": abort_marker,
830
+ "activity_extended": bool(activity_timeout and stamp[1]
831
+ and duration > timeout),
832
+ "activity_timeout": activity_timeout,
833
+ "last_output_age": max(0.0, time.time() - stamp[0]),
834
+ }
835
+ finally:
836
+ if log_fh:
837
+ try:
838
+ log_fh.close()
839
+ except Exception:
840
+ pass
841
+
842
+
843
+ # ---------------------------------------------------------------- 各家适配
844
+
845
+ def _parse_codex_jsonl(stdout):
846
+ """解析 codex exec JSONL 事件流。返回 (text, usage, sid)。
847
+
848
+ usage 细分来自 turn.completed:input_tokens(含 cached)、cached_input_tokens、
849
+ output_tokens、reasoning_output_tokens;多 turn 累加。
850
+ sid 来自 thread.started 的 thread_id(§07 T1.1:供后续 revise/fix 复用会话,
851
+ 会话内前缀按供应商缓存读计价)。
852
+ """
853
+ text = ""
854
+ sid = ""
855
+ usage = {"input": 0, "output": 0, "cached": 0, "reasoning": 0, "total": 0}
856
+ for line in stdout.splitlines():
857
+ line = line.strip()
858
+ if not line.startswith("{"):
859
+ continue
860
+ try:
861
+ ev = json.loads(line)
862
+ except Exception:
863
+ continue
864
+ if ev.get("type") == "thread.started":
865
+ sid = str(ev.get("thread_id") or sid)
866
+ elif ev.get("type") == "item.completed":
867
+ item = ev.get("item") or {}
868
+ if item.get("type") == "agent_message" and item.get("text"):
869
+ text = item["text"]
870
+ elif ev.get("type") == "turn.completed":
871
+ u = ev.get("usage") or {}
872
+ inp = int(u.get("input_tokens") or 0)
873
+ out = int(u.get("output_tokens") or 0)
874
+ usage["input"] += inp
875
+ usage["output"] += out
876
+ usage["cached"] += int(u.get("cached_input_tokens") or 0)
877
+ usage["reasoning"] += int(u.get("reasoning_output_tokens") or 0)
878
+ usage["total"] += (u.get("total_tokens") if u.get("total_tokens") is not None
879
+ else inp + out)
880
+ return text, usage, sid
881
+
882
+
883
+ def _codex_work_events(stdout):
884
+ """统计本轮事件流里真实「动了手」的事件数(命令执行/文件改动/MCP 工具)。
885
+
886
+ 实现步空转闸的客观判据:2026-09-17 mo-so 实现步模型一条命令都没发,纯口头
887
+ 谎报「进程执行被策略拦截」却 exit 0 + 一段像模像样的总结——text 看不出敷衍,
888
+ command_execution 计数为 0 才是硬证据。"""
889
+ n = 0
890
+ for line in stdout.splitlines():
891
+ line = line.strip()
892
+ if not line.startswith("{"):
893
+ continue
894
+ try:
895
+ ev = json.loads(line)
896
+ except Exception:
897
+ continue
898
+ if ev.get("type") == "item.completed" and (
899
+ ev.get("item") or {}).get("type") in (
900
+ "command_execution", "file_change", "mcp_tool_call"):
901
+ n += 1
902
+ return n
903
+
904
+
905
+ def _codex_fail_msg(stdout):
906
+ """从 JSONL 事件流提取终态失败消息;无失败返回 ""。
907
+
908
+ 2026-09-16 实测:配额/限流只出现在 error 与 turn.failed 事件里,旧解析器
909
+ 两者都丢——进程退出码非 0 时错误串里只剩 "Reading prompt from stdin...",
910
+ _quota_error/_transient_error 判不出可降级,健康后继模型从未被尝试。
911
+ Reconnecting... 是 CLI 内部重试噪音(可能自愈),不取作终态错误;重复
912
+ 重连由进程看门狗提前中断。turn.failed 优先于裸 error(后者取最后一条兜底)。
913
+ """
914
+ terminal, last_err = "", ""
915
+ for line in (stdout or "").splitlines():
916
+ line = line.strip()
917
+ if not line.startswith("{"):
918
+ continue
919
+ try:
920
+ ev = json.loads(line)
921
+ except Exception:
922
+ continue
923
+ t = ev.get("type")
924
+ if t == "turn.failed":
925
+ e = ev.get("error")
926
+ terminal = e.get("message") if isinstance(e, dict) else str(e or "")
927
+ elif t == "error":
928
+ m = str(ev.get("message") or "")
929
+ if m and "reconnecting" not in m.lower():
930
+ last_err = m
931
+ return terminal or last_err
932
+
933
+
934
+ def _parse_claude_json(stdout):
935
+ data = None
936
+ try:
937
+ data = json.loads(stdout)
938
+ except Exception:
939
+ # stream-json:逐行事件取最后一条 type=="result"(与旧单 JSON 同构)
940
+ for line in stdout.splitlines():
941
+ line = line.strip()
942
+ if not line.startswith("{"):
943
+ continue
944
+ try:
945
+ ev = json.loads(line)
946
+ except Exception:
947
+ continue
948
+ if isinstance(ev, dict) and ev.get("type") == "result":
949
+ data = ev
950
+ if not isinstance(data, dict):
951
+ return None
952
+ u = data.get("usage") or {}
953
+ inp = int(u.get("input_tokens") or 0)
954
+ out = int(u.get("output_tokens") or 0)
955
+ # 缓存读 + 缓存写都计入 cached(读是省钱的部分,写是额外消耗的部分)
956
+ cached = int(u.get("cache_read_input_tokens") or 0) + \
957
+ int(u.get("cache_creation_input_tokens") or 0)
958
+ usage = {"input": inp, "output": out, "cached": cached, "reasoning": 0,
959
+ "total": inp + out + cached}
960
+ # 网关实服模型:请求 deepseek-v4.1-flash 却被网关映射成 gemini-3.8-flash
961
+ # 之类(2026-09-23 夜班实案)——用量统计记错名、失败排查看错病根,都
962
+ # 靠 result 行的 modelUsage 对出来
963
+ served = sorted({str(k) for k in ((data.get("modelUsage") or {}) or {})})
964
+ return {
965
+ "text": data.get("result") or "",
966
+ "cost_usd": data.get("total_cost_usd") or 0.0,
967
+ "usage": usage,
968
+ "tokens": usage["total"],
969
+ "is_error": bool(data.get("is_error")),
970
+ # §07 T1.1:claude -p 返回本次会话 id,供 --resume 复用
971
+ "sid": str(data.get("session_id") or ""),
972
+ "served_models": served,
973
+ }
974
+
975
+
976
+ def _codex_provider_args(cp):
977
+ """把外部供应商注入为一次性的 codex model_provider(-c 覆盖,不改配置文件)。"""
978
+ name = cp.get("name", "orch")
979
+ return ["-c", 'model_provider="%s"' % name,
980
+ "-c", 'model_providers.%s.name="%s"' % (name, name),
981
+ "-c", 'model_providers.%s.base_url="%s"' % (name, cp.get("base_url", "")),
982
+ "-c", 'model_providers.%s.env_key="%s"' % (name, cp.get("env_key", "ORCH_API_KEY")),
983
+ "-c", 'model_providers.%s.wire_api="%s"' % (name, cp.get("wire_api", "responses"))]
984
+
985
+
986
+ def _model_flag(kind, model):
987
+ if kind in ("codex", "qwen"):
988
+ return ["-m", model]
989
+ return ["--model", model] # claude / opencode / aider
990
+
991
+
992
+ def _transient_error(err):
993
+ """Shared retryable upstream/transport classification."""
994
+ return _shared_transient_error(err)
995
+
996
+
997
+ def _quota_error(err):
998
+ """Quota and rate-limit checks share the taxonomy used by key health."""
999
+ return _shared_quota_error(err)
1000
+
1001
+
1002
+ def _auth_error(err):
1003
+ """Only explicit credential failures are auth errors; 403 is separate."""
1004
+ if _permission_error(err):
1005
+ return False
1006
+ return _shared_auth_error(err)
1007
+
1008
+
1009
+ def _permission_error(err):
1010
+ """识别明确的 HTTP 403 权限拒绝;它是上游故障,不等同于无效 API Key。"""
1011
+ return _shared_forbidden_error(err)
1012
+
1013
+
1014
+ def _attempt_credential(att):
1015
+ """绑定链中凭据的非敏感身份;不把密钥值写入日志或尝试记录。"""
1016
+ return (str((att or {}).get("provider_id") or ""),
1017
+ str((att or {}).get("key_id") or ""))
1018
+
1019
+
1020
+ # 限流(429)专项:与一般瞬态不同,它是「等一个窗口就常能自愈」的病——
1021
+ # 2026-09-22 四连败实测:claude ENOTFOUND 换将 codex 后仍与同一上游撞 429,
1022
+ # 链上无第三条路时整步立刻判死;而限流窗口通常按分钟计,原地等一个窗口
1023
+ # 再试一次,好过把整轮 run 直接烧成 failed(欠费不同源,不适用宽限)。
1024
+ RATE_LIMIT_GRACE_S = 45 # 宽限等待时长:限流窗口通常按分钟计,等一个再试
1025
+ RATE_LIMIT_GRACE_N = 1 # 每步宽限次数:只兜一次,防限流长拖整轮时间
1026
+
1027
+
1028
+ def _refusal_error(err):
1029
+ return classify_error_text(err) == ErrorCode.VENDOR_REFUSAL
1030
+
1031
+
1032
+ def _attempt_upstream(att):
1033
+ """返回规范化上游身份;同一 host 换模型不重复烧超时。"""
1034
+ provider = (att or {}).get("provider") or {}
1035
+ base_url = str(provider.get("base_url") or "").strip()
1036
+ normalized = upstream.normalize_upstream(base_url)
1037
+ if normalized:
1038
+ return normalized
1039
+ provider_id = str((att or {}).get("provider_id") or "").strip()
1040
+ return "provider:" + provider_id if provider_id else "unknown"
1041
+
1042
+
1043
+ def _claude_drift_note(att_model, parsed):
1044
+ """请求模型 vs 网关实服模型不一致时的注记;一致或无从判断返回空。"""
1045
+ served = (parsed or {}).get("served_models") or []
1046
+ if not served or not att_model or served == [att_model]:
1047
+ return ""
1048
+ return "[模型漂移] 请求 %s,上游实服 %s" % (att_model, ",".join(served))
1049
+
1050
+
1051
+ def _log_note(log_path, text):
1052
+ if not log_path or not text:
1053
+ return
1054
+ try:
1055
+ with open(log_path, "a", encoding="utf-8") as fh:
1056
+ fh.write("\n[%s]\n" % text)
1057
+ except OSError:
1058
+ pass
1059
+
1060
+
1061
+ def _rate_limited(err):
1062
+ return _shared_rate_limited_error(err)
1063
+
1064
+
1065
+ # 审计日志错误行探针:只认强信号,避免把回显提示词里的普通词当错误
1066
+ _LOG_ERR_HINT = re.compile(
1067
+ r"(403|forbidden|request not allowed|permission denied|access denied"
1068
+ r"|429|402|rate[_ ]?limit|quota|余额|欠费|insufficient|billing|arrears"
1069
+ r"|401|unauthorized|unauthorised|invalid[_ ]api[_ ]key|authentication[_ ]error"
1070
+ r"|ENOTFOUND|ECONNREFUSED|failed to run prompt|api_error|overloaded)",
1071
+ re.I)
1072
+
1073
+
1074
+ def _augment_error_from_log(out, log_path, limit=300):
1075
+ """stdout/stderr 尾段没有错误信号时,从流式审计日志里补最后一条错误行。
1076
+ 真错误有时只走流式管道(kimi 撞 429 时捕获输出只剩启动横幅),不补的
1077
+ 话路由层与用户都只看到「退出码 1;stderr/stdout: kimi version 2.0.2」。"""
1078
+ if not log_path or not out.get("error"):
1079
+ return
1080
+ try:
1081
+ with open(log_path, "rb") as fh:
1082
+ fh.seek(0, 2)
1083
+ size = fh.tell()
1084
+ fh.seek(max(0, size - 8192))
1085
+ blob = fh.read().decode("utf-8", "ignore")
1086
+ except OSError:
1087
+ return
1088
+ hits = [ln.strip() for ln in blob.splitlines() if _LOG_ERR_HINT.search(ln)]
1089
+ if not hits:
1090
+ return
1091
+ out["error"] = (out["error"] + ";日志错误行: " + hits[-1][:limit])[-800:]
1092
+
1093
+
1094
+ def _grace_wait(cancel_event, seconds, log_path=None, why=""):
1095
+ """链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
1096
+ if log_path:
1097
+ try:
1098
+ with open(log_path, "a", encoding="utf-8") as fh:
1099
+ fh.write("\n[限流宽限] %s:等待 %ds 后原地重试一次\n"
1100
+ % (why or "上游限流(429)", seconds))
1101
+ except Exception:
1102
+ pass
1103
+ end = time.monotonic() + seconds
1104
+ while time.monotonic() < end:
1105
+ if cancel_event is not None and cancel_event.is_set():
1106
+ return False
1107
+ time.sleep(min(3, max(0.5, end - time.monotonic())))
1108
+ return cancel_event is None or not cancel_event.is_set()
1109
+
1110
+
1111
+ def attempt_cancelled(res):
1112
+ """这次调用是不是被用户取消的。
1113
+
1114
+ 取消是用户意志不是上游表现:KEY 冷却、供应商健康、评测作废三本惩罚账都必须
1115
+ 跳过(docs/execution-standard.md 统一执行算法第 3 条与 CANCELLED 行)。只看
1116
+ 错误文案不行——取消串是「取消;stderr/stdout: <被杀进程尾部>」,尾部碰巧含
1117
+ 429/欠费字样就会把健康 KEY 打进 30 分钟冷却。
1118
+ """
1119
+ res = res if isinstance(res, dict) else {}
1120
+ raw = res.get("raw") if isinstance(res.get("raw"), dict) else {}
1121
+ return (bool(res.get("cancelled")) or bool(raw.get("cancelled"))
1122
+ or error_code_value(res.get("error_code")) == ErrorCode.CANCELLED)
1123
+
1124
+
1125
+ def _report_key(att, out):
1126
+ """把这次尝试的结果回写到 KEY 账本:欠费/失败 → 冷却,成功 → 清错误。
1127
+
1128
+ 回写失败绝不能影响主流程(账本是旁路),所以整体吞异常。
1129
+ """
1130
+ pid, kid = att.get("provider_id") or "", att.get("key_id") or ""
1131
+ if not (pid and kid) or attempt_cancelled(out):
1132
+ return
1133
+ try:
1134
+ from . import modelhub
1135
+ if out.get("ok"):
1136
+ modelhub.note_key_ok(pid, kid)
1137
+ elif out.get("error"):
1138
+ modelhub.note_key_error(pid, kid, out["error"])
1139
+ except Exception:
1140
+ pass
1141
+
1142
+
1143
+ def _classify_failure(res, *, parsed=None, kind="", attempt_done=False, empty_output=False):
1144
+ """根据 run_process 结果 + 解析结果,返回 ErrorCode 字符串。
1145
+
1146
+ 设计稿:docs/migration/01-defense-patterns.md §5D + §2D。
1147
+
1148
+ Args:
1149
+ res: run_process 返回的 dict(含 ok/cancelled/timed_out/exit_code/stdout/stderr)
1150
+ parsed: claude 解析后的 dict(或 None);codex 由 caller 处理
1151
+ kind: "claude" | "codex" | "generic" | ...
1152
+ attempt_done: claude 空响应是否已重试一次(True=已是第二次)
1153
+ empty_output: 调用方已确认 stdout 为空(用于 generic/codex 的空响应)
1154
+
1155
+ Returns:
1156
+ ErrorCode 枚举值字符串;成功返回 ""。
1157
+ """
1158
+ if res.get("cancelled"):
1159
+ return ErrorCode.CANCELLED
1160
+ if res.get("timed_out"):
1161
+ return ErrorCode.TIMEOUT
1162
+ if not res.get("ok") or (parsed and parsed.get("is_error")):
1163
+ detail = "\n".join((str(res.get("stderr") or ""),
1164
+ str(res.get("stdout") or ""),
1165
+ str((parsed or {}).get("text") or "")))
1166
+ if _permission_error(detail):
1167
+ return ErrorCode.FORBIDDEN
1168
+ # Preserve actionable upstream distinctions before collapsing non-zero exits
1169
+ # into VENDOR_ERROR. The shared classifier is also used by modelhub and routing.
1170
+ if not res.get("ok") or (parsed and parsed.get("is_error")):
1171
+ detail = "\n".join((str(res.get("stderr") or ""),
1172
+ str(res.get("stdout") or ""),
1173
+ str((parsed or {}).get("text") or "")))
1174
+ classified = classify_error_text(detail)
1175
+ if classified is not None:
1176
+ return classified
1177
+ # claude 解析失败(进程 ok 但 JSON 不可解析)
1178
+ if kind == "claude" and parsed is None:
1179
+ return ErrorCode.PARSE_FAIL
1180
+ # claude 明确 is_error
1181
+ if parsed and parsed.get("is_error"):
1182
+ return ErrorCode.VENDOR_REFUSAL
1183
+ # claude 两次都空
1184
+ if kind == "claude" and attempt_done and not (parsed or {}).get("text", "").strip():
1185
+ return ErrorCode.EMPTY
1186
+ # generic/codex 空输出(caller 已确认)
1187
+ if empty_output and not parsed:
1188
+ return ErrorCode.EMPTY
1189
+ # 退出码非 0(vendor 内部崩溃)
1190
+ ec = res.get("exit_code")
1191
+ if ec is not None and ec != 0:
1192
+ return ErrorCode.VENDOR_ERROR
1193
+ return ""
1194
+
1195
+
1196
+ def _resolve_attempts(agent):
1197
+ """把 agent 的模型配置展开为逐次尝试列表。
1198
+
1199
+ 优先用跨厂商链 call_chain(每条自带 env / codex_provider,来自不同供应商);
1200
+ 无链时退回 model + model_fallbacks(同一 CLI 进程内换 -m,沿用 agent 级注入)。
1201
+
1202
+ 链条目可能带 key_id/provider_id(同一厂商多把 KEY 展开成的多条)——失败时
1203
+ 据此把「哪把 KEY 不行」回写冷却,后续解析自动切备用。
1204
+ """
1205
+ chain = agent.get("call_chain") or []
1206
+ if chain:
1207
+ return [{"model": (e.get("model") or "").strip() or None,
1208
+ "env": dict(e.get("env") or {}),
1209
+ "provider": e.get("provider") or {},
1210
+ "from_chain": True,
1211
+ "own_cp": "codex_provider" in e,
1212
+ "codex_provider": e.get("codex_provider"),
1213
+ "provider_id": e.get("provider_id") or "",
1214
+ "key_id": e.get("key_id") or ""} for e in chain]
1215
+ base_model = agent.get("model")
1216
+ fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
1217
+ models_to_try = ([base_model] if base_model else []) + fb
1218
+ provider = agent.get("provider") or {}
1219
+ return [{"model": m or None, "env": {}, "provider": provider,
1220
+ "from_chain": False, "own_cp": False,
1221
+ "codex_provider": None,
1222
+ "provider_id": provider.get("id") if isinstance(provider, dict) else "",
1223
+ "key_id": ""}
1224
+ for m in (models_to_try or [None])[:3]]
1225
+
1226
+
1227
+ def _attempt_protocol(att):
1228
+ """尝试身份里的协议:链/供应商条目自带 protocol 才记,取不到留空——
1229
+ 留空表示未声明,不得回填猜测(chat/responses 之分要靠适配层实测)。"""
1230
+ prov = att.get("provider")
1231
+ if isinstance(prov, dict) and prov.get("protocol"):
1232
+ return str(prov["protocol"])[:24]
1233
+ return ""
1234
+
1235
+
1236
+ def _codex_sandbox(readonly):
1237
+ """codex 沙箱档位。写步默认 danger-full-access(2026-09-17 用户拍板「默认给全部
1238
+ 权限」):workspace-write 禁网+禁盘外写,模型偶发还会误判沙箱受限、谎报
1239
+ 「进程执行被策略拦截」直接躺平(mo-so BUG#27596 实测,沙箱探针证明命令本可跑)。
1240
+ 只读步(评审/规划)仍 read-only——评审者可写会污染 git diff 裁决。
1241
+ env TUTTI_CODEX_SANDBOX 可钉死某档(workspace-write / read-only / danger-full-access)。"""
1242
+ if readonly:
1243
+ return "read-only"
1244
+ env = os.environ.get("TUTTI_CODEX_SANDBOX", "").strip().lower()
1245
+ if env in ("workspace-write", "read-only", "danger-full-access"):
1246
+ return env
1247
+ return "danger-full-access"
1248
+
1249
+
1250
+ def _prompt_to_file(prompt, workdir):
1251
+ """超长提示词落盘(工作目录优先,保证评审 CLI 沙箱内可读),返回绝对路径。"""
1252
+ try:
1253
+ d = workdir if workdir and os.path.isdir(workdir) else None
1254
+ fd, path = tempfile.mkstemp(prefix="tutti_prompt_", suffix=".md", dir=d)
1255
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
1256
+ f.write(prompt)
1257
+ return path
1258
+ except Exception:
1259
+ return None
1260
+
1261
+
1262
+ # Windows 上不少 CLI 先经过 cmd.exe/.cmd 垫片,实际安全上限接近 8191,
1263
+ # 而不是 CreateProcess 的理论 32767。留出命令、路径和环境参数余量,超过
1264
+ # 该值统一落盘,避免 MiMo/Kimi 评审在启动前直接报「命令行太长」。
1265
+ _ARGV_PROMPT_SAFE = 20000
1266
+ _ARGV_PROMPT_SHIM_SAFE = 6000
1267
+
1268
+
1269
+ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=None):
1270
+ """构建一次 CLI 调用的 (argv, stdin_text, prompt, tmp_files)。model 可为 None=CLI 默认。
1271
+ images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。
1272
+ tmp_files:为绕开命令行长度限制落盘的指令临时文件,调用方用完(进程结束后)删除。"""
1273
+ env = {}
1274
+ argv = None
1275
+ stdin_text = None
1276
+ tmp_files = []
1277
+ imgs = [str(p) for p in (images or []) if p]
1278
+ if kind == "codex":
1279
+ cp = agent.get("codex_provider")
1280
+ reasoning = str(agent.get("reasoning_effort") or "").strip().lower()
1281
+ if sid:
1282
+ # resume 子命令不支持 -s 也不支持 --full-auto(0.154 实测:
1283
+ # "unexpected argument '--full-auto'")——读/写模式都用 -c sandbox_mode
1284
+ argv = resolve_command(agent["command"]) + [
1285
+ "exec", "resume", sid, "-",
1286
+ "--skip-git-repo-check", "--json"]
1287
+ if model:
1288
+ argv += ["-m", model]
1289
+ argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
1290
+ if cp:
1291
+ argv += _codex_provider_args(cp)
1292
+ else:
1293
+ argv = resolve_command(agent["command"]) + [
1294
+ "exec", "--skip-git-repo-check", "--json",
1295
+ "-s", _codex_sandbox(readonly)]
1296
+ if model:
1297
+ argv += ["-m", model]
1298
+ if cp:
1299
+ argv += _codex_provider_args(cp)
1300
+ if reasoning in ("low", "medium", "high", "xhigh"):
1301
+ argv += ["-c", 'model_reasoning_effort="%s"' % reasoning]
1302
+ # codex exec 与 exec resume 都支持 -i:图片直接附到 prompt(exec resume 的
1303
+ # -i 附在恢复后发送的首条消息上,即本次 stdin prompt)
1304
+ for p in imgs:
1305
+ argv += ["-i", p]
1306
+ stdin_text = prompt
1307
+ elif kind == "claude":
1308
+ # stream-json(2026-09-17 起):逐行事件边跑边吐——停滞看门狗有得看,
1309
+ # 步骤日志实时可读;result 末行与旧单 JSON 同构,解析器双形态兼容
1310
+ argv = resolve_command(agent["command"]) + [
1311
+ "-p", "--output-format", "stream-json", "--verbose"]
1312
+ if sid:
1313
+ argv += ["--resume", sid]
1314
+ if model:
1315
+ argv += ["--model", model]
1316
+ if readonly:
1317
+ # 实测本机自定义网关在 -p 模式下工具续接会丢最终结果(用工具必空)。
1318
+ # 评审/规划所需的上下文已内嵌在提示词中,显式禁用工具最稳。
1319
+ prompt = "(请勿使用任何工具,直接依据下方内容回答。)\n\n" + prompt
1320
+ elif os.environ.get("TUTTI_CLAUDE_PERMS", "").strip() == "acceptEdits":
1321
+ argv += ["--permission-mode", "acceptEdits"]
1322
+ else:
1323
+ # 无人值守 -p 下 acceptEdits 只自动放行改文件,Bash 一律权限拒绝 →
1324
+ # 模型以为环境受限谎报「被策略拦截」躺平(同 codex 沙箱误判案)。
1325
+ # 默认跳过全部权限检查(2026-09-17 用户拍板),env 可收回旧行为。
1326
+ argv += ["--dangerously-skip-permissions"]
1327
+ # 绑定链 env 落一次性 --settings:claude CLI 里用户 settings.json 的 env
1328
+ # 会覆盖进程 env(2026-09-22 双监听器实测),光靠 run_process 的 env 注入,
1329
+ # 链首供应商会被用户配置顶掉(a.test 毒配置案的放大器)。--settings 的
1330
+ # 优先级高于用户 settings.json(同日实测),临时文件进程结束随 tmp_files 删。
1331
+ ant = {k: v for k, v in (agent.get("env") or {}).items()
1332
+ if k.startswith("ANTHROPIC_")}
1333
+ if ant:
1334
+ try:
1335
+ fd, sp = tempfile.mkstemp(prefix="tutti_claude_settings_", suffix=".json")
1336
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
1337
+ json.dump({"env": ant}, f)
1338
+ argv += ["--settings", sp]
1339
+ tmp_files.append(sp)
1340
+ except Exception:
1341
+ pass # 落盘失败退回纯 env 注入(旧路径),不拦运行
1342
+ stdin_text = prompt
1343
+ elif kind == "opencode":
1344
+ argv = resolve_command(agent["command"]) + ["run"]
1345
+ if workdir:
1346
+ # opencode 的 shell 不总跟进程 cwd 走(服务端复用时会落在旧项目根,
1347
+ # 2026-09-17 mo-so 实测 agent 在 Temp 里全盘找代码)——显式钉死
1348
+ argv += ["--dir", str(workdir)]
1349
+ if sid:
1350
+ argv += ["-s", sid] # 无头续会话:-s 指定会话 id(-c 只能接最近一次)
1351
+ if model:
1352
+ # opencode 只认 provider/model 全名(裸名直接 UnknownError,实测);
1353
+ # CodeBee 同步进其配置的 provider id 固定为 orch,故裸名补前缀。
1354
+ # 绑定里已写全名(含 /)的尊重原值。
1355
+ argv += ["--model", model if "/" in model else "orch/" + model]
1356
+ stdin_text = prompt # 无位置参数且 stdin 有内容时读 stdin
1357
+ elif kind == "qwen": # gemini-cli 系:无参数且 stdin 有内容时读 stdin
1358
+ argv = resolve_command(agent["command"])
1359
+ if sid:
1360
+ argv += ["-r", sid] # 恢复指定会话(~/.qwen/projects/*/chats/<sid>.jsonl)
1361
+ if model:
1362
+ argv += ["-m", model]
1363
+ stdin_text = prompt
1364
+ elif kind == "aider":
1365
+ argv = resolve_command(agent["command"]) + [
1366
+ "--yes-always", "--no-auto-commits", "--no-check-update",
1367
+ "--no-gitignore", "--no-pretty", "--no-stream",
1368
+ # 无头执行没有 Windows 控制台,aider 建 PromptSession 抛错并打一行
1369
+ # "Can't initialize prompt toolkit: No Windows console found..."
1370
+ # (非致命,但它排在输出最前,UI 失败摘要只取头部 → 真错误被顶掉,
1371
+ # 2026-09-22 mo-so 实案整条失败原因显示成这句噪声)。关掉花式输入
1372
+ # 就不再建 PromptSession,噪声从源头消失。
1373
+ "--no-fancy-input",
1374
+ # 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
1375
+ # 纯属刷屏(2026-09-20 实测占满步骤日志头部)
1376
+ "--no-show-model-warnings", "--message", prompt]
1377
+ if model:
1378
+ # litellm 靠 provider 前缀路由,裸模型名直接报
1379
+ # "LLM Provider NOT provided"。前缀按本条尝试实际注入的端点协议定:
1380
+ # ANTHROPIC_BASE_URL=anthropic 面(Bigmodel 等)、OPENAI_API_BASE=
1381
+ # openai 兼容面;都没有=CLI 本机默认场景,模型名保持用户原样
1382
+ envd = agent.get("env") or {}
1383
+ if "/" not in model:
1384
+ if envd.get("ANTHROPIC_BASE_URL"):
1385
+ model = "anthropic/" + model
1386
+ elif envd.get("OPENAI_API_BASE"):
1387
+ model = "openai/" + model
1388
+ argv += ["--model", model]
1389
+ else: # generic:模板把 {prompt}/{session} 嵌进参数(注意 cmd 行长度限制)
1390
+ tmpl = agent.get("argv_template") or ["-p", "{prompt}"]
1391
+ if sid and agent.get("resume_argv_template"):
1392
+ tmpl = agent["resume_argv_template"]
1393
+ argv = resolve_command(agent["command"]) + [
1394
+ str(a).replace("{prompt}", prompt).replace("{session}", sid) for a in tmpl]
1395
+ # 提示词在 argv 里 → 必须绕开 cmd 重解析(见 _npm_shim_bypass)
1396
+ argv = _npm_shim_bypass(argv)
1397
+ if "{prompt}" not in tmpl:
1398
+ stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
1399
+ # Windows CreateProcess 命令行上限 32767 字符:全局评审会把全书文本(约 6 万
1400
+ # 字)嵌进 argv,直接 WinError 206 启动失败(2026-09-18 七猫甜宠案,kimi 实证)。
1401
+ # 超长时落盘临时文件、参数位换成读文件指令——评审/作者 CLI 非交互模式均带
1402
+ # 读文件工具(kimi 0.43 实测可读),读不到的调用输出不可解析,由评审全挂
1403
+ # 防线兜底判失败,绝不静默降级。
1404
+ prompt_safe = _ARGV_PROMPT_SAFE
1405
+ if argv:
1406
+ # 垫片检测扫前三个 token:cmd /c xxx.CMD 形态下 argv[0] 是 cmd.exe,
1407
+ # 真正的 .cmd 垫片在 argv[2]——只看 argv[0] 会漏判,中等长度提示词
1408
+ # (6000-20000 字)留在指令行直接撞 cmd.exe 8191 上限
1409
+ # (2026-09-24 评审「命令行太长」实案)
1410
+ heads = [str(a).lower() for a in argv[:3]]
1411
+ if any(a.endswith((".cmd", ".bat")) or
1412
+ os.path.basename(a) in ("npm", "npx", "pnpm", "yarn", "bun")
1413
+ for a in heads):
1414
+ prompt_safe = _ARGV_PROMPT_SHIM_SAFE
1415
+ elif os.name == "nt" and kind == "generic":
1416
+ # Vendor wrappers are not always recognisable as npm shims. Keep
1417
+ # generic prompts below the cmd.exe limit in that case too.
1418
+ prompt_safe = _ARGV_PROMPT_SHIM_SAFE
1419
+ if argv is not None and stdin_text is None and len(prompt) >= prompt_safe \
1420
+ and argv.count(prompt) == 1:
1421
+ pf = _prompt_to_file(prompt, workdir)
1422
+ if pf:
1423
+ argv[argv.index(prompt)] = (
1424
+ "[系统] 本次完整指令因命令行长度限制已写入文件:%s\n"
1425
+ "请先用读文件工具完整读取该文件,然后把文件内容当作你的任务指令执行。" % pf)
1426
+ # Keep the original payload on stdin as a compatibility path for
1427
+ # simple generic adapters and test shims that read stdin. CLI tools
1428
+ # that intentionally use argv still receive the bounded file hint.
1429
+ if kind == "generic" and stdin_text is None and len(tmpl) > 1:
1430
+ # 模板明确将提示词放进 argv 时,原文只保存在文件里;同时再
1431
+ # 写入 stdin 会让部分包装器重复读取,也无法解决 Windows 命令行上限。
1432
+ stdin_text = None
1433
+ tmp_files.append(pf)
1434
+ return argv, stdin_text, prompt, tmp_files
1435
+
1436
+
1437
+ def _check_approval(agent):
1438
+ """5G:catalog entry sensitive + policy=NEVER → 拒绝(无人值守不静默降级)。
1439
+
1440
+ Policy 来源:环境变量 TUTTI_APPROVAL_POLICY(默认 "never")。
1441
+ sensitive 字段从 catalog orch.sensitive 读取。
1442
+
1443
+ Returns:
1444
+ (True, "") 表示放行;
1445
+ (False, reason) 表示拒绝(error_code=ENV_BLOCK)。
1446
+ """
1447
+ policy = os.environ.get("TUTTI_APPROVAL_POLICY", "never").strip().lower()
1448
+ sensitive = bool((agent.get("orch") or {}).get("sensitive"))
1449
+ if not sensitive:
1450
+ return True, ""
1451
+ if policy == "never":
1452
+ return False, "sensitive step blocked by policy=never (set TUTTI_APPROVAL_POLICY=ask to require explicit approval)"
1453
+ # policy=ask 留 Phase 5 做完整 UI 弹窗流程;当前等价 never
1454
+ return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
1455
+
1456
+
1457
+ def _stall_timeout(env_name, default):
1458
+ """停滞看门狗秒数:stdout/stderr 持续无新输出超过该时长 → 判卡死提前杀,
1459
+ 错误按超时归类走既有换模型/换将链路。只对事件流持续流动的 CLI 启用
1460
+ (codex JSONL / claude stream-json);qwen/opencode/generic 结束才一次性
1461
+ 输出,开了会误杀正常长任务,仍靠总超时兜底。env 可调,0=关闭。"""
1462
+ try:
1463
+ return max(0, int(os.environ.get(env_name, str(default))))
1464
+ except Exception:
1465
+ return default
1466
+
1467
+
1468
+ def _activity_timeout(env_name, default=DEFAULT_STREAM_ACTIVITY_TIMEOUT_S):
1469
+ """流式 CLI 的活动延长窗口,独立于停滞看门狗配置。"""
1470
+ try:
1471
+ return max(0, int(os.environ.get(env_name, str(default))))
1472
+ except Exception:
1473
+ return default
1474
+
1475
+
1476
+ def _max_model_attempts(has_deadline=False):
1477
+ """返回一次 runner 调用允许消耗的候选数。
1478
+
1479
+ 生产任务会传入统一 deadline,默认收敛到 3 个候选;没有 deadline 的
1480
+ 旧式直接调用保留最多 4 个候选,以兼容已有的「同一上游两模型后换
1481
+ 供应商」策略。运维可用环境变量进一步收紧/放宽,最少 1 个。
1482
+ """
1483
+ default = 3 if has_deadline else 4
1484
+ try:
1485
+ value = int(os.environ.get("TUTTI_MAX_MODEL_ATTEMPTS", str(default)))
1486
+ return max(1, min(12, value))
1487
+ except (TypeError, ValueError):
1488
+ return default
1489
+
1490
+
1491
+ _INVALID_MODEL_NAMES = frozenset(("auto", "default", "none"))
1492
+
1493
+
1494
+ def _invalid_model(model):
1495
+ return str(model or "").strip().lower() in _INVALID_MODEL_NAMES
1496
+
1497
+
1498
+ def run_agent(agent, prompt, workdir=None, readonly=True,
1499
+ timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
1500
+ images=None, require_tools=False, deadline=None):
1501
+ """执行一次智能体调用,返回统一结构
1502
+ {ok, text, json, cost_usd, tokens, error, error_code, raw}。
1503
+ agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
1504
+ (codex: exec resume;claude: --resume;opencode/mimo: run -s;qwen: -r;
1505
+ generic: catalog orch.resume_argv_template)。
1506
+ images:任务图片附件的绝对路径,仅 codex 原生支持(-i);其余智能体靠
1507
+ 提示词里的 _attachments/ 路径 + 自身读文件能力获取,无读图工具时静默忽略。
1508
+ require_tools:实现步空转闸(仅 codex 事件流可判)——True 时本轮零
1509
+ command_execution/file_change/mcp_tool_call 事件即判 VENDOR_REFUSAL,
1510
+ 防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
1511
+
1512
+ 模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
1513
+ 主模型 + 降级备选;瞬态/配额/超时/上游拒答换下一条,取消与解析
1514
+ 失败不降级;同一上游超时两次即跳过该上游剩余模型。
1515
+
1516
+ 5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout;
1517
+ 单模型时限由该配置或 caller timeout 决定,任务 deadline 可进一步收紧;
1518
+ 持续 stream 活动交由 stall_timeout 看门狗收口。deadline 是
1519
+ 任务共享的 ``time.monotonic()`` 绝对截止时刻;传入后所有模型、空响应
1520
+ 重试和限流宽限共用同一剩余预算。
1521
+ """
1522
+ # 5E:catalog orch.timeout_ms 优先
1523
+ orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
1524
+ if orch_timeout_ms:
1525
+ timeout = float(orch_timeout_ms) / 1000.0
1526
+ try:
1527
+ timeout = float(timeout)
1528
+ except (TypeError, ValueError):
1529
+ timeout = float(DEFAULT_TIMEOUT)
1530
+ timeout = max(0.0, timeout)
1531
+ try:
1532
+ deadline = float(deadline) if deadline is not None else None
1533
+ except (TypeError, ValueError):
1534
+ deadline = None
1535
+ if deadline is not None and time.monotonic() >= deadline:
1536
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1537
+ "tokens": 0, "usage": None, "error": "任务总时限已到,未启动模型",
1538
+ "error_code": ErrorCode.TIMEOUT,
1539
+ "raw": {"exit_code": None, "timed_out": True,
1540
+ "deadline_exceeded": True, "duration": 0.0},
1541
+ "kind": agent.get("kind", "generic"), "model": None,
1542
+ "attempts": []}
1543
+ kind = agent.get("kind", "generic")
1544
+ if agent.get("runtime_config_sync_failed"):
1545
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1546
+ "tokens": 0, "usage": None,
1547
+ "error": agent.get("runtime_config_sync_error") or
1548
+ "CLI 绑定配置同步失败,已阻断本次执行",
1549
+ "error_code": ErrorCode.ENV_BLOCK, "raw": None,
1550
+ "kind": kind, "model": None, "attempts": []}
1551
+ if kind == "aider":
1552
+ repo_issue = _git_repo_issue(workdir)
1553
+ if repo_issue:
1554
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1555
+ "tokens": 0, "usage": None, "error": repo_issue,
1556
+ "error_code": ErrorCode.VENDOR_ERROR, "raw": None,
1557
+ "kind": kind, "model": agent.get("model"), "attempts": []}
1558
+ if kind == "codex":
1559
+ stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
1560
+ activity_t = _activity_timeout("TUTTI_CODEX_ACTIVITY_TIMEOUT")
1561
+ elif kind == "claude":
1562
+ stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
1563
+ activity_t = _activity_timeout("TUTTI_CLAUDE_ACTIVITY_TIMEOUT")
1564
+ else:
1565
+ # 数据驱动:catalog orch.stall_timeout_s——给 kimi/qwen 这类评审用
1566
+ # CLI 配置后,静默挂死即杀(qwen 900s:2026-09-22 MCP 收尾死锁案);
1567
+ # mimo 等结束才一次性输出的仍留 0(开了必误杀),靠总超时兜底
1568
+ try:
1569
+ stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
1570
+ except Exception:
1571
+ stall_t = 0
1572
+ activity_t = 0
1573
+ # 5G:approval NEVER 一线(无人值守不静默降级)
1574
+ ok, reason = _check_approval(agent)
1575
+ if not ok:
1576
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
1577
+ "usage": None, "error": reason,
1578
+ "error_code": ErrorCode.ENV_BLOCK,
1579
+ "raw": None, "kind": kind, "model": None, "attempts": []}
1580
+ base_env = dict(agent.get("env") or {})
1581
+ if kind == "claude":
1582
+ bash = find_git_bash()
1583
+ if bash:
1584
+ base_env.setdefault("CLAUDE_CODE_GIT_BASH_PATH", bash)
1585
+ base_env.setdefault("CLAUDE_CODE_MAX_OUTPUT_TOKENS", "16000")
1586
+ sid = (resume or "").strip() if agent.get("mode") == "real" else ""
1587
+ if sid and kind == "generic" and not agent.get("resume_argv_template"):
1588
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
1589
+ "usage": None,
1590
+ "error": "该 CLI 未配置会话恢复(catalog orch.resume_argv_template),"
1591
+ "无法在已有会话上继续",
1592
+ "error_code": ErrorCode.VENDOR_ERROR,
1593
+ "sid": "", "raw": None, "kind": kind, "model": None, "attempts": []}
1594
+
1595
+ attempts = _resolve_attempts(agent)
1596
+ # 启动前过滤明显的占位模型名。它们常由某个 CLI 的会话标题/默认配置
1597
+ # 泄漏进编排链;让子进程自己等待网关返回会把一个确定性配置错拖满
1598
+ # 多轮超时。非法项不消耗模型调用预算,后面的真实候选仍可接手。
1599
+ filtered_attempts = []
1600
+ preflight_failures = []
1601
+ for att in attempts:
1602
+ if _invalid_model(att.get("model")):
1603
+ preflight_failures.append({
1604
+ "model": att.get("model"),
1605
+ "provider_id": att.get("provider_id") or "",
1606
+ "provider": (att.get("provider") or {}).get("name", "")
1607
+ if isinstance(att.get("provider"), dict) else "",
1608
+ "key_id": att.get("key_id") or "",
1609
+ "protocol": _attempt_protocol(att),
1610
+ "ok": False, "skipped": True, "error":
1611
+ "无效模型名 %r,启动前跳过" % att.get("model"),
1612
+ })
1613
+ continue
1614
+ filtered_attempts.append(att)
1615
+ attempts = filtered_attempts[:_max_model_attempts(deadline is not None)]
1616
+ out = None
1617
+ attempt_history = list(preflight_failures)
1618
+ if not attempts:
1619
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1620
+ "tokens": 0, "usage": None,
1621
+ "error": (preflight_failures[-1].get("error")
1622
+ if preflight_failures else "没有可尝试的模型"),
1623
+ "error_code": ErrorCode.VENDOR_ERROR, "raw": None,
1624
+ "kind": kind, "model": None, "attempts": attempt_history}
1625
+ grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
1626
+ timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
1627
+ skipped_upstreams = set()
1628
+ forbidden_upstreams = set()
1629
+ auth_failed_credentials = set()
1630
+ for ai, att in enumerate(attempts):
1631
+ credential = _attempt_credential(att)
1632
+ if credential in auth_failed_credentials:
1633
+ attempt_history.append({
1634
+ "model": att.get("model") or "",
1635
+ "provider_id": att.get("provider_id") or "",
1636
+ "provider": ((att.get("provider") or {}).get("name", "")
1637
+ if isinstance(att.get("provider"), dict) else ""),
1638
+ "kind": kind, "key_id": att.get("key_id") or "",
1639
+ "protocol": _attempt_protocol(att),
1640
+ "ok": False, "skipped": True,
1641
+ "error_code": ErrorCode.VENDOR_ERROR,
1642
+ "error": "同一凭据已被认证拒绝,跳过该凭据的其他模型",
1643
+ })
1644
+ if out is not None:
1645
+ out["attempts"] = list(attempt_history)
1646
+ continue
1647
+ upstream = _attempt_upstream(att)
1648
+ if upstream in skipped_upstreams or upstream in forbidden_upstreams:
1649
+ continue
1650
+ # 有任务 deadline 时,所有候选共享同一个截止时刻;否则每个候选
1651
+ # 使用 catalog 配置或 caller 提供的单模型时限。
1652
+ model_deadline = (att.get("_deadline") or deadline or
1653
+ (time.monotonic() + timeout))
1654
+ env = dict(base_env)
1655
+ env.update(att["env"])
1656
+ if kind == "codex":
1657
+ # 网关自定义模型名(如 [opencode]xxx)是合法 API 模型名不能改,但方括号
1658
+ # 是非法 OTel tag 值 → codex_otel 每个 SSE 事件刷 2 条 WARN 淹没真输出。
1659
+ # 只静音遥测模块(codex 不认 RUST_LOG 也无害),其余 WARN 全保留。
1660
+ env.setdefault("RUST_LOG", "codex_otel=off")
1661
+ eff_agent = dict(agent)
1662
+ eff_agent["env"] = env
1663
+ if att["own_cp"]:
1664
+ eff_agent["codex_provider"] = att["codex_provider"]
1665
+ elif att["from_chain"] and "codex_provider" in eff_agent:
1666
+ # 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
1667
+ del eff_agent["codex_provider"]
1668
+ for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
1669
+ remaining = model_deadline - time.monotonic()
1670
+ if remaining <= 0:
1671
+ res = {"ok": False, "exit_code": None, "stdout": "", "stderr": "",
1672
+ "duration": timeout, "cancelled": False, "timed_out": True,
1673
+ "stalled": False,
1674
+ "deadline_exceeded": deadline is not None}
1675
+ else:
1676
+ argv, stdin_text, prompt_eff, tmp_files = _build_call(
1677
+ eff_agent, kind, sid, readonly,
1678
+ att["model"], prompt,
1679
+ images=images if kind == "codex" else None,
1680
+ workdir=workdir)
1681
+ try:
1682
+ repeat_guard = (("Reconnecting...", 2)
1683
+ if kind == "codex" else None)
1684
+ stream_abort_markers = (
1685
+ (("stream disconnected", 1),
1686
+ ("stream closed before response.completed", 1))
1687
+ if kind == "codex" else None)
1688
+ # Grok's CLI can keep a dead HTTP connection alive for the
1689
+ # full 20-minute generic timeout. Two identical transport
1690
+ # errors are enough evidence to stop this process and let
1691
+ # the next bound agent try.
1692
+ if agent.get("id") == "grok-build":
1693
+ stream_abort_markers = (
1694
+ ("reqwest error stream", 2),
1695
+ ("error sending request for url", 2),
1696
+ )
1697
+ res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1698
+ timeout=min(timeout, remaining), deadline=deadline,
1699
+ cancel_event=cancel_event,
1700
+ log_path=log_path, stall_timeout=stall_t,
1701
+ activity_timeout=activity_t,
1702
+ repeat_abort=repeat_guard,
1703
+ abort_markers=stream_abort_markers)
1704
+ finally:
1705
+ # 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
1706
+ for tf in tmp_files:
1707
+ try:
1708
+ os.remove(tf)
1709
+ except OSError:
1710
+ pass
1711
+ out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1712
+ "tokens": 0, "usage": None, "error": "", "error_code": "",
1713
+ "sid": "", "raw": res, "kind": kind, "model": att["model"],
1714
+ "provider_id": att.get("provider_id") or "",
1715
+ "provider": att.get("provider") or {}}
1716
+ if not res["ok"]:
1717
+ # stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
1718
+ # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
1719
+ # 里——只取其一会让 _quota_error/_transient_error 判空。
1720
+ # 先洗后切:切片会割断 ANSI 序列,留下 `[91m` 这种裸残尾直接进 UI
1721
+ tail = clean_cli_text(
1722
+ ((res["stderr"] or "") + "\n" + (res["stdout"] or ""))[-4000:]).strip()[-600:]
1723
+ head = ("输出停滞 %ss(stall timed out,疑似卡死已提前终止)" % stall_t
1724
+ if res.get("stalled")
1725
+ else "超时" if res["timed_out"]
1726
+ else "取消" if res["cancelled"]
1727
+ else "退出码 %s" % res["exit_code"])
1728
+ if res.get("deadline_exceeded"):
1729
+ head = "任务总时限已到"
1730
+ out["error"] = head + (";stderr/stdout: " + tail if tail else "")
1731
+ if kind == "codex":
1732
+ fm = _codex_fail_msg(res["stdout"])
1733
+ if fm:
1734
+ # 事件流终态错误比原始 JSONL 尾部可读,也是链降级判定依据
1735
+ out["error"] = "codex: %s(退出码 %s)" % (fm, res["exit_code"])
1736
+ # 5D+2D:错误码归一
1737
+ if kind == "claude":
1738
+ parsed_err = _parse_claude_json(res["stdout"] or "")
1739
+ if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
1740
+ out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
1741
+ drift = _claude_drift_note(att.get("model"), parsed_err)
1742
+ if drift:
1743
+ out["error"] += ";" + drift
1744
+ _log_note(log_path, drift)
1745
+ if kind == "codex" and att.get("provider_id"):
1746
+ el = out["error"].lower()
1747
+ if (("wire_api" in el and "no longer supported" in el)
1748
+ or "no route matched" in el
1749
+ or "同一网络错误重复" in out["error"]):
1750
+ # codex 撞 chat-only 供应商(0.154 只讲 responses):
1751
+ # 自动冷却该供应商 30 分钟,链展开/路由绑定分随之
1752
+ # 自动绕开——不用人工改绑定(2026-09-17 mo-so 实测)
1753
+ try:
1754
+ from . import modelhub as _mh
1755
+ _mh.note_codex_wire_dead(att["provider_id"])
1756
+ except Exception:
1757
+ pass
1758
+ elif "no model configured" in out["error"].lower():
1759
+ # CLI 本体没配置(kimi 实测)→ 运行期自愈:把绑定注入其
1760
+ # 自家配置,本次换将之后的下一轮即可用
1761
+ try:
1762
+ from . import manager as _mg
1763
+ note = _mg.sync_cli_config_now(agent.get("id", ""))
1764
+ if note and log_path:
1765
+ try:
1766
+ with open(log_path, "a", encoding="utf-8") as fh:
1767
+ fh.write("\n[自愈] %s\n" % note)
1768
+ except Exception:
1769
+ pass
1770
+ except Exception:
1771
+ pass
1772
+ if not (_quota_error(out["error"]) or _transient_error(out["error"])
1773
+ or _rate_limited(out["error"])
1774
+ or _permission_error(out["error"])):
1775
+ # 尾段清洗后没有错误信号而流式审计日志里有:补日志错误行。
1776
+ # 2026-09-22 实测 kimi 撞 429 时 stderr/stdout 只剩启动横幅
1777
+ # (真错误只走了流式管道),路由层看不到配额关键词,换将
1778
+ # 判死因全靠猜。
1779
+ _augment_error_from_log(out, log_path)
1780
+ out["error_code"] = (ErrorCode.FORBIDDEN
1781
+ if _permission_error(out["error"])
1782
+ else classify_error_text(out["error"])
1783
+ or _classify_failure(res, kind=kind))
1784
+ break
1785
+ if kind == "codex":
1786
+ out["text"], out["usage"], out_sid = _parse_codex_jsonl(res["stdout"])
1787
+ out["sid"] = out_sid # §07 T1.1:会话 id 供 revise/fix 复用
1788
+ out["tokens"] = out["usage"]["total"]
1789
+ # 退出码 0 不代表成功:配额耗尽时 turn.failed 收尾、进程仍正常退出,
1790
+ # 不判失败的话编排者会把错误信息当成果往下传
1791
+ fm = _codex_fail_msg(res["stdout"])
1792
+ if fm:
1793
+ out["ok"] = False
1794
+ out["error"] = "codex: %s" % fm
1795
+ out["error_code"] = (ErrorCode.FORBIDDEN
1796
+ if _permission_error(out["error"])
1797
+ else classify_error_text(out["error"])
1798
+ or ErrorCode.VENDOR_ERROR)
1799
+ elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
1800
+ out["text"] = clean_cli_text(res["stdout"][-2000:])
1801
+ if require_tools and out["ok"] and _codex_work_events(res["stdout"]) == 0:
1802
+ # 实现步空转闸:exit 0 + 有话但零动手 → 判拒绝(fatal 语义正确,
1803
+ # 不把谎报的「已改完」静默传给下游;auto 模式换将逻辑按 ok 触发,
1804
+ # 仍可换别的 CLI 再试)。原文尾段留进错误供人核对。
1805
+ out["ok"] = False
1806
+ out["error"] = ("实现步零工具调用:模型未执行任何命令/文件改动,"
1807
+ "仅口头汇报(典型如谎报环境受限)。回答尾段: %s"
1808
+ % (out["text"] or "")[-300:])
1809
+ out["error_code"] = ErrorCode.VENDOR_REFUSAL
1810
+ if not out["text"] and out["ok"]:
1811
+ out["error_code"] = _classify_failure(res, kind="codex", empty_output=True)
1812
+ elif kind == "claude":
1813
+ parsed = _parse_claude_json(res["stdout"])
1814
+ if parsed is None:
1815
+ out["ok"] = False
1816
+ out["error"] = "claude 输出无法解析为 JSON;stdout 尾部: " + res["stdout"][-500:]
1817
+ out["error_code"] = _classify_failure(res, kind="claude", parsed=None)
1818
+ break
1819
+ out["text"] = parsed["text"]
1820
+ out["cost_usd"] = parsed["cost_usd"]
1821
+ out["tokens"] = parsed["tokens"]
1822
+ out["usage"] = parsed["usage"]
1823
+ out["sid"] = parsed.get("sid") or "" # §07 T1.1
1824
+ drift = _claude_drift_note(att.get("model"), parsed)
1825
+ if drift:
1826
+ _log_note(log_path, drift)
1827
+ if parsed["is_error"]:
1828
+ out["ok"] = False
1829
+ out["error"] = "claude 返回 is_error: " + parsed["text"][:500]
1830
+ if drift:
1831
+ out["error"] += ";" + drift
1832
+ out["error_code"] = _classify_failure(res, kind="claude", parsed=parsed)
1833
+ break
1834
+ if not out["text"] and attempt == 0:
1835
+ continue # 空响应,同模型重试
1836
+ if not out["text"] and attempt == 1:
1837
+ out["error_code"] = _classify_failure(
1838
+ res, kind="claude", parsed=parsed, attempt_done=True)
1839
+ else:
1840
+ # generic(aider/opencode/kimi…)的 stdout 就是终端转录:ANSI 颜色、
1841
+ # \r 进度条、画线框全在里面。不清洗的话它同时污染两处——步骤摘要
1842
+ # (详情页卡片显示成 ◆/─ 墙)与评审 JSON 解析(转义混进正文)
1843
+ out["text"] = clean_cli_text(res["stdout"]).strip()
1844
+ if not out["text"]:
1845
+ out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
1846
+ break
1847
+ _report_key(att, out)
1848
+ raw = out.get("raw") or {}
1849
+ attempt_history.append({
1850
+ "model": out.get("model") or att.get("model") or "",
1851
+ "provider_id": out.get("provider_id") or att.get("provider_id") or "",
1852
+ "provider": ((out.get("provider") or {}).get("name", "")
1853
+ if isinstance(out.get("provider"), dict) else ""),
1854
+ "kind": kind, "key_id": att.get("key_id") or "",
1855
+ "protocol": _attempt_protocol(att),
1856
+ "ok": bool(out.get("ok")),
1857
+ "duration": raw.get("duration", 0.0),
1858
+ "tokens": out.get("tokens") or 0,
1859
+ "usage": out.get("usage") or None,
1860
+ "cost_usd": out.get("cost_usd") or 0.0,
1861
+ "timed_out": bool(raw.get("timed_out")),
1862
+ "deadline_exceeded": bool(raw.get("deadline_exceeded")),
1863
+ "abort_marker": raw.get("abort_marker"),
1864
+ "error_code": error_code_value(out.get("error_code")),
1865
+ "error": (out.get("error") or "")[:500],
1866
+ "upstream": upstream,
1867
+ })
1868
+ out["attempts"] = list(attempt_history)
1869
+ out["forbidden_upstreams"] = sorted(forbidden_upstreams)
1870
+ if out["ok"] or ai == len(attempts) - 1:
1871
+ remaining = model_deadline - time.monotonic()
1872
+ if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
1873
+ and remaining > 2
1874
+ and not (cancel_event is not None and cancel_event.is_set())):
1875
+ # 链尾限流宽限:enumerate 活列表——把链尾这条 append 回去,
1876
+ # 下一轮迭代就是「原地重试一次」;再撞限流时预算已耗尽照常判死。
1877
+ grace_left -= 1
1878
+ grace_s = min(RATE_LIMIT_GRACE_S, remaining - 1)
1879
+ if _grace_wait(cancel_event, grace_s, log_path=log_path):
1880
+ retry = dict(att, _deadline=model_deadline)
1881
+ attempts.append(retry)
1882
+ continue
1883
+ return out
1884
+ if _auth_error(out.get("error")):
1885
+ auth_failed_credentials.add(credential)
1886
+ if not any(_attempt_credential(candidate) not in auth_failed_credentials
1887
+ for candidate in attempts[ai + 1:]):
1888
+ return out
1889
+ continue
1890
+ if _permission_error(out.get("error")):
1891
+ # 403 通常是该网关/上游拒绝当前请求;同一 host 换模型只会重撞,
1892
+ # 但不同 host 的候选仍可能有权限,应继续尝试异上游链项。
1893
+ if upstream != "unknown":
1894
+ forbidden_upstreams.add(upstream)
1895
+ out["forbidden_upstreams"] = sorted(forbidden_upstreams)
1896
+ if not any(_attempt_upstream(candidate) not in forbidden_upstreams
1897
+ for candidate in attempts[ai + 1:]):
1898
+ return out
1899
+ continue
1900
+ # 换将闸门:什么失败值得烧链上下一个候选。
1901
+ # · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
1902
+ # · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
1903
+ # 2026-09-23 夜班实案:旧逻辑里超时能否换将全赌输出尾部碰巧含有
1904
+ # "timeout" 字样(当时是步骤里读到的代码 timeout=900 撞的表)。
1905
+ # · 上游内容拒答(refusal):是那家模型的过滤决定,不是任务终态,
1906
+ # 换异上游候选常常就活;判死则整 run 白烧(同案:gemini 拒答后
1907
+ # 链上云知声候选从未被尝试)。
1908
+ raw = out.get("raw") or {}
1909
+ if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
1910
+ or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
1911
+ or _refusal_error(out.get("error"))
1912
+ or _permission_error(out.get("error"))
1913
+ or raw.get("timed_out"))):
1914
+ return out # 取消/解析失败等真终态不降级
1915
+ # 同一上游的模型连续两次超时就收手——
1916
+ # 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
1917
+ # 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最多
1918
+ # 两个模型预算后转向其他上游。其余瞬态/拒答仍按链长全部尝试:各家病根
1919
+ # 不同,每个候选都值得一次机会。
1920
+ if raw.get("timed_out"):
1921
+ timed_out_upstreams[upstream] = timed_out_upstreams.get(upstream, 0) + 1
1922
+ if timed_out_upstreams[upstream] >= 2:
1923
+ skipped_upstreams.add(upstream)
1924
+ return out
1925
+
1926
+
1927
+ def _loads_lenient(text):
1928
+ """容错 json.loads:修复字符串内未转义引号后再解析。
1929
+
1930
+ 模型高频病:评审 note 里带英文直引号("第5章"冷战三天"使用…"),
1931
+ 严格 JSON 被打碎。判据:字符串内遇到 `"` 时向后看第一个非空白字符,
1932
+ 是 , } ] : 视为结构性收尾引号,否则按字面引号转义。"""
1933
+ out = []
1934
+ i, n = 0, len(text)
1935
+ in_str = False
1936
+ while i < n:
1937
+ c = text[i]
1938
+ if in_str:
1939
+ if c == "\\" and i + 1 < n:
1940
+ out.append(text[i:i + 2])
1941
+ i += 2
1942
+ continue
1943
+ if c == '"':
1944
+ j = i + 1
1945
+ while j < n and text[j] in " \t\r\n":
1946
+ j += 1
1947
+ nxt = text[j] if j < n else ""
1948
+ if nxt in ",}]:":
1949
+ in_str = False
1950
+ out.append(c)
1951
+ else:
1952
+ out.append('\\"')
1953
+ i += 1
1954
+ continue
1955
+ out.append(c)
1956
+ i += 1
1957
+ continue
1958
+ if c == '"':
1959
+ in_str = True
1960
+ out.append(c)
1961
+ i += 1
1962
+ return json.loads("".join(out))
1963
+
1964
+
1965
+ def extract_json(text):
1966
+ """从模型回复中提取 JSON:直接解析 → ```json 围栏 → 平衡花括号扫描。"""
1967
+ if not text:
1968
+ return None
1969
+ text = text.strip()
1970
+ try:
1971
+ return json.loads(text)
1972
+ except Exception:
1973
+ pass
1974
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
1975
+ if m:
1976
+ try:
1977
+ return json.loads(m.group(1))
1978
+ except Exception:
1979
+ pass
1980
+ try:
1981
+ return _loads_lenient(m.group(1))
1982
+ except Exception:
1983
+ pass
1984
+ start = text.find("{")
1985
+ while start != -1:
1986
+ depth = 0
1987
+ for i in range(start, len(text)):
1988
+ c = text[i]
1989
+ if c == "{":
1990
+ depth += 1
1991
+ elif c == "}":
1992
+ depth -= 1
1993
+ if depth == 0:
1994
+ try:
1995
+ return json.loads(text[start:i + 1])
1996
+ except Exception:
1997
+ break
1998
+ start = text.find("{", start + 1)
1999
+ return None
2000
+
2001
+
2002
+ def as_scores(gj):
2003
+ """评审解析第二道网:花括号兜底扫描会掉进第一个可平衡的子对象——
2004
+ 外层 JSON 病得重时返回的是「维度→分数」本体(无 scores 键),这里包回
2005
+ 评审形状,否则良评审被误判成「输出不可解析」(2026-09-18 七猫案:
2006
+ kimi 正常出分却因内嵌引号整轮判评审全挂,连环白烧自动续跑)。"""
2007
+ if isinstance(gj, dict) and not isinstance(gj.get("scores"), dict):
2008
+ vals = {k: v for k, v in gj.items()
2009
+ if isinstance(v, (int, float)) and not isinstance(v, bool)}
2010
+ if vals and len(vals) == len(gj) and len(vals) >= 3:
2011
+ return {"scores": vals, "issues": [], "summary": ""}
2012
+ return gj if isinstance(gj, dict) else None
2013
+
2014
+
2015
+ def extract_scores_from_text(text):
2016
+ """评审解析第四道网(借鉴 BAML 的宽容提取):模型把分数写成散文键值对
2017
+ 完全不出 JSON 时(2026-09-18 真实案例:kimi 正文提分),从文本直接抓
2018
+ 「维度:N 分」模式。返回 scores dict 或 {}。"""
2019
+ if not text:
2020
+ return {}
2021
+ scores = {}
2022
+ for m in re.finditer(
2023
+ r"[\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9 ]{0,11}"
2024
+ r"[::]\s*(\d{1,2}(?:\.\d)?)\s*分?", text):
2025
+ dim = m.group(0).rsplit(":", 1)[0].rsplit(":", 1)[0].strip()
2026
+ try:
2027
+ val = float(m.group(1))
2028
+ except ValueError:
2029
+ continue
2030
+ if not dim or dim in scores or not (1.0 <= val <= 10.0):
2031
+ continue
2032
+ scores[dim] = round(val, 1)
2033
+ return scores if len(scores) >= 3 else {}
2034
+
2035
+
2036
+ def scores_from_prose(text, dims):
2037
+ """评审解析第三道网:agentic CLI 有时把 JSON 写进文件、stdout 只留中文
2038
+ 总结(「情节 8 / 人物 8 / …」)。JSON 全灭后按维度名从正文提分;
2039
+ 至少命中 3 个维度才认(防普通行文里的巧合数字)。"""
2040
+ if not text or not dims:
2041
+ return {}
2042
+ found = {}
2043
+ for d in dims:
2044
+ m = re.search(re.escape(d) + r"\s*[::/是]?\s*([0-9]+(?:\.[0-9]+)?)", text)
2045
+ if m:
2046
+ try:
2047
+ found[d] = float(m.group(1))
2048
+ except (TypeError, ValueError):
2049
+ pass
2050
+ return found if len(found) >= 3 else {}