codebee 0.1.4 → 0.1.6

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.
@@ -66,6 +66,10 @@ def _build_agent(entry):
66
66
  # 小时级 token 配额(可选,0/缺省=不限):路由时对本小时用量超标的
67
67
  # 智能体降权(munder-difflin 式配额感知),订阅型 CLI 不至于被单任务打爆
68
68
  "quota_tokens_per_hour": int(orch.get("quota_tokens_per_hour") or 0),
69
+ # 整包透传 orch:per-agent 运行时开关(timeout_ms 5E、stall_timeout_s
70
+ # 看门狗等)都在 runner 侧读取——此前只挑字段,catalog 上配的
71
+ # timeout_ms/stall 根本流不到 runner(2026-09-17 连载 c35 实测)
72
+ "orch": orch,
69
73
  }
70
74
 
71
75
 
@@ -14,9 +14,10 @@ MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
14
 
15
15
 
16
16
  def _binding_bonus(agent_id):
17
- """绑定链可用性加分/减分:链上有可用条目 +8,解析为空(回落 CLI 本机默认)
18
- -25。2026-09-16 实测:静态能力基线让配额烧干的 codex 永远压过健康备用 CLI
19
- 绑定空的 CLI 更是连用户配置的模型都没用上——先按「能不能按配置跑起来」校准。"""
17
+ """绑定链可用性加分/减分:链上有可用条目 +8,解析为空 -25。2026-09-16 实测:
18
+ 静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,绑定空的 CLI 更是连
19
+ 用户配置的模型都没用上——先按「能不能按配置跑起来」校准。2026-09-17
20
+ 空链步骤在 pipeline 直接判失败(不再静默回落本机默认),此处只管排序。"""
20
21
  try:
21
22
  from . import modelhub
22
23
  b = modelhub.resolve_binding(agent_id)
@@ -29,8 +30,13 @@ def _history_bonus(stats, agent_id, ttype):
29
30
  s = (stats.get(agent_id) or {}).get(ttype) or {}
30
31
  if not s.get("runs"):
31
32
  return 0.0
32
- win_rate = s["wins"] / s["runs"]
33
- return round(18.0 * win_rate + min(6.0, s["runs"]), 1) # 胜率为主,经验为辅
33
+ runs = s["runs"]
34
+ win_rate = s["wins"] / runs
35
+ # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
36
+ # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
37
+ # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
38
+ loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
39
+ return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
34
40
 
35
41
 
36
42
  def score(agent, role, ttype, stats=None):
@@ -44,11 +50,11 @@ def score(agent, role, ttype, stats=None):
44
50
  if bb > 0:
45
51
  btxt = ",绑定链可用(+%s)" % bb
46
52
  elif bb < 0:
47
- btxt = ",绑定链为空将回落 CLI 本机默认配置(%s)" % bb
53
+ btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
48
54
  hb = _history_bonus(stats, agent.get("id"), ttype)
49
55
  total = base + bb + hb
50
56
  hs = (stats.get(agent.get("id")) or {}).get(ttype)
51
- htxt = (",历史 %d/%d 胜(+%s)" % (hs["wins"], hs["runs"], hb)) if hs else ",无历史记录"
57
+ htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
52
58
  quota_txt = ""
53
59
  quota = int(agent.get("quota_tokens_per_hour") or 0)
54
60
  if quota > 0:
@@ -58,6 +58,28 @@ def resolve_command(command):
58
58
  return [path]
59
59
 
60
60
 
61
+ def _npm_shim_bypass(argv):
62
+ """generic 类 CLI 把提示词嵌进 argv;经 cmd /c 重解析时引号/特殊字符会把
63
+ 提示词截烂(kimi 实测:模型只看到 UTF-8 须知、任务本体整段丢失)。识别
64
+ 标准 npm .cmd 垫片(%_prog% + %dp0% 目标脚本),改 node 直启绕开 cmd。
65
+ 识别不出标准形态原样返回。"""
66
+ if len(argv) >= 3 and argv[0] == "cmd" and argv[1] == "/c" \
67
+ and str(argv[2]).lower().endswith((".cmd", ".bat")):
68
+ shim = argv[2]
69
+ try:
70
+ text = open(shim, encoding="utf-8", errors="replace").read()
71
+ except Exception:
72
+ return argv
73
+ m = re.search(r'%_prog%"\s+"?%dp0%(\\[^"\n]+?\.(?:mjs|js))"?', text)
74
+ if not m:
75
+ return argv
76
+ script = os.path.normpath(
77
+ os.path.join(os.path.dirname(shim), m.group(1).lstrip("\\")))
78
+ node = os.path.join(os.path.dirname(shim), "node.exe")
79
+ return [node if os.path.isfile(node) else "node", script] + list(argv[3:])
80
+ return argv
81
+
82
+
61
83
  def _kill_tree(pid):
62
84
  try:
63
85
  subprocess.run(
@@ -81,18 +103,21 @@ def _drain_streams(proc, t_out, t_err, *, timeout=10):
81
103
  t.join(timeout=remaining)
82
104
 
83
105
 
84
- def _pipe_reader(stream, chunks, log_fh):
106
+ def _pipe_reader(stream, chunks, log_fh, stamp=None):
85
107
  """持续读子进程管道并实时落盘。
86
108
 
87
109
  必须用 read1():BufferedReader.read(n) 会阻塞到凑满 n 字节或 EOF,
88
110
  在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
89
111
  read1() 只要有数据就返回,日志才能真正边跑边看。
112
+ stamp:共享 [最后输出时刻],停滞看门狗据此判定进程是否卡死。
90
113
  """
91
114
  while True:
92
115
  b = stream.read1(65536)
93
116
  if not b:
94
117
  break
95
118
  chunks.append(b)
119
+ if stamp is not None:
120
+ stamp[0] = time.time()
96
121
  if log_fh:
97
122
  try:
98
123
  log_fh.write(b)
@@ -258,16 +283,22 @@ def pretty_cli_log(text, max_event_chars=4000):
258
283
 
259
284
 
260
285
  def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
261
- timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None):
286
+ timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
287
+ stall_timeout=0):
262
288
  """通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
263
289
 
264
- 返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out}。
290
+ stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
291
+ 新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
292
+ 持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
293
+ 开了会把正常长任务误杀。
294
+
295
+ 返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
265
296
  """
266
297
  if shell_cmd:
267
298
  argv = ["cmd", "/c", shell_cmd]
268
299
  if argv is None:
269
300
  return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
270
- "duration": 0.0, "cancelled": False, "timed_out": False}
301
+ "duration": 0.0, "cancelled": False, "timed_out": False, "stalled": False}
271
302
  full_env = scrub_env(os.environ.copy(), mode="drop")
272
303
  if env:
273
304
  # 5A:env 关键字环境变量注入用户传入的 env(属于有意注入,例如模型 API key)
@@ -302,8 +333,9 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
302
333
  "stderr": "启动失败: %r" % e, "duration": 0.0,
303
334
  "cancelled": False, "timed_out": False}
304
335
  out_chunks, err_chunks = [], []
305
- t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh), daemon=True)
306
- t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh), daemon=True)
336
+ stamp = [time.time()] # 最后输出时刻(两条管道共同刷新)
337
+ t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh, stamp), daemon=True)
338
+ t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh, stamp), daemon=True)
307
339
  t_out.start()
308
340
  t_err.start()
309
341
  if stdin_text is not None:
@@ -319,7 +351,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
319
351
  pass
320
352
  threading.Thread(target=_feed, daemon=True).start()
321
353
  start = time.time()
322
- cancelled = timed_out = False
354
+ cancelled = timed_out = stalled = False
323
355
  while True:
324
356
  try:
325
357
  proc.wait(timeout=0.4)
@@ -336,6 +368,14 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
336
368
  _kill_tree(proc.pid)
337
369
  _drain_streams(proc, t_out, t_err, timeout=5)
338
370
  break
371
+ if stall_timeout and time.time() - stamp[0] > stall_timeout:
372
+ # 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
373
+ # 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
374
+ stalled = True
375
+ timed_out = True
376
+ _kill_tree(proc.pid)
377
+ _drain_streams(proc, t_out, t_err, timeout=5)
378
+ break
339
379
  duration = round(time.time() - start, 1)
340
380
  t_out.join(timeout=5)
341
381
  t_err.join(timeout=5)
@@ -350,12 +390,15 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
350
390
  pass
351
391
  if cancelled:
352
392
  stderr += "\n[已被用户取消]"
393
+ elif stalled:
394
+ stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
353
395
  elif timed_out:
354
396
  stderr += "\n[超时 %ss,已终止进程树]" % timeout
355
397
  return {
356
398
  "ok": exit_code == 0 and not cancelled and not timed_out,
357
399
  "exit_code": exit_code, "stdout": stdout, "stderr": stderr,
358
400
  "duration": duration, "cancelled": cancelled, "timed_out": timed_out,
401
+ "stalled": stalled,
359
402
  }
360
403
  finally:
361
404
  if log_fh:
@@ -405,6 +448,28 @@ def _parse_codex_jsonl(stdout):
405
448
  return text, usage, sid
406
449
 
407
450
 
451
+ def _codex_work_events(stdout):
452
+ """统计本轮事件流里真实「动了手」的事件数(命令执行/文件改动/MCP 工具)。
453
+
454
+ 实现步空转闸的客观判据:2026-09-17 mo-so 实现步模型一条命令都没发,纯口头
455
+ 谎报「进程执行被策略拦截」却 exit 0 + 一段像模像样的总结——text 看不出敷衍,
456
+ command_execution 计数为 0 才是硬证据。"""
457
+ n = 0
458
+ for line in stdout.splitlines():
459
+ line = line.strip()
460
+ if not line.startswith("{"):
461
+ continue
462
+ try:
463
+ ev = json.loads(line)
464
+ except Exception:
465
+ continue
466
+ if ev.get("type") == "item.completed" and (
467
+ ev.get("item") or {}).get("type") in (
468
+ "command_execution", "file_change", "mcp_tool_call"):
469
+ n += 1
470
+ return n
471
+
472
+
408
473
  def _codex_fail_msg(stdout):
409
474
  """从 JSONL 事件流提取终态失败消息;无失败返回 ""。
410
475
 
@@ -435,10 +500,21 @@ def _codex_fail_msg(stdout):
435
500
 
436
501
 
437
502
  def _parse_claude_json(stdout):
503
+ data = None
438
504
  try:
439
505
  data = json.loads(stdout)
440
506
  except Exception:
441
- return None
507
+ # stream-json:逐行事件取最后一条 type=="result"(与旧单 JSON 同构)
508
+ for line in stdout.splitlines():
509
+ line = line.strip()
510
+ if not line.startswith("{"):
511
+ continue
512
+ try:
513
+ ev = json.loads(line)
514
+ except Exception:
515
+ continue
516
+ if isinstance(ev, dict) and ev.get("type") == "result":
517
+ data = ev
442
518
  if not isinstance(data, dict):
443
519
  return None
444
520
  u = data.get("usage") or {}
@@ -478,6 +554,7 @@ def _model_flag(kind, model):
478
554
 
479
555
  _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
480
556
  "unavailable", "overloaded", "rate limit", "timeout", "timed out",
557
+ "输出停滞",
481
558
  # 2026-09-15 连载验收实测:网关故障形态远不止 HTTP 5xx——
482
559
  # Z.ai 报 "400 [1211] Unknown Model"(模型临时下架)、qwen 连本地
483
560
  # 端点 ECONNREFUSED、codex initialize 空响应,这些都被旧表判成
@@ -585,7 +662,21 @@ def _resolve_attempts(agent):
585
662
  for m in (models_to_try or [None])[:3]]
586
663
 
587
664
 
588
- def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
665
+ def _codex_sandbox(readonly):
666
+ """codex 沙箱档位。写步默认 danger-full-access(2026-09-17 用户拍板「默认给全部
667
+ 权限」):workspace-write 禁网+禁盘外写,模型偶发还会误判沙箱受限、谎报
668
+ 「进程执行被策略拦截」直接躺平(mo-so BUG#27596 实测,沙箱探针证明命令本可跑)。
669
+ 只读步(评审/规划)仍 read-only——评审者可写会污染 git diff 裁决。
670
+ env TUTTI_CODEX_SANDBOX 可钉死某档(workspace-write / read-only / danger-full-access)。"""
671
+ if readonly:
672
+ return "read-only"
673
+ env = os.environ.get("TUTTI_CODEX_SANDBOX", "").strip().lower()
674
+ if env in ("workspace-write", "read-only", "danger-full-access"):
675
+ return env
676
+ return "danger-full-access"
677
+
678
+
679
+ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=None):
589
680
  """构建一次 CLI 调用的 (argv, stdin_text, prompt)。model 可为 None=CLI 默认。
590
681
  images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。"""
591
682
  env = {}
@@ -602,14 +693,13 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
602
693
  "--skip-git-repo-check", "--json"]
603
694
  if model:
604
695
  argv += ["-m", model]
605
- argv += ["-c", 'sandbox_mode="%s"' %
606
- ("read-only" if readonly else "workspace-write")]
696
+ argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
607
697
  if cp:
608
698
  argv += _codex_provider_args(cp)
609
699
  else:
610
700
  argv = resolve_command(agent["command"]) + [
611
701
  "exec", "--skip-git-repo-check", "--json",
612
- "-s", "read-only" if readonly else "workspace-write"]
702
+ "-s", _codex_sandbox(readonly)]
613
703
  if model:
614
704
  argv += ["-m", model]
615
705
  if cp:
@@ -620,7 +710,10 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
620
710
  argv += ["-i", p]
621
711
  stdin_text = prompt
622
712
  elif kind == "claude":
623
- argv = resolve_command(agent["command"]) + ["-p", "--output-format", "json"]
713
+ # stream-json(2026-09-17 起):逐行事件边跑边吐——停滞看门狗有得看,
714
+ # 步骤日志实时可读;result 末行与旧单 JSON 同构,解析器双形态兼容
715
+ argv = resolve_command(agent["command"]) + [
716
+ "-p", "--output-format", "stream-json", "--verbose"]
624
717
  if sid:
625
718
  argv += ["--resume", sid]
626
719
  if model:
@@ -629,15 +722,27 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
629
722
  # 实测本机自定义网关在 -p 模式下工具续接会丢最终结果(用工具必空)。
630
723
  # 评审/规划所需的上下文已内嵌在提示词中,显式禁用工具最稳。
631
724
  prompt = "(请勿使用任何工具,直接依据下方内容回答。)\n\n" + prompt
632
- else:
725
+ elif os.environ.get("TUTTI_CLAUDE_PERMS", "").strip() == "acceptEdits":
633
726
  argv += ["--permission-mode", "acceptEdits"]
727
+ else:
728
+ # 无人值守 -p 下 acceptEdits 只自动放行改文件,Bash 一律权限拒绝 →
729
+ # 模型以为环境受限谎报「被策略拦截」躺平(同 codex 沙箱误判案)。
730
+ # 默认跳过全部权限检查(2026-09-17 用户拍板),env 可收回旧行为。
731
+ argv += ["--dangerously-skip-permissions"]
634
732
  stdin_text = prompt
635
733
  elif kind == "opencode":
636
734
  argv = resolve_command(agent["command"]) + ["run"]
735
+ if workdir:
736
+ # opencode 的 shell 不总跟进程 cwd 走(服务端复用时会落在旧项目根,
737
+ # 2026-09-17 mo-so 实测 agent 在 Temp 里全盘找代码)——显式钉死
738
+ argv += ["--dir", str(workdir)]
637
739
  if sid:
638
740
  argv += ["-s", sid] # 无头续会话:-s 指定会话 id(-c 只能接最近一次)
639
741
  if model:
640
- argv += ["--model", model]
742
+ # opencode 只认 provider/model 全名(裸名直接 UnknownError,实测);
743
+ # CodeBee 同步进其配置的 provider id 固定为 orch,故裸名补前缀。
744
+ # 绑定里已写全名(含 /)的尊重原值。
745
+ argv += ["--model", model if "/" in model else "orch/" + model]
641
746
  stdin_text = prompt # 无位置参数且 stdin 有内容时读 stdin
642
747
  elif kind == "qwen": # gemini-cli 系:无参数且 stdin 有内容时读 stdin
643
748
  argv = resolve_command(agent["command"])
@@ -657,6 +762,8 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
657
762
  tmpl = agent["resume_argv_template"]
658
763
  argv = resolve_command(agent["command"]) + [
659
764
  str(a).replace("{prompt}", prompt).replace("{session}", sid) for a in tmpl]
765
+ # 提示词在 argv 里 → 必须绕开 cmd 重解析(见 _npm_shim_bypass)
766
+ argv = _npm_shim_bypass(argv)
660
767
  if "{prompt}" not in tmpl:
661
768
  stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
662
769
  return argv, stdin_text, prompt
@@ -682,9 +789,20 @@ def _check_approval(agent):
682
789
  return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
683
790
 
684
791
 
792
+ def _stall_timeout(env_name, default):
793
+ """停滞看门狗秒数:stdout/stderr 持续无新输出超过该时长 → 判卡死提前杀,
794
+ 错误按超时归类走既有换模型/换将链路。只对事件流持续流动的 CLI 启用
795
+ (codex JSONL / claude stream-json);qwen/opencode/generic 结束才一次性
796
+ 输出,开了会误杀正常长任务,仍靠总超时兜底。env 可调,0=关闭。"""
797
+ try:
798
+ return max(0, int(os.environ.get(env_name, str(default))))
799
+ except Exception:
800
+ return default
801
+
802
+
685
803
  def run_agent(agent, prompt, workdir=None, readonly=True,
686
804
  timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
687
- images=None):
805
+ images=None, require_tools=False):
688
806
  """执行一次智能体调用,返回统一结构
689
807
  {ok, text, json, cost_usd, tokens, error, error_code, raw}。
690
808
  agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
@@ -692,6 +810,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
692
810
  generic: catalog orch.resume_argv_template)。
693
811
  images:任务图片附件的绝对路径,仅 codex 原生支持(-i);其余智能体靠
694
812
  提示词里的 _attachments/ 路径 + 自身读文件能力获取,无读图工具时静默忽略。
813
+ require_tools:实现步空转闸(仅 codex 事件流可判)——True 时本轮零
814
+ command_execution/file_change/mcp_tool_call 事件即判 VENDOR_REFUSAL,
815
+ 防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
695
816
 
696
817
  模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
697
818
  主模型 + 降级备选;瞬态错误才换下一条,取消/超时/解析失败不降级。
@@ -703,6 +824,18 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
703
824
  if orch_timeout_ms:
704
825
  timeout = float(orch_timeout_ms) / 1000.0
705
826
  kind = agent.get("kind", "generic")
827
+ if kind == "codex":
828
+ stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
829
+ elif kind == "claude":
830
+ stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
831
+ else:
832
+ # 数据驱动:catalog orch.stall_timeout_s——给 kimi 这类边写边吐进度行的
833
+ # CLI 配置后,静默挂死 10 分钟即杀(不必耗满总超时);qwen/mimo 这类
834
+ # 结束才一次性输出的留 0(开了必误杀),靠总超时兜底
835
+ try:
836
+ stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
837
+ except Exception:
838
+ stall_t = 0
706
839
  # 5G:approval NEVER 一线(无人值守不静默降级)
707
840
  ok, reason = _check_approval(agent)
708
841
  if not ok:
@@ -745,9 +878,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
745
878
  for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
746
879
  argv, stdin_text, prompt_eff = _build_call(eff_agent, kind, sid, readonly,
747
880
  att["model"], prompt,
748
- images=images if kind == "codex" else None)
881
+ images=images if kind == "codex" else None,
882
+ workdir=workdir)
749
883
  res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
750
- timeout=timeout, cancel_event=cancel_event, log_path=log_path)
884
+ timeout=timeout, cancel_event=cancel_event, log_path=log_path,
885
+ stall_timeout=stall_t)
751
886
  out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
752
887
  "tokens": 0, "usage": None, "error": "", "error_code": "",
753
888
  "sid": "", "raw": res, "kind": kind, "model": att["model"]}
@@ -756,9 +891,12 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
756
891
  # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
757
892
  # 里——只取其一会让 _quota_error/_transient_error 判空。
758
893
  tail = ((res["stderr"] or "") + "\n" + (res["stdout"] or "")).strip()[-600:]
759
- out["error"] = (("超时" if res["timed_out"] else "取消" if res["cancelled"]
760
- else "退出码 %s" % res["exit_code"])
761
- + (";stderr/stdout: " + tail if tail else ""))
894
+ head = ("输出停滞 %ss(stall timed out,疑似卡死已提前终止)" % stall_t
895
+ if res.get("stalled")
896
+ else "超时" if res["timed_out"]
897
+ else "取消" if res["cancelled"]
898
+ else "退出码 %s" % res["exit_code"])
899
+ out["error"] = head + (";stderr/stdout: " + tail if tail else "")
762
900
  if kind == "codex":
763
901
  fm = _codex_fail_msg(res["stdout"])
764
902
  if fm:
@@ -769,6 +907,32 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
769
907
  parsed_err = _parse_claude_json(res["stdout"] or "")
770
908
  if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
771
909
  out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
910
+ if kind == "codex" and att.get("provider_id"):
911
+ el = out["error"].lower()
912
+ if (("wire_api" in el and "no longer supported" in el)
913
+ or "no route matched" in el):
914
+ # codex 撞 chat-only 供应商(0.154 只讲 responses):
915
+ # 自动冷却该供应商 30 分钟,链展开/路由绑定分随之
916
+ # 自动绕开——不用人工改绑定(2026-09-17 mo-so 实测)
917
+ try:
918
+ from . import modelhub as _mh
919
+ _mh.note_codex_wire_dead(att["provider_id"])
920
+ except Exception:
921
+ pass
922
+ elif "no model configured" in out["error"].lower():
923
+ # CLI 本体没配置(kimi 实测)→ 运行期自愈:把绑定注入其
924
+ # 自家配置,本次换将之后的下一轮即可用
925
+ try:
926
+ from . import manager as _mg
927
+ note = _mg.sync_cli_config_now(agent.get("id", ""))
928
+ if note and log_path:
929
+ try:
930
+ with open(log_path, "a", encoding="utf-8") as fh:
931
+ fh.write("\n[自愈] %s\n" % note)
932
+ except Exception:
933
+ pass
934
+ except Exception:
935
+ pass
772
936
  out["error_code"] = _classify_failure(res, kind=kind)
773
937
  break
774
938
  if kind == "codex":
@@ -784,6 +948,15 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
784
948
  out["error_code"] = ErrorCode.VENDOR_ERROR
785
949
  elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
786
950
  out["text"] = res["stdout"][-2000:]
951
+ if require_tools and out["ok"] and _codex_work_events(res["stdout"]) == 0:
952
+ # 实现步空转闸:exit 0 + 有话但零动手 → 判拒绝(fatal 语义正确,
953
+ # 不把谎报的「已改完」静默传给下游;auto 模式换将逻辑按 ok 触发,
954
+ # 仍可换别的 CLI 再试)。原文尾段留进错误供人核对。
955
+ out["ok"] = False
956
+ out["error"] = ("实现步零工具调用:模型未执行任何命令/文件改动,"
957
+ "仅口头汇报(典型如谎报环境受限)。回答尾段: %s"
958
+ % (out["text"] or "")[-300:])
959
+ out["error_code"] = ErrorCode.VENDOR_REFUSAL
787
960
  if not out["text"] and out["ok"]:
788
961
  out["error_code"] = _classify_failure(res, kind="codex", empty_output=True)
789
962
  elif kind == "claude":
@@ -62,23 +62,71 @@ def _ver_tuple(s):
62
62
  return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
63
63
 
64
64
 
65
- def _npm_latest():
66
- """npm view codebee version;返回 (latest, err)
67
- 用 _PKG_NAME 变量而非行内字面量:发布名改过一次(tutti-orchestrator→codebee),
68
- 硬编码两处容易漏改;Mimosa 安全基线要求 argv 可执行文件为字面量,参数用变量不违反。"""
65
+ _RELNOTES_RE = re.compile(
66
+ r"<!--\s*relnotes:start\s*-->(.*?)<!--\s*relnotes:end\s*-->", re.S)
67
+
68
+
69
+ def _relnotes(readme):
70
+ """README 里 relnotes 标记段的内容(发布前手工更新,见 CHANGELOG.md 头部说明)。"""
71
+ m = _RELNOTES_RE.search(str(readme or ""))
72
+ return m.group(1).strip() if m else ""
73
+
74
+
75
+ def _npm_meta():
76
+ """npm view <pkg> --json;返回 (latest, relnotes, err)。
77
+
78
+ relnotes 来自 registry 元数据里的 README(与查新同一条 npm 通道,不依赖
79
+ GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
80
+ readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
69
81
  r = runner.run_process(
70
- argv=["cmd", "/c", "npm", "view", _PKG_NAME, "version"], timeout=60)
82
+ argv=["cmd", "/c", "npm", "view", _PKG_NAME, "--json"], timeout=60)
71
83
  if not r["ok"]:
72
- return "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
73
- m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
74
- return (m.group(0) if m else ""), ("" if m else "npm 输出无法解析")
84
+ return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
85
+ ver, notes = "", ""
86
+ try:
87
+ meta = json.loads(r["stdout"] or "{}")
88
+ if isinstance(meta, dict):
89
+ ver = str(meta.get("version") or "")
90
+ notes = _relnotes(meta.get("readme") or "")
91
+ except Exception:
92
+ pass
93
+ if not ver: # 旧 npm --json 失败时退回纯文本解析
94
+ m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
95
+ ver = m.group(0) if m else ""
96
+ if ver and not notes:
97
+ r2 = runner.run_process(
98
+ argv=["cmd", "/c", "npm", "view", _PKG_NAME, "readme"], timeout=60)
99
+ if r2["ok"]:
100
+ notes = _relnotes(r2["stdout"] or "")
101
+ return ver, notes, ("" if ver else "npm 输出无法解析")
102
+
103
+
104
+ def changelog_section(version):
105
+ """本地 CHANGELOG.md 中 version 对应小节(升级重启后「本次更新内容」用)。"""
106
+ v = re.escape(str(version or "").lstrip("v"))
107
+ m = re.search(r"(?ms)^## v?%s\b[^\n]*$(.*?)(?=^## |\Z)" % v,
108
+ _read_changelog())
109
+ if not m:
110
+ return ""
111
+ body = m.group(0).strip()
112
+ return body
113
+
114
+
115
+ def _read_changelog():
116
+ try:
117
+ return (paths.ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
118
+ except Exception:
119
+ return ""
75
120
 
76
121
 
77
122
  def check(force=False):
78
- """GET /api/selfupdate 载荷:{mode, current, latest, has_update, note}。"""
123
+ """GET /api/selfupdate 载荷:{mode, current, latest, has_update, note,
124
+ notes, whatsnew}。notes=远端新版本的更新内容(npm readme relnotes 段);
125
+ whatsnew=当前版本在本地 CHANGELOG.md 的小节(升级重启后弹「本次更新内容」)。"""
79
126
  mode = install_mode()
80
127
  cur = package_version()
81
- out = {"mode": mode, "current": cur, "latest": "", "has_update": False, "note": ""}
128
+ out = {"mode": mode, "current": cur, "latest": "", "has_update": False,
129
+ "note": "", "notes": "", "whatsnew": changelog_section(cur)}
82
130
  if mode != "npm":
83
131
  out["note"] = ("开发仓库模式:请用 git pull 更新(自动升级会覆盖未提交的代码)"
84
132
  if mode == "repo" else "非 npm 安装,无法自动更新")
@@ -87,8 +135,9 @@ def check(force=False):
87
135
  if not force and _CHECK_CACHE["result"] and \
88
136
  time.time() - _CHECK_CACHE["ts"] < _UPDATE_TTL:
89
137
  return dict(_CHECK_CACHE["result"])
90
- latest, err = _npm_latest()
138
+ latest, notes, err = _npm_meta()
91
139
  out["latest"] = latest
140
+ out["notes"] = notes
92
141
  if err:
93
142
  out["note"] = "查询新版本失败:" + err
94
143
  elif latest and cur:
package/app/core/store.py CHANGED
@@ -244,8 +244,9 @@ def _valid_id(task_id):
244
244
  def set_book_meta(task_id, platform, entry):
245
245
  """写任务的作品信息状态(book_meta[platform] = {status, data?, error?, at})。
246
246
 
247
- 一键生成是后台线程跑的,前端靠任务 JSON 里的这个字段看进度(SSE 推送)。
248
- 任务不存在返回 False。"""
247
+ 一键生成是后台线程跑的,前端靠任务 JSON 里的这个字段看进度。这里必须
248
+ bump_state:SSE 存活时前端不主动拉状态,不推送则卡片停在旧状态(点生成
249
+ 不翻「生成中」、跑完不翻「已生成」)。任务不存在返回 False。"""
249
250
  if not _valid_id(task_id):
250
251
  return False
251
252
  with LOCK:
@@ -254,6 +255,7 @@ def set_book_meta(task_id, platform, entry):
254
255
  return False
255
256
  task.setdefault("book_meta", {})[platform] = entry
256
257
  _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
258
+ bump_state()
257
259
  return True
258
260
 
259
261