codebee 0.1.55 → 0.1.56
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 +6 -0
- package/README.md +3 -3
- package/app/core/jobs.py +2 -2
- package/app/core/knowledge.py +5 -0
- package/app/core/modelhub.py +17 -1
- package/app/core/pipeline.py +13 -5
- package/app/core/runner.py +119 -44
- package/app/core/zentao.py +13 -0
- package/app/main.py +43 -3
- package/app/ui/app.js +266 -43
- package/app/ui/board.css +260 -0
- package/app/ui/i18n.js +1 -0
- package/app/ui/style.css +8 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -23,6 +23,12 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
23
23
|
|
|
24
24
|
- **首选 KEY 超限自动切备用了**:同厂商多密钥此前只认欠费类文案——首选 KEY 撞上 429 限流(智谱「并发数超过限制」「每分钟Token数已超过上限」、claude 的 rate_limit_error、codex 的 "429 Too Many Requests" 等)只记一笔错误、不进冷却,每个新步骤仍从超限的首选 KEY 重新烧起。现在限流文案与欠费同口径:进冷却 30 分钟、密钥镜像自动切下一把、链展开跳过冷却中的 KEY(到期或「恢复」按钮一键回首选)。顺带补一处漏账:无显式链时「换模型回退」的链条目不带 KEY 标记,这类重试失败此前完全不记 KEY 账。
|
|
25
25
|
|
|
26
|
+
## v0.1.56(2026-09-23)
|
|
27
|
+
|
|
28
|
+
- 鉴权错误不再触发换将:密钥失效类错误换哪个 CLI 都一样,直接报错省时间
|
|
29
|
+
- 超时任务提供「恢复」入口;排队任务状态文案更明确
|
|
30
|
+
- 驾驶舱轮询载荷瘦身(大屏常开不再吃带宽);移动端任务详情操作按钮对齐
|
|
31
|
+
|
|
26
32
|
## v0.1.55(2026-09-23)
|
|
27
33
|
|
|
28
34
|
- 知识库可信度分级:引用核验借鉴——知识条目带 confidence 分级,low 直接丢弃,中高按置信度加权注入
|
package/README.md
CHANGED
|
@@ -21,10 +21,10 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
|
-
### 最新版更新内容(v0.1.
|
|
24
|
+
### 最新版更新内容(v0.1.56)
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
26
|
+
- 鉴权类错误不再无谓换将;超时任务新增「恢复」入口
|
|
27
|
+
- 驾驶舱轮询载荷瘦身;移动端详情操作对齐;排队状态文案更明确
|
|
28
28
|
<!-- relnotes:end -->
|
|
29
29
|
|
|
30
30
|
---
|
package/app/core/jobs.py
CHANGED
|
@@ -344,12 +344,12 @@ def _requeue_await_slot(job, waits_left=None):
|
|
|
344
344
|
return
|
|
345
345
|
if _restart_drain:
|
|
346
346
|
_close_unstarted(job, "服务正在完成升级重启;本次未排队,请稍后重试",
|
|
347
|
-
statuses=("running"
|
|
347
|
+
statuses=("queued", "running"))
|
|
348
348
|
elif waits_left > 0:
|
|
349
349
|
_requeue_await_slot(job, waits_left - 1)
|
|
350
350
|
else:
|
|
351
351
|
_close_unstarted(job, "并发位已满,排队等待超时仍未起跑,请稍后重试",
|
|
352
|
-
statuses=("running"
|
|
352
|
+
statuses=("queued", "running"))
|
|
353
353
|
|
|
354
354
|
with _timer_lock:
|
|
355
355
|
if run_id in _deferred_timers:
|
package/app/core/knowledge.py
CHANGED
|
@@ -251,6 +251,11 @@ def block_for(task):
|
|
|
251
251
|
stale = _is_stale(x.get("as_of"))
|
|
252
252
|
mark = ("(事实截至 %s,可能过期,请自行核实时效)" % x.get("as_of") if stale
|
|
253
253
|
else ("(事实截至 %s)" % x.get("as_of") if x.get("as_of") else ""))
|
|
254
|
+
# 置信标注(引用核验续):medium 不标(默认噪音为零),low 早已
|
|
255
|
+
# 在学习口丢弃——只有 high 显式加冕,模型据以分配采信权重
|
|
256
|
+
conf = str(x.get("confidence") or "medium").strip().lower()
|
|
257
|
+
if conf == "high":
|
|
258
|
+
mark += "[有据]"
|
|
254
259
|
lines.append("- **%s**%s:%s" % (x["title"], mark, x["body"]))
|
|
255
260
|
used.append(x["id"])
|
|
256
261
|
text = "## 知识库(已确认的领域知识,供参考)\n\n" + "\n".join(lines)
|
package/app/core/modelhub.py
CHANGED
|
@@ -681,6 +681,7 @@ def provider_view():
|
|
|
681
681
|
# 读取点(凭据注入/健康探测/取模型列表)无需感知多 KEY 结构。没配 keys 的老数据
|
|
682
682
|
# 由 _provider_keys 按 api_key 现场合成一条——文件一个字节都不用改。
|
|
683
683
|
_KEY_COOLDOWN_S = 30 * 60 # 欠费类失败后的冷却时长
|
|
684
|
+
_AUTH_COOLDOWN_S = 30 * 60 # 明确认证拒绝后的冷却时长,可在 UI 手动恢复
|
|
684
685
|
MAX_PROVIDER_KEYS = 8 # 单厂商 KEY 上限
|
|
685
686
|
MAX_CHAIN_ATTEMPTS = 8 # 链展开后的尝试上限(模型 × KEY)
|
|
686
687
|
# 欠费/配额/限流类失败:换 KEY 有意义(同厂商另一账号=另一份额度与并发桶),
|
|
@@ -699,6 +700,19 @@ def _quota_error(err):
|
|
|
699
700
|
return any(k in e for k in _QUOTA_HINTS)
|
|
700
701
|
|
|
701
702
|
|
|
703
|
+
_AUTH_HINTS = ("invalid api key", "invalid_api_key", "api key is invalid",
|
|
704
|
+
"incorrect api key", "unauthorized", "unauthorised",
|
|
705
|
+
"authentication failed", "authentication error",
|
|
706
|
+
"authentication_error", "invalid token", "invalid_token",
|
|
707
|
+
"http 401", "status code 401")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _auth_error(err):
|
|
711
|
+
"""明确的凭据认证拒绝;不把一般 403/模型权限错误误判成 KEY 错误。"""
|
|
712
|
+
e = (err or "").lower()
|
|
713
|
+
return any(k in e for k in _AUTH_HINTS)
|
|
714
|
+
|
|
715
|
+
|
|
702
716
|
def _provider_keys(prov, available_only=False, now=None):
|
|
703
717
|
"""供应商的 KEY 列表,顺序=调用顺序。返回 [{id,key,label,enabled,cooling,...}]。
|
|
704
718
|
|
|
@@ -826,7 +840,7 @@ def key_op(provider_id, op, key_id="", key="", label="", enabled=None, ids=None)
|
|
|
826
840
|
|
|
827
841
|
|
|
828
842
|
def note_key_error(provider_id, key_id, error=""):
|
|
829
|
-
"""一次 KEY
|
|
843
|
+
"""一次 KEY 级失败回写:欠费或明确认证拒绝进冷却,后续解析切备用 KEY。"""
|
|
830
844
|
import time as _t
|
|
831
845
|
if not provider_id or not key_id:
|
|
832
846
|
return
|
|
@@ -842,6 +856,8 @@ def note_key_error(provider_id, key_id, error=""):
|
|
|
842
856
|
target["last_fail_at"] = _t.time()
|
|
843
857
|
if _quota_error(error):
|
|
844
858
|
target["cool_until"] = _t.time() + _KEY_COOLDOWN_S
|
|
859
|
+
elif _auth_error(error):
|
|
860
|
+
target["cool_until"] = _t.time() + _AUTH_COOLDOWN_S
|
|
845
861
|
_sync_api_key(prov)
|
|
846
862
|
_save(data)
|
|
847
863
|
|
package/app/core/pipeline.py
CHANGED
|
@@ -1240,11 +1240,19 @@ def _run_code(run, task, agents, ev, stats, mode):
|
|
|
1240
1240
|
return False, res
|
|
1241
1241
|
return True, res
|
|
1242
1242
|
|
|
1243
|
-
ok, res = _run_one(impl_agent)
|
|
1244
|
-
if ok:
|
|
1245
|
-
return True
|
|
1246
|
-
|
|
1247
|
-
|
|
1243
|
+
ok, res = _run_one(impl_agent)
|
|
1244
|
+
if ok:
|
|
1245
|
+
return True
|
|
1246
|
+
if runner._auth_error(res.get("error") or ""):
|
|
1247
|
+
auth_err = ("实现步骤失败(上游拒绝认证;已停止自动换 CLI,避免隐式切换供应商)。"
|
|
1248
|
+
"请检查当前绑定的 Key、账号权限和端点;需要认证降级时,"
|
|
1249
|
+
"请在该 CLI 的绑定链中显式配置不同凭据。错误:%s"
|
|
1250
|
+
% (res.get("error") or "")[:500])
|
|
1251
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
1252
|
+
error=auth_err, ended_at=_now())
|
|
1253
|
+
return False
|
|
1254
|
+
# 单路实现失败:Best-of-N 赛马兜底(借鉴 orca worktree 择优)——多路并行
|
|
1255
|
+
# 各自 worktree 隔离实现+验证,胜者 diff 回主工作区;失败回落走换将
|
|
1248
1256
|
if (mode == "auto" and impl_agent.get("mode") == "real"
|
|
1249
1257
|
and max(1, min(3, int(task.get("best_of") or 1))) >= 2
|
|
1250
1258
|
and resume_ctx is None
|
package/app/core/runner.py
CHANGED
|
@@ -30,6 +30,7 @@ from .error_codes import ErrorCode
|
|
|
30
30
|
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
31
31
|
DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
|
|
32
32
|
MAX_ATTEMPT_TIMEOUT_S = 60 # 无进展时单模型最多等 1 分钟
|
|
33
|
+
DEFAULT_STREAM_ACTIVITY_TIMEOUT_S = 180 # 流式 CLI 的活动延长窗口
|
|
33
34
|
|
|
34
35
|
_BASH_CANDIDATES = [
|
|
35
36
|
r"D:\Git\usr\bin\bash.exe",
|
|
@@ -237,9 +238,13 @@ def _pipe_reader(stream, chunks, log_fh, stamp=None):
|
|
|
237
238
|
b = stream.read1(65536)
|
|
238
239
|
if not b:
|
|
239
240
|
break
|
|
240
|
-
chunks.append(b)
|
|
241
|
-
if stamp is not None:
|
|
242
|
-
stamp[0] = time.time()
|
|
241
|
+
chunks.append(b)
|
|
242
|
+
if stamp is not None:
|
|
243
|
+
stamp[0] = time.time()
|
|
244
|
+
if len(stamp) > 1:
|
|
245
|
+
stamp[1] = True
|
|
246
|
+
if len(stamp) > 2:
|
|
247
|
+
stamp[2] = time.monotonic()
|
|
243
248
|
if log_fh:
|
|
244
249
|
try:
|
|
245
250
|
log_fh.write(b)
|
|
@@ -601,13 +606,17 @@ def _normalize_abort_markers(value):
|
|
|
601
606
|
def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
602
607
|
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
|
|
603
608
|
stall_timeout=0, repeat_abort=None, deadline=None,
|
|
604
|
-
abort_markers=None):
|
|
609
|
+
abort_markers=None, activity_timeout=0):
|
|
605
610
|
"""通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
|
|
606
611
|
|
|
607
|
-
stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
|
|
608
|
-
新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
|
|
609
|
-
持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
|
|
610
|
-
开了会把正常长任务误杀。
|
|
612
|
+
stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
|
|
613
|
+
新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
|
|
614
|
+
持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
|
|
615
|
+
开了会把正常长任务误杀。
|
|
616
|
+
|
|
617
|
+
activity_timeout:基础 timeout 到期后,最近仍有真实输出的流式 CLI 允许继续
|
|
618
|
+
运行的最长静默窗口(秒,0=关闭)。它独立于 stall_timeout,避免环境把
|
|
619
|
+
停滞看门狗设为 0 后,Claude/Codex 又在基础超时处被硬杀;总延长仍有上限。
|
|
611
620
|
|
|
612
621
|
repeat_abort:可选 (marker, count),也接受多个 ``(marker, count)``;同一
|
|
613
622
|
致命错误达到次数即提前杀树,处理 CLI 自身不断打印重连消息、因此永远
|
|
@@ -636,6 +645,10 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
636
645
|
timeout = max(0.0, float(timeout))
|
|
637
646
|
except (TypeError, ValueError):
|
|
638
647
|
timeout = float(DEFAULT_TIMEOUT)
|
|
648
|
+
try:
|
|
649
|
+
activity_timeout = max(0.0, float(activity_timeout))
|
|
650
|
+
except (TypeError, ValueError):
|
|
651
|
+
activity_timeout = 0.0
|
|
639
652
|
if deadline is not None and time.monotonic() >= deadline:
|
|
640
653
|
return {"ok": False, "exit_code": None, "stdout": "",
|
|
641
654
|
"stderr": "[任务总时限已到,未启动子进程]", "duration": 0.0,
|
|
@@ -679,7 +692,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
679
692
|
"cancelled": False, "timed_out": False, "stalled": False,
|
|
680
693
|
"deadline_exceeded": False, "abort_marker": None}
|
|
681
694
|
out_chunks, err_chunks = [], []
|
|
682
|
-
stamp = [time.time()]
|
|
695
|
+
stamp = [time.time(), False, time.monotonic()] # [墙钟、是否有输出、单调时钟]
|
|
683
696
|
t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh, stamp), daemon=True)
|
|
684
697
|
t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh, stamp), daemon=True)
|
|
685
698
|
t_out.start()
|
|
@@ -696,7 +709,8 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
696
709
|
except Exception:
|
|
697
710
|
pass
|
|
698
711
|
threading.Thread(target=_feed, daemon=True).start()
|
|
699
|
-
start = time.time()
|
|
712
|
+
start = time.time()
|
|
713
|
+
start_mono = time.monotonic()
|
|
700
714
|
cancelled = timed_out = stalled = repeat_aborted = False
|
|
701
715
|
deadline_exceeded = False
|
|
702
716
|
abort_marker = None
|
|
@@ -717,11 +731,14 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
717
731
|
_kill_tree(proc.pid)
|
|
718
732
|
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
719
733
|
break
|
|
720
|
-
|
|
734
|
+
elapsed = time.monotonic() - start_mono
|
|
735
|
+
if elapsed > timeout:
|
|
721
736
|
# Claude/Codex 的工具循环可能持续吐 stream-json 事件而迟迟
|
|
722
|
-
# 不给最终 result
|
|
723
|
-
#
|
|
724
|
-
active =
|
|
737
|
+
# 不给最终 result。活动延长与 stall 看门狗分离:无输出的挂死
|
|
738
|
+
# 仍按原单模型 timeout 快速换将,收到过输出则最多再给一个活动窗口。
|
|
739
|
+
active = (activity_timeout > 0 and stamp[1]
|
|
740
|
+
and time.monotonic() - stamp[2] < activity_timeout
|
|
741
|
+
and elapsed < timeout + activity_timeout)
|
|
725
742
|
if not active:
|
|
726
743
|
timed_out = True
|
|
727
744
|
_kill_tree(proc.pid)
|
|
@@ -782,12 +799,16 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
782
799
|
stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
|
|
783
800
|
elif timed_out:
|
|
784
801
|
stderr += "\n[超时 %ss,已终止进程树]" % timeout
|
|
785
|
-
return {
|
|
786
|
-
"ok": exit_code == 0 and not cancelled and not timed_out,
|
|
787
|
-
"exit_code": exit_code, "stdout": stdout, "stderr": stderr,
|
|
802
|
+
return {
|
|
803
|
+
"ok": exit_code == 0 and not cancelled and not timed_out,
|
|
804
|
+
"exit_code": exit_code, "stdout": stdout, "stderr": stderr,
|
|
788
805
|
"duration": duration, "cancelled": cancelled, "timed_out": timed_out,
|
|
789
806
|
"stalled": stalled, "deadline_exceeded": deadline_exceeded,
|
|
790
807
|
"abort_marker": abort_marker,
|
|
808
|
+
"activity_extended": bool(activity_timeout and stamp[1]
|
|
809
|
+
and duration > timeout),
|
|
810
|
+
"activity_timeout": activity_timeout,
|
|
811
|
+
"last_output_age": max(0.0, time.time() - stamp[0]),
|
|
791
812
|
}
|
|
792
813
|
finally:
|
|
793
814
|
if log_fh:
|
|
@@ -975,15 +996,33 @@ def _transient_error(err):
|
|
|
975
996
|
|
|
976
997
|
# 欠费/配额/限流耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
|
|
977
998
|
# 与 modelhub._QUOTA_HINTS 同源,这里独立一份避免 core 模块间循环依赖。
|
|
978
|
-
_QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
979
|
-
"payment required", "402", "欠费", "余额", "额度", "exceeded",
|
|
980
|
-
"too many requests", "rate limit", "rate_limit", "http 429",
|
|
981
|
-
"并发", "超过限")
|
|
999
|
+
_QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
1000
|
+
"payment required", "402", "欠费", "余额", "额度", "exceeded",
|
|
1001
|
+
"too many requests", "rate limit", "rate_limit", "http 429",
|
|
1002
|
+
"并发", "超过限")
|
|
982
1003
|
|
|
983
1004
|
|
|
984
|
-
def _quota_error(err):
|
|
985
|
-
err = (err or "").lower()
|
|
986
|
-
return any(k in err for k in _QUOTA)
|
|
1005
|
+
def _quota_error(err):
|
|
1006
|
+
err = (err or "").lower()
|
|
1007
|
+
return any(k in err for k in _QUOTA)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
_AUTH = ("invalid api key", "invalid_api_key", "api key is invalid",
|
|
1011
|
+
"incorrect api key", "unauthorized", "unauthorised",
|
|
1012
|
+
"authentication failed", "authentication error", "authentication_error",
|
|
1013
|
+
"invalid token", "invalid_token", "http 401", "status code 401")
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _auth_error(err):
|
|
1017
|
+
"""只识别明确的凭据拒绝,不把一般模型权限错误(403)当成 KEY 错。"""
|
|
1018
|
+
err = (err or "").lower()
|
|
1019
|
+
return any(k in err for k in _AUTH)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _attempt_credential(att):
|
|
1023
|
+
"""绑定链中凭据的非敏感身份;不把密钥值写入日志或尝试记录。"""
|
|
1024
|
+
return (str((att or {}).get("provider_id") or ""),
|
|
1025
|
+
str((att or {}).get("key_id") or ""))
|
|
987
1026
|
|
|
988
1027
|
|
|
989
1028
|
# 限流(429)专项:与一般瞬态不同,它是「等一个窗口就常能自愈」的病——
|
|
@@ -1044,10 +1083,11 @@ def _rate_limited(err):
|
|
|
1044
1083
|
|
|
1045
1084
|
|
|
1046
1085
|
# 审计日志错误行探针:只认强信号,避免把回显提示词里的普通词当错误
|
|
1047
|
-
_LOG_ERR_HINT = re.compile(
|
|
1048
|
-
r"(429|402|rate[_ ]?limit|quota|余额|欠费|insufficient|billing|arrears"
|
|
1049
|
-
r"|
|
|
1050
|
-
|
|
1086
|
+
_LOG_ERR_HINT = re.compile(
|
|
1087
|
+
r"(429|402|rate[_ ]?limit|quota|余额|欠费|insufficient|billing|arrears"
|
|
1088
|
+
r"|401|unauthorized|unauthorised|invalid[_ ]api[_ ]key|authentication[_ ]error"
|
|
1089
|
+
r"|ENOTFOUND|ECONNREFUSED|failed to run prompt|api_error|overloaded)",
|
|
1090
|
+
re.I)
|
|
1051
1091
|
|
|
1052
1092
|
|
|
1053
1093
|
def _augment_error_from_log(out, log_path, limit=300):
|
|
@@ -1359,7 +1399,9 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
1359
1399
|
# simple generic adapters and test shims that read stdin. CLI tools
|
|
1360
1400
|
# that intentionally use argv still receive the bounded file hint.
|
|
1361
1401
|
if kind == "generic" and stdin_text is None and len(tmpl) > 1:
|
|
1362
|
-
|
|
1402
|
+
# 模板明确将提示词放进 argv 时,原文只保存在文件里;同时再
|
|
1403
|
+
# 写入 stdin 会让部分包装器重复读取,也无法解决 Windows 命令行上限。
|
|
1404
|
+
stdin_text = None
|
|
1363
1405
|
tmp_files.append(pf)
|
|
1364
1406
|
return argv, stdin_text, prompt, tmp_files
|
|
1365
1407
|
|
|
@@ -1384,15 +1426,23 @@ def _check_approval(agent):
|
|
|
1384
1426
|
return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
|
|
1385
1427
|
|
|
1386
1428
|
|
|
1387
|
-
def _stall_timeout(env_name, default):
|
|
1429
|
+
def _stall_timeout(env_name, default):
|
|
1388
1430
|
"""停滞看门狗秒数:stdout/stderr 持续无新输出超过该时长 → 判卡死提前杀,
|
|
1389
1431
|
错误按超时归类走既有换模型/换将链路。只对事件流持续流动的 CLI 启用
|
|
1390
1432
|
(codex JSONL / claude stream-json);qwen/opencode/generic 结束才一次性
|
|
1391
1433
|
输出,开了会误杀正常长任务,仍靠总超时兜底。env 可调,0=关闭。"""
|
|
1392
1434
|
try:
|
|
1393
1435
|
return max(0, int(os.environ.get(env_name, str(default))))
|
|
1394
|
-
except Exception:
|
|
1395
|
-
return default
|
|
1436
|
+
except Exception:
|
|
1437
|
+
return default
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _activity_timeout(env_name, default=DEFAULT_STREAM_ACTIVITY_TIMEOUT_S):
|
|
1441
|
+
"""流式 CLI 的活动延长窗口,独立于停滞看门狗配置。"""
|
|
1442
|
+
try:
|
|
1443
|
+
return max(0, int(os.environ.get(env_name, str(default))))
|
|
1444
|
+
except Exception:
|
|
1445
|
+
return default
|
|
1396
1446
|
|
|
1397
1447
|
|
|
1398
1448
|
def _max_model_attempts(has_deadline=False):
|
|
@@ -1471,18 +1521,21 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1471
1521
|
"tokens": 0, "usage": None, "error": repo_issue,
|
|
1472
1522
|
"error_code": ErrorCode.VENDOR_ERROR, "raw": None,
|
|
1473
1523
|
"kind": kind, "model": agent.get("model"), "attempts": []}
|
|
1474
|
-
if kind == "codex":
|
|
1475
|
-
stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1524
|
+
if kind == "codex":
|
|
1525
|
+
stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
|
|
1526
|
+
activity_t = _activity_timeout("TUTTI_CODEX_ACTIVITY_TIMEOUT")
|
|
1527
|
+
elif kind == "claude":
|
|
1528
|
+
stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
|
|
1529
|
+
activity_t = _activity_timeout("TUTTI_CLAUDE_ACTIVITY_TIMEOUT")
|
|
1530
|
+
else:
|
|
1479
1531
|
# 数据驱动:catalog orch.stall_timeout_s——给 kimi/qwen 这类评审用
|
|
1480
1532
|
# CLI 配置后,静默挂死即杀(qwen 900s:2026-09-22 MCP 收尾死锁案);
|
|
1481
1533
|
# mimo 等结束才一次性输出的仍留 0(开了必误杀),靠总超时兜底
|
|
1482
1534
|
try:
|
|
1483
1535
|
stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
|
|
1484
|
-
except Exception:
|
|
1485
|
-
stall_t = 0
|
|
1536
|
+
except Exception:
|
|
1537
|
+
stall_t = 0
|
|
1538
|
+
activity_t = 0
|
|
1486
1539
|
# 5G:approval NEVER 一线(无人值守不静默降级)
|
|
1487
1540
|
ok, reason = _check_approval(agent)
|
|
1488
1541
|
if not ok:
|
|
@@ -1536,7 +1589,22 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1536
1589
|
grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
|
|
1537
1590
|
timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
|
|
1538
1591
|
skipped_upstreams = set()
|
|
1592
|
+
auth_failed_credentials = set()
|
|
1539
1593
|
for ai, att in enumerate(attempts):
|
|
1594
|
+
credential = _attempt_credential(att)
|
|
1595
|
+
if credential in auth_failed_credentials:
|
|
1596
|
+
attempt_history.append({
|
|
1597
|
+
"model": att.get("model") or "",
|
|
1598
|
+
"provider_id": att.get("provider_id") or "",
|
|
1599
|
+
"provider": ((att.get("provider") or {}).get("name", "")
|
|
1600
|
+
if isinstance(att.get("provider"), dict) else ""),
|
|
1601
|
+
"kind": kind, "ok": False, "skipped": True,
|
|
1602
|
+
"error_code": ErrorCode.VENDOR_ERROR,
|
|
1603
|
+
"error": "同一凭据已被认证拒绝,跳过该凭据的其他模型",
|
|
1604
|
+
})
|
|
1605
|
+
if out is not None:
|
|
1606
|
+
out["attempts"] = list(attempt_history)
|
|
1607
|
+
continue
|
|
1540
1608
|
upstream = _attempt_upstream(att)
|
|
1541
1609
|
if upstream in skipped_upstreams:
|
|
1542
1610
|
continue
|
|
@@ -1582,6 +1650,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1582
1650
|
timeout=min(timeout, remaining), deadline=deadline,
|
|
1583
1651
|
cancel_event=cancel_event,
|
|
1584
1652
|
log_path=log_path, stall_timeout=stall_t,
|
|
1653
|
+
activity_timeout=activity_t,
|
|
1585
1654
|
repeat_abort=repeat_guard,
|
|
1586
1655
|
abort_markers=stream_abort_markers)
|
|
1587
1656
|
finally:
|
|
@@ -1751,7 +1820,13 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1751
1820
|
attempts.append(retry)
|
|
1752
1821
|
continue
|
|
1753
1822
|
return out
|
|
1754
|
-
|
|
1823
|
+
if _auth_error(out.get("error")):
|
|
1824
|
+
auth_failed_credentials.add(credential)
|
|
1825
|
+
if not any(_attempt_credential(candidate) not in auth_failed_credentials
|
|
1826
|
+
for candidate in attempts[ai + 1:]):
|
|
1827
|
+
return out
|
|
1828
|
+
continue
|
|
1829
|
+
# 换将闸门:什么失败值得烧链上下一个候选。
|
|
1755
1830
|
# · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
|
|
1756
1831
|
# · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
|
|
1757
1832
|
# 2026-09-23 夜班实案:旧逻辑里超时能否换将全赌输出尾部碰巧含有
|
|
@@ -1760,10 +1835,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1760
1835
|
# 换异上游候选常常就活;判死则整 run 白烧(同案:gemini 拒答后
|
|
1761
1836
|
# 链上云知声候选从未被尝试)。
|
|
1762
1837
|
raw = out.get("raw") or {}
|
|
1763
|
-
if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
|
|
1764
|
-
or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
|
|
1765
|
-
or _refusal_error(out.get("error"))
|
|
1766
|
-
or raw.get("timed_out"))):
|
|
1838
|
+
if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
|
|
1839
|
+
or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
|
|
1840
|
+
or _refusal_error(out.get("error"))
|
|
1841
|
+
or raw.get("timed_out"))):
|
|
1767
1842
|
return out # 取消/解析失败等真终态不降级
|
|
1768
1843
|
# 同一上游的模型连续两次超时就收手——
|
|
1769
1844
|
# 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
|
package/app/core/zentao.py
CHANGED
|
@@ -1965,6 +1965,17 @@ def save_config(patch):
|
|
|
1965
1965
|
return view()["config"]
|
|
1966
1966
|
|
|
1967
1967
|
|
|
1968
|
+
def bug_web_base(cfg=None):
|
|
1969
|
+
"""给前端拼 bug 详情页用的 web 根:探测出的有效地基(含自动补的 /zentao 子路径)
|
|
1970
|
+
优先,没探测过回落用户填的原始地址。返回 stripped 根或空串。"""
|
|
1971
|
+
c = dict(cfg) if cfg else _cfg()
|
|
1972
|
+
hit = resolved_base_url(c.get("base_url"))
|
|
1973
|
+
if hit:
|
|
1974
|
+
return hit
|
|
1975
|
+
b = re.sub(r"/api\.php/v1$", "", str(c.get("base_url") or "").strip().rstrip("/"))
|
|
1976
|
+
return b if b.startswith(("http://", "https://")) else ""
|
|
1977
|
+
|
|
1978
|
+
|
|
1968
1979
|
def view():
|
|
1969
1980
|
"""前端视图:配置脱敏(password 只回是否已设)+ claims 列表(新在前)。"""
|
|
1970
1981
|
_ensure_loaded()
|
|
@@ -1973,9 +1984,11 @@ def view():
|
|
|
1973
1984
|
has_pw = bool(cfg.get("password"))
|
|
1974
1985
|
cfg["password"] = ""
|
|
1975
1986
|
cfg["has_password"] = has_pw
|
|
1987
|
+
bug_base = bug_web_base(cfg)
|
|
1976
1988
|
claims = sorted(_STATE["claims"].values(),
|
|
1977
1989
|
key=lambda c: str(c.get("claimed_at") or ""), reverse=True)
|
|
1978
1990
|
return {"config": cfg,
|
|
1991
|
+
"bug_base": bug_base,
|
|
1979
1992
|
"claims": [dict(c) for c in claims],
|
|
1980
1993
|
"last_scan": _STATE.get("last_scan") or "",
|
|
1981
1994
|
"next_scan": _STATE.get("next_scan") or "",
|
package/app/main.py
CHANGED
|
@@ -367,6 +367,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
367
367
|
if m:
|
|
368
368
|
run = store.get_run(m.group(1))
|
|
369
369
|
return self._json(200, {"run": run}) if run else self._json(404, {"error": "not found"})
|
|
370
|
+
m = re.match(r"^/api/runs/([^/]+)/messages$", path)
|
|
371
|
+
if m:
|
|
372
|
+
run = store.get_run(m.group(1))
|
|
373
|
+
if not run:
|
|
374
|
+
return self._json(404, {"error": "not found"})
|
|
375
|
+
return self._json(200, {"messages": run.get("messages") or []})
|
|
370
376
|
m = re.match(r"^/api/runs/([^/]+)/report$", path)
|
|
371
377
|
if m:
|
|
372
378
|
run = store.get_run(m.group(1))
|
|
@@ -419,6 +425,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
419
425
|
if m:
|
|
420
426
|
# 任务检查器(右缘停靠列)专用:轻量聚合、可轮询,不带 diff 文本
|
|
421
427
|
return self._api_task_side(m.group(1))
|
|
428
|
+
m = re.match(r"^/api/tasks/([^/]+)/detail$", path)
|
|
429
|
+
if m:
|
|
430
|
+
task = store.get_task(m.group(1))
|
|
431
|
+
return self._json(200, {"task": task}) if task else self._json(404, {"error": "not found"})
|
|
422
432
|
m = re.match(r"^/api/tasks/([^/]+)/runs$", path)
|
|
423
433
|
if m:
|
|
424
434
|
# 任务级详情用:该任务全部 run(含 steps),不受前端 run 窗口限制
|
|
@@ -2365,16 +2375,20 @@ def _state_payload(client_id="", ver=None):
|
|
|
2365
2375
|
agents = registry.effective_agents(catalog.load(), manager.detect_all())
|
|
2366
2376
|
if ver is None:
|
|
2367
2377
|
ver = store.state_version()
|
|
2378
|
+
runs = store.list_runs(40)
|
|
2379
|
+
latest = store.latest_run_by_task()
|
|
2368
2380
|
return {
|
|
2369
2381
|
"v": ver,
|
|
2370
2382
|
"agents": agents,
|
|
2371
2383
|
"tasks": store.list_tasks(30, archived=False),
|
|
2372
|
-
|
|
2384
|
+
# 归档任务默认隐藏;目标/上下文可能很大,只有查看或恢复时再按需读取。
|
|
2385
|
+
"archived_tasks": [_state_task_summary(t) for t in store.list_tasks(30, archived=True)],
|
|
2373
2386
|
# 每个任务的最近一次运行(不受 runs 窗口限制):侧栏靠它展示各任务真实近况
|
|
2374
|
-
"task_latest":
|
|
2387
|
+
"task_latest": {tid: _state_run_summary(run) for tid, run in latest.items()},
|
|
2375
2388
|
# 每个任务的运行次数/步骤总数(全量):侧栏「查看全部」的计数来源
|
|
2376
2389
|
"task_stats": store.task_run_stats(),
|
|
2377
|
-
|
|
2390
|
+
# 列表与 SSE 只带摘要;日志、模型输出、计划、消息正文等走详情 API。
|
|
2391
|
+
"runs": [_state_run_summary(run) for run in runs],
|
|
2378
2392
|
"control": remote.control_view(client_id),
|
|
2379
2393
|
# 供应商健康/告警(顶栏横幅数据源;有告警时 bump_state 会推给所有端)
|
|
2380
2394
|
"health": health.snapshot(),
|
|
@@ -2383,6 +2397,32 @@ def _state_payload(client_id="", ver=None):
|
|
|
2383
2397
|
}
|
|
2384
2398
|
|
|
2385
2399
|
|
|
2400
|
+
def _state_task_summary(task):
|
|
2401
|
+
"""归档任务的最小索引;大字段由 /api/tasks/<id>/detail 按需读取。"""
|
|
2402
|
+
fields = ("id", "title", "type", "engine", "workdir", "status",
|
|
2403
|
+
"created_at", "archived", "git_state", "serial")
|
|
2404
|
+
return {key: task[key] for key in fields if key in task}
|
|
2405
|
+
|
|
2406
|
+
|
|
2407
|
+
def _state_run_summary(run):
|
|
2408
|
+
"""列表/SSE 投影,不重复携带运行详情中的大字段。"""
|
|
2409
|
+
fields = ("id", "task_id", "kind", "op", "title", "status", "created_at",
|
|
2410
|
+
"started_at", "ended_at", "summary", "error", "paused", "direct_session",
|
|
2411
|
+
"auto_resumes", "resume_enqueue_at", "cost_usd", "tokens")
|
|
2412
|
+
summary = {key: run[key] for key in fields if key in run}
|
|
2413
|
+
summary["step_count"] = len(run.get("steps") or [])
|
|
2414
|
+
messages = run.get("messages") or []
|
|
2415
|
+
summary["message_count"] = len(messages)
|
|
2416
|
+
summary["pending_message_count"] = sum(1 for message in messages if not message.get("consumed"))
|
|
2417
|
+
verdict = run.get("verdict") or {}
|
|
2418
|
+
if isinstance(verdict, dict):
|
|
2419
|
+
checks = ("pass", "publishable", "verify_pass", "review_pass")
|
|
2420
|
+
compact = {key: verdict[key] for key in checks if key in verdict}
|
|
2421
|
+
if compact:
|
|
2422
|
+
summary["verdict"] = compact
|
|
2423
|
+
return summary
|
|
2424
|
+
|
|
2425
|
+
|
|
2386
2426
|
class ThreadedServer(ThreadingHTTPServer):
|
|
2387
2427
|
"""Windows 下 SO_REUSEADDR 允许两个进程同时 LISTEN 同一端口(请求随机
|
|
2388
2428
|
分发到其中一个——服务"时好时坏"的根源)。独占锁让第二个实例在这里
|