codebee 0.1.100 → 0.1.102

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.
@@ -586,6 +586,8 @@ def _external_sandbox_block_reason(agent, workdir, sandbox):
586
586
  from . import policy
587
587
  root = os.path.realpath(workdir or ".")
588
588
  normalized = policy.normalize_sandbox(sandbox, root)
589
+ if normalized.get("backend") == "docker":
590
+ return "沙箱拒绝:Docker 目前仅接入内置命令工具,外部 CLI 未验证容器启动,拒绝运行"
589
591
  if (agent or {}).get("kind") == "codex":
590
592
  if normalized.get("network") is False:
591
593
  return "沙箱拒绝:该 CLI 后端未接入网络隔离,拒绝运行网络受限任务"
@@ -912,7 +914,8 @@ def _finish_step_result(run_id, step, res, role, agent, start):
912
914
  followups=res.get("followups"),
913
915
  # 思考过程(内置智能体流式抓取):落进步骤记录,对话气泡折叠展示
914
916
  thinking=res.get("reasoning") or None,
915
- partial=bool(res.get("partial")))
917
+ partial=bool(res.get("partial")),
918
+ acp_events=(raw.get("acp_events") or []))
916
919
  try:
917
920
  checkpoint_status = ("unknown" if raw.get("unknown") else
918
921
  "timeout" if status == "timeout" else
@@ -2591,6 +2594,36 @@ __CRITIQUE__
2591
2594
  - 针对性改进所有 major 问题;保持既定风格与设定。
2592
2595
  - 完成后用 3 句话说明本轮改了什么。"""
2593
2596
 
2597
+ # 评审口径单一真源:评审面提示词(单章/全局)与作者面注入块从这里同源提取,
2598
+ # 两边永不脱节。此前评审清单/签约专项/全局口径只活在评审面,作者第一遍写完
2599
+ # 被打回才知道按什么标准被审(2026-10-10 实案:全章过线仍卡全局 major)。
2600
+ # 作者面刻意不下发评分机制本体(1-10 分/阈值/宁严勿宽)——对写作者零价值,
2601
+ # 透下去只喂响应试素材;清单条目本身全是质量正向要求,事前下发只有好处。
2602
+ REVIEW_CHECKLIST_ITEMS = (
2603
+ "叙述语域与视角统一",
2604
+ "复杂情感落到动作、五感或生理细节",
2605
+ "开篇在前 600 字进入具体冲突",
2606
+ "每章发生真实的局面变化",
2607
+ "无可删的重复铺垫与长段解释",
2608
+ )
2609
+ GLOBAL_REVIEW_FOCUS = ("主线一致性、人物弧光、开篇吸引力、文风表现、情感表达、"
2610
+ "情节推进与节奏控制")
2611
+
2612
+
2613
+ def author_review_rubric_block(dims, signing_dims):
2614
+ """作者面评审口径块:起草/修订提示词里事前下发「会按什么标准被审」,
2615
+ 与评审面同源(REVIEW_CHECKLIST_ITEMS / GLOBAL_REVIEW_FOCUS)。静态文本,
2616
+ 同批各章字节一致,不碎前缀缓存。"""
2617
+ lines = [
2618
+ "## 评审口径(本书会按以下标准审稿,写作时对照自查)",
2619
+ "- 评分维度:" + " / ".join(dims) + "。",
2620
+ "- 逐项硬清单:" + ";".join(REVIEW_CHECKLIST_ITEMS) + "。",
2621
+ "- 全书级关注:" + GLOBAL_REVIEW_FOCUS + "。",
2622
+ "- 签约专项维度:" + "、".join(signing_dims) + "。",
2623
+ ]
2624
+ return "\n".join(lines) + "\n"
2625
+
2626
+
2594
2627
  NOVEL_CRITIQUE_PROMPT = """你是严格的评审(不要使用任何工具、不要修改文件,只依据下方稿件内容评审)。
2595
2628
  请输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
2596
2629
  {
@@ -2599,7 +2632,7 @@ NOVEL_CRITIQUE_PROMPT = """你是严格的评审(不要使用任何工具、
2599
2632
  "summary": "一句话总评"
2600
2633
  }
2601
2634
  每个维度打 1-10 分(可为小数),宁严勿宽。major 问题必须给 quote(系统会逐条校验引文是否真在稿件中——编造的引文会被降档标记)。
2602
- 评审必须逐项回答:叙述语域与视角是否统一;复杂情感是否有动作、五感或生理细节;开篇是否在前 600 字进入具体冲突;本章是否发生局面变化;是否存在可删的重复铺垫或长段解释。
2635
+ 评审逐项核查以下各项是否成立:%s。""" % (";".join(REVIEW_CHECKLIST_ITEMS)) + """
2603
2636
 
2604
2637
  ## 待评审稿件
2605
2638
  ---
@@ -2885,6 +2918,7 @@ __VOLUME__
2885
2918
  - 本章主要情绪/戏剧变化:__HIGHLIGHT__(按其重要性决定篇幅和表现方式;不强制放大,不为满足标签伪造高潮)
2886
2919
  __LOCKED_CH__
2887
2920
  __STALE_NOTE__
2921
+ __REDO_ISSUES__
2888
2922
  - 正文约 __WORDS__ 字,中文,直接开写正文(可含本章标题行)。
2889
2923
 
2890
2924
  ## 前情提要(此前各章结尾摘录,衔接用)
@@ -2899,6 +2933,7 @@ __STYLE__
2899
2933
  ## 签约质量门禁
2900
2934
  __QUALITY_GATE__
2901
2935
 
2936
+ __REVIEW_RUBRIC__
2902
2937
  - 写完文件后,最终回复只输出一行:`第 __I__ 章完成(约 __WORDS__ 字)`——不要在回复里复述或解释正文。"""
2903
2938
 
2904
2939
  SERIAL_REVISE_PROMPT = """你是一名网文作者。第 __I__ 章没有通过评审,请修订文件 `__FILE__`(直接改写该文件)。文件必须以 UTF-8 编码保存(PowerShell 显式加 -Encoding UTF8,禁止依赖默认编码)。
@@ -2913,6 +2948,7 @@ __BEATS__
2913
2948
  (本章爽点/情绪爆点:__HIGHLIGHT__)
2914
2949
  __LOCKED_CH__
2915
2950
 
2951
+ __REVIEW_RUBRIC__
2916
2952
  ## 本章评审意见
2917
2953
  __CRITIQUE__
2918
2954
 
@@ -2936,7 +2972,7 @@ SERIAL_GLOBAL_PROMPT = """你是独立小说质量评审(不要修改任何文
2936
2972
  "issues": [{"dim": "维度名", "severity": "major|minor", "note": "具体问题(指明哪一章)", "quote": "支撑该问题的稿件原文连续片段(≥8字,逐字摘录不许改写)"}],
2937
2973
  "summary": "一句话总评:所读范围内的文本优势、主要风险与未验证项;不得预测签约结果"
2938
2974
  }
2939
- 每个维度打 1-10 分,按统一文本 rubric 与所读证据评估,不以“宁严勿宽”压分。重点关注:主线一致性、人物弧光、开篇吸引力、文风表现、情感表达、情节推进与节奏控制。
2975
+ 每个维度打 1-10 分,按统一文本 rubric 与所读证据评估,不以“宁严勿宽”压分。重点关注:%s。""" % GLOBAL_REVIEW_FOCUS + """
2940
2976
  若是全书最初的前 3 章,可观察视角人物、故事承诺、开篇兑现和读者理解成本;固定字数点、钩子/爽点密度仅作参考,不是扣分门槛。发现问题须给可定位原文引文,并区分文本事实、解释和建议。
2941
2977
  major 问题必须给 quote(系统会逐条校验引文是否真在稿件中——编造的引文会被降档标记)。
2942
2978
 
@@ -3342,6 +3378,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3342
3378
  # 签约专项维度作为额外硬性检查注入提示词与报告证据。
3343
3379
  dims = task.get("rubric") or DEFAULT_RUBRIC
3344
3380
  signing_dims = novel_quality.rubric_for({"type": "serial_novel"})
3381
+ # 作者面评审口径块(与评审面同源提取):起草/修订/打磨提示词都带
3382
+ rubric_block = author_review_rubric_block(dims, signing_dims)
3345
3383
  threshold = task.get("threshold", 7.0)
3346
3384
  threshold_ch = threshold - 0.5 if threshold >= 7.5 else threshold # 单章阈值略放宽 0.5 分
3347
3385
  dimkey = ", ".join('"%s": 0' % d for d in dims)
@@ -3412,6 +3450,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3412
3450
  inherit = run.get("inherit") or {}
3413
3451
  done_set = set(inherit.get("done_chapters") or [])
3414
3452
  inh_scores = {c.get("chapter"): c for c in (inherit.get("chapter_scores") or [])}
3453
+ # 未达标重写的原因账(store.retry_task 从 verdict.major_issues 下发):
3454
+ # 章节级+全局级 major,渲染进对应重写章的起草提示词;达标续跑为空表
3455
+ redo_issues = [x for x in (inherit.get("redo_issues") or []) if isinstance(x, dict)]
3415
3456
  if inherit.get("outline"):
3416
3457
  outline = inherit["outline"]
3417
3458
  # 历史遗留:降级/模板大纲被继承时,真实任务宁可中止重生成,也不按空模板写全书
@@ -3840,6 +3881,26 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3840
3881
  if os.path.exists(os.path.join(workdir, ch_file)):
3841
3882
  stale_note = ("- 注意:目录里已有一版**未通过评审**的旧稿 `%s`——不要校验沿用、"
3842
3883
  "不要增量修补,按本章任务直接覆盖重写。" % ch_file)
3884
+ # 上一轮未达标原因(「重写未达标章」链路):本章被点名的 major 与
3885
+ # 全书级 major 随起草提示词下发——原因只躺在报告里、作者盲写等
3886
+ # 修订拉回,白白多烧一轮评审(2026-10-10 实案)。
3887
+ def _issue_ch(x):
3888
+ try:
3889
+ return int(x.get("chapter"))
3890
+ except (TypeError, ValueError):
3891
+ return None
3892
+
3893
+ redo_lines = []
3894
+ mine = [x for x in redo_issues if _issue_ch(x) == i]
3895
+ book_lvl = [x for x in redo_issues if _issue_ch(x) is None]
3896
+ if mine:
3897
+ redo_lines.append("- 本章被上一轮评审点名的问题(重写必须逐条消解):")
3898
+ redo_lines += _major_lines(mine)
3899
+ if book_lvl:
3900
+ redo_lines.append("- 上一轮全书级 major 问题(本章重写须配合消解,不得相抵):")
3901
+ redo_lines += _major_lines(book_lvl)
3902
+ redo_note = ("## 上一轮评审未达标原因(必读)\n" + "\n".join(redo_lines) + "\n"
3903
+ ) if redo_lines else ""
3843
3904
  # stable_order:同一任务 8 个章节的技能块必须字节级一致(§07 T1.2' 前缀缓存)
3844
3905
  # 三块分开留底:起草重试的分层降级(_serial_shrunk_block)要按
3845
3906
  # 「经验库/圣经」各自的身份收缩,折成一整块就只剩 4K 硬截一层了
@@ -3878,6 +3939,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3878
3939
  .replace("__HOOK__", branch_hook or ch.get("hook") or "留下悬念")
3879
3940
  .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
3880
3941
  .replace("__STALE_NOTE__", stale_note)
3942
+ .replace("__REDO_ISSUES__", redo_note)
3943
+ .replace("__REVIEW_RUBRIC__", rubric_block)
3881
3944
  .replace("__HIGHLIGHT__", ch.get("highlight") or "按剧情要点自然铺设一处小冲突/小反转")
3882
3945
  .replace("__STYLE__", style_tpl)
3883
3946
  .replace("__QUALITY_GATE__", novel_quality.opening_requirements(i, wpc))
@@ -4031,7 +4094,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4031
4094
  if not res["ok"]:
4032
4095
  # 成品是文件不是退出码:CLI 超时但章稿已完整落盘(终章长文实测
4033
4096
  # 反复出现——文件写完、收尾声明没等到)就送评审门把关,别整章作废
4034
- live = (store.get_run(run_id).get("steps") or [])
4097
+ live = ((store.get_run(run_id) or {}).get("steps") or [])
4035
4098
  if live:
4036
4099
  store.finish_step(run_id, live[-1]["n"], "done",
4037
4100
  summary="起草调用超时,但章稿已完整落盘(约 %d 字)——交评审门判质量"
@@ -4290,6 +4353,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4290
4353
  .replace("__VOLUME__", vol_block_for(i))
4291
4354
  .replace("__BEATS__", ch.get("beats") or "按大纲推进")
4292
4355
  .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
4356
+ .replace("__REVIEW_RUBRIC__", rubric_block)
4293
4357
  .replace("__HIGHLIGHT__", ch.get("highlight")
4294
4358
  or "按剧情要点自然铺设一处小冲突/小反转")
4295
4359
  .replace("__STYLE__", style_tpl)
@@ -4445,6 +4509,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4445
4509
  .replace("__VOLUME__", vol_block_for(j))
4446
4510
  .replace("__BEATS__", _wch.get("beats") or "按大纲推进")
4447
4511
  .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, j))
4512
+ .replace("__REVIEW_RUBRIC__", rubric_block)
4448
4513
  .replace("__HIGHLIGHT__", _wch.get("highlight")
4449
4514
  or "按剧情要点自然铺设一处小冲突/小反转")
4450
4515
  .replace("__STYLE__", style_tpl)
@@ -4656,6 +4721,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4656
4721
  .replace("__VOLUME__", vol_block_for(i))
4657
4722
  .replace("__BEATS__", ch.get("beats") or "按大纲推进")
4658
4723
  .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
4724
+ .replace("__REVIEW_RUBRIC__", rubric_block)
4659
4725
  .replace("__HIGHLIGHT__", ch.get("highlight")
4660
4726
  or "按剧情要点自然铺设一处小冲突/小反转")
4661
4727
  .replace("__CRITIQUE__", crit)
@@ -5940,8 +6006,23 @@ def execute_run(run_id):
5940
6006
  status="cancelled", ended_at=_now())
5941
6007
  except Exception as e:
5942
6008
  import traceback
6009
+ # 崩溃现场随错误行下发(2026-10-10 Mac 实案:连载连烧两轮自动续跑都
6010
+ # 死于 AttributeError("'NoneType' object has no attribute 'get'"),
6011
+ # 错误行只有一行 repr 无从定位;traceback 全文虽落 run 目录 error.log,
6012
+ # 但 UI 上看不见)。尾帧 = 文件:行号:函数名,一行说清死在哪;同因止损
6013
+ # 的错误签名因此按崩溃位置区分——同位置才算同因,代码升级后行号漂移
6014
+ # 自动能多试一轮(新代码可能已修复),次数上限仍兜底。
6015
+ _tb_tail = ""
6016
+ try:
6017
+ _frames = traceback.extract_tb(e.__traceback__)
6018
+ if _frames:
6019
+ _f = _frames[-1]
6020
+ _tb_tail = "(崩溃于 %s:%s %s)" % (
6021
+ os.path.basename(_f.filename), _f.lineno, _f.name)
6022
+ except Exception:
6023
+ pass
5943
6024
  store.update_run(run_id, expected_status="running", status="failed",
5944
- error=repr(e)[:500], ended_at=_now())
6025
+ error=(repr(e)[:380] + _tb_tail)[:500], ended_at=_now())
5945
6026
  try:
5946
6027
  err_path = store.run_dir(run_id) / "error.log"
5947
6028
  if _inside(str(store.run_dir(run_id).parent), str(err_path)):
@@ -371,14 +371,21 @@ def install(plugin_id, roots=None):
371
371
  "skill_count": len(old.get("skill_pack_ids") or [])}, None
372
372
  source = Path(desc["dir"]).resolve()
373
373
  target = (_install_root() / plugin_id).resolve()
374
+ market_id = "plugin-" + plugin_id
375
+ # 回滚标记必须在 try 之前就绑定:_copy_plugin/load_manifest 在记账前
376
+ # 抛错时 except 要靠它决定是否清 market 包,未初始化会把人话错误
377
+ # 炸成 UnboundLocalError→500,还会跳过残留目录清理让重装永远撞车。
378
+ market_written = False
374
379
  try:
375
380
  target.relative_to(_install_root().resolve())
381
+ if target.exists() and target != source:
382
+ # 有登记记录的已装插件在上面 already 分支就返回了;走到这里
383
+ # 还存在目标目录只可能是上次安装中途失败的残缺副本,清掉重装。
384
+ shutil.rmtree(target, ignore_errors=True)
376
385
  _copy_plugin(source, target)
377
386
  installed_desc = load_manifest(target)
378
387
  files = _skill_files(installed_desc)
379
- market_id = "plugin-" + plugin_id
380
388
  skill_result = None
381
- market_written = False
382
389
  if files:
383
390
  skill_result, err = market.install_files(
384
391
  market_id, installed_desc["display_name"], files,
@@ -50,9 +50,18 @@ def normalize_sandbox(spec=None, workdir=None):
50
50
  else:
51
51
  # Invalid policy input must never silently widen network access.
52
52
  network = False
53
+ backend = str(spec.get("backend") or "native").strip().lower()
54
+ if backend not in ("native", "docker"):
55
+ raise ValueError("沙箱 backend 必须是 native 或 docker")
56
+ image = str(spec.get("docker_image") or "").strip()[:240]
57
+ if backend == "docker":
58
+ import re
59
+ if not image or not re.match(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]*$", image):
60
+ raise ValueError("Docker 镜像名无效")
53
61
  return {"allowed_roots": [str(x) for x in roots], "env_allowlist": env,
54
62
  "network": network, "timeout_s": timeout,
55
63
  "max_output_bytes": output,
64
+ "backend": backend, "docker_image": image,
56
65
  "disabled_tools": normalize_disabled_tools(spec.get("disabled_tools"))}
57
66
 
58
67
 
@@ -24,6 +24,7 @@ import time
24
24
  PACE_S = 45 # 章间间隔(防风控节奏),测试里可置 0
25
25
  IDLE_POLL_S = 2 # 等 manager busy 结束的轮询步长
26
26
  IDLE_TIMEOUT_S = 420 # 单章最长等待(含浏览器操作与人工确认窗口)
27
+ TRANSIENT_RETRY_S = 5 # 连接类瞬断的重试前静默(等浏览器缓过来)
27
28
 
28
29
  _CAP_DEFAULT = 10
29
30
  _STREAK_DEFAULT = 3
@@ -95,6 +96,36 @@ def _fail_window_text():
95
96
  return ("%g 小时" % h) if h < 24 else ("%g 天" % (h / 24))
96
97
 
97
98
 
99
+ # 连接类瞬断标记(2026-10-10 存草稿实案:45 章 fill 时「连接被关闭」整链
100
+ # 停摆)——命中即对该章重试一次再弃,不因浏览器抖动废掉整批
101
+ _TRANSIENT_MARKS = ("连接断开", "连接被关闭", "10053", "10054", "等响应超时",
102
+ "已中止", "目标已关闭")
103
+ # 幂等拒起标记(manager 层台账/实况对账的拒绝话术)——命中即跳过该章,
104
+ # 不是失败
105
+ _IDEMPOTENT_MARKS = ("已成功发布过", "已存过草稿")
106
+
107
+
108
+ def _is_transient(err):
109
+ e = str(err or "")
110
+ return any(m in e for m in _TRANSIENT_MARKS)
111
+
112
+
113
+ def _is_idempotent_reject(err):
114
+ e = str(err or "")
115
+ return any(m in e for m in _IDEMPOTENT_MARKS)
116
+
117
+
118
+ def other_batch_running(task_id):
119
+ """除本任务外是否还有别的自动发布/存草稿批次在跑。命中返回对方
120
+ task_id,否则空串——草稿链与之互斥(共用平台浏览器会互踩页签,
121
+ 2026-10-10 实案:连载发章占着浏览器,草稿流 fill 被掐断连接)。"""
122
+ with _LOCK:
123
+ for tid, ent in _running.items():
124
+ if tid != str(task_id) and ent.get("status") == "running":
125
+ return str(tid)
126
+ return ""
127
+
128
+
98
129
  # ---------------------------------------------------------------- 待发枚举
99
130
  # 文档目录里的编号文件不是章节稿:章纲/大纲按章编号(第85章-xxx.md、
100
131
  # vol-1-ch-16.md),全按章号算会把待发清单撑出一堆「假章」——批量选择
@@ -282,7 +313,10 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
282
313
  as_draft=True(全部发草稿,2026-10-09):逐章自动填稿并点「存草稿」,
283
314
  不上线、不受质量闸拦、无需人工确认,一章接一章连跑到清空待发清单
284
315
  (章间仍按 PACE_S 防风控节奏);提交发布由用户到平台草稿箱手工完成,
285
- 届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。"""
316
+ 届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。
317
+ 草稿链三条专属护栏(2026-10-10 假成功案):起跑前先 attach-only 对账
318
+ 平台实况并重算待发(已发布/已存稿剔除,对不了账就拒起);跑批期间
319
+ 检测到另一批自动发布即让位停止(平台浏览器互斥)。"""
286
320
  from .. import store
287
321
  from . import ledger, manager
288
322
  if platform not in manager.PLATFORMS:
@@ -301,6 +335,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
301
335
  cur = _running.get(task_id) or {}
302
336
  if cur.get("status") == "running":
303
337
  return False, "该任务已有自动发布进行中,请等本轮结束"
338
+ if as_draft:
339
+ other = other_batch_running(task_id)
340
+ if other:
341
+ return False, ("检测到另一批自动发布/存草稿进行中(任务 %s)——两边"
342
+ "共用平台浏览器会互踩,请等它跑完再存草稿" % other)
304
343
  task = store.get_task(task_id)
305
344
  if not task:
306
345
  return False, "任务不存在"
@@ -329,6 +368,7 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
329
368
  pend, err = pending(task_id, platform)
330
369
  if err:
331
370
  return False, err
371
+ want = None
332
372
  if only is not None:
333
373
  try:
334
374
  want = {int(n) for n in (only or []) if n}
@@ -346,13 +386,49 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
346
386
  st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
347
387
  "done": 0, "total": len(pend), "status": "running", "error": "",
348
388
  "auto_submit": bool(auto_submit), "as_draft": bool(as_draft),
349
- "last_chapter": 0}
389
+ "last_chapter": 0, "skipped": 0, "notes": []}
350
390
  with _LOCK:
351
391
  _running[task_id] = st
352
392
 
353
393
  def run():
354
394
  try:
355
- for item in pend:
395
+ items = pend
396
+ if as_draft:
397
+ # 起草前对账(2026-10-10 假成功案):台账只记得自己发的章,
398
+ # 手工补交的只有平台知道——attach-only 刷一次校准再重算待发,
399
+ # 已发布/已存稿章就地剔除,不去撞序号重复。对不了账(浏览器
400
+ # 未连接/忙)整个拒起:存草稿本来就需要平台浏览器,宁等空窗
401
+ # 不盲跑;平台没配章节计数时跳过对账维持旧路径。
402
+ ok_r, why_r = manager.sync_published(task_id, platform,
403
+ manual=False)
404
+ if not ok_r and "未配置章节计数" not in str(why_r or ""):
405
+ st["status"] = "error"
406
+ st["error"] = ("存草稿前对账失败:%s。为防把已发布章重复存稿,"
407
+ "本次未起草;请连接平台浏览器并等它空闲后重试"
408
+ % why_r)
409
+ return
410
+ fresh, _ = pending(task_id, platform)
411
+ if want is not None:
412
+ fresh = [p for p in fresh if p["chapter_no"] in want]
413
+ if not fresh:
414
+ st["status"] = "done"
415
+ st["message"] = ("对账后没有可存草稿的章节(所选均已发布或"
416
+ "已存稿);确需重存请先在平台删旧稿")
417
+ return
418
+ if len(fresh) != st["total"]:
419
+ st["notes"].append("对账剔除 %d 章已发布/已存稿,实存 %d 章"
420
+ % (st["total"] - len(fresh), len(fresh)))
421
+ st["total"] = len(fresh)
422
+ items = fresh
423
+ for item in items:
424
+ if as_draft:
425
+ other = other_batch_running(task_id)
426
+ if other:
427
+ st["status"] = "error"
428
+ st["error"] = ("检测到另一批自动发布已启动(任务 %s),"
429
+ "存草稿让位停止;已存 %d 章落平台草稿箱,"
430
+ "等对方跑完再续" % (other, st["done"]))
431
+ return
356
432
  g_ok, why = guards(task_id, platform) # 每章前复查(中途也能拦)
357
433
  if not g_ok:
358
434
  st["status"] = "error"
@@ -366,6 +442,22 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
366
442
  ok2, err2 = manager.upload_chapter_async(
367
443
  task_id, platform, str(Path(wd) / item["file"]),
368
444
  **upload_kwargs)
445
+ if not ok2 and as_draft and _is_idempotent_reject(err2):
446
+ # 幂等拒起=这章已发布/已存过稿:跳过,不是失败——
447
+ # 整链不该因「没活干」停摆
448
+ st["skipped"] += 1
449
+ st["notes"].append("第 %d 章跳过:%s"
450
+ % (item["chapter_no"], str(err2)[:60]))
451
+ continue
452
+ if not ok2 and as_draft and _is_transient(err2):
453
+ # 连接类瞬断(连载流互踩/浏览器抖动):重试一次再弃
454
+ st["notes"].append("第 %d 章连接中断,%d 秒后重试一次"
455
+ % (item["chapter_no"],
456
+ TRANSIENT_RETRY_S))
457
+ time.sleep(TRANSIENT_RETRY_S)
458
+ ok2, err2 = manager.upload_chapter_async(
459
+ task_id, platform, str(Path(wd) / item["file"]),
460
+ **upload_kwargs)
369
461
  if not ok2:
370
462
  st["status"] = "error"
371
463
  st["error"] = "第 %d 章发起失败:%s" % (item["chapter_no"], err2)
@@ -387,12 +479,15 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
387
479
  if item["chapter_no"] not in ledger.drafted_chapters(
388
480
  task_id, platform):
389
481
  st["status"] = "error"
390
- st["error"] = ("第 %d 章存草稿未确认,后续章节未存(详见"
391
- "发布台账与截图存证)" % item["chapter_no"])
482
+ st["error"] = ("第 %d 章存草稿未确认(台账未落草稿账),"
483
+ "后续章节未存;截图存证见发布台账,若实际"
484
+ "已保存成功,把页面反馈文案照截图校准进"
485
+ "流程表 expect_text 步骤"
486
+ % item["chapter_no"])
392
487
  return
393
488
  st["done"] += 1
394
489
  st["last_chapter"] = item["chapter_no"]
395
- if st["done"] < st["total"]:
490
+ if st["done"] + st["skipped"] < st["total"]:
396
491
  time.sleep(PACE_S)
397
492
  continue
398
493
  if not auto_submit:
@@ -416,9 +511,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
416
511
  time.sleep(PACE_S)
417
512
  st["status"] = "done"
418
513
  if as_draft:
419
- st["message"] = ("已存草稿 %d 章(未发布,不受质量闸拦不代表可直发);"
420
- "请到平台章节管理/草稿箱逐章检查后提交发布"
421
- % st["done"])
514
+ st["message"] = ("已存草稿 %d 章%s(未发布,不受质量闸拦不代表可"
515
+ "直发);请到平台章节管理/草稿箱逐章检查后提交"
516
+ "发布"
517
+ % (st["done"], ("、跳过 %d 章" % st["skipped"])
518
+ if st["skipped"] else ""))
422
519
  elif not auto_submit:
423
520
  st["message"] = "第 %d 章已填好,请在浏览器里确认提交,再重新校准" % st["last_chapter"]
424
521
  except Exception as e: # 线程内绝不能悬挂无终态
@@ -110,12 +110,17 @@ UPLOAD_CHAPTER = [
110
110
  "optional": True},
111
111
  ]
112
112
 
113
- # 发章存草稿(2026-10-09 全部发草稿):与 UPLOAD_CHAPTER 同一张填表前段,
114
- # 终点不同——点编辑器头部「存草稿」直接落草稿箱,不走「下一步→内容检测→
115
- # 发布提示→提交」发布链。草稿不上线,批量连跑无需人工确认;提交发布由
116
- # 用户到章节管理/草稿箱手工完成。verify 沿用章节管理页断言(草稿行同样
117
- # 出现在列表里,标题/章号标记可命中);若平台改版把草稿挪去独立草稿箱页,
118
- # 首轮失败的 step 截图会指出来,照 flows-fanqie.json 校准即可。
113
+ # 发章存草稿(2026-10-09 全部发草稿;2026-10-10 假成功案重构):
114
+ # 与 UPLOAD_CHAPTER 同一张填表前段,终点不同——点编辑器头部「存草稿」直接
115
+ # 落草稿箱,不走「下一步→内容检测→发布提示→提交」发布链。三点教训入表:
116
+ # 1) submit 带 always=true:草稿流程跑在 auto_submit=false 语义下,而这里的
117
+ # 「存草稿」点击本身就是保存动作——被人工闸吞掉时 manager 把「填好未存」
118
+ # 记成成功(44 章假成功实案)。
119
+ # 2) expect_text 等编辑器页保存反馈为主凭据(标记文案以真机校准为准,失败
120
+ # 自动落 fail 截图);「已保存」这类自动保存字样刻意不入标记——编辑器
121
+ # 自动保存不可依赖,不能当本次点击的证据。
122
+ # 3) verify 仍走章节管理页兜底(新章草稿行同样在列表里);对「已发布章重复
123
+ # 起草」它必然假通过——该情形由起草前对账(auto 层)剔除,不靠本表。
119
124
  UPLOAD_CHAPTER_DRAFT = [
120
125
  {"do": "navigate", "url": "{editor_url}"},
121
126
  {"do": "url_any", "any": ["fanqienovel.com"]},
@@ -131,10 +136,11 @@ UPLOAD_CHAPTER_DRAFT = [
131
136
  {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
132
137
  {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
133
138
  {"do": "shot", "name": "chapter-filled-draft"},
134
- {"do": "submit", "text": "存草稿", "scope": "button", "tries": 15},
135
- {"do": "js_click", "text": "存草稿", "scope": "button", "tries": 10,
136
- "optional": True, "skip_if_modal": True},
137
- {"do": "sleep", "s": 2.5},
139
+ {"do": "submit", "text": "存草稿", "scope": "button", "tries": 15, "always": True},
140
+ {"do": "sleep", "s": 1.2},
141
+ {"do": "shot", "name": "chapter-draft-clicked"},
142
+ {"do": "expect_text",
143
+ "any": ["保存成功", "存草稿成功", "草稿已保存", "已存草稿"], "timeout": 12},
138
144
  {"do": "verify", "url": "{chapter_manage_url}",
139
145
  "any": ["第{chapter_no}章", "{chapter_name}"], "settle": 6, "new_tab": True},
140
146
  ]
@@ -558,7 +558,10 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
558
558
  if log:
559
559
  log("PROBE " + json.dumps(info, ensure_ascii=False)[:4000])
560
560
  elif act == "submit":
561
- if not auto_submit:
561
+ # always=true 的提交步(草稿流的「存草稿」)本身就是保存动作,
562
+ # 不吃 auto_submit 人工闸——被闸吞掉时流程停在「填好未存」,
563
+ # manager 却照记成功(2026-10-10 44 章假成功实案)
564
+ if not auto_submit and not st.get("always"):
562
565
  note(i, "已填好未提交——请人工检查后提交(auto_submit=false)")
563
566
  if shot:
564
567
  shot("ready-manual-submit")
@@ -686,6 +689,31 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
686
689
  finally:
687
690
  if new_tab:
688
691
  vpage.close_tab()
692
+ elif act == "expect_text":
693
+ # 页面反馈等待(草稿保存凭据,2026-10-10):轮询页面文本直到
694
+ # 出现任一成功标记(toast/状态字样)——verify 的章节管理页对
695
+ # 「已发布章重复起草」必然假通过,编辑器保存反馈才是本次动作
696
+ # 自己的证据。超时即失败:宁可带 fail 截图拦下,不可把「没
697
+ # 存上」记成成功。标记文案以真机校准为准。
698
+ marks = [str(m) for m in (st.get("any") or [])]
699
+ if not marks:
700
+ continue
701
+ deadline = time.time() + float(st.get("timeout") or 12)
702
+ hit = ""
703
+ while time.time() < deadline:
704
+ body_txt = str(page.call(
705
+ "()=>(document.body.innerText||'')", timeout=15) or "")
706
+ hit = next((m for m in marks if m in body_txt), "")
707
+ if hit:
708
+ break
709
+ time.sleep(0.8)
710
+ if not hit:
711
+ raise FlowError("未见保存成功反馈(等了 %ds,标记:%s)——"
712
+ "若截图里实际已保存成功,请把真实反馈文案"
713
+ "校准进流程表"
714
+ % (int(float(st.get("timeout") or 12)),
715
+ "、".join(marks)[:60]))
716
+ note(i, "反馈命中「%s」" % hit)
689
717
  elif act == "volume":
690
718
  # 发章带卷(2026-10-08 批量自动发布·自动分卷):确保目标卷
691
719
  # 在平台上存在(缺则走「新建分卷」)。平台新章按卷自动归类、
@@ -897,6 +897,14 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
897
897
  book_ref = dict(book)
898
898
  if _st(plat).get("status") == "busy":
899
899
  return False, "该平台有操作正在进行中"
900
+ if as_draft:
901
+ # 草稿与别的自动发布批次互斥(2026-10-10 实案:连载发章占着浏览器,
902
+ # 草稿流 fill 被掐断连接)——单章草稿同样不让起
903
+ from . import auto as _auto
904
+ other = _auto.other_batch_running(task_id)
905
+ if other:
906
+ return False, ("检测到另一批自动发布进行中(任务 %s),平台浏览器会"
907
+ "被互踩;等它跑完再存草稿" % other)
900
908
  ch_no, title, body, err = read_chapter(chapter_file)
901
909
  if err:
902
910
  return False, err
@@ -0,0 +1,95 @@
1
+ """Optional process sandbox launchers.
2
+
3
+ The default CodeBee install has no container dependency. Docker is an explicit
4
+ opt-in backend selected by a task policy and a configured image; this module
5
+ only builds argv and never silently falls back to a weaker backend.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+
15
+ class SandboxUnavailable(RuntimeError):
16
+ pass
17
+
18
+
19
+ def requested_backend(sandbox):
20
+ value = str((sandbox or {}).get("backend") or "native").strip().lower()
21
+ return value or "native"
22
+
23
+
24
+ def docker_argv(command, *, sandbox, workdir, image=None, extra_args=(),
25
+ container_workdir=None,
26
+ container_command=("/bin/sh", "-lc")):
27
+ """Build a restricted ``docker run`` argv for a command or stdio server."""
28
+ if requested_backend(sandbox) != "docker":
29
+ raise SandboxUnavailable("Docker 后端未被任务策略启用")
30
+ docker = shutil.which("docker")
31
+ if not docker:
32
+ raise SandboxUnavailable("当前系统没有可用的 Docker CLI")
33
+ root = Path(workdir or ".").expanduser().resolve(strict=True)
34
+ image = str(image or (sandbox or {}).get("docker_image") or "").strip()
35
+ if not image or len(image) > 240 or not re.match(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]*$", image):
36
+ raise SandboxUnavailable("Docker 后端必须配置可信镜像")
37
+ roots = []
38
+ for value in (sandbox or {}).get("allowed_roots") or [str(root)]:
39
+ candidate = Path(value).expanduser().resolve(strict=True)
40
+ # Docker currently mounts exactly one root. Require the task workspace
41
+ # itself; narrower roots need a separately resolved container cwd and
42
+ # must never accidentally broaden into an ancestor mount.
43
+ if candidate != root:
44
+ raise SandboxUnavailable("Docker 目前只支持挂载任务工作目录")
45
+ if candidate not in roots:
46
+ roots.append(candidate)
47
+ if not roots:
48
+ raise SandboxUnavailable("Docker 后端没有允许目录")
49
+ selected_root = next((candidate for candidate in roots
50
+ if candidate == root or candidate in root.parents
51
+ or root in candidate.parents), None)
52
+ if selected_root is None:
53
+ raise SandboxUnavailable("当前工作目录不在 Docker 允许目录内")
54
+ if len(roots) != 1:
55
+ raise SandboxUnavailable("Docker 暂只支持一个明确的允许目录")
56
+ argv = [docker, "run", "--rm", "--init", "--read-only",
57
+ "--security-opt", "no-new-privileges", "--cap-drop=ALL",
58
+ "--ipc=none", "--pids-limit", "256", "--memory", "2g", "--cpus", "2",
59
+ "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m"]
60
+ if hasattr(os, "getuid") and hasattr(os, "getgid"):
61
+ argv += ["--user", "%d:%d" % (os.getuid(), os.getgid())]
62
+ else:
63
+ argv += ["--user", "65534:65534"]
64
+ argv += ["--network", "none" if not bool((sandbox or {}).get("network", True)) else "bridge"]
65
+ for name in (sandbox or {}).get("env_allowlist") or []:
66
+ name = str(name)
67
+ if name and name in os.environ:
68
+ # Value is supplied through the filtered Docker client environment;
69
+ # never place credentials in argv, where process/log inspection sees them.
70
+ argv += ["--env", name]
71
+ for candidate in roots:
72
+ if "," in str(candidate):
73
+ raise SandboxUnavailable("Docker 允许目录包含不支持的逗号")
74
+ argv += ["--mount", "type=bind,src=%s,dst=/workspace" % str(candidate)]
75
+ if container_workdir is None:
76
+ rel_workdir = root.relative_to(selected_root).as_posix()
77
+ container_workdir = "/workspace" + ("/" + rel_workdir if rel_workdir != "." else "")
78
+ if not str(container_workdir).startswith("/") or ".." in Path(container_workdir).parts:
79
+ raise SandboxUnavailable("Docker 容器工作目录无效")
80
+ argv += ["--workdir", str(container_workdir), *list(extra_args), image]
81
+ if isinstance(command, (list, tuple)):
82
+ argv += [str(x) for x in command]
83
+ else:
84
+ argv += list(container_command) + [str(command)]
85
+ return argv
86
+
87
+
88
+ def docker_env(sandbox):
89
+ """Docker receives only explicitly allowed host environment values."""
90
+ out = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
91
+ "HOME": "/tmp", "TMPDIR": "/tmp"}
92
+ for name in (sandbox or {}).get("env_allowlist") or []:
93
+ if name in os.environ:
94
+ out[str(name)] = os.environ[str(name)]
95
+ return out