codebee 0.1.49 → 0.1.51
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.
- package/CHANGELOG.md +31 -0
- package/README.md +4 -150
- package/app/core/aiflavor.py +14 -0
- package/app/core/builtin_agent.py +567 -74
- package/app/core/jobs.py +170 -42
- package/app/core/knowledge.py +432 -405
- package/app/core/manager.py +100 -10
- package/app/core/market.py +27 -1
- package/app/core/modelhub.py +53 -5
- package/app/core/pipeline.py +193 -77
- package/app/core/planner.py +14 -5
- package/app/core/preview.py +293 -0
- package/app/core/router.py +189 -104
- package/app/core/runner.py +370 -144
- package/app/core/settings.py +11 -4
- package/app/core/skills.py +36 -12
- package/app/core/store.py +74 -2
- package/app/core/zentao.py +189 -30
- package/app/main.py +95 -7
- package/app/ui/app.js +13256 -12876
- package/app/ui/i18n.js +97 -34
- package/app/ui/index.html +55 -12
- package/app/ui/style.css +217 -26
- package/package.json +1 -1
package/app/core/runner.py
CHANGED
|
@@ -58,52 +58,91 @@ def find_git_bash():
|
|
|
58
58
|
return None
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def resolve_command(command):
|
|
61
|
+
def resolve_command(command):
|
|
62
62
|
"""命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
|
|
63
63
|
path = shutil.which(command) or command
|
|
64
64
|
low = path.lower()
|
|
65
65
|
if low.endswith(".cmd") or low.endswith(".bat"):
|
|
66
66
|
return ["cmd", "/c", path]
|
|
67
|
-
return [path]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return
|
|
90
|
-
if
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
67
|
+
return [path]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _orphan_pack_files(workdir):
|
|
71
|
+
"""列出缺少同名 .idx 的 pack 文件名(无则空列表)。
|
|
72
|
+
|
|
73
|
+
Git 本体扫目录时发现 pack 没有对应 .idx,只打一行 warning 就跳过该包,
|
|
74
|
+
所以 git status/fsck 全都正常;aider 走的 gitpython/gitdb 却按
|
|
75
|
+
pack-*.pack 逐个打开同名 .idx,缺一次就抛 FileNotFoundError,被它记成
|
|
76
|
+
「is your git repo corrupted?」——2026-09-22 mo-so 实案:pack-a52e675e
|
|
77
|
+
.idx 缺失,aider 换将后跑满 14 分钟才失败,报错头还是 prompt toolkit 噪声。
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
probe = subprocess.run(
|
|
81
|
+
["git", "-C", workdir, "rev-parse", "--git-path", "objects/pack"],
|
|
82
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
83
|
+
text=True, timeout=5, check=False,
|
|
84
|
+
)
|
|
85
|
+
except (OSError, subprocess.SubprocessError):
|
|
86
|
+
return []
|
|
87
|
+
rel = (probe.stdout or "").strip()
|
|
88
|
+
if probe.returncode != 0 or not rel:
|
|
89
|
+
return []
|
|
90
|
+
pack_dir = rel if os.path.isabs(rel) else os.path.join(workdir, rel)
|
|
91
|
+
try:
|
|
92
|
+
names = set(os.listdir(pack_dir))
|
|
93
|
+
except OSError:
|
|
94
|
+
return []
|
|
95
|
+
return sorted(n for n in names
|
|
96
|
+
if n.startswith("pack-") and n.endswith(".pack")
|
|
97
|
+
and (n[:-len(".pack")] + ".idx") not in names)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _git_repo_issue(workdir):
|
|
101
|
+
"""返回工作目录 Git 仓库的可读性问题;无 Git 仓库时返回空串。
|
|
102
|
+
|
|
103
|
+
Aider 会在启动后自行读取索引。索引包缺失时它通常先写入 .gitignore,
|
|
104
|
+
再在深层调用中才报错,用户会误以为任务卡住;提前做一次廉价预检能把
|
|
105
|
+
错误变成可操作的任务失败信息。
|
|
106
|
+
"""
|
|
107
|
+
if not workdir or not os.path.isdir(workdir):
|
|
108
|
+
return ""
|
|
109
|
+
git_dir = os.path.join(workdir, ".git")
|
|
110
|
+
if not os.path.exists(git_dir):
|
|
111
|
+
return ""
|
|
112
|
+
try:
|
|
113
|
+
probe = subprocess.run(
|
|
114
|
+
["git", "-C", workdir, "rev-parse", "--git-dir"],
|
|
115
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
116
|
+
text=True, timeout=5, check=False,
|
|
117
|
+
)
|
|
118
|
+
except (OSError, subprocess.SubprocessError):
|
|
119
|
+
return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
|
|
120
|
+
if probe.returncode != 0:
|
|
121
|
+
detail = (probe.stderr or probe.stdout or "仓库元数据不可读").strip()
|
|
122
|
+
return "Git 仓库不可读:%s;请先执行 git fsck/恢复 .git 对象后重试" % detail[-300:]
|
|
123
|
+
try:
|
|
124
|
+
probe = subprocess.run(
|
|
125
|
+
["git", "-C", workdir, "status", "--porcelain", "--untracked-files=no"],
|
|
126
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
127
|
+
text=True, timeout=8, check=False,
|
|
128
|
+
)
|
|
129
|
+
except subprocess.TimeoutExpired:
|
|
130
|
+
return "Git 仓库检查超时;请先执行 git status/git fsck 修复后重试"
|
|
131
|
+
except OSError:
|
|
132
|
+
return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
|
|
133
|
+
if probe.returncode != 0:
|
|
134
|
+
detail = (probe.stderr or probe.stdout or "仓库对象或索引不可读").strip()
|
|
135
|
+
return "Git 仓库损坏或对象缺失:%s;请先执行 git fsck 并恢复仓库后重试" % detail[-300:]
|
|
136
|
+
# git status 对「pack 缺 .idx」不敏感(只 warning 跳过该包),必须单独查:
|
|
137
|
+
# aider 的 gitdb 按 pack-*.pack 逐个开同名 .idx,缺一个就整步失败。
|
|
138
|
+
orphans = _orphan_pack_files(workdir)
|
|
139
|
+
if orphans:
|
|
140
|
+
return ("Git 仓库 pack 索引缺失:%s 没有同名 .idx(git 本体只警告跳过,"
|
|
141
|
+
"Aider 的 gitdb 会直接报「Unable to list files in git repo」)。"
|
|
142
|
+
"修复:在 %s 目录对每个 pack 执行 `git index-pack <pack文件>` "
|
|
143
|
+
"重建索引,或临时改用 Codex/Claude 实现器。"
|
|
144
|
+
% (", ".join(orphans[:3]), os.path.join(".git", "objects", "pack")))
|
|
145
|
+
return ""
|
|
107
146
|
|
|
108
147
|
|
|
109
148
|
def _npm_shim_bypass(argv):
|
|
@@ -115,8 +154,8 @@ def _npm_shim_bypass(argv):
|
|
|
115
154
|
and str(argv[2]).lower().endswith((".cmd", ".bat")):
|
|
116
155
|
shim = argv[2]
|
|
117
156
|
try:
|
|
118
|
-
with open(shim, encoding="utf-8", errors="replace") as fh:
|
|
119
|
-
text = fh.read()
|
|
157
|
+
with open(shim, encoding="utf-8", errors="replace") as fh:
|
|
158
|
+
text = fh.read()
|
|
120
159
|
except Exception:
|
|
121
160
|
return argv
|
|
122
161
|
m = re.search(r'%_prog%"\s+"?%dp0%(\\[^"\n]+?\.(?:mjs|js))"?', text)
|
|
@@ -187,20 +226,32 @@ def _pipe_reader(stream, chunks, log_fh, stamp=None):
|
|
|
187
226
|
在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
|
|
188
227
|
read1() 只要有数据就返回,日志才能真正边跑边看。
|
|
189
228
|
stamp:共享 [最后输出时刻],停滞看门狗据此判定进程是否卡死。
|
|
229
|
+
关闭也归本线程:EOF(或杀树后的异常)后自关。主线程绝不能 close()——
|
|
230
|
+
close 要抢 BufferedReader 的锁,而锁被还在 read1 等 EOF 的本线程攥着,
|
|
231
|
+
孙进程漏杀时它攥着管道到自然死,主线程就陪锁到天荒地老(2026-09-22
|
|
232
|
+
run_command 超时杀 ping 实测:taskkill /T 失手 → close 卡满命令时长)。
|
|
190
233
|
"""
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
stamp
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
234
|
+
try:
|
|
235
|
+
while True:
|
|
236
|
+
b = stream.read1(65536)
|
|
237
|
+
if not b:
|
|
238
|
+
break
|
|
239
|
+
chunks.append(b)
|
|
240
|
+
if stamp is not None:
|
|
241
|
+
stamp[0] = time.time()
|
|
242
|
+
if log_fh:
|
|
243
|
+
try:
|
|
244
|
+
log_fh.write(b)
|
|
245
|
+
log_fh.flush()
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
except Exception:
|
|
249
|
+
pass # 杀树瞬间句柄作废:ValueError/OSError 都算正常收场
|
|
250
|
+
finally:
|
|
251
|
+
try:
|
|
252
|
+
stream.close()
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
204
255
|
|
|
205
256
|
|
|
206
257
|
def _decode_line(blob):
|
|
@@ -400,8 +451,21 @@ def pretty_cli_log(text, max_event_chars=4000):
|
|
|
400
451
|
--json 模式下 stdout 全是机器事件(thread/turn/item…),智能体真正在说的
|
|
401
452
|
话被埋在转义 JSON 里;这里逐行翻译成【消息】【思考】【命令】,让日志抽屉
|
|
402
453
|
读到的是「蜂在干什么」。解析失败或未识别的事件类型原样保留,不吞内容。
|
|
454
|
+
|
|
455
|
+
claude stream-json 同场翻译(2026-09-22 真实案:3.2MB 步骤日志 15828 条
|
|
456
|
+
thinking_tokens 心跳,每 SSE 分片一条、数值只涨 1~2):思考心跳按邻近组
|
|
457
|
+
折成一行;init/api_retry/assistant/result 翻译成可读行——api_retry 是
|
|
458
|
+
429/断连诊断金矿,逐条保留不折叠。只影响抽屉显示,原始日志与解析不动。
|
|
403
459
|
"""
|
|
404
460
|
out = []
|
|
461
|
+
hb = [0, 0] # claude 思考心跳邻近组:[条数, 最大估算 tokens]
|
|
462
|
+
msg_seen = [False] # 是否已出过【消息】(防 result 再重复一遍正文)
|
|
463
|
+
|
|
464
|
+
def _flush_thinking():
|
|
465
|
+
if hb[0]:
|
|
466
|
+
out.append("— 思考中(心跳 ×%d 已折叠,估算 ~%d tokens)—" % (hb[0], hb[1]))
|
|
467
|
+
hb[0] = hb[1] = 0
|
|
468
|
+
|
|
405
469
|
for ln in (text or "").splitlines():
|
|
406
470
|
s = ln.strip()
|
|
407
471
|
ev = None
|
|
@@ -410,10 +474,68 @@ def pretty_cli_log(text, max_event_chars=4000):
|
|
|
410
474
|
ev = json.loads(s)
|
|
411
475
|
except Exception:
|
|
412
476
|
ev = None
|
|
477
|
+
if isinstance(ev, dict) and ev.get("type") == "system" \
|
|
478
|
+
and (ev.get("subtype") or "") == "thinking_tokens":
|
|
479
|
+
try:
|
|
480
|
+
n = int(ev.get("estimated_tokens") or 0)
|
|
481
|
+
except Exception:
|
|
482
|
+
n = 0
|
|
483
|
+
hb[0] += 1
|
|
484
|
+
if n > hb[1]:
|
|
485
|
+
hb[1] = n
|
|
486
|
+
continue
|
|
487
|
+
_flush_thinking()
|
|
413
488
|
if not isinstance(ev, dict):
|
|
414
489
|
out.append(ln)
|
|
415
490
|
continue
|
|
416
491
|
typ = ev.get("type") or ""
|
|
492
|
+
if typ == "system": # claude 其余系统事件
|
|
493
|
+
sub = ev.get("subtype") or ""
|
|
494
|
+
if sub == "init":
|
|
495
|
+
out.append("— 会话启动(model=%s)—" % (ev.get("model") or "?"))
|
|
496
|
+
elif sub == "api_retry":
|
|
497
|
+
out.append("— API 重试 %s/%s(等 %sms):%s —" % (
|
|
498
|
+
ev.get("attempt", "?"), ev.get("max_retries", "?"),
|
|
499
|
+
ev.get("retry_delay_ms", "?"), ev.get("error") or "?"))
|
|
500
|
+
else:
|
|
501
|
+
out.append(ln) # 未知 subtype 原样保留,不吞内容
|
|
502
|
+
continue
|
|
503
|
+
if typ == "assistant": # claude 正文/思考/工具调用都在 content 块里
|
|
504
|
+
for blk in ((ev.get("message") or {}).get("content") or []):
|
|
505
|
+
if not isinstance(blk, dict):
|
|
506
|
+
continue
|
|
507
|
+
bt = blk.get("type") or ""
|
|
508
|
+
if bt == "thinking":
|
|
509
|
+
th = " ".join(str(blk.get("thinking") or "").split())
|
|
510
|
+
if th:
|
|
511
|
+
out.append("【思考】" + th[:200])
|
|
512
|
+
elif bt == "text":
|
|
513
|
+
tx = str(blk.get("text") or "")
|
|
514
|
+
if tx:
|
|
515
|
+
out.append("【消息】" + tx[:max_event_chars])
|
|
516
|
+
msg_seen[0] = True
|
|
517
|
+
elif bt == "tool_use":
|
|
518
|
+
out.append("【命令】%s %s" % (
|
|
519
|
+
blk.get("name") or "?",
|
|
520
|
+
json.dumps(blk.get("input") or "", ensure_ascii=False)[:200]))
|
|
521
|
+
continue
|
|
522
|
+
if typ in ("user", "stream_event"):
|
|
523
|
+
continue # claude 工具回包/流片段是过程噪音
|
|
524
|
+
if typ == "result": # claude 收尾:统计一行;报错带原因
|
|
525
|
+
u = ev.get("usage") or {}
|
|
526
|
+
if ev.get("is_error"):
|
|
527
|
+
out.append("— 完成(报错):" + str(ev.get("result") or "")[:300] + " —")
|
|
528
|
+
else:
|
|
529
|
+
cost = ev.get("total_cost_usd")
|
|
530
|
+
if not msg_seen[0]:
|
|
531
|
+
tx = str(ev.get("result") or "")
|
|
532
|
+
if tx: # 旧单 JSON 模式没有 assistant 事件,正文在这补上
|
|
533
|
+
out.append("【消息】" + tx[:max_event_chars])
|
|
534
|
+
msg_seen[0] = True
|
|
535
|
+
out.append("— 完成(tokens 入 %s / 出 %s,%s)—" % (
|
|
536
|
+
u.get("input_tokens", "?"), u.get("output_tokens", "?"),
|
|
537
|
+
("$%.2f" % cost) if isinstance(cost, (int, float)) else "?"))
|
|
538
|
+
continue
|
|
417
539
|
if typ in ("thread.started", "turn.started"):
|
|
418
540
|
continue
|
|
419
541
|
if typ == "turn.completed":
|
|
@@ -436,18 +558,18 @@ def pretty_cli_log(text, max_event_chars=4000):
|
|
|
436
558
|
return "\n".join(out)
|
|
437
559
|
|
|
438
560
|
|
|
439
|
-
def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
440
|
-
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
|
|
441
|
-
stall_timeout=0, repeat_abort=None):
|
|
561
|
+
def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
562
|
+
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
|
|
563
|
+
stall_timeout=0, repeat_abort=None):
|
|
442
564
|
"""通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
|
|
443
565
|
|
|
444
566
|
stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
|
|
445
567
|
新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
|
|
446
|
-
持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
|
|
447
|
-
开了会把正常长任务误杀。
|
|
448
|
-
|
|
449
|
-
repeat_abort:可选 (marker, count)。同一致命错误达到次数即提前杀树,处理
|
|
450
|
-
CLI 自身不断打印重连消息、因此永远触发不了静默看门狗的假运行。
|
|
568
|
+
持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
|
|
569
|
+
开了会把正常长任务误杀。
|
|
570
|
+
|
|
571
|
+
repeat_abort:可选 (marker, count)。同一致命错误达到次数即提前杀树,处理
|
|
572
|
+
CLI 自身不断打印重连消息、因此永远触发不了静默看门狗的假运行。
|
|
451
573
|
|
|
452
574
|
返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
|
|
453
575
|
"""
|
|
@@ -510,7 +632,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
510
632
|
pass
|
|
511
633
|
threading.Thread(target=_feed, daemon=True).start()
|
|
512
634
|
start = time.time()
|
|
513
|
-
cancelled = timed_out = stalled = repeat_aborted = False
|
|
635
|
+
cancelled = timed_out = stalled = repeat_aborted = False
|
|
514
636
|
while True:
|
|
515
637
|
try:
|
|
516
638
|
proc.wait(timeout=0.4)
|
|
@@ -527,44 +649,46 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
527
649
|
_kill_tree(proc.pid)
|
|
528
650
|
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
529
651
|
break
|
|
530
|
-
if stall_timeout and time.time() - stamp[0] > stall_timeout:
|
|
652
|
+
if stall_timeout and time.time() - stamp[0] > stall_timeout:
|
|
531
653
|
# 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
|
|
532
654
|
# 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
|
|
533
655
|
stalled = True
|
|
534
656
|
timed_out = True
|
|
535
657
|
_kill_tree(proc.pid)
|
|
536
|
-
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
537
|
-
break
|
|
538
|
-
if repeat_abort:
|
|
539
|
-
try:
|
|
540
|
-
marker, limit = repeat_abort
|
|
541
|
-
recent = decode_output(b"".join(out_chunks)[-8000:] +
|
|
542
|
-
b"\n" + b"".join(err_chunks)[-8000:])
|
|
543
|
-
if marker and recent.count(str(marker)) >= int(limit):
|
|
544
|
-
repeat_aborted = True
|
|
545
|
-
timed_out = True
|
|
546
|
-
_kill_tree(proc.pid)
|
|
547
|
-
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
548
|
-
break
|
|
549
|
-
except (TypeError, ValueError):
|
|
550
|
-
pass
|
|
658
|
+
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
659
|
+
break
|
|
660
|
+
if repeat_abort:
|
|
661
|
+
try:
|
|
662
|
+
marker, limit = repeat_abort
|
|
663
|
+
recent = decode_output(b"".join(out_chunks)[-8000:] +
|
|
664
|
+
b"\n" + b"".join(err_chunks)[-8000:])
|
|
665
|
+
if marker and recent.count(str(marker)) >= int(limit):
|
|
666
|
+
repeat_aborted = True
|
|
667
|
+
timed_out = True
|
|
668
|
+
_kill_tree(proc.pid)
|
|
669
|
+
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
670
|
+
break
|
|
671
|
+
except (TypeError, ValueError):
|
|
672
|
+
pass
|
|
551
673
|
duration = round(time.time() - start, 1)
|
|
552
674
|
t_out.join(timeout=5)
|
|
553
675
|
t_err.join(timeout=5)
|
|
554
676
|
exit_code = proc.returncode
|
|
555
677
|
stdout = decode_output(b"".join(out_chunks))
|
|
556
678
|
stderr = decode_output(b"".join(err_chunks))
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
stderr += "\n[
|
|
567
|
-
elif
|
|
679
|
+
# stdout/stderr 的 close 已归 _pipe_reader 自线程(EOF 后自关):这里再
|
|
680
|
+
# close 会抢 read1 手里的锁,孙进程漏杀攥着管道时就死等(见其 docstring)。
|
|
681
|
+
# stdin 无读线程,_feed 写完自关;这里兜底一次(DEVNULL 时本就无事)。
|
|
682
|
+
try:
|
|
683
|
+
if proc.stdin:
|
|
684
|
+
proc.stdin.close()
|
|
685
|
+
except Exception:
|
|
686
|
+
pass
|
|
687
|
+
if cancelled:
|
|
688
|
+
stderr += "\n[已被用户取消]"
|
|
689
|
+
elif repeat_aborted:
|
|
690
|
+
stderr += "\n[同一网络错误重复 %s 次,已提前终止进程树]" % repeat_abort[1]
|
|
691
|
+
elif stalled:
|
|
568
692
|
stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
|
|
569
693
|
elif timed_out:
|
|
570
694
|
stderr += "\n[超时 %ss,已终止进程树]" % timeout
|
|
@@ -739,7 +863,12 @@ _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
|
|
|
739
863
|
# 2026-09-20 连载评审实测:codex 网关断流(stream disconnected)
|
|
740
864
|
# 与 opencode 服务端 500(Unexpected server error)都是「重试/换将
|
|
741
865
|
# 就可能活」的瞬态病,旧表判成终态导致整链早死
|
|
742
|
-
"stream disconnected", "unexpected server error"
|
|
866
|
+
"stream disconnected", "unexpected server error",
|
|
867
|
+
# 2026-09-22 mo-so 实案:claude 报 "Can't reach the API server —
|
|
868
|
+
# check your internet or DNS (ENOTFOUND)",一家端点解析不到不代表
|
|
869
|
+
# 跨厂商链上别家也不通,旧表判成终态直接跳去换将别的 CLI
|
|
870
|
+
"enotfound", "getaddrinfo", "name or service not known",
|
|
871
|
+
"name resolution")
|
|
743
872
|
|
|
744
873
|
|
|
745
874
|
def _transient_error(err):
|
|
@@ -747,10 +876,12 @@ def _transient_error(err):
|
|
|
747
876
|
return any(k in err for k in _TRANSIENT)
|
|
748
877
|
|
|
749
878
|
|
|
750
|
-
#
|
|
879
|
+
# 欠费/配额/限流耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
|
|
751
880
|
# 与 modelhub._QUOTA_HINTS 同源,这里独立一份避免 core 模块间循环依赖。
|
|
752
881
|
_QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
753
|
-
"payment required", "402", "欠费", "余额", "额度", "exceeded"
|
|
882
|
+
"payment required", "402", "欠费", "余额", "额度", "exceeded",
|
|
883
|
+
"too many requests", "rate limit", "rate_limit", "http 429",
|
|
884
|
+
"并发", "超过限")
|
|
754
885
|
|
|
755
886
|
|
|
756
887
|
def _quota_error(err):
|
|
@@ -758,6 +889,64 @@ def _quota_error(err):
|
|
|
758
889
|
return any(k in err for k in _QUOTA)
|
|
759
890
|
|
|
760
891
|
|
|
892
|
+
# 限流(429)专项:与一般瞬态不同,它是「等一个窗口就常能自愈」的病——
|
|
893
|
+
# 2026-09-22 四连败实测:claude ENOTFOUND 换将 codex 后仍与同一上游撞 429,
|
|
894
|
+
# 链上无第三条路时整步立刻判死;而限流窗口通常按分钟计,原地等一个窗口
|
|
895
|
+
# 再试一次,好过把整轮 run 直接烧成 failed(欠费不同源,不适用宽限)。
|
|
896
|
+
_RATE_LIMIT = ("429", "rate limit", "too many requests", "限流")
|
|
897
|
+
RATE_LIMIT_GRACE_S = 45 # 宽限等待时长:限流窗口通常按分钟计,等一个再试
|
|
898
|
+
RATE_LIMIT_GRACE_N = 1 # 每步宽限次数:只兜一次,防限流长拖整轮时间
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _rate_limited(err):
|
|
902
|
+
err = (err or "").lower()
|
|
903
|
+
return any(k in err for k in _RATE_LIMIT)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# 审计日志错误行探针:只认强信号,避免把回显提示词里的普通词当错误
|
|
907
|
+
_LOG_ERR_HINT = re.compile(
|
|
908
|
+
r"(429|402|rate[_ ]?limit|quota|余额|欠费|insufficient|billing|arrears"
|
|
909
|
+
r"|ENOTFOUND|ECONNREFUSED|failed to run prompt|api_error|overloaded)",
|
|
910
|
+
re.I)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _augment_error_from_log(out, log_path, limit=300):
|
|
914
|
+
"""stdout/stderr 尾段没有错误信号时,从流式审计日志里补最后一条错误行。
|
|
915
|
+
真错误有时只走流式管道(kimi 撞 429 时捕获输出只剩启动横幅),不补的
|
|
916
|
+
话路由层与用户都只看到「退出码 1;stderr/stdout: kimi version 2.0.2」。"""
|
|
917
|
+
if not log_path or not out.get("error"):
|
|
918
|
+
return
|
|
919
|
+
try:
|
|
920
|
+
with open(log_path, "rb") as fh:
|
|
921
|
+
fh.seek(0, 2)
|
|
922
|
+
size = fh.tell()
|
|
923
|
+
fh.seek(max(0, size - 8192))
|
|
924
|
+
blob = fh.read().decode("utf-8", "ignore")
|
|
925
|
+
except OSError:
|
|
926
|
+
return
|
|
927
|
+
hits = [ln.strip() for ln in blob.splitlines() if _LOG_ERR_HINT.search(ln)]
|
|
928
|
+
if not hits:
|
|
929
|
+
return
|
|
930
|
+
out["error"] = (out["error"] + ";日志错误行: " + hits[-1][:limit])[-800:]
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _grace_wait(cancel_event, seconds, log_path=None, why=""):
|
|
934
|
+
"""链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
|
|
935
|
+
if log_path:
|
|
936
|
+
try:
|
|
937
|
+
with open(log_path, "a", encoding="utf-8") as fh:
|
|
938
|
+
fh.write("\n[限流宽限] %s:等待 %ds 后原地重试一次\n"
|
|
939
|
+
% (why or "上游限流(429)", seconds))
|
|
940
|
+
except Exception:
|
|
941
|
+
pass
|
|
942
|
+
end = time.time() + seconds
|
|
943
|
+
while time.time() < end:
|
|
944
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
945
|
+
return False
|
|
946
|
+
time.sleep(min(3, max(0.5, end - time.time())))
|
|
947
|
+
return cancel_event is None or not cancel_event.is_set()
|
|
948
|
+
|
|
949
|
+
|
|
761
950
|
def _report_key(att, out):
|
|
762
951
|
"""把这次尝试的结果回写到 KEY 账本:欠费/失败 → 冷却,成功 → 清错误。
|
|
763
952
|
|
|
@@ -825,10 +1014,10 @@ def _resolve_attempts(agent):
|
|
|
825
1014
|
"""
|
|
826
1015
|
chain = agent.get("call_chain") or []
|
|
827
1016
|
if chain:
|
|
828
|
-
return [{"model": (e.get("model") or "").strip() or None,
|
|
829
|
-
"env": dict(e.get("env") or {}),
|
|
830
|
-
"provider": e.get("provider") or {},
|
|
831
|
-
"from_chain": True,
|
|
1017
|
+
return [{"model": (e.get("model") or "").strip() or None,
|
|
1018
|
+
"env": dict(e.get("env") or {}),
|
|
1019
|
+
"provider": e.get("provider") or {},
|
|
1020
|
+
"from_chain": True,
|
|
832
1021
|
"own_cp": "codex_provider" in e,
|
|
833
1022
|
"codex_provider": e.get("codex_provider"),
|
|
834
1023
|
"provider_id": e.get("provider_id") or "",
|
|
@@ -836,12 +1025,12 @@ def _resolve_attempts(agent):
|
|
|
836
1025
|
base_model = agent.get("model")
|
|
837
1026
|
fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
|
|
838
1027
|
models_to_try = ([base_model] if base_model else []) + fb
|
|
839
|
-
provider = agent.get("provider") or {}
|
|
840
|
-
return [{"model": m or None, "env": {}, "provider": provider,
|
|
841
|
-
"from_chain": False, "own_cp": False,
|
|
842
|
-
"codex_provider": None,
|
|
843
|
-
"provider_id": provider.get("id") if isinstance(provider, dict) else "",
|
|
844
|
-
"key_id": ""}
|
|
1028
|
+
provider = agent.get("provider") or {}
|
|
1029
|
+
return [{"model": m or None, "env": {}, "provider": provider,
|
|
1030
|
+
"from_chain": False, "own_cp": False,
|
|
1031
|
+
"codex_provider": None,
|
|
1032
|
+
"provider_id": provider.get("id") if isinstance(provider, dict) else "",
|
|
1033
|
+
"key_id": ""}
|
|
845
1034
|
for m in (models_to_try or [None])[:3]]
|
|
846
1035
|
|
|
847
1036
|
|
|
@@ -883,9 +1072,9 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
883
1072
|
stdin_text = None
|
|
884
1073
|
tmp_files = []
|
|
885
1074
|
imgs = [str(p) for p in (images or []) if p]
|
|
886
|
-
if kind == "codex":
|
|
887
|
-
cp = agent.get("codex_provider")
|
|
888
|
-
reasoning = str(agent.get("reasoning_effort") or "").strip().lower()
|
|
1075
|
+
if kind == "codex":
|
|
1076
|
+
cp = agent.get("codex_provider")
|
|
1077
|
+
reasoning = str(agent.get("reasoning_effort") or "").strip().lower()
|
|
889
1078
|
if sid:
|
|
890
1079
|
# resume 子命令不支持 -s 也不支持 --full-auto(0.154 实测:
|
|
891
1080
|
# "unexpected argument '--full-auto'")——读/写模式都用 -c sandbox_mode
|
|
@@ -895,18 +1084,18 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
895
1084
|
if model:
|
|
896
1085
|
argv += ["-m", model]
|
|
897
1086
|
argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
|
|
898
|
-
if cp:
|
|
899
|
-
argv += _codex_provider_args(cp)
|
|
1087
|
+
if cp:
|
|
1088
|
+
argv += _codex_provider_args(cp)
|
|
900
1089
|
else:
|
|
901
1090
|
argv = resolve_command(agent["command"]) + [
|
|
902
1091
|
"exec", "--skip-git-repo-check", "--json",
|
|
903
1092
|
"-s", _codex_sandbox(readonly)]
|
|
904
1093
|
if model:
|
|
905
1094
|
argv += ["-m", model]
|
|
906
|
-
if cp:
|
|
907
|
-
argv += _codex_provider_args(cp)
|
|
908
|
-
if reasoning in ("low", "medium", "high", "xhigh"):
|
|
909
|
-
argv += ["-c", 'model_reasoning_effort="%s"' % reasoning]
|
|
1095
|
+
if cp:
|
|
1096
|
+
argv += _codex_provider_args(cp)
|
|
1097
|
+
if reasoning in ("low", "medium", "high", "xhigh"):
|
|
1098
|
+
argv += ["-c", 'model_reasoning_effort="%s"' % reasoning]
|
|
910
1099
|
# codex exec 与 exec resume 都支持 -i:图片直接附到 prompt(exec resume 的
|
|
911
1100
|
# -i 附在恢复后发送的首条消息上,即本次 stdin prompt)
|
|
912
1101
|
for p in imgs:
|
|
@@ -932,6 +1121,21 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
932
1121
|
# 模型以为环境受限谎报「被策略拦截」躺平(同 codex 沙箱误判案)。
|
|
933
1122
|
# 默认跳过全部权限检查(2026-09-17 用户拍板),env 可收回旧行为。
|
|
934
1123
|
argv += ["--dangerously-skip-permissions"]
|
|
1124
|
+
# 绑定链 env 落一次性 --settings:claude CLI 里用户 settings.json 的 env
|
|
1125
|
+
# 会覆盖进程 env(2026-09-22 双监听器实测),光靠 run_process 的 env 注入,
|
|
1126
|
+
# 链首供应商会被用户配置顶掉(a.test 毒配置案的放大器)。--settings 的
|
|
1127
|
+
# 优先级高于用户 settings.json(同日实测),临时文件进程结束随 tmp_files 删。
|
|
1128
|
+
ant = {k: v for k, v in (agent.get("env") or {}).items()
|
|
1129
|
+
if k.startswith("ANTHROPIC_")}
|
|
1130
|
+
if ant:
|
|
1131
|
+
try:
|
|
1132
|
+
fd, sp = tempfile.mkstemp(prefix="tutti_claude_settings_", suffix=".json")
|
|
1133
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
1134
|
+
json.dump({"env": ant}, f)
|
|
1135
|
+
argv += ["--settings", sp]
|
|
1136
|
+
tmp_files.append(sp)
|
|
1137
|
+
except Exception:
|
|
1138
|
+
pass # 落盘失败退回纯 env 注入(旧路径),不拦运行
|
|
935
1139
|
stdin_text = prompt
|
|
936
1140
|
elif kind == "opencode":
|
|
937
1141
|
argv = resolve_command(agent["command"]) + ["run"]
|
|
@@ -954,11 +1158,17 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
954
1158
|
if model:
|
|
955
1159
|
argv += ["-m", model]
|
|
956
1160
|
stdin_text = prompt
|
|
957
|
-
elif kind == "aider":
|
|
958
|
-
argv = resolve_command(agent["command"]) + [
|
|
959
|
-
"--yes-always", "--no-auto-commits", "--no-check-update",
|
|
960
|
-
"--no-gitignore", "--no-pretty", "--no-stream",
|
|
961
|
-
#
|
|
1161
|
+
elif kind == "aider":
|
|
1162
|
+
argv = resolve_command(agent["command"]) + [
|
|
1163
|
+
"--yes-always", "--no-auto-commits", "--no-check-update",
|
|
1164
|
+
"--no-gitignore", "--no-pretty", "--no-stream",
|
|
1165
|
+
# 无头执行没有 Windows 控制台,aider 建 PromptSession 抛错并打一行
|
|
1166
|
+
# "Can't initialize prompt toolkit: No Windows console found..."
|
|
1167
|
+
# (非致命,但它排在输出最前,UI 失败摘要只取头部 → 真错误被顶掉,
|
|
1168
|
+
# 2026-09-22 mo-so 实案整条失败原因显示成这句噪声)。关掉花式输入
|
|
1169
|
+
# 就不再建 PromptSession,噪声从源头消失。
|
|
1170
|
+
"--no-fancy-input",
|
|
1171
|
+
# 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
|
|
962
1172
|
# 纯属刷屏(2026-09-20 实测占满步骤日志头部)
|
|
963
1173
|
"--no-show-model-warnings", "--message", prompt]
|
|
964
1174
|
if model:
|
|
@@ -1030,7 +1240,7 @@ def _stall_timeout(env_name, default):
|
|
|
1030
1240
|
return default
|
|
1031
1241
|
|
|
1032
1242
|
|
|
1033
|
-
def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
1243
|
+
def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
1034
1244
|
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
|
|
1035
1245
|
images=None, require_tools=False):
|
|
1036
1246
|
"""执行一次智能体调用,返回统一结构
|
|
@@ -1053,14 +1263,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1053
1263
|
orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
|
|
1054
1264
|
if orch_timeout_ms:
|
|
1055
1265
|
timeout = float(orch_timeout_ms) / 1000.0
|
|
1056
|
-
kind = agent.get("kind", "generic")
|
|
1057
|
-
if kind == "aider":
|
|
1058
|
-
repo_issue = _git_repo_issue(workdir)
|
|
1059
|
-
if repo_issue:
|
|
1060
|
-
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
1061
|
-
"tokens": 0, "usage": None, "error": repo_issue,
|
|
1062
|
-
"error_code": ErrorCode.VENDOR_ERROR, "raw": None,
|
|
1063
|
-
"kind": kind, "model": agent.get("model")}
|
|
1266
|
+
kind = agent.get("kind", "generic")
|
|
1267
|
+
if kind == "aider":
|
|
1268
|
+
repo_issue = _git_repo_issue(workdir)
|
|
1269
|
+
if repo_issue:
|
|
1270
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
1271
|
+
"tokens": 0, "usage": None, "error": repo_issue,
|
|
1272
|
+
"error_code": ErrorCode.VENDOR_ERROR, "raw": None,
|
|
1273
|
+
"kind": kind, "model": agent.get("model")}
|
|
1064
1274
|
if kind == "codex":
|
|
1065
1275
|
stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
|
|
1066
1276
|
elif kind == "claude":
|
|
@@ -1097,6 +1307,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1097
1307
|
|
|
1098
1308
|
attempts = _resolve_attempts(agent)
|
|
1099
1309
|
out = None
|
|
1310
|
+
grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
|
|
1100
1311
|
for ai, att in enumerate(attempts):
|
|
1101
1312
|
env = dict(base_env)
|
|
1102
1313
|
env.update(att["env"])
|
|
@@ -1118,12 +1329,12 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1118
1329
|
att["model"], prompt,
|
|
1119
1330
|
images=images if kind == "codex" else None,
|
|
1120
1331
|
workdir=workdir)
|
|
1121
|
-
try:
|
|
1122
|
-
repeat_guard = (("Reconnecting... waiting for network", 5)
|
|
1123
|
-
if kind == "codex" else None)
|
|
1124
|
-
res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
|
|
1125
|
-
timeout=timeout, cancel_event=cancel_event, log_path=log_path,
|
|
1126
|
-
stall_timeout=stall_t, repeat_abort=repeat_guard)
|
|
1332
|
+
try:
|
|
1333
|
+
repeat_guard = (("Reconnecting... waiting for network", 5)
|
|
1334
|
+
if kind == "codex" else None)
|
|
1335
|
+
res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
|
|
1336
|
+
timeout=timeout, cancel_event=cancel_event, log_path=log_path,
|
|
1337
|
+
stall_timeout=stall_t, repeat_abort=repeat_guard)
|
|
1127
1338
|
finally:
|
|
1128
1339
|
# 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
|
|
1129
1340
|
for tf in tmp_files:
|
|
@@ -1131,11 +1342,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1131
1342
|
os.remove(tf)
|
|
1132
1343
|
except OSError:
|
|
1133
1344
|
pass
|
|
1134
|
-
out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
|
|
1135
|
-
"tokens": 0, "usage": None, "error": "", "error_code": "",
|
|
1136
|
-
"sid": "", "raw": res, "kind": kind, "model": att["model"],
|
|
1137
|
-
"provider_id": att.get("provider_id") or "",
|
|
1138
|
-
"provider": att.get("provider") or {}}
|
|
1345
|
+
out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
|
|
1346
|
+
"tokens": 0, "usage": None, "error": "", "error_code": "",
|
|
1347
|
+
"sid": "", "raw": res, "kind": kind, "model": att["model"],
|
|
1348
|
+
"provider_id": att.get("provider_id") or "",
|
|
1349
|
+
"provider": att.get("provider") or {}}
|
|
1139
1350
|
if not res["ok"]:
|
|
1140
1351
|
# stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
|
|
1141
1352
|
# stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
|
|
@@ -1159,11 +1370,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1159
1370
|
parsed_err = _parse_claude_json(res["stdout"] or "")
|
|
1160
1371
|
if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
|
|
1161
1372
|
out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
|
|
1162
|
-
if kind == "codex" and att.get("provider_id"):
|
|
1163
|
-
el = out["error"].lower()
|
|
1164
|
-
if (("wire_api" in el and "no longer supported" in el)
|
|
1165
|
-
or "no route matched" in el
|
|
1166
|
-
or "同一网络错误重复" in out["error"]):
|
|
1373
|
+
if kind == "codex" and att.get("provider_id"):
|
|
1374
|
+
el = out["error"].lower()
|
|
1375
|
+
if (("wire_api" in el and "no longer supported" in el)
|
|
1376
|
+
or "no route matched" in el
|
|
1377
|
+
or "同一网络错误重复" in out["error"]):
|
|
1167
1378
|
# codex 撞 chat-only 供应商(0.154 只讲 responses):
|
|
1168
1379
|
# 自动冷却该供应商 30 分钟,链展开/路由绑定分随之
|
|
1169
1380
|
# 自动绕开——不用人工改绑定(2026-09-17 mo-so 实测)
|
|
@@ -1186,6 +1397,13 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1186
1397
|
pass
|
|
1187
1398
|
except Exception:
|
|
1188
1399
|
pass
|
|
1400
|
+
if not (_quota_error(out["error"]) or _transient_error(out["error"])
|
|
1401
|
+
or _rate_limited(out["error"])):
|
|
1402
|
+
# 尾段清洗后没有错误信号而流式审计日志里有:补日志错误行。
|
|
1403
|
+
# 2026-09-22 实测 kimi 撞 429 时 stderr/stdout 只剩启动横幅
|
|
1404
|
+
# (真错误只走了流式管道),路由层看不到配额关键词,换将
|
|
1405
|
+
# 判死因全靠猜。
|
|
1406
|
+
_augment_error_from_log(out, log_path)
|
|
1189
1407
|
out["error_code"] = _classify_failure(res, kind=kind)
|
|
1190
1408
|
break
|
|
1191
1409
|
if kind == "codex":
|
|
@@ -1244,6 +1462,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1244
1462
|
break
|
|
1245
1463
|
_report_key(att, out)
|
|
1246
1464
|
if out["ok"] or ai == len(attempts) - 1:
|
|
1465
|
+
if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
|
|
1466
|
+
and not (cancel_event is not None and cancel_event.is_set())):
|
|
1467
|
+
# 链尾限流宽限:enumerate 活列表——把链尾这条 append 回去,
|
|
1468
|
+
# 下一轮迭代就是「原地重试一次」;再撞限流时预算已耗尽照常判死。
|
|
1469
|
+
grace_left -= 1
|
|
1470
|
+
if _grace_wait(cancel_event, RATE_LIMIT_GRACE_S, log_path=log_path):
|
|
1471
|
+
attempts.append(att)
|
|
1472
|
+
continue
|
|
1247
1473
|
return out
|
|
1248
1474
|
# 瞬态网络错误 → 换下一条;欠费/配额耗尽同样换(可能是同厂商的备用 KEY,
|
|
1249
1475
|
# 也可能是另一家厂商)——账单断了死磕同一把 KEY 没有任何意义。
|