codebee 0.1.52 → 0.1.54
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 +16 -0
- package/README.md +4 -3
- package/app/core/automation.py +57 -3
- package/app/core/board.py +209 -0
- package/app/core/branching.py +105 -0
- package/app/core/chaptersafe.py +32 -0
- package/app/core/jobs.py +1 -1
- package/app/core/pipeline.py +111 -7
- package/app/core/runner.py +146 -49
- package/app/core/share_page.py +4 -1
- package/app/core/store.py +13 -1
- package/app/main.py +4 -1
- package/app/ui/app.js +13403 -13321
- package/app/ui/board.css +292 -0
- package/app/ui/board.html +84 -0
- package/app/ui/board.js +259 -0
- package/app/ui/i18n.js +24 -0
- package/app/ui/index.html +2 -0
- package/app/ui/style.css +17 -0
- package/package.json +1 -1
package/app/core/pipeline.py
CHANGED
|
@@ -19,7 +19,7 @@ import re
|
|
|
19
19
|
import threading
|
|
20
20
|
import time
|
|
21
21
|
|
|
22
|
-
from . import aiflavor, attachments, catalog, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
|
|
22
|
+
from . import aiflavor, attachments, branching, catalog, chaptersafe, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
|
|
23
23
|
from . import builtin_agent
|
|
24
24
|
from . import diagnostics
|
|
25
25
|
from . import paths as paths_mod
|
|
@@ -331,7 +331,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
|
|
|
331
331
|
if dead_binding:
|
|
332
332
|
note = ((note + ";") if note else "") + "⚠ " + dead_msg
|
|
333
333
|
step, log_abs = store.add_step(run_id, role, agent["id"],
|
|
334
|
-
agent.get("label", agent["id"]), note=note
|
|
334
|
+
agent.get("label", agent["id"]), note=note,
|
|
335
|
+
model=agent.get("model") or "")
|
|
335
336
|
start = time.time()
|
|
336
337
|
if dead_binding:
|
|
337
338
|
from .error_codes import ErrorCode
|
|
@@ -393,7 +394,8 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
|
|
|
393
394
|
followups=True 时从回答末尾解析「建议追问」块(直连对话专用协议):
|
|
394
395
|
剥离出结构化列表落步骤记录,正文保持干净。"""
|
|
395
396
|
_wait_gate(run_id, ev)
|
|
396
|
-
step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note
|
|
397
|
+
step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note,
|
|
398
|
+
model=bi.get("model") or "")
|
|
397
399
|
start = time.time()
|
|
398
400
|
agent_pseudo = {"id": "builtin", "label": "CodeBee", "kind": "builtin", "mode": "real",
|
|
399
401
|
"provider": {"id": bi.get("provider_id") or "",
|
|
@@ -940,6 +942,72 @@ def _record_dispatch_completed(run_id, task, result, verify_pass=None, review_pa
|
|
|
940
942
|
pass
|
|
941
943
|
|
|
942
944
|
|
|
945
|
+
def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
|
|
946
|
+
stats, prompt, workdir, step_wd, ev, resume_ctx,
|
|
947
|
+
draft_note, draft_res):
|
|
948
|
+
"""Auto content tasks may try another configured CLI after a draft failure.
|
|
949
|
+
|
|
950
|
+
Model/provider failover has already been exhausted inside run_agent. Keep
|
|
951
|
+
manual execution, user cancellation and resumed sessions pinned to the
|
|
952
|
+
user's explicit choice.
|
|
953
|
+
"""
|
|
954
|
+
raw = draft_res.get("raw") or {}
|
|
955
|
+
if (mode == "manual" or impl.get("mode") != "real" or resume_ctx
|
|
956
|
+
or raw.get("cancelled") or draft_res.get("error_code") == "CANCELLED"):
|
|
957
|
+
return draft_res, impl, draft_note
|
|
958
|
+
|
|
959
|
+
tried = {impl.get("id")}
|
|
960
|
+
failures = ["%s:%s" % (impl.get("id") or "CLI",
|
|
961
|
+
(draft_res.get("error") or "起草失败")[:180])]
|
|
962
|
+
dead_upstreams = []
|
|
963
|
+
first_upstreams = router.agent_upstreams(impl.get("id"))
|
|
964
|
+
if first_upstreams:
|
|
965
|
+
dead_upstreams.append(first_upstreams)
|
|
966
|
+
current = impl
|
|
967
|
+
last_impl = impl
|
|
968
|
+
last_result = draft_res
|
|
969
|
+
last_note = draft_note
|
|
970
|
+
|
|
971
|
+
while True:
|
|
972
|
+
_check_cancel(ev)
|
|
973
|
+
candidate, reason = router.pick_switch_candidate(
|
|
974
|
+
agents, "implement", task.get("type") or "novel", stats,
|
|
975
|
+
exclude=tried, dead_upstreams=dead_upstreams)
|
|
976
|
+
if candidate is None or candidate.get("id") in tried:
|
|
977
|
+
break
|
|
978
|
+
tried.add(candidate.get("id"))
|
|
979
|
+
if candidate.get("mode") != "real":
|
|
980
|
+
continue
|
|
981
|
+
|
|
982
|
+
why = (last_result.get("error") or "起草失败")[:180]
|
|
983
|
+
switch_note = ("起草自动换 CLI:%s → %s(%s);前序失败:%s" % (
|
|
984
|
+
current.get("id") or "CLI", candidate.get("id") or "CLI",
|
|
985
|
+
reason or "自动路由", why))
|
|
986
|
+
attempt_note = (draft_note + ";" if draft_note else "") + switch_note
|
|
987
|
+
bound = modelhub.bind_agent(candidate, difficulty)
|
|
988
|
+
last_result = _run_step(
|
|
989
|
+
run_id, "draft", bound, prompt, step_wd, readonly=False, ev=ev,
|
|
990
|
+
note=attempt_note, images=_task_images(task, workdir))
|
|
991
|
+
last_impl = candidate
|
|
992
|
+
last_note = attempt_note
|
|
993
|
+
if last_result.get("ok"):
|
|
994
|
+
return last_result, candidate, attempt_note
|
|
995
|
+
|
|
996
|
+
failures.append("%s:%s" % (candidate.get("id") or "CLI",
|
|
997
|
+
(last_result.get("error") or "起草失败")[:180]))
|
|
998
|
+
if (last_result.get("raw") or {}).get("cancelled"):
|
|
999
|
+
break
|
|
1000
|
+
ups = router.agent_upstreams(candidate.get("id"))
|
|
1001
|
+
if ups:
|
|
1002
|
+
dead_upstreams.append(ups)
|
|
1003
|
+
current = candidate
|
|
1004
|
+
|
|
1005
|
+
if last_impl is not impl:
|
|
1006
|
+
last_result = dict(last_result)
|
|
1007
|
+
last_result["error"] = "CLI 自动切换均失败:%s" % ";".join(failures[-4:])
|
|
1008
|
+
return last_result, last_impl, last_note
|
|
1009
|
+
|
|
1010
|
+
|
|
943
1011
|
def _run_code(run, task, agents, ev, stats, mode):
|
|
944
1012
|
run_id = run["id"]
|
|
945
1013
|
workdir = task["workdir"]
|
|
@@ -2108,8 +2176,9 @@ def _wc(text):
|
|
|
2108
2176
|
|
|
2109
2177
|
|
|
2110
2178
|
def _write_chapter(workdir, i, text):
|
|
2111
|
-
|
|
2112
|
-
|
|
2179
|
+
# 安全落盘委托(inkos 安全章节工作区借鉴):tmp+原子改名,写一半
|
|
2180
|
+
# 崩溃不留半章冒充成稿;路径守卫在 chaptersafe(resolve+parents)
|
|
2181
|
+
chaptersafe.atomic_write_chapter(workdir, i, text)
|
|
2113
2182
|
|
|
2114
2183
|
|
|
2115
2184
|
def _all_ge(scores, threshold):
|
|
@@ -2556,6 +2625,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2556
2625
|
scope = ("本章 = 大纲第 %d 章" % i) if start == 1 else (
|
|
2557
2626
|
"本批为第 %d–%d 章,下列按全书章号列出各章要点" % (start, end))
|
|
2558
2627
|
|
|
2628
|
+
# 多线剧情推演(inkos 借鉴 2026-09-23):serial.branches>=2 时
|
|
2629
|
+
# 写章前一次编排者调用生成 N 条分支节拍、自荐择优,选中分支替换
|
|
2630
|
+
# 本章 BEATS/HOOK(计划级赛马,省 prose 级开销);失败静默走原大纲。
|
|
2631
|
+
branch_beats = branch_hook = None
|
|
2632
|
+
try:
|
|
2633
|
+
n_branch = max(1, min(3, int(serial.get("branches") or 1)))
|
|
2634
|
+
if n_branch >= 2:
|
|
2635
|
+
got = branching.plan_branches(
|
|
2636
|
+
run_id, task, i, task["goal"], outline_txt, prev, n_branch)
|
|
2637
|
+
if got:
|
|
2638
|
+
branch_beats, branch_hook = got
|
|
2639
|
+
except Exception:
|
|
2640
|
+
branch_beats = None
|
|
2641
|
+
|
|
2559
2642
|
def _draft_prompt(vfile):
|
|
2560
2643
|
return (SERIAL_CHAPTER_PROMPT
|
|
2561
2644
|
.replace("__SKILLS__", sk_block)
|
|
@@ -2567,8 +2650,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2567
2650
|
.replace("__PREV__", prev)
|
|
2568
2651
|
.replace("__LEDGER__", ledger_txt)
|
|
2569
2652
|
.replace("__TITLE__", ch["title"])
|
|
2570
|
-
.replace("__BEATS__", ch["beats"] or "按大纲推进")
|
|
2571
|
-
.replace("__HOOK__", ch.get("hook") or "留下悬念")
|
|
2653
|
+
.replace("__BEATS__", branch_beats or ch["beats"] or "按大纲推进")
|
|
2654
|
+
.replace("__HOOK__", branch_hook or ch.get("hook") or "留下悬念")
|
|
2572
2655
|
.replace("__WORDS__", str(wpc)))
|
|
2573
2656
|
|
|
2574
2657
|
n_variants = max(1, min(3, int(serial.get("variants") or 1)))
|
|
@@ -3477,6 +3560,27 @@ def _run_content_review(run, task, agents, ev, stats, mode):
|
|
|
3477
3560
|
ev=ev, note=draft_note,
|
|
3478
3561
|
resume=resume_ctx["session"] if resume_ctx else None,
|
|
3479
3562
|
images=_task_images(task, workdir))
|
|
3563
|
+
if not draft_res["ok"]:
|
|
3564
|
+
draft_res, actual_impl, fallback_note = _retry_content_draft_with_cli(
|
|
3565
|
+
run_id, task, agents, impl, difficulty, mode, stats,
|
|
3566
|
+
_draft_prompt_for(ms_name), workdir, step_wd, ev, resume_ctx,
|
|
3567
|
+
draft_note, draft_res)
|
|
3568
|
+
if actual_impl.get("id") != impl.get("id"):
|
|
3569
|
+
impl = actual_impl
|
|
3570
|
+
draft_note = fallback_note
|
|
3571
|
+
route["author"] = fallback_note
|
|
3572
|
+
if mode != "manual":
|
|
3573
|
+
critic_pool, route["critics"] = router.pick_critics(
|
|
3574
|
+
agents, task.get("type") or "novel", stats, impl=impl)
|
|
3575
|
+
critics = critic_pool[:workflow["reviewers"]]
|
|
3576
|
+
plan = planner.make_novel_plan(_steered_task(run_id, task), impl, critics)
|
|
3577
|
+
store.update_run(run_id, route=route, plan=plan)
|
|
3578
|
+
_record_actual_route(
|
|
3579
|
+
run_id, task, agents, stats, impl, critics=critics,
|
|
3580
|
+
implement_reason=route.get("author", ""),
|
|
3581
|
+
review_reason=route.get("critics", ""))
|
|
3582
|
+
if draft_res.get("raw", {}).get("cancelled"):
|
|
3583
|
+
_check_cancel(ev)
|
|
3480
3584
|
if not draft_res["ok"]:
|
|
3481
3585
|
store.update_run(run_id, expected_status="running", status="failed",
|
|
3482
3586
|
error="起草失败: %s" % draft_res.get("error"),
|
package/app/core/runner.py
CHANGED
|
@@ -28,7 +28,8 @@ from .error_codes import ErrorCode
|
|
|
28
28
|
# 非 Windows 必须置 0:POSIX 的 Popen 对非零 creationflags 直接抛 ValueError,
|
|
29
29
|
# 置 0 则两边通用(remote.py 同款守卫)。
|
|
30
30
|
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
31
|
-
DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
|
|
31
|
+
DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
|
|
32
|
+
MAX_ATTEMPT_TIMEOUT_S = 60 # 单模型上游最多等 1 分钟,随后换模型/供应商
|
|
32
33
|
|
|
33
34
|
_BASH_CANDIDATES = [
|
|
34
35
|
r"D:\Git\usr\bin\bash.exe",
|
|
@@ -774,8 +775,8 @@ def _codex_fail_msg(stdout):
|
|
|
774
775
|
2026-09-16 实测:配额/限流只出现在 error 与 turn.failed 事件里,旧解析器
|
|
775
776
|
两者都丢——进程退出码非 0 时错误串里只剩 "Reading prompt from stdin...",
|
|
776
777
|
_quota_error/_transient_error 判不出可降级,健康后继模型从未被尝试。
|
|
777
|
-
Reconnecting... 是 CLI
|
|
778
|
-
|
|
778
|
+
Reconnecting... 是 CLI 内部重试噪音(可能自愈),不取作终态错误;重复
|
|
779
|
+
重连由进程看门狗提前中断。turn.failed 优先于裸 error(后者取最后一条兜底)。
|
|
779
780
|
"""
|
|
780
781
|
terminal, last_err = "", ""
|
|
781
782
|
for line in (stdout or "").splitlines():
|
|
@@ -823,6 +824,10 @@ def _parse_claude_json(stdout):
|
|
|
823
824
|
int(u.get("cache_creation_input_tokens") or 0)
|
|
824
825
|
usage = {"input": inp, "output": out, "cached": cached, "reasoning": 0,
|
|
825
826
|
"total": inp + out + cached}
|
|
827
|
+
# 网关实服模型:请求 deepseek-v4.1-flash 却被网关映射成 gemini-3.8-flash
|
|
828
|
+
# 之类(2026-09-23 夜班实案)——用量统计记错名、失败排查看错病根,都
|
|
829
|
+
# 靠 result 行的 modelUsage 对出来
|
|
830
|
+
served = sorted({str(k) for k in ((data.get("modelUsage") or {}) or {})})
|
|
826
831
|
return {
|
|
827
832
|
"text": data.get("result") or "",
|
|
828
833
|
"cost_usd": data.get("total_cost_usd") or 0.0,
|
|
@@ -831,6 +836,7 @@ def _parse_claude_json(stdout):
|
|
|
831
836
|
"is_error": bool(data.get("is_error")),
|
|
832
837
|
# §07 T1.1:claude -p 返回本次会话 id,供 --resume 复用
|
|
833
838
|
"sid": str(data.get("session_id") or ""),
|
|
839
|
+
"served_models": served,
|
|
834
840
|
}
|
|
835
841
|
|
|
836
842
|
|
|
@@ -898,6 +904,49 @@ RATE_LIMIT_GRACE_S = 45 # 宽限等待时长:限流窗口通常按分钟
|
|
|
898
904
|
RATE_LIMIT_GRACE_N = 1 # 每步宽限次数:只兜一次,防限流长拖整轮时间
|
|
899
905
|
|
|
900
906
|
|
|
907
|
+
# 上游内容拒答(refusal):claude CLI 把 refusal stop_reason 渲染成
|
|
908
|
+
# "API Error: <model> can't help with this. Start a new session to continue."
|
|
909
|
+
# 这是网关后面那个模型对本次会话内容的过滤决定,不是任务终态——换一家
|
|
910
|
+
# 异上游候选常常就能跑通。2026-09-23 夜班实案:doc 起草被 gemini 拒答后
|
|
911
|
+
# 判成非瞬态整链早死,链上云知声候选根本没被尝试。
|
|
912
|
+
_REFUSAL = ("can't help with this", "couldn't help with this",
|
|
913
|
+
'stop_reason":"refusal"', "content policy", "content filtering")
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _refusal_error(err):
|
|
917
|
+
err = (err or "").lower()
|
|
918
|
+
return any(k in err for k in _REFUSAL)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _attempt_upstream(att):
|
|
922
|
+
"""返回尝试项的上游身份;同一 host 换模型/供应商别名不重复烧超时。"""
|
|
923
|
+
provider = (att or {}).get("provider") or {}
|
|
924
|
+
base_url = str(provider.get("base_url") or "").strip()
|
|
925
|
+
match = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)", base_url)
|
|
926
|
+
if match:
|
|
927
|
+
return match.group(1).lower()
|
|
928
|
+
provider_id = str((att or {}).get("provider_id") or "").strip()
|
|
929
|
+
return "provider:" + provider_id if provider_id else "unknown"
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _claude_drift_note(att_model, parsed):
|
|
933
|
+
"""请求模型 vs 网关实服模型不一致时的注记;一致或无从判断返回空。"""
|
|
934
|
+
served = (parsed or {}).get("served_models") or []
|
|
935
|
+
if not served or not att_model or served == [att_model]:
|
|
936
|
+
return ""
|
|
937
|
+
return "[模型漂移] 请求 %s,上游实服 %s" % (att_model, ",".join(served))
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _log_note(log_path, text):
|
|
941
|
+
if not log_path or not text:
|
|
942
|
+
return
|
|
943
|
+
try:
|
|
944
|
+
with open(log_path, "a", encoding="utf-8") as fh:
|
|
945
|
+
fh.write("\n[%s]\n" % text)
|
|
946
|
+
except OSError:
|
|
947
|
+
pass
|
|
948
|
+
|
|
949
|
+
|
|
901
950
|
def _rate_limited(err):
|
|
902
951
|
err = (err or "").lower()
|
|
903
952
|
return any(k in err for k in _RATE_LIMIT)
|
|
@@ -930,7 +979,7 @@ def _augment_error_from_log(out, log_path, limit=300):
|
|
|
930
979
|
out["error"] = (out["error"] + ";日志错误行: " + hits[-1][:limit])[-800:]
|
|
931
980
|
|
|
932
981
|
|
|
933
|
-
def _grace_wait(cancel_event, seconds, log_path=None, why=""):
|
|
982
|
+
def _grace_wait(cancel_event, seconds, log_path=None, why=""):
|
|
934
983
|
"""链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
|
|
935
984
|
if log_path:
|
|
936
985
|
try:
|
|
@@ -939,11 +988,11 @@ def _grace_wait(cancel_event, seconds, log_path=None, why=""):
|
|
|
939
988
|
% (why or "上游限流(429)", seconds))
|
|
940
989
|
except Exception:
|
|
941
990
|
pass
|
|
942
|
-
end = time.
|
|
943
|
-
while time.
|
|
944
|
-
if cancel_event is not None and cancel_event.is_set():
|
|
945
|
-
return False
|
|
946
|
-
time.sleep(min(3, max(0.5, end - time.
|
|
991
|
+
end = time.monotonic() + seconds
|
|
992
|
+
while time.monotonic() < end:
|
|
993
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
994
|
+
return False
|
|
995
|
+
time.sleep(min(3, max(0.5, end - time.monotonic())))
|
|
947
996
|
return cancel_event is None or not cancel_event.is_set()
|
|
948
997
|
|
|
949
998
|
|
|
@@ -1255,14 +1304,17 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1255
1304
|
防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
|
|
1256
1305
|
|
|
1257
1306
|
模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
|
|
1258
|
-
主模型 +
|
|
1307
|
+
主模型 + 降级备选;瞬态/配额/超时/上游拒答换下一条,取消与解析
|
|
1308
|
+
失败不降级;同一上游超时两次即跳过该上游剩余模型。
|
|
1259
1309
|
|
|
1260
|
-
5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout
|
|
1310
|
+
5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout;
|
|
1311
|
+
但单个模型/上游调用硬封顶 60 秒,超时即试链上下一家。
|
|
1261
1312
|
"""
|
|
1262
1313
|
# 5E:catalog orch.timeout_ms 优先
|
|
1263
1314
|
orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
|
|
1264
|
-
if orch_timeout_ms:
|
|
1265
|
-
timeout = float(orch_timeout_ms) / 1000.0
|
|
1315
|
+
if orch_timeout_ms:
|
|
1316
|
+
timeout = float(orch_timeout_ms) / 1000.0
|
|
1317
|
+
timeout = min(float(timeout), MAX_ATTEMPT_TIMEOUT_S)
|
|
1266
1318
|
kind = agent.get("kind", "generic")
|
|
1267
1319
|
if kind == "aider":
|
|
1268
1320
|
repo_issue = _git_repo_issue(workdir)
|
|
@@ -1308,8 +1360,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1308
1360
|
attempts = _resolve_attempts(agent)
|
|
1309
1361
|
out = None
|
|
1310
1362
|
grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
|
|
1311
|
-
|
|
1312
|
-
|
|
1363
|
+
timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
|
|
1364
|
+
skipped_upstreams = set()
|
|
1365
|
+
for ai, att in enumerate(attempts):
|
|
1366
|
+
upstream = _attempt_upstream(att)
|
|
1367
|
+
if upstream in skipped_upstreams:
|
|
1368
|
+
continue
|
|
1369
|
+
model_deadline = att.get("_deadline") or (time.monotonic() + timeout)
|
|
1370
|
+
env = dict(base_env)
|
|
1313
1371
|
env.update(att["env"])
|
|
1314
1372
|
if kind == "codex":
|
|
1315
1373
|
# 网关自定义模型名(如 [opencode]xxx)是合法 API 模型名不能改,但方括号
|
|
@@ -1323,25 +1381,32 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1323
1381
|
elif att["from_chain"] and "codex_provider" in eff_agent:
|
|
1324
1382
|
# 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
|
|
1325
1383
|
del eff_agent["codex_provider"]
|
|
1326
|
-
for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1384
|
+
for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
|
|
1385
|
+
remaining = model_deadline - time.monotonic()
|
|
1386
|
+
if remaining <= 0:
|
|
1387
|
+
res = {"ok": False, "exit_code": None, "stdout": "", "stderr": "",
|
|
1388
|
+
"duration": timeout, "cancelled": False, "timed_out": True,
|
|
1389
|
+
"stalled": False}
|
|
1390
|
+
else:
|
|
1391
|
+
argv, stdin_text, prompt_eff, tmp_files = _build_call(
|
|
1392
|
+
eff_agent, kind, sid, readonly,
|
|
1393
|
+
att["model"], prompt,
|
|
1394
|
+
images=images if kind == "codex" else None,
|
|
1395
|
+
workdir=workdir)
|
|
1396
|
+
try:
|
|
1397
|
+
repeat_guard = (("Reconnecting...", 2)
|
|
1398
|
+
if kind == "codex" else None)
|
|
1399
|
+
res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
|
|
1400
|
+
timeout=min(timeout, remaining), cancel_event=cancel_event,
|
|
1401
|
+
log_path=log_path, stall_timeout=stall_t,
|
|
1402
|
+
repeat_abort=repeat_guard)
|
|
1403
|
+
finally:
|
|
1404
|
+
# 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
|
|
1405
|
+
for tf in tmp_files:
|
|
1406
|
+
try:
|
|
1407
|
+
os.remove(tf)
|
|
1408
|
+
except OSError:
|
|
1409
|
+
pass
|
|
1345
1410
|
out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
|
|
1346
1411
|
"tokens": 0, "usage": None, "error": "", "error_code": "",
|
|
1347
1412
|
"sid": "", "raw": res, "kind": kind, "model": att["model"],
|
|
@@ -1370,6 +1435,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1370
1435
|
parsed_err = _parse_claude_json(res["stdout"] or "")
|
|
1371
1436
|
if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
|
|
1372
1437
|
out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
|
|
1438
|
+
drift = _claude_drift_note(att.get("model"), parsed_err)
|
|
1439
|
+
if drift:
|
|
1440
|
+
out["error"] += ";" + drift
|
|
1441
|
+
_log_note(log_path, drift)
|
|
1373
1442
|
if kind == "codex" and att.get("provider_id"):
|
|
1374
1443
|
el = out["error"].lower()
|
|
1375
1444
|
if (("wire_api" in el and "no longer supported" in el)
|
|
@@ -1442,9 +1511,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1442
1511
|
out["tokens"] = parsed["tokens"]
|
|
1443
1512
|
out["usage"] = parsed["usage"]
|
|
1444
1513
|
out["sid"] = parsed.get("sid") or "" # §07 T1.1
|
|
1514
|
+
drift = _claude_drift_note(att.get("model"), parsed)
|
|
1515
|
+
if drift:
|
|
1516
|
+
_log_note(log_path, drift)
|
|
1445
1517
|
if parsed["is_error"]:
|
|
1446
1518
|
out["ok"] = False
|
|
1447
1519
|
out["error"] = "claude 返回 is_error: " + parsed["text"][:500]
|
|
1520
|
+
if drift:
|
|
1521
|
+
out["error"] += ";" + drift
|
|
1448
1522
|
out["error_code"] = _classify_failure(res, kind="claude", parsed=parsed)
|
|
1449
1523
|
break
|
|
1450
1524
|
if not out["text"] and attempt == 0:
|
|
@@ -1460,21 +1534,44 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1460
1534
|
if not out["text"]:
|
|
1461
1535
|
out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
|
|
1462
1536
|
break
|
|
1463
|
-
_report_key(att, out)
|
|
1464
|
-
if out["ok"] or ai == len(attempts) - 1:
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
return out
|
|
1537
|
+
_report_key(att, out)
|
|
1538
|
+
if out["ok"] or ai == len(attempts) - 1:
|
|
1539
|
+
remaining = model_deadline - time.monotonic()
|
|
1540
|
+
if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
|
|
1541
|
+
and remaining > 2
|
|
1542
|
+
and not (cancel_event is not None and cancel_event.is_set())):
|
|
1543
|
+
# 链尾限流宽限:enumerate 活列表——把链尾这条 append 回去,
|
|
1544
|
+
# 下一轮迭代就是「原地重试一次」;再撞限流时预算已耗尽照常判死。
|
|
1545
|
+
grace_left -= 1
|
|
1546
|
+
grace_s = min(RATE_LIMIT_GRACE_S, remaining - 1)
|
|
1547
|
+
if _grace_wait(cancel_event, grace_s, log_path=log_path):
|
|
1548
|
+
retry = dict(att, _deadline=model_deadline)
|
|
1549
|
+
attempts.append(retry)
|
|
1550
|
+
continue
|
|
1551
|
+
return out
|
|
1552
|
+
# 换将闸门:什么失败值得烧链上下一个候选。
|
|
1553
|
+
# · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
|
|
1554
|
+
# · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
|
|
1555
|
+
# 2026-09-23 夜班实案:旧逻辑里超时能否换将全赌输出尾部碰巧含有
|
|
1556
|
+
# "timeout" 字样(当时是步骤里读到的代码 timeout=900 撞的表)。
|
|
1557
|
+
# · 上游内容拒答(refusal):是那家模型的过滤决定,不是任务终态,
|
|
1558
|
+
# 换异上游候选常常就活;判死则整 run 白烧(同案:gemini 拒答后
|
|
1559
|
+
# 链上云知声候选从未被尝试)。
|
|
1560
|
+
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"))):
|
|
1565
|
+
return out # 取消/解析失败等真终态不降级
|
|
1566
|
+
# 同一上游的模型连续两次超时就收手——
|
|
1567
|
+
# 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
|
|
1568
|
+
# 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最多
|
|
1569
|
+
# 两个模型预算后转向其他上游。其余瞬态/拒答仍按链长全部尝试:各家病根
|
|
1570
|
+
# 不同,每个候选都值得一次机会。
|
|
1571
|
+
if raw.get("timed_out"):
|
|
1572
|
+
timed_out_upstreams[upstream] = timed_out_upstreams.get(upstream, 0) + 1
|
|
1573
|
+
if timed_out_upstreams[upstream] >= 2:
|
|
1574
|
+
skipped_upstreams.add(upstream)
|
|
1478
1575
|
return out
|
|
1479
1576
|
|
|
1480
1577
|
|
package/app/core/share_page.py
CHANGED
|
@@ -87,12 +87,15 @@ def render_share_html(run, task, report_text):
|
|
|
87
87
|
|
|
88
88
|
steps_rows = ""
|
|
89
89
|
for s in run.get("steps") or []:
|
|
90
|
+
who = s.get("agent_label") or s.get("agent") or ""
|
|
91
|
+
if s.get("model"):
|
|
92
|
+
who += " · " + str(s["model"])
|
|
90
93
|
steps_rows += (
|
|
91
94
|
'<div class="step"><span class="n">%02d</span>'
|
|
92
95
|
'<span class="role">%s</span><span class="who">%s</span>'
|
|
93
96
|
'<span class="sum">%s</span><span class="st %s">%s</span></div>'
|
|
94
97
|
% (s.get("n") or 0, _esc(s.get("role") or ""),
|
|
95
|
-
_esc(
|
|
98
|
+
_esc(who),
|
|
96
99
|
_esc((s.get("summary") or "")[:220]), _esc(s.get("status") or ""),
|
|
97
100
|
_esc(s.get("status") or "")))
|
|
98
101
|
|
package/app/core/store.py
CHANGED
|
@@ -223,6 +223,15 @@ def create_task(payload):
|
|
|
223
223
|
v = 1
|
|
224
224
|
if v > 1:
|
|
225
225
|
s["variants"] = v
|
|
226
|
+
# 多线剧情推演(inkos 借鉴 2026-09-23):2-3 = 写章前生成
|
|
227
|
+
# N 条分支节拍计划择优注入(计划级赛马,省 prose 级开销);
|
|
228
|
+
# 越界钳到 3(与 variants 同口径:上限仍可用,1=不启用不落键)
|
|
229
|
+
try:
|
|
230
|
+
br = max(1, min(3, int(serial.get("branches") or 1)))
|
|
231
|
+
except Exception:
|
|
232
|
+
br = 1
|
|
233
|
+
if br > 1:
|
|
234
|
+
s["branches"] = br
|
|
226
235
|
# 分卷:显式卷表优先(新建任务时用户明确给出的分卷信息),
|
|
227
236
|
# 其次每卷章数等长切卷。卷边界一律由 volumes 模块从「章号」
|
|
228
237
|
# 确定性推导——续写批次沿用同一份卷表/每卷章数即自动接上同一卷,
|
|
@@ -1528,7 +1537,7 @@ def update_task_params(task_id, patch):
|
|
|
1528
1537
|
return True, ""
|
|
1529
1538
|
|
|
1530
1539
|
|
|
1531
|
-
def add_step(run_id, role, agent_id, agent_label, note=""):
|
|
1540
|
+
def add_step(run_id, role, agent_id, agent_label, note="", model=""):
|
|
1532
1541
|
with LOCK:
|
|
1533
1542
|
run = _RUNS.get(run_id)
|
|
1534
1543
|
if not run:
|
|
@@ -1537,6 +1546,9 @@ def add_step(run_id, role, agent_id, agent_label, note=""):
|
|
|
1537
1546
|
step = {
|
|
1538
1547
|
"n": n, "role": role, "agent": agent_id, "agent_label": agent_label,
|
|
1539
1548
|
"note": note,
|
|
1549
|
+
# 起跑即记解析到的模型:运行中蜂巢卡/步骤列表就能看到「在用哪个模型」,
|
|
1550
|
+
# 不必等 finish_step 用实际结果覆盖(换将成功后以实际为准)
|
|
1551
|
+
"model": str(model or "")[:80],
|
|
1540
1552
|
"status": "running", "started_at": time.strftime("%H:%M:%S"),
|
|
1541
1553
|
"ended_at": None, "duration_s": None, "exit_code": None,
|
|
1542
1554
|
"summary": "", "log": None, "cost_usd": 0.0, "tokens": 0,
|
package/app/main.py
CHANGED
|
@@ -30,7 +30,7 @@ if os.environ.get("TUTTI_ISOLATE_HOME", "").strip() == "1":
|
|
|
30
30
|
os.environ["USERPROFILE"] = _fake_home
|
|
31
31
|
os.environ["HOME"] = _fake_home
|
|
32
32
|
|
|
33
|
-
from core import automation, catalog, flows, jobs, manager, market, market_remote, preview, registry, remote, settings, store
|
|
33
|
+
from core import automation, board, catalog, flows, jobs, manager, market, market_remote, preview, registry, remote, settings, store
|
|
34
34
|
from core import paths
|
|
35
35
|
from core import health
|
|
36
36
|
import pick_dialog
|
|
@@ -235,6 +235,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
235
235
|
if path == "/api/health":
|
|
236
236
|
from core import health
|
|
237
237
|
return self._json(200, health.snapshot())
|
|
238
|
+
if path == "/api/board":
|
|
239
|
+
# 任务驾驶舱大屏的轻量聚合(KB 级;别让它去拉 /api/state 全量)
|
|
240
|
+
return self._json(200, board.payload())
|
|
238
241
|
if path == "/api/connect":
|
|
239
242
|
# 供设置页「手机连接」弹框生成二维码;远程打开需令牌,天然受保护
|
|
240
243
|
return self._json(200, {"urls": remote.build_connect_urls(PORT),
|