codebee 0.1.53 → 0.1.55

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.
@@ -2156,11 +2156,14 @@ def _entry_endpoint(prov, allowed):
2156
2156
  """
2157
2157
  proto = prov.get("protocol")
2158
2158
  if proto in allowed:
2159
- return proto, prov.get("base_url"), prov.get("wire_api", "responses")
2159
+ base = prov.get("base_url")
2160
+ if not _wire_endpoint_compatible(proto, base):
2161
+ return None
2162
+ return proto, base, prov.get("wire_api", "responses")
2160
2163
  caps = prov.get("wire_caps") or {}
2161
2164
  for p in allowed:
2162
2165
  cap = caps.get(p) or {}
2163
- if cap.get("base"):
2166
+ if cap.get("base") and _wire_endpoint_compatible(p, cap["base"]):
2164
2167
  return p, cap["base"], cap.get("wire_api") or "responses"
2165
2168
  return None
2166
2169
 
@@ -2172,10 +2175,12 @@ def _protocol_candidates(prov):
2172
2175
  调用方逐个试、全失败才报错——不猜一条去发请求。"""
2173
2176
  proto = (prov or {}).get("protocol")
2174
2177
  if proto in _PROTOCOLS:
2175
- return [(proto, (prov or {}).get("base_url") or "")]
2178
+ base = (prov or {}).get("base_url") or ""
2179
+ return ([(proto, base)] if _wire_endpoint_compatible(proto, base) else [])
2176
2180
  caps = (prov or {}).get("wire_caps") or {}
2177
2181
  return [(p, caps[p]["base"]) for p in _WIRE_PREFERENCE
2178
- if (caps.get(p) or {}).get("base")]
2182
+ if (caps.get(p) or {}).get("base")
2183
+ and _wire_endpoint_compatible(p, (caps.get(p) or {}).get("base"))]
2179
2184
 
2180
2185
 
2181
2186
  def _ensure_wire_candidates(provider_id, prov, model_name=""):
@@ -2195,6 +2200,26 @@ def _ensure_wire_candidates(provider_id, prov, model_name=""):
2195
2200
  return (_protocol_candidates(prov2) if prov2 else []), (note or "")
2196
2201
 
2197
2202
 
2203
+ def _wire_endpoint_compatible(proto, base_url):
2204
+ """Reject an endpoint whose URL path contradicts the selected wire.
2205
+
2206
+ Some providers expose an Anthropic API under a path containing ``anthropic``
2207
+ and may have stale ``wire_caps`` from an earlier probe that recorded that
2208
+ same URL as OpenAI/Responses. Codex then retries forever because it sends
2209
+ Responses events to the Anthropic route. A path marker is a conservative
2210
+ local guard; normal shared roots such as ``https://host/v1`` remain valid
2211
+ for both protocols.
2212
+ """
2213
+ if proto != "openai" or not base_url:
2214
+ return True
2215
+ try:
2216
+ import urllib.parse
2217
+ path = (urllib.parse.urlsplit(str(base_url)).path or "").lower()
2218
+ except Exception:
2219
+ return True
2220
+ return re.search(r"(?:^|/)anthropic(?:/|$)", path) is None
2221
+
2222
+
2198
2223
  def bindable_protocols(agent_kind_or_id):
2199
2224
  """该 CLI 可绑定的 wire 协议(与 resolve_binding 的 allowed 一致)。
2200
2225
  供死链告警/失败文案解释「为什么绑不上」:claude 只认 anthropic,
@@ -2851,6 +2876,10 @@ def _wire_base_candidates(base_url, target_proto):
2851
2876
  cands = [base if base.endswith("/v1") else base + "/v1"]
2852
2877
  if not base.endswith("/v1"):
2853
2878
  cands.append(base)
2879
+ # `/api/anthropic` 是 Anthropic 专用入口,不要把它拼成
2880
+ # `/api/anthropic[/v1]/responses` 再误记为 Codex 能力。
2881
+ if not _wire_endpoint_compatible(target_proto, base):
2882
+ cands = []
2854
2883
  else:
2855
2884
  # anthropic 习惯:CLI 在 base 后拼 /v1/messages,base 本身不含 /v1
2856
2885
  cands = [base[:-3] if base.endswith("/v1") else base]
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, attachments, branching, 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
@@ -30,17 +30,60 @@ from .repeat_guard import guard as repeat_guard
30
30
  DEFAULT_RUBRIC = ["情节", "人物", "文笔", "节奏", "吸引力"]
31
31
 
32
32
 
33
- class Cancelled(Exception):
34
- pass
33
+ class Cancelled(Exception):
34
+ pass
35
+
36
+
37
+ class TaskTimeout(Exception):
38
+ pass
35
39
 
36
40
 
37
41
  def _now():
38
42
  return time.strftime("%Y-%m-%d %H:%M:%S")
39
43
 
40
44
 
41
- def _check_cancel(ev):
42
- if ev is not None and ev.is_set():
43
- raise Cancelled()
45
+ def _check_cancel(ev):
46
+ if ev is not None and ev.is_set():
47
+ raise Cancelled()
48
+
49
+
50
+ def _run_deadline(run_id):
51
+ """Convert the persisted wall-clock cutoff into a monotonic deadline."""
52
+ run = store.get_run(run_id) or {}
53
+ try:
54
+ deadline_at = float(run.get("deadline_at"))
55
+ except (TypeError, ValueError):
56
+ return None
57
+ return time.monotonic() + (deadline_at - time.time())
58
+
59
+
60
+ def _ensure_budget(run_id):
61
+ deadline = _run_deadline(run_id)
62
+ if deadline is not None and time.monotonic() >= deadline:
63
+ store.update_run(run_id, expected_status="running", status="timeout",
64
+ ended_at=_now(), error="任务总时限已到")
65
+ raise TaskTimeout()
66
+ return deadline
67
+
68
+
69
+ def _step_timeout(run_id, requested, deadline=None):
70
+ value = max(0.0, float(requested or runner.DEFAULT_TIMEOUT))
71
+ deadline = deadline if deadline is not None else _run_deadline(run_id)
72
+ if deadline is not None:
73
+ value = min(value, max(0.0, deadline - time.monotonic()))
74
+ return value, deadline
75
+
76
+
77
+ def _planner_call(fn, *args, deadline=None, **kwargs):
78
+ """Allow older injected planner doubles to omit the new deadline kwarg."""
79
+ if deadline is None:
80
+ return fn(*args, **kwargs)
81
+ try:
82
+ return fn(*args, deadline=deadline, **kwargs)
83
+ except TypeError as exc:
84
+ if "unexpected keyword argument 'deadline'" not in str(exc):
85
+ raise
86
+ return fn(*args, **kwargs)
44
87
 
45
88
 
46
89
  def _inside(dirpath, target):
@@ -195,12 +238,12 @@ def _get_session(run_id):
195
238
  return s
196
239
 
197
240
 
198
- def _make_llm_caller(agent, workdir):
241
+ def _make_llm_caller(agent, workdir, deadline=None):
199
242
  """压缩摘要用 LLM:直接复用当前 step 的 agent(同 CLI 同模型)。"""
200
243
  def caller(messages):
201
244
  prompt = "\n\n".join(m.get("content", "") for m in messages)
202
- res = runner.run_agent(agent, prompt, workdir=workdir, readonly=True,
203
- timeout=300)
245
+ res = runner.run_agent(agent, prompt, workdir=workdir, readonly=True,
246
+ timeout=300, deadline=deadline)
204
247
  return res.get("text") or ""
205
248
  return caller
206
249
 
@@ -303,9 +346,11 @@ def _binding_dead_msg(agent):
303
346
  "请在「模型调度(可选)」页为该 CLI 指定已启用的供应商")
304
347
 
305
348
 
306
- def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False):
307
- """执行一个智能体步骤并记录。返回 runner 统一结果。"""
308
- _wait_gate(run_id, ev)
349
+ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False):
350
+ """执行一个智能体步骤并记录。返回 runner 统一结果。"""
351
+ _wait_gate(run_id, ev)
352
+ deadline = _ensure_budget(run_id)
353
+ timeout, deadline = _step_timeout(run_id, timeout, deadline)
309
354
  # 绑定解析为空分两种(2026-09-18 起 区分对待):
310
355
  # · 从没配过链(binding_configured=False):回落 CLI 本机默认照跑——
311
356
  # 用户根本没在 CodeBee 里配供应商,谈不到「烧自己配的配额」;判失败
@@ -331,7 +376,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
331
376
  if dead_binding:
332
377
  note = ((note + ";") if note else "") + "⚠ " + dead_msg
333
378
  step, log_abs = store.add_step(run_id, role, agent["id"],
334
- agent.get("label", agent["id"]), note=note)
379
+ agent.get("label", agent["id"]), note=note,
380
+ model=agent.get("model") or "")
335
381
  start = time.time()
336
382
  if dead_binding:
337
383
  from .error_codes import ErrorCode
@@ -372,19 +418,24 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
372
418
  if directive_block:
373
419
  prompt = directive_block + "\n\n---\n\n" + prompt
374
420
  effective_prompt = (guard["reminder"] + "\n\n---\n\n" + prompt) if guard["reminder"] else prompt
375
- res = _spawn_step(session_run_id=run_id, role=role, agent=agent,
376
- prompt=effective_prompt, workdir=workdir, readonly=readonly,
377
- ev=ev, timeout=timeout, resume=resume, step=step,
378
- log_abs=log_abs, images=images, require_tools=require_tools)
421
+ res = _spawn_step(session_run_id=run_id, role=role, agent=agent,
422
+ prompt=effective_prompt, workdir=workdir, readonly=readonly,
423
+ ev=ev, timeout=timeout, resume=resume, step=step,
424
+ log_abs=log_abs, images=images, require_tools=require_tools,
425
+ deadline=deadline)
379
426
  # 先收尾再查取消:取消时进程已被 run_process 杀停,若先抛 Cancelled,
380
- # 步骤记录会永远停在「运行中」变僵尸(与 _run_verify 的顺序对齐)
381
- _finish_step_result(run_id, step, res, role, agent, start)
382
- _check_cancel(ev)
427
+ # 步骤记录会永远停在「运行中」变僵尸(与 _run_verify 的顺序对齐)
428
+ _finish_step_result(run_id, step, res, role, agent, start)
429
+ if (res.get("raw") or {}).get("deadline_exceeded"):
430
+ store.update_run(run_id, expected_status="running", status="timeout",
431
+ ended_at=_now(), error="任务总时限已到")
432
+ raise TaskTimeout()
433
+ _check_cancel(ev)
383
434
  return res
384
435
 
385
436
 
386
- def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None,
387
- followups=False):
437
+ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None,
438
+ followups=False):
388
439
  """内置智能体步骤:直连模型 API + 工具循环(builtin_agent),不经 CLI 进程。
389
440
 
390
441
  与 _run_step 对齐的三件事:暂停/取消闸门、运行中指令 drain 注入、重复调用
@@ -392,8 +443,10 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
392
443
  日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。
393
444
  followups=True 时从回答末尾解析「建议追问」块(直连对话专用协议):
394
445
  剥离出结构化列表落步骤记录,正文保持干净。"""
395
- _wait_gate(run_id, ev)
396
- step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note)
446
+ _wait_gate(run_id, ev)
447
+ deadline = _ensure_budget(run_id)
448
+ step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note,
449
+ model=bi.get("model") or "")
397
450
  start = time.time()
398
451
  agent_pseudo = {"id": "builtin", "label": "CodeBee", "kind": "builtin", "mode": "real",
399
452
  "provider": {"id": bi.get("provider_id") or "",
@@ -435,9 +488,11 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
435
488
  def _on_activity(line):
436
489
  store.stream_step(run_id, step["n"], activity=str(line or "")[:200])
437
490
 
438
- res = builtin_agent.run(bi, prompt, workdir, cancel_event=ev, log=_log, images=images,
439
- on_reason=_on_reason, on_stream=_on_stream,
440
- on_activity=_on_activity)
491
+ remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
492
+ res = builtin_agent.run(bi, prompt, workdir, timeout=remaining or 180,
493
+ deadline=deadline, cancel_event=ev, log=_log, images=images,
494
+ on_reason=_on_reason, on_stream=_on_stream,
495
+ on_activity=_on_activity)
441
496
  if followups and res.get("ok"):
442
497
  clean, fups = _parse_followups(res.get("text") or "")
443
498
  if fups:
@@ -458,9 +513,13 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
458
513
  res["raw"]["duration"] = time.time() - start
459
514
  res["tokens"] = int(usage.get("total") or 0)
460
515
  res.setdefault("cost_usd", 0.0)
461
- # 先收尾再查取消:与 _run_step 同序,防步骤记录停在「运行中」变僵尸
462
- _finish_step_result(run_id, step, res, role, agent_pseudo, start)
463
- _check_cancel(ev)
516
+ # 先收尾再查取消:与 _run_step 同序,防步骤记录停在「运行中」变僵尸
517
+ _finish_step_result(run_id, step, res, role, agent_pseudo, start)
518
+ if (res.get("raw") or {}).get("deadline_exceeded"):
519
+ store.update_run(run_id, expected_status="running", status="timeout",
520
+ ended_at=_now(), error="任务总时限已到")
521
+ raise TaskTimeout()
522
+ _check_cancel(ev)
464
523
  return res
465
524
 
466
525
 
@@ -483,8 +542,9 @@ def _budget_max_tokens():
483
542
  return 0
484
543
 
485
544
 
486
- def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
487
- timeout, resume, step, log_abs, images=None, require_tools=False):
545
+ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
546
+ timeout, resume, step, log_abs, images=None, require_tools=False,
547
+ deadline=None):
488
548
  """真实 CLI 调用:压缩灰度路径或原路径。"""
489
549
  # T2.1 预算闸:已用 token 达到单次 run 上限 → 阻断后续真实调用(ENV_BLOCK)。
490
550
  # 只拦「下一步」,允许越过线的当前步完成;auto 续跑可在用户调高预算后接手。
@@ -507,10 +567,10 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
507
567
  if _compaction_enabled() and not resume:
508
568
  # Phase 2(1D):撑爆 → 压缩 → 守门重试;同时把 usage 累进 token_meter(1C)
509
569
  session = _get_session(session_run_id)
510
- llm_caller = _make_llm_caller(agent, workdir)
511
- call_kwargs = dict(workdir=workdir, readonly=readonly,
512
- timeout=timeout, cancel_event=ev, log_path=str(log_abs),
513
- images=images, require_tools=require_tools)
570
+ llm_caller = _make_llm_caller(agent, workdir, deadline=deadline)
571
+ call_kwargs = dict(workdir=workdir, readonly=readonly,
572
+ timeout=timeout, cancel_event=ev, log_path=str(log_abs),
573
+ images=images, require_tools=require_tools, deadline=deadline)
514
574
 
515
575
  def _call(p, **kw):
516
576
  nonlocal usage_recorded
@@ -535,9 +595,10 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
535
595
  session, _call, prompt, model=agent.get("model") or "",
536
596
  llm_caller=llm_caller)
537
597
  else:
538
- res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
539
- timeout=timeout, cancel_event=ev, log_path=str(log_abs),
540
- resume=resume, images=images, require_tools=require_tools)
598
+ res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
599
+ timeout=timeout, cancel_event=ev, log_path=str(log_abs),
600
+ resume=resume, images=images, require_tools=require_tools,
601
+ deadline=deadline)
541
602
  # 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
542
603
  # 下一步永远看到 used=0,max_tokens_per_run 只是一个无效设置。
543
604
  if not usage_recorded:
@@ -724,14 +785,17 @@ def _files_hint(sub):
724
785
  "`%s`" % str(f) for f in files[:10])
725
786
 
726
787
 
727
- def _run_verify(run_id, task, workdir, ev):
728
- """确定性验证。返回 (verify_pass, ran)。"""
729
- if not task.get("verify_command"):
730
- return True, False
788
+ def _run_verify(run_id, task, workdir, ev):
789
+ """确定性验证。返回 (verify_pass, ran)。"""
790
+ deadline = _ensure_budget(run_id)
791
+ if not task.get("verify_command"):
792
+ return True, False
731
793
  step, log_abs = store.add_step(run_id, "verify", "builtin", "内置验证器")
732
794
  start = time.time()
733
- r = runner.run_process(shell_cmd=task["verify_command"], cwd=workdir,
734
- timeout=600, cancel_event=ev, log_path=str(log_abs))
795
+ timeout, deadline = _step_timeout(run_id, 600, deadline)
796
+ r = runner.run_process(shell_cmd=task["verify_command"], cwd=workdir,
797
+ timeout=timeout, deadline=deadline, cancel_event=ev,
798
+ log_path=str(log_abs))
735
799
  ok = r["ok"]
736
800
  # 与 _finish_step_result 同一套显示层细分:超时/取消杀停不再冒充「失败」
737
801
  if r.get("cancelled"):
@@ -741,9 +805,13 @@ def _run_verify(run_id, task, workdir, ev):
741
805
  else:
742
806
  v_status = "done" if ok else "failed"
743
807
  v_sum = "验证通过" if ok else "验证失败(exit %s)" % r["exit_code"]
744
- store.finish_step(run_id, step["n"], v_status,
745
- summary=v_sum,
746
- exit_code=r["exit_code"], duration_s=time.time() - start)
808
+ store.finish_step(run_id, step["n"], v_status,
809
+ summary=v_sum,
810
+ exit_code=r["exit_code"], duration_s=time.time() - start)
811
+ if r.get("deadline_exceeded"):
812
+ store.update_run(run_id, expected_status="running", status="timeout",
813
+ ended_at=_now(), error="任务总时限已到")
814
+ raise TaskTimeout()
747
815
  _check_cancel(ev)
748
816
  return ok, True
749
817
 
@@ -940,6 +1008,72 @@ def _record_dispatch_completed(run_id, task, result, verify_pass=None, review_pa
940
1008
  pass
941
1009
 
942
1010
 
1011
+ def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
1012
+ stats, prompt, workdir, step_wd, ev, resume_ctx,
1013
+ draft_note, draft_res):
1014
+ """Auto content tasks may try another configured CLI after a draft failure.
1015
+
1016
+ Model/provider failover has already been exhausted inside run_agent. Keep
1017
+ manual execution, user cancellation and resumed sessions pinned to the
1018
+ user's explicit choice.
1019
+ """
1020
+ raw = draft_res.get("raw") or {}
1021
+ if (mode == "manual" or impl.get("mode") != "real" or resume_ctx
1022
+ or raw.get("cancelled") or draft_res.get("error_code") == "CANCELLED"):
1023
+ return draft_res, impl, draft_note
1024
+
1025
+ tried = {impl.get("id")}
1026
+ failures = ["%s:%s" % (impl.get("id") or "CLI",
1027
+ (draft_res.get("error") or "起草失败")[:180])]
1028
+ dead_upstreams = []
1029
+ first_upstreams = router.agent_upstreams(impl.get("id"))
1030
+ if first_upstreams:
1031
+ dead_upstreams.append(first_upstreams)
1032
+ current = impl
1033
+ last_impl = impl
1034
+ last_result = draft_res
1035
+ last_note = draft_note
1036
+
1037
+ while True:
1038
+ _check_cancel(ev)
1039
+ candidate, reason = router.pick_switch_candidate(
1040
+ agents, "implement", task.get("type") or "novel", stats,
1041
+ exclude=tried, dead_upstreams=dead_upstreams)
1042
+ if candidate is None or candidate.get("id") in tried:
1043
+ break
1044
+ tried.add(candidate.get("id"))
1045
+ if candidate.get("mode") != "real":
1046
+ continue
1047
+
1048
+ why = (last_result.get("error") or "起草失败")[:180]
1049
+ switch_note = ("起草自动换 CLI:%s → %s(%s);前序失败:%s" % (
1050
+ current.get("id") or "CLI", candidate.get("id") or "CLI",
1051
+ reason or "自动路由", why))
1052
+ attempt_note = (draft_note + ";" if draft_note else "") + switch_note
1053
+ bound = modelhub.bind_agent(candidate, difficulty)
1054
+ last_result = _run_step(
1055
+ run_id, "draft", bound, prompt, step_wd, readonly=False, ev=ev,
1056
+ note=attempt_note, images=_task_images(task, workdir))
1057
+ last_impl = candidate
1058
+ last_note = attempt_note
1059
+ if last_result.get("ok"):
1060
+ return last_result, candidate, attempt_note
1061
+
1062
+ failures.append("%s:%s" % (candidate.get("id") or "CLI",
1063
+ (last_result.get("error") or "起草失败")[:180]))
1064
+ if (last_result.get("raw") or {}).get("cancelled"):
1065
+ break
1066
+ ups = router.agent_upstreams(candidate.get("id"))
1067
+ if ups:
1068
+ dead_upstreams.append(ups)
1069
+ current = candidate
1070
+
1071
+ if last_impl is not impl:
1072
+ last_result = dict(last_result)
1073
+ last_result["error"] = "CLI 自动切换均失败:%s" % ";".join(failures[-4:])
1074
+ return last_result, last_impl, last_note
1075
+
1076
+
943
1077
  def _run_code(run, task, agents, ev, stats, mode):
944
1078
  run_id = run["id"]
945
1079
  workdir = task["workdir"]
@@ -989,11 +1123,14 @@ def _run_code(run, task, agents, ev, stats, mode):
989
1123
  _wait_gate(run_id, ev)
990
1124
  plan_step, plan_log = store.add_step(run_id, "plan", impl["id"], impl.get("label"),
991
1125
  note=route.get("implementer", ""))
992
- plan = planner.make_code_plan(_steered_task(run_id, task),
993
- modelhub.bind_agent(impl, difficulty),
994
- _resume_workdir(resume_ctx, workdir), ev,
995
- resume=resume_ctx["session"] if resume_ctx else None,
996
- log_path=str(plan_log) if plan_log else None)
1126
+ plan = _planner_call(
1127
+ planner.make_code_plan, _steered_task(run_id, task),
1128
+ modelhub.bind_agent(impl, difficulty),
1129
+ _resume_workdir(resume_ctx, workdir), ev,
1130
+ resume=resume_ctx["session"] if resume_ctx else None,
1131
+ log_path=str(plan_log) if plan_log else None,
1132
+ deadline=_run_deadline(run_id))
1133
+ _ensure_budget(run_id)
997
1134
  # 规划器判定优先于启发式(仅当用户未显式指定难度)
998
1135
  if not explicit and plan.get("difficulty") in ("easy", "hard"):
999
1136
  difficulty = plan["difficulty"]
@@ -2108,8 +2245,9 @@ def _wc(text):
2108
2245
 
2109
2246
 
2110
2247
  def _write_chapter(workdir, i, text):
2111
- with _chapter_io(workdir, i, "w") as f:
2112
- f.write(text)
2248
+ # 安全落盘委托(inkos 安全章节工作区借鉴):tmp+原子改名,写一半
2249
+ # 崩溃不留半章冒充成稿;路径守卫在 chaptersafe(resolve+parents)
2250
+ chaptersafe.atomic_write_chapter(workdir, i, text)
2113
2251
 
2114
2252
 
2115
2253
  def _all_ge(scores, threshold):
@@ -2260,8 +2398,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2260
2398
  _wait_gate(run_id, ev)
2261
2399
  outline_step, outline_log = store.add_step(run_id, "outline", impl["id"], impl.get("label"),
2262
2400
  note=route.get("author", ""))
2263
- outline = planner.make_serial_outline(_steered_task(run_id, task), impl, workdir, ev,
2264
- log_path=str(outline_log) if outline_log else None)
2401
+ outline = _planner_call(
2402
+ planner.make_serial_outline, _steered_task(run_id, task), impl, workdir, ev,
2403
+ log_path=str(outline_log) if outline_log else None,
2404
+ deadline=_run_deadline(run_id))
2405
+ _ensure_budget(run_id)
2265
2406
  if outline.get("degraded") and impl.get("mode") != "mock":
2266
2407
  # 兜底模板只有章号没有情节,据此写出的两万字等于废稿——
2267
2408
  # 中止并交给自动续跑等编排者恢复后重试,而不是空转烧配额。
@@ -3431,8 +3572,11 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3431
3572
  draft_note = route.get("author", "") if mode != "manual" else ""
3432
3573
 
3433
3574
  # 编排者大纲:只对真实执行有意义;失败静默退回无大纲(喂入带指令的任务副本)
3434
- outline = (planner.make_review_outline(_steered_task(run_id, task))
3435
- if workflow["outline"] and impl.get("mode") != "mock" else None)
3575
+ outline = (_planner_call(planner.make_review_outline,
3576
+ _steered_task(run_id, task),
3577
+ deadline=_run_deadline(run_id))
3578
+ if workflow["outline"] and impl.get("mode") != "mock" else None)
3579
+ _ensure_budget(run_id)
3436
3580
  if outline:
3437
3581
  workflow = task_compile.content_workflow(
3438
3582
  task, difficulty, plan=outline, mode=mode)
@@ -3491,6 +3635,27 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3491
3635
  ev=ev, note=draft_note,
3492
3636
  resume=resume_ctx["session"] if resume_ctx else None,
3493
3637
  images=_task_images(task, workdir))
3638
+ if not draft_res["ok"]:
3639
+ draft_res, actual_impl, fallback_note = _retry_content_draft_with_cli(
3640
+ run_id, task, agents, impl, difficulty, mode, stats,
3641
+ _draft_prompt_for(ms_name), workdir, step_wd, ev, resume_ctx,
3642
+ draft_note, draft_res)
3643
+ if actual_impl.get("id") != impl.get("id"):
3644
+ impl = actual_impl
3645
+ draft_note = fallback_note
3646
+ route["author"] = fallback_note
3647
+ if mode != "manual":
3648
+ critic_pool, route["critics"] = router.pick_critics(
3649
+ agents, task.get("type") or "novel", stats, impl=impl)
3650
+ critics = critic_pool[:workflow["reviewers"]]
3651
+ plan = planner.make_novel_plan(_steered_task(run_id, task), impl, critics)
3652
+ store.update_run(run_id, route=route, plan=plan)
3653
+ _record_actual_route(
3654
+ run_id, task, agents, stats, impl, critics=critics,
3655
+ implement_reason=route.get("author", ""),
3656
+ review_reason=route.get("critics", ""))
3657
+ if draft_res.get("raw", {}).get("cancelled"):
3658
+ _check_cancel(ev)
3494
3659
  if not draft_res["ok"]:
3495
3660
  store.update_run(run_id, expected_status="running", status="failed",
3496
3661
  error="起草失败: %s" % draft_res.get("error"),
@@ -3899,10 +4064,14 @@ def execute_run(run_id):
3899
4064
  if store.update_run(run_id, expected_status=initial_status, status="running",
3900
4065
  started_at=_now()) is None:
3901
4066
  return
3902
- if task is None:
3903
- store.update_run(run_id, expected_status="running", status="failed",
3904
- error="找不到任务 %s" % run.get("task_id"), ended_at=_now())
3905
- return
4067
+ if task is None:
4068
+ store.update_run(run_id, expected_status="running", status="failed",
4069
+ error="找不到任务 %s" % run.get("task_id"), ended_at=_now())
4070
+ return
4071
+ try:
4072
+ _ensure_budget(run_id)
4073
+ except TaskTimeout:
4074
+ return
3906
4075
  # 统一任务编译:旧字段继续供各引擎读取,规格作为运行级诊断与调度输入落盘。
3907
4076
  task_spec = task_compile.compile_task(task)
3908
4077
  store.update_run(run_id, task_spec=task_spec,
@@ -4021,9 +4190,12 @@ def execute_run(run_id):
4021
4190
  critics, impl, route, resume_ctx, difficulty)
4022
4191
  else:
4023
4192
  _run_content_review(run, task, agents, ev, stats, mode)
4024
- except Cancelled:
4025
- store.update_run(run_id, expected_status="running",
4026
- status="cancelled", ended_at=_now())
4193
+ except TaskTimeout:
4194
+ store.update_run(run_id, expected_status="running",
4195
+ status="timeout", ended_at=_now(), error="任务总时限已到")
4196
+ except Cancelled:
4197
+ store.update_run(run_id, expected_status="running",
4198
+ status="cancelled", ended_at=_now())
4027
4199
  except Exception as e:
4028
4200
  import traceback
4029
4201
  store.update_run(run_id, expected_status="running", status="failed",
@@ -69,7 +69,7 @@ def _log_streamer(log_path, min_chars=400, min_secs=0.8):
69
69
  return cb
70
70
 
71
71
 
72
- def _outline_timeout():
72
+ def _outline_timeout():
73
73
  """大纲/规划的 CLI 兜底超时(秒):env TUTTI_OUTLINE_TIMEOUT(秒)优先,
74
74
  其次设置 orchestrator.outline_timeout_s,缺省 900。"""
75
75
  try:
@@ -86,7 +86,16 @@ def _outline_timeout():
86
86
  return v
87
87
  except Exception:
88
88
  pass
89
- return DEFAULT_OUTLINE_TIMEOUT
89
+ return DEFAULT_OUTLINE_TIMEOUT
90
+
91
+
92
+ def _deadline_timeout(deadline, requested):
93
+ if deadline is None:
94
+ return requested
95
+ try:
96
+ return max(1, min(float(requested), deadline - time.monotonic()))
97
+ except (TypeError, ValueError):
98
+ return requested
90
99
 
91
100
 
92
101
  def _log_usage(source, role, task, res, agent=None, tool="", model="", provider="",
@@ -346,7 +355,8 @@ def book_volume_plan(task, outline, upto):
346
355
  return volumes.merge_titles(plan, named)
347
356
 
348
357
 
349
- def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None):
358
+ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None,
359
+ deadline=None):
350
360
  """连载大纲:编排者 API 优先 → 作者 CLI → 模板。返回 {book_title, chapters:[{title,beats,hook}]}。
351
361
 
352
362
  续写批次(serial.start_chapter > 1):改用续写大纲提示词,注入前情大纲与
@@ -443,8 +453,8 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
443
453
  # prompt 逐字节相同,命中省一次全量规划调用。第 2 次尝试故意
444
454
  # 旁路缓存——首试 ok 但 JSON 不合法时,重试要的是「再抽一次样
445
455
  # 本」而不是同一份坏文本。
446
- res = modelhub.chat(prov["id"], model, prompt,
447
- max_tokens=16000, timeout=300, on_delta=cb,
456
+ res = modelhub.chat(prov["id"], model, prompt,
457
+ max_tokens=16000, timeout=_deadline_timeout(deadline, 300), on_delta=cb,
448
458
  reasoning_effort=_reasoning_effort(task),
449
459
  cache_ttl=3600 if _attempt == 1 else 0)
450
460
  cb.flush()
@@ -473,7 +483,8 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
473
483
  _append_log(log_path, "===== 作者 CLI(%s)=====" % author_agent.get("id", "?"))
474
484
  res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
475
485
  workdir=workdir or task.get("workdir"), readonly=True,
476
- timeout=_outline_timeout(), cancel_event=ev,
486
+ timeout=_deadline_timeout(deadline, _outline_timeout()),
487
+ deadline=deadline, cancel_event=ev,
477
488
  log_path=log_path)
478
489
  _log_usage("outline", "outline", task, res, agent=author_agent)
479
490
  if not res["ok"]:
@@ -591,11 +602,13 @@ def _reasoning_effort(task):
591
602
  return "medium"
592
603
 
593
604
 
594
- def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=None):
605
+ def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=None,
606
+ deadline=None):
595
607
  """代码任务计划:编排者 API 优先 → CLI 智能体 → 单步模板。"""
596
608
  orch = _orchestrator()
597
609
  if orch:
598
- plan = _orch_code_plan(task, orch[0], orch[1], log_path=log_path)
610
+ plan = _orch_code_plan(task, orch[0], orch[1], log_path=log_path,
611
+ deadline=deadline)
599
612
  if plan:
600
613
  return plan
601
614
  if planner_agent is None:
@@ -607,7 +620,8 @@ def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=
607
620
  .replace("__CONTEXT__", task.get("context") or "(无)")
608
621
  .replace("__VERIFY__", task.get("verify_command") or "(未配置)"))
609
622
  res = runner.run_agent(planner_agent, prompt, workdir=workdir, readonly=True,
610
- timeout=300, cancel_event=ev, resume=resume,
623
+ timeout=_deadline_timeout(deadline, 300), deadline=deadline,
624
+ cancel_event=ev, resume=resume,
611
625
  log_path=log_path)
612
626
  _log_usage("plan", "plan", task, res, agent=planner_agent)
613
627
  data = runner.extract_json(res.get("text") or "")
@@ -624,7 +638,7 @@ def _fallback_code_plan(task, note):
624
638
  "steps": [{"title": "实现任务", "detail": task["goal"]}]}
625
639
 
626
640
 
627
- def _orch_code_plan(task, prov, model, log_path=None):
641
+ def _orch_code_plan(task, prov, model, log_path=None, deadline=None):
628
642
  cb = _log_streamer(log_path)
629
643
  # §07 T2.2:代码计划同任务同 prompt(断点续跑/重试),TTL 1h 精确缓存
630
644
  res = modelhub.chat(prov["id"], model,
@@ -632,7 +646,7 @@ def _orch_code_plan(task, prov, model, log_path=None):
632
646
  .replace("__GOAL__", task["goal"])
633
647
  .replace("__CONTEXT__", task.get("context") or "(无)")
634
648
  .replace("__VERIFY__", task.get("verify_command") or "(未配置)")),
635
- max_tokens=8000, timeout=300, on_delta=cb,
649
+ max_tokens=8000, timeout=_deadline_timeout(deadline, 300), on_delta=cb,
636
650
  reasoning_effort=_reasoning_effort(task),
637
651
  cache_ttl=3600)
638
652
  cb.flush()
@@ -652,7 +666,7 @@ def _orch_code_plan(task, prov, model, log_path=None):
652
666
  "workflow": _plan_workflow(data)}
653
667
 
654
668
 
655
- def make_review_outline(task):
669
+ def make_review_outline(task, deadline=None):
656
670
  """review 类任务:编排者产出写作大纲(失败返回 None,起草退回无大纲)。"""
657
671
  orch = _orchestrator()
658
672
  if not orch:
@@ -662,7 +676,7 @@ def make_review_outline(task):
662
676
  (REVIEW_OUTLINE_PROMPT
663
677
  .replace("__GOAL__", task["goal"])
664
678
  .replace("__CONTEXT__", task.get("context") or "(无)")),
665
- max_tokens=8000, timeout=300,
679
+ max_tokens=8000, timeout=_deadline_timeout(deadline, 300),
666
680
  reasoning_effort=_reasoning_effort(task),
667
681
  cache_ttl=3600) # §07 T2.2:同任务重试幂等,TTL 1h
668
682
  _log_usage("outline", "outline", task, res, model=model,