codebee 0.1.54 → 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 +13 -0
- package/README.md +3 -4
- package/app/core/builtin_agent.py +67 -26
- package/app/core/jobs.py +23 -4
- package/app/core/knowledge.py +26 -4
- package/app/core/modelhub.py +50 -5
- package/app/core/pipeline.py +156 -66
- package/app/core/planner.py +27 -13
- package/app/core/runner.py +398 -121
- package/app/core/store.py +64 -27
- package/app/core/zentao.py +39 -6
- package/app/main.py +43 -3
- package/app/ui/app.js +289 -56
- package/app/ui/board.css +616 -258
- package/app/ui/board.html +119 -41
- package/app/ui/board.js +60 -14
- package/app/ui/i18n.js +2699 -2659
- package/app/ui/index.html +2 -0
- package/app/ui/style.css +8 -6
- package/package.json +1 -1
package/app/core/runner.py
CHANGED
|
@@ -29,7 +29,8 @@ from .error_codes import ErrorCode
|
|
|
29
29
|
# 置 0 则两边通用(remote.py 同款守卫)。
|
|
30
30
|
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
31
31
|
DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
|
|
32
|
-
MAX_ATTEMPT_TIMEOUT_S = 60 #
|
|
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)
|
|
@@ -559,27 +564,98 @@ def pretty_cli_log(text, max_event_chars=4000):
|
|
|
559
564
|
return "\n".join(out)
|
|
560
565
|
|
|
561
566
|
|
|
562
|
-
def
|
|
563
|
-
|
|
564
|
-
|
|
567
|
+
def _normalize_abort_markers(value):
|
|
568
|
+
"""把重复错误配置规范成 ``[(marker, count), ...]``。
|
|
569
|
+
|
|
570
|
+
旧调用方传的是单个 ``(marker, count)`` 元组,必须继续支持;新的
|
|
571
|
+
调用方可以传字典或元组列表,以便同一 CLI 同时对多种断流文案止损。
|
|
572
|
+
"""
|
|
573
|
+
if not value:
|
|
574
|
+
return []
|
|
575
|
+
if isinstance(value, str):
|
|
576
|
+
return [(value, 1)]
|
|
577
|
+
if isinstance(value, dict):
|
|
578
|
+
raw = list(value.items())
|
|
579
|
+
elif (isinstance(value, (tuple, list)) and len(value) == 2
|
|
580
|
+
and isinstance(value[0], str)):
|
|
581
|
+
raw = [value]
|
|
582
|
+
else:
|
|
583
|
+
try:
|
|
584
|
+
raw = list(value)
|
|
585
|
+
except TypeError:
|
|
586
|
+
return []
|
|
587
|
+
out = []
|
|
588
|
+
for item in raw:
|
|
589
|
+
if isinstance(item, str):
|
|
590
|
+
marker, limit = item, 1
|
|
591
|
+
else:
|
|
592
|
+
try:
|
|
593
|
+
marker, limit = item
|
|
594
|
+
except (TypeError, ValueError):
|
|
595
|
+
continue
|
|
596
|
+
marker = str(marker or "")
|
|
597
|
+
try:
|
|
598
|
+
limit = max(1, int(limit))
|
|
599
|
+
except (TypeError, ValueError):
|
|
600
|
+
continue
|
|
601
|
+
if marker:
|
|
602
|
+
out.append((marker, limit))
|
|
603
|
+
return out
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
607
|
+
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
|
|
608
|
+
stall_timeout=0, repeat_abort=None, deadline=None,
|
|
609
|
+
abort_markers=None, activity_timeout=0):
|
|
565
610
|
"""通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
|
|
566
611
|
|
|
567
|
-
stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
|
|
568
|
-
新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
|
|
569
|
-
持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
|
|
570
|
-
开了会把正常长任务误杀。
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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 又在基础超时处被硬杀;总延长仍有上限。
|
|
620
|
+
|
|
621
|
+
repeat_abort:可选 (marker, count),也接受多个 ``(marker, count)``;同一
|
|
622
|
+
致命错误达到次数即提前杀树,处理 CLI 自身不断打印重连消息、因此永远
|
|
623
|
+
触发不了静默看门狗的假运行。abort_markers 是兼容扩展,用于不改变旧
|
|
624
|
+
repeat_abort 参数形状的情况下追加多种一次性断流标记。
|
|
625
|
+
|
|
626
|
+
deadline:可选的 ``time.monotonic()`` 绝对截止时刻。它比 timeout 优先,
|
|
627
|
+
到点会杀掉进程树并返回 deadline_exceeded=True,调用方可把整个任务收口
|
|
628
|
+
为 timeout,而不是把它误记成普通供应商失败。
|
|
629
|
+
|
|
630
|
+
返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled,
|
|
631
|
+
deadline_exceeded, abort_marker}。
|
|
632
|
+
"""
|
|
577
633
|
if shell_cmd:
|
|
578
634
|
# shell 串的解析器随平台:重定向/引号语法两边通用,只是解释器不同
|
|
579
635
|
argv = ["cmd", "/c", shell_cmd] if os.name == "nt" else ["/bin/sh", "-c", shell_cmd]
|
|
580
|
-
if argv is None:
|
|
581
|
-
return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
|
|
582
|
-
"duration": 0.0, "cancelled": False, "timed_out": False,
|
|
636
|
+
if argv is None:
|
|
637
|
+
return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
|
|
638
|
+
"duration": 0.0, "cancelled": False, "timed_out": False,
|
|
639
|
+
"stalled": False, "deadline_exceeded": False, "abort_marker": None}
|
|
640
|
+
try:
|
|
641
|
+
deadline = float(deadline) if deadline is not None else None
|
|
642
|
+
except (TypeError, ValueError):
|
|
643
|
+
deadline = None
|
|
644
|
+
try:
|
|
645
|
+
timeout = max(0.0, float(timeout))
|
|
646
|
+
except (TypeError, ValueError):
|
|
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
|
|
652
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
653
|
+
return {"ok": False, "exit_code": None, "stdout": "",
|
|
654
|
+
"stderr": "[任务总时限已到,未启动子进程]", "duration": 0.0,
|
|
655
|
+
"cancelled": False, "timed_out": True, "stalled": False,
|
|
656
|
+
"deadline_exceeded": True, "abort_marker": None}
|
|
657
|
+
aborts = _normalize_abort_markers(repeat_abort)
|
|
658
|
+
aborts.extend(_normalize_abort_markers(abort_markers))
|
|
583
659
|
full_env = scrub_env(os.environ.copy(), mode="drop")
|
|
584
660
|
if env:
|
|
585
661
|
# 5A:env 关键字环境变量注入用户传入的 env(属于有意注入,例如模型 API key)
|
|
@@ -610,12 +686,13 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
610
686
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
611
687
|
creationflags=CREATE_NO_WINDOW,
|
|
612
688
|
start_new_session=(os.name != "nt")) # POSIX 需独立进程组供 killpg 杀树;Windows 忽略该参数
|
|
613
|
-
except Exception as e:
|
|
614
|
-
return {"ok": False, "exit_code": None, "stdout": "",
|
|
615
|
-
"stderr": "启动失败: %r" % e, "duration": 0.0,
|
|
616
|
-
"cancelled": False, "timed_out": False
|
|
689
|
+
except Exception as e:
|
|
690
|
+
return {"ok": False, "exit_code": None, "stdout": "",
|
|
691
|
+
"stderr": "启动失败: %r" % e, "duration": 0.0,
|
|
692
|
+
"cancelled": False, "timed_out": False, "stalled": False,
|
|
693
|
+
"deadline_exceeded": False, "abort_marker": None}
|
|
617
694
|
out_chunks, err_chunks = [], []
|
|
618
|
-
stamp = [time.time()]
|
|
695
|
+
stamp = [time.time(), False, time.monotonic()] # [墙钟、是否有输出、单调时钟]
|
|
619
696
|
t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh, stamp), daemon=True)
|
|
620
697
|
t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh, stamp), daemon=True)
|
|
621
698
|
t_out.start()
|
|
@@ -632,24 +709,41 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
632
709
|
except Exception:
|
|
633
710
|
pass
|
|
634
711
|
threading.Thread(target=_feed, daemon=True).start()
|
|
635
|
-
start = time.time()
|
|
636
|
-
|
|
637
|
-
|
|
712
|
+
start = time.time()
|
|
713
|
+
start_mono = time.monotonic()
|
|
714
|
+
cancelled = timed_out = stalled = repeat_aborted = False
|
|
715
|
+
deadline_exceeded = False
|
|
716
|
+
abort_marker = None
|
|
717
|
+
while True:
|
|
638
718
|
try:
|
|
639
719
|
proc.wait(timeout=0.4)
|
|
640
720
|
break
|
|
641
721
|
except subprocess.TimeoutExpired:
|
|
642
722
|
pass
|
|
643
|
-
if cancel_event is not None and cancel_event.is_set():
|
|
644
|
-
cancelled = True
|
|
645
|
-
_kill_tree(proc.pid)
|
|
646
|
-
_drain_streams(proc, t_out, t_err, timeout=10)
|
|
647
|
-
break
|
|
648
|
-
if time.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
723
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
724
|
+
cancelled = True
|
|
725
|
+
_kill_tree(proc.pid)
|
|
726
|
+
_drain_streams(proc, t_out, t_err, timeout=10)
|
|
727
|
+
break
|
|
728
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
729
|
+
deadline_exceeded = True
|
|
730
|
+
timed_out = True
|
|
731
|
+
_kill_tree(proc.pid)
|
|
732
|
+
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
733
|
+
break
|
|
734
|
+
elapsed = time.monotonic() - start_mono
|
|
735
|
+
if elapsed > timeout:
|
|
736
|
+
# Claude/Codex 的工具循环可能持续吐 stream-json 事件而迟迟
|
|
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)
|
|
742
|
+
if not active:
|
|
743
|
+
timed_out = True
|
|
744
|
+
_kill_tree(proc.pid)
|
|
745
|
+
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
746
|
+
break
|
|
653
747
|
if stall_timeout and time.time() - stamp[0] > stall_timeout:
|
|
654
748
|
# 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
|
|
655
749
|
# 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
|
|
@@ -658,19 +752,22 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
658
752
|
_kill_tree(proc.pid)
|
|
659
753
|
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
660
754
|
break
|
|
661
|
-
if
|
|
662
|
-
try:
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
755
|
+
if aborts:
|
|
756
|
+
try:
|
|
757
|
+
recent = decode_output(b"".join(out_chunks)[-8000:] +
|
|
758
|
+
b"\n" + b"".join(err_chunks)[-8000:])
|
|
759
|
+
for marker, limit in aborts:
|
|
760
|
+
if recent.count(marker) >= limit:
|
|
761
|
+
repeat_aborted = True
|
|
762
|
+
abort_marker = marker
|
|
763
|
+
timed_out = True
|
|
764
|
+
_kill_tree(proc.pid)
|
|
765
|
+
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
766
|
+
break
|
|
767
|
+
if repeat_aborted:
|
|
768
|
+
break
|
|
769
|
+
except (TypeError, ValueError):
|
|
770
|
+
pass
|
|
674
771
|
duration = round(time.time() - start, 1)
|
|
675
772
|
t_out.join(timeout=5)
|
|
676
773
|
t_err.join(timeout=5)
|
|
@@ -687,18 +784,32 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
687
784
|
pass
|
|
688
785
|
if cancelled:
|
|
689
786
|
stderr += "\n[已被用户取消]"
|
|
690
|
-
elif repeat_aborted:
|
|
691
|
-
|
|
787
|
+
elif repeat_aborted:
|
|
788
|
+
limit = next((n for m, n in aborts if m == abort_marker), "?")
|
|
789
|
+
if len(aborts) == 1 and abort_markers is None:
|
|
790
|
+
# 保留旧版 UI/测试已经依赖的文案;多标记配置使用更具体的
|
|
791
|
+
# 标记名,方便定位究竟是哪一种断流触发了止损。
|
|
792
|
+
stderr += "\n[同一网络错误重复 %s 次,已提前终止进程树]" % limit
|
|
793
|
+
else:
|
|
794
|
+
stderr += "\n[错误标记 %s 重复 %s 次,已提前终止进程树]" % (
|
|
795
|
+
abort_marker, limit)
|
|
796
|
+
elif deadline_exceeded:
|
|
797
|
+
stderr += "\n[任务总时限已到,已终止进程树]"
|
|
692
798
|
elif stalled:
|
|
693
799
|
stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
|
|
694
800
|
elif timed_out:
|
|
695
801
|
stderr += "\n[超时 %ss,已终止进程树]" % timeout
|
|
696
|
-
return {
|
|
697
|
-
"ok": exit_code == 0 and not cancelled and not timed_out,
|
|
698
|
-
"exit_code": exit_code, "stdout": stdout, "stderr": stderr,
|
|
699
|
-
"duration": duration, "cancelled": cancelled, "timed_out": timed_out,
|
|
700
|
-
"stalled": stalled,
|
|
701
|
-
|
|
802
|
+
return {
|
|
803
|
+
"ok": exit_code == 0 and not cancelled and not timed_out,
|
|
804
|
+
"exit_code": exit_code, "stdout": stdout, "stderr": stderr,
|
|
805
|
+
"duration": duration, "cancelled": cancelled, "timed_out": timed_out,
|
|
806
|
+
"stalled": stalled, "deadline_exceeded": deadline_exceeded,
|
|
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]),
|
|
812
|
+
}
|
|
702
813
|
finally:
|
|
703
814
|
if log_fh:
|
|
704
815
|
try:
|
|
@@ -869,7 +980,8 @@ _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
|
|
|
869
980
|
# 2026-09-20 连载评审实测:codex 网关断流(stream disconnected)
|
|
870
981
|
# 与 opencode 服务端 500(Unexpected server error)都是「重试/换将
|
|
871
982
|
# 就可能活」的瞬态病,旧表判成终态导致整链早死
|
|
872
|
-
"stream disconnected", "
|
|
983
|
+
"stream disconnected", "stream closed before response.completed",
|
|
984
|
+
"unexpected server error",
|
|
873
985
|
# 2026-09-22 mo-so 实案:claude 报 "Can't reach the API server —
|
|
874
986
|
# check your internet or DNS (ENOTFOUND)",一家端点解析不到不代表
|
|
875
987
|
# 跨厂商链上别家也不通,旧表判成终态直接跳去换将别的 CLI
|
|
@@ -884,15 +996,33 @@ def _transient_error(err):
|
|
|
884
996
|
|
|
885
997
|
# 欠费/配额/限流耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
|
|
886
998
|
# 与 modelhub._QUOTA_HINTS 同源,这里独立一份避免 core 模块间循环依赖。
|
|
887
|
-
_QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
888
|
-
"payment required", "402", "欠费", "余额", "额度", "exceeded",
|
|
889
|
-
"too many requests", "rate limit", "rate_limit", "http 429",
|
|
890
|
-
"并发", "超过限")
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
def _quota_error(err):
|
|
894
|
-
err = (err or "").lower()
|
|
895
|
-
return any(k in err for k in _QUOTA)
|
|
999
|
+
_QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
1000
|
+
"payment required", "402", "欠费", "余额", "额度", "exceeded",
|
|
1001
|
+
"too many requests", "rate limit", "rate_limit", "http 429",
|
|
1002
|
+
"并发", "超过限")
|
|
1003
|
+
|
|
1004
|
+
|
|
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 ""))
|
|
896
1026
|
|
|
897
1027
|
|
|
898
1028
|
# 限流(429)专项:与一般瞬态不同,它是「等一个窗口就常能自愈」的病——
|
|
@@ -953,10 +1083,11 @@ def _rate_limited(err):
|
|
|
953
1083
|
|
|
954
1084
|
|
|
955
1085
|
# 审计日志错误行探针:只认强信号,避免把回显提示词里的普通词当错误
|
|
956
|
-
_LOG_ERR_HINT = re.compile(
|
|
957
|
-
r"(429|402|rate[_ ]?limit|quota|余额|欠费|insufficient|billing|arrears"
|
|
958
|
-
r"|
|
|
959
|
-
|
|
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)
|
|
960
1091
|
|
|
961
1092
|
|
|
962
1093
|
def _augment_error_from_log(out, log_path, limit=300):
|
|
@@ -1109,7 +1240,11 @@ def _prompt_to_file(prompt, workdir):
|
|
|
1109
1240
|
return None
|
|
1110
1241
|
|
|
1111
1242
|
|
|
1112
|
-
|
|
1243
|
+
# Windows 上不少 CLI 先经过 cmd.exe/.cmd 垫片,实际安全上限接近 8191,
|
|
1244
|
+
# 而不是 CreateProcess 的理论 32767。留出命令、路径和环境参数余量,超过
|
|
1245
|
+
# 该值统一落盘,避免 MiMo/Kimi 评审在启动前直接报「命令行太长」。
|
|
1246
|
+
_ARGV_PROMPT_SAFE = 20000
|
|
1247
|
+
_ARGV_PROMPT_SHIM_SAFE = 6000
|
|
1113
1248
|
|
|
1114
1249
|
|
|
1115
1250
|
def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=None):
|
|
@@ -1247,14 +1382,27 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
1247
1382
|
# 超长时落盘临时文件、参数位换成读文件指令——评审/作者 CLI 非交互模式均带
|
|
1248
1383
|
# 读文件工具(kimi 0.43 实测可读),读不到的调用输出不可解析,由评审全挂
|
|
1249
1384
|
# 防线兜底判失败,绝不静默降级。
|
|
1250
|
-
|
|
1251
|
-
|
|
1385
|
+
prompt_safe = _ARGV_PROMPT_SAFE
|
|
1386
|
+
if argv:
|
|
1387
|
+
launcher = str(argv[0]).lower()
|
|
1388
|
+
if (launcher.endswith((".cmd", ".bat")) or
|
|
1389
|
+
os.path.basename(launcher) in ("npm", "npx", "pnpm", "yarn", "bun")):
|
|
1390
|
+
prompt_safe = _ARGV_PROMPT_SHIM_SAFE
|
|
1391
|
+
if argv is not None and stdin_text is None and len(prompt) >= prompt_safe \
|
|
1392
|
+
and argv.count(prompt) == 1:
|
|
1252
1393
|
pf = _prompt_to_file(prompt, workdir)
|
|
1253
|
-
if pf:
|
|
1254
|
-
argv[argv.index(prompt)] = (
|
|
1255
|
-
"[系统] 本次完整指令因命令行长度限制已写入文件:%s\n"
|
|
1256
|
-
"请先用读文件工具完整读取该文件,然后把文件内容当作你的任务指令执行。" % pf)
|
|
1257
|
-
|
|
1394
|
+
if pf:
|
|
1395
|
+
argv[argv.index(prompt)] = (
|
|
1396
|
+
"[系统] 本次完整指令因命令行长度限制已写入文件:%s\n"
|
|
1397
|
+
"请先用读文件工具完整读取该文件,然后把文件内容当作你的任务指令执行。" % pf)
|
|
1398
|
+
# Keep the original payload on stdin as a compatibility path for
|
|
1399
|
+
# simple generic adapters and test shims that read stdin. CLI tools
|
|
1400
|
+
# that intentionally use argv still receive the bounded file hint.
|
|
1401
|
+
if kind == "generic" and stdin_text is None and len(tmpl) > 1:
|
|
1402
|
+
# 模板明确将提示词放进 argv 时,原文只保存在文件里;同时再
|
|
1403
|
+
# 写入 stdin 会让部分包装器重复读取,也无法解决 Windows 命令行上限。
|
|
1404
|
+
stdin_text = None
|
|
1405
|
+
tmp_files.append(pf)
|
|
1258
1406
|
return argv, stdin_text, prompt, tmp_files
|
|
1259
1407
|
|
|
1260
1408
|
|
|
@@ -1278,20 +1426,50 @@ def _check_approval(agent):
|
|
|
1278
1426
|
return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
|
|
1279
1427
|
|
|
1280
1428
|
|
|
1281
|
-
def _stall_timeout(env_name, default):
|
|
1429
|
+
def _stall_timeout(env_name, default):
|
|
1282
1430
|
"""停滞看门狗秒数:stdout/stderr 持续无新输出超过该时长 → 判卡死提前杀,
|
|
1283
1431
|
错误按超时归类走既有换模型/换将链路。只对事件流持续流动的 CLI 启用
|
|
1284
1432
|
(codex JSONL / claude stream-json);qwen/opencode/generic 结束才一次性
|
|
1285
1433
|
输出,开了会误杀正常长任务,仍靠总超时兜底。env 可调,0=关闭。"""
|
|
1286
1434
|
try:
|
|
1287
1435
|
return max(0, int(os.environ.get(env_name, str(default))))
|
|
1288
|
-
except Exception:
|
|
1289
|
-
return default
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
def
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _max_model_attempts(has_deadline=False):
|
|
1449
|
+
"""返回一次 runner 调用允许消耗的候选数。
|
|
1450
|
+
|
|
1451
|
+
生产任务会传入统一 deadline,默认收敛到 3 个候选;没有 deadline 的
|
|
1452
|
+
旧式直接调用保留最多 4 个候选,以兼容已有的「同一上游两模型后换
|
|
1453
|
+
供应商」策略。运维可用环境变量进一步收紧/放宽,最少 1 个。
|
|
1454
|
+
"""
|
|
1455
|
+
default = 3 if has_deadline else 4
|
|
1456
|
+
try:
|
|
1457
|
+
value = int(os.environ.get("TUTTI_MAX_MODEL_ATTEMPTS", str(default)))
|
|
1458
|
+
return max(1, min(12, value))
|
|
1459
|
+
except (TypeError, ValueError):
|
|
1460
|
+
return default
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
_INVALID_MODEL_NAMES = frozenset(("auto", "default", "none"))
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _invalid_model(model):
|
|
1467
|
+
return str(model or "").strip().lower() in _INVALID_MODEL_NAMES
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
1471
|
+
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
|
|
1472
|
+
images=None, require_tools=False, deadline=None):
|
|
1295
1473
|
"""执行一次智能体调用,返回统一结构
|
|
1296
1474
|
{ok, text, json, cost_usd, tokens, error, error_code, raw}。
|
|
1297
1475
|
agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
|
|
@@ -1308,40 +1486,63 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1308
1486
|
失败不降级;同一上游超时两次即跳过该上游剩余模型。
|
|
1309
1487
|
|
|
1310
1488
|
5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout;
|
|
1311
|
-
|
|
1312
|
-
|
|
1489
|
+
单模型无进展时最多等 60 秒,持续 stream 活动交由 stall_timeout 看门狗
|
|
1490
|
+
收口。deadline 是
|
|
1491
|
+
任务共享的 ``time.monotonic()`` 绝对截止时刻;传入后所有模型、空响应
|
|
1492
|
+
重试和限流宽限共用同一剩余预算。
|
|
1493
|
+
"""
|
|
1313
1494
|
# 5E:catalog orch.timeout_ms 优先
|
|
1314
1495
|
orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
|
|
1315
1496
|
if orch_timeout_ms:
|
|
1316
1497
|
timeout = float(orch_timeout_ms) / 1000.0
|
|
1317
|
-
|
|
1498
|
+
try:
|
|
1499
|
+
timeout = float(timeout)
|
|
1500
|
+
except (TypeError, ValueError):
|
|
1501
|
+
timeout = float(DEFAULT_TIMEOUT)
|
|
1502
|
+
timeout = max(0.0, timeout)
|
|
1503
|
+
try:
|
|
1504
|
+
deadline = float(deadline) if deadline is not None else None
|
|
1505
|
+
except (TypeError, ValueError):
|
|
1506
|
+
deadline = None
|
|
1507
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1508
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
1509
|
+
"tokens": 0, "usage": None, "error": "任务总时限已到,未启动模型",
|
|
1510
|
+
"error_code": ErrorCode.TIMEOUT,
|
|
1511
|
+
"raw": {"exit_code": None, "timed_out": True,
|
|
1512
|
+
"deadline_exceeded": True, "duration": 0.0},
|
|
1513
|
+
"kind": agent.get("kind", "generic"), "model": None,
|
|
1514
|
+
"attempts": []}
|
|
1515
|
+
timeout = min(timeout, MAX_ATTEMPT_TIMEOUT_S)
|
|
1318
1516
|
kind = agent.get("kind", "generic")
|
|
1319
1517
|
if kind == "aider":
|
|
1320
1518
|
repo_issue = _git_repo_issue(workdir)
|
|
1321
1519
|
if repo_issue:
|
|
1322
1520
|
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
1323
|
-
"tokens": 0, "usage": None, "error": repo_issue,
|
|
1324
|
-
"error_code": ErrorCode.VENDOR_ERROR, "raw": None,
|
|
1325
|
-
"kind": kind, "model": agent.get("model")}
|
|
1326
|
-
if kind == "codex":
|
|
1327
|
-
stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1521
|
+
"tokens": 0, "usage": None, "error": repo_issue,
|
|
1522
|
+
"error_code": ErrorCode.VENDOR_ERROR, "raw": None,
|
|
1523
|
+
"kind": kind, "model": agent.get("model"), "attempts": []}
|
|
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:
|
|
1331
1531
|
# 数据驱动:catalog orch.stall_timeout_s——给 kimi/qwen 这类评审用
|
|
1332
1532
|
# CLI 配置后,静默挂死即杀(qwen 900s:2026-09-22 MCP 收尾死锁案);
|
|
1333
1533
|
# mimo 等结束才一次性输出的仍留 0(开了必误杀),靠总超时兜底
|
|
1334
1534
|
try:
|
|
1335
1535
|
stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
|
|
1336
|
-
except Exception:
|
|
1337
|
-
stall_t = 0
|
|
1536
|
+
except Exception:
|
|
1537
|
+
stall_t = 0
|
|
1538
|
+
activity_t = 0
|
|
1338
1539
|
# 5G:approval NEVER 一线(无人值守不静默降级)
|
|
1339
1540
|
ok, reason = _check_approval(agent)
|
|
1340
1541
|
if not ok:
|
|
1341
1542
|
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
|
|
1342
|
-
"usage": None, "error": reason,
|
|
1343
|
-
"error_code": ErrorCode.ENV_BLOCK,
|
|
1344
|
-
"raw": None, "kind": kind, "model": None}
|
|
1543
|
+
"usage": None, "error": reason,
|
|
1544
|
+
"error_code": ErrorCode.ENV_BLOCK,
|
|
1545
|
+
"raw": None, "kind": kind, "model": None, "attempts": []}
|
|
1345
1546
|
base_env = dict(agent.get("env") or {})
|
|
1346
1547
|
if kind == "claude":
|
|
1347
1548
|
bash = find_git_bash()
|
|
@@ -1354,19 +1555,63 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1354
1555
|
"usage": None,
|
|
1355
1556
|
"error": "该 CLI 未配置会话恢复(catalog orch.resume_argv_template),"
|
|
1356
1557
|
"无法在已有会话上继续",
|
|
1357
|
-
"error_code": ErrorCode.VENDOR_ERROR,
|
|
1358
|
-
"sid": "", "raw": None, "kind": kind, "model": None}
|
|
1359
|
-
|
|
1360
|
-
attempts = _resolve_attempts(agent)
|
|
1361
|
-
|
|
1362
|
-
|
|
1558
|
+
"error_code": ErrorCode.VENDOR_ERROR,
|
|
1559
|
+
"sid": "", "raw": None, "kind": kind, "model": None, "attempts": []}
|
|
1560
|
+
|
|
1561
|
+
attempts = _resolve_attempts(agent)
|
|
1562
|
+
# 启动前过滤明显的占位模型名。它们常由某个 CLI 的会话标题/默认配置
|
|
1563
|
+
# 泄漏进编排链;让子进程自己等待网关返回会把一个确定性配置错拖满
|
|
1564
|
+
# 多轮超时。非法项不消耗模型调用预算,后面的真实候选仍可接手。
|
|
1565
|
+
filtered_attempts = []
|
|
1566
|
+
preflight_failures = []
|
|
1567
|
+
for att in attempts:
|
|
1568
|
+
if _invalid_model(att.get("model")):
|
|
1569
|
+
preflight_failures.append({
|
|
1570
|
+
"model": att.get("model"),
|
|
1571
|
+
"provider_id": att.get("provider_id") or "",
|
|
1572
|
+
"provider": (att.get("provider") or {}).get("name", "")
|
|
1573
|
+
if isinstance(att.get("provider"), dict) else "",
|
|
1574
|
+
"ok": False, "skipped": True, "error":
|
|
1575
|
+
"无效模型名 %r,启动前跳过" % att.get("model"),
|
|
1576
|
+
})
|
|
1577
|
+
continue
|
|
1578
|
+
filtered_attempts.append(att)
|
|
1579
|
+
attempts = filtered_attempts[:_max_model_attempts(deadline is not None)]
|
|
1580
|
+
out = None
|
|
1581
|
+
attempt_history = list(preflight_failures)
|
|
1582
|
+
if not attempts:
|
|
1583
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
1584
|
+
"tokens": 0, "usage": None,
|
|
1585
|
+
"error": (preflight_failures[-1].get("error")
|
|
1586
|
+
if preflight_failures else "没有可尝试的模型"),
|
|
1587
|
+
"error_code": ErrorCode.VENDOR_ERROR, "raw": None,
|
|
1588
|
+
"kind": kind, "model": None, "attempts": attempt_history}
|
|
1589
|
+
grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
|
|
1363
1590
|
timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
|
|
1364
1591
|
skipped_upstreams = set()
|
|
1592
|
+
auth_failed_credentials = set()
|
|
1365
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
|
|
1366
1608
|
upstream = _attempt_upstream(att)
|
|
1367
1609
|
if upstream in skipped_upstreams:
|
|
1368
1610
|
continue
|
|
1369
|
-
|
|
1611
|
+
# 有任务 deadline 时,所有候选共享同一个截止时刻;没有 deadline
|
|
1612
|
+
# 的历史直接调用保留「每个候选最多 60 秒」的旧语义。
|
|
1613
|
+
model_deadline = (att.get("_deadline") or deadline or
|
|
1614
|
+
(time.monotonic() + timeout))
|
|
1370
1615
|
env = dict(base_env)
|
|
1371
1616
|
env.update(att["env"])
|
|
1372
1617
|
if kind == "codex":
|
|
@@ -1386,7 +1631,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1386
1631
|
if remaining <= 0:
|
|
1387
1632
|
res = {"ok": False, "exit_code": None, "stdout": "", "stderr": "",
|
|
1388
1633
|
"duration": timeout, "cancelled": False, "timed_out": True,
|
|
1389
|
-
"stalled": False
|
|
1634
|
+
"stalled": False,
|
|
1635
|
+
"deadline_exceeded": deadline is not None}
|
|
1390
1636
|
else:
|
|
1391
1637
|
argv, stdin_text, prompt_eff, tmp_files = _build_call(
|
|
1392
1638
|
eff_agent, kind, sid, readonly,
|
|
@@ -1396,10 +1642,17 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1396
1642
|
try:
|
|
1397
1643
|
repeat_guard = (("Reconnecting...", 2)
|
|
1398
1644
|
if kind == "codex" else None)
|
|
1645
|
+
stream_abort_markers = (
|
|
1646
|
+
(("stream disconnected", 1),
|
|
1647
|
+
("stream closed before response.completed", 1))
|
|
1648
|
+
if kind == "codex" else None)
|
|
1399
1649
|
res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
|
|
1400
|
-
timeout=min(timeout, remaining),
|
|
1650
|
+
timeout=min(timeout, remaining), deadline=deadline,
|
|
1651
|
+
cancel_event=cancel_event,
|
|
1401
1652
|
log_path=log_path, stall_timeout=stall_t,
|
|
1402
|
-
|
|
1653
|
+
activity_timeout=activity_t,
|
|
1654
|
+
repeat_abort=repeat_guard,
|
|
1655
|
+
abort_markers=stream_abort_markers)
|
|
1403
1656
|
finally:
|
|
1404
1657
|
# 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
|
|
1405
1658
|
for tf in tmp_files:
|
|
@@ -1424,7 +1677,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1424
1677
|
else "超时" if res["timed_out"]
|
|
1425
1678
|
else "取消" if res["cancelled"]
|
|
1426
1679
|
else "退出码 %s" % res["exit_code"])
|
|
1427
|
-
|
|
1680
|
+
if res.get("deadline_exceeded"):
|
|
1681
|
+
head = "任务总时限已到"
|
|
1682
|
+
out["error"] = head + (";stderr/stdout: " + tail if tail else "")
|
|
1428
1683
|
if kind == "codex":
|
|
1429
1684
|
fm = _codex_fail_msg(res["stdout"])
|
|
1430
1685
|
if fm:
|
|
@@ -1535,6 +1790,22 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1535
1790
|
out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
|
|
1536
1791
|
break
|
|
1537
1792
|
_report_key(att, out)
|
|
1793
|
+
raw = out.get("raw") or {}
|
|
1794
|
+
attempt_history.append({
|
|
1795
|
+
"model": out.get("model") or att.get("model") or "",
|
|
1796
|
+
"provider_id": out.get("provider_id") or att.get("provider_id") or "",
|
|
1797
|
+
"provider": ((out.get("provider") or {}).get("name", "")
|
|
1798
|
+
if isinstance(out.get("provider"), dict) else ""),
|
|
1799
|
+
"kind": kind,
|
|
1800
|
+
"ok": bool(out.get("ok")),
|
|
1801
|
+
"duration": raw.get("duration", 0.0),
|
|
1802
|
+
"timed_out": bool(raw.get("timed_out")),
|
|
1803
|
+
"deadline_exceeded": bool(raw.get("deadline_exceeded")),
|
|
1804
|
+
"abort_marker": raw.get("abort_marker"),
|
|
1805
|
+
"error_code": str(out.get("error_code") or ""),
|
|
1806
|
+
"error": (out.get("error") or "")[:500],
|
|
1807
|
+
})
|
|
1808
|
+
out["attempts"] = list(attempt_history)
|
|
1538
1809
|
if out["ok"] or ai == len(attempts) - 1:
|
|
1539
1810
|
remaining = model_deadline - time.monotonic()
|
|
1540
1811
|
if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
|
|
@@ -1549,7 +1820,13 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1549
1820
|
attempts.append(retry)
|
|
1550
1821
|
continue
|
|
1551
1822
|
return out
|
|
1552
|
-
|
|
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
|
+
# 换将闸门:什么失败值得烧链上下一个候选。
|
|
1553
1830
|
# · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
|
|
1554
1831
|
# · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
|
|
1555
1832
|
# 2026-09-23 夜班实案:旧逻辑里超时能否换将全赌输出尾部碰巧含有
|
|
@@ -1558,10 +1835,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1558
1835
|
# 换异上游候选常常就活;判死则整 run 白烧(同案:gemini 拒答后
|
|
1559
1836
|
# 链上云知声候选从未被尝试)。
|
|
1560
1837
|
raw = out.get("raw") or {}
|
|
1561
|
-
if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
|
|
1562
|
-
or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
|
|
1563
|
-
or _refusal_error(out.get("error"))
|
|
1564
|
-
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"))):
|
|
1565
1842
|
return out # 取消/解析失败等真终态不降级
|
|
1566
1843
|
# 同一上游的模型连续两次超时就收手——
|
|
1567
1844
|
# 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
|