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 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.55)
24
+ ### 最新版更新内容(v0.1.56)
25
25
 
26
- - 知识库可信度分级:low 直接丢弃,注入按置信度加权
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:
@@ -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)
@@ -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 级失败回写:欠费类进冷却(后续解析自动跳过 → 切备用 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
 
@@ -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
- # 单路实现失败:Best-of-N 赛马兜底(借鉴 orca worktree 择优)——多路并行
1247
- # 各自 worktree 隔离实现+验证,胜者 diff 回主工作区;失败回落走换将
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
@@ -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
- if time.time() - start > timeout:
734
+ elapsed = time.monotonic() - start_mono
735
+ if elapsed > timeout:
721
736
  # Claude/Codex 的工具循环可能持续吐 stream-json 事件而迟迟
722
- # 不给最终 result。只要仍有活动,交给 stall_timeout 作为静默
723
- # 上限;无输出的挂死仍按原单模型 timeout 快速换将。
724
- active = stall_timeout and time.time() - stamp[0] < stall_timeout
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"|ENOTFOUND|ECONNREFUSED|failed to run prompt|api_error|overloaded)",
1050
- re.I)
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
- stdin_text = prompt
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
- elif kind == "claude":
1477
- stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
1478
- else:
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 集群教训;
@@ -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
- "archived_tasks": store.list_tasks(30, archived=True),
2384
+ # 归档任务默认隐藏;目标/上下文可能很大,只有查看或恢复时再按需读取。
2385
+ "archived_tasks": [_state_task_summary(t) for t in store.list_tasks(30, archived=True)],
2373
2386
  # 每个任务的最近一次运行(不受 runs 窗口限制):侧栏靠它展示各任务真实近况
2374
- "task_latest": store.latest_run_by_task(),
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
- "runs": store.list_runs(40),
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
  分发到其中一个——服务"时好时坏"的根源)。独占锁让第二个实例在这里