codebee 0.1.95 → 0.1.96

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