codebee 0.1.18 → 0.1.20

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.
@@ -310,7 +310,8 @@ def _wait_gate(run_id, ev):
310
310
  return
311
311
  if ev is not None and ev.is_set():
312
312
  return
313
- time.sleep(1.0)
313
+ # ev.wait 睡等:取消置位即刻醒来,不必耗满 1s 轮询间隔
314
+ (ev.wait(1.0) if ev is not None else time.sleep(1.0))
314
315
 
315
316
 
316
317
  def _binding_dead_msg(agent):
@@ -501,6 +502,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
501
502
  "error_code": ErrorCode.ENV_BLOCK, "sid": "",
502
503
  "raw": {"exit_code": None}, "kind": agent.get("kind", "generic"),
503
504
  "model": agent.get("model")}
505
+ usage_recorded = False
504
506
  if _compaction_enabled() and not resume:
505
507
  # Phase 2(1D):撑爆 → 压缩 → 守门重试;同时把 usage 累进 token_meter(1C)
506
508
  session = _get_session(session_run_id)
@@ -510,6 +512,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
510
512
  images=images, require_tools=require_tools)
511
513
 
512
514
  def _call(p, **kw):
515
+ nonlocal usage_recorded
513
516
  # 模型可见即已记录(§1A 不变量):入参/出参先落 session 日志
514
517
  session.append("user_message", {"content": p, "role": role},
515
518
  turn_id=str(step["n"]))
@@ -522,6 +525,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
522
525
  from .token_meter import token_meter
523
526
  token_meter.accumulate(session_run_id, r.get("usage"),
524
527
  model=r.get("model") or "")
528
+ usage_recorded = True
525
529
  except Exception:
526
530
  pass
527
531
  return r
@@ -533,6 +537,15 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
533
537
  res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
534
538
  timeout=timeout, cancel_event=ev, log_path=str(log_abs),
535
539
  resume=resume, images=images, require_tools=require_tools)
540
+ # 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
541
+ # 下一步永远看到 used=0,max_tokens_per_run 只是一个无效设置。
542
+ if not usage_recorded:
543
+ try:
544
+ from .token_meter import token_meter
545
+ token_meter.accumulate(session_run_id, res.get("usage"),
546
+ model=res.get("model") or "")
547
+ except Exception:
548
+ pass
536
549
  return res
537
550
 
538
551
 
@@ -632,6 +645,7 @@ __GOAL__
632
645
 
633
646
  ## 本步指令
634
647
  __SUBTASK__
648
+ __FILES__
635
649
 
636
650
  ## 背景与上下文
637
651
  __CONTEXT__
@@ -693,6 +707,16 @@ def _verify_hint(task):
693
707
  return ""
694
708
 
695
709
 
710
+ def _files_hint(sub):
711
+ """计划锚定文件清单(OpenSpec explore 借鉴):计划里带了 files 就明示改动面,
712
+ 让实现者知道该动哪些文件、不必全盘摸索。无 files 时返回空串(旧计划兼容)。"""
713
+ files = (sub or {}).get("files")
714
+ if not isinstance(files, list) or not files:
715
+ return ""
716
+ return "\n- 本步涉及文件(计划已锚定,先读再改):" + "、".join(
717
+ "`%s`" % str(f) for f in files[:10])
718
+
719
+
696
720
  def _run_verify(run_id, task, workdir, ev):
697
721
  """确定性验证。返回 (verify_pass, ran)。"""
698
722
  if not task.get("verify_command"):
@@ -803,6 +827,7 @@ def _code_bestof(run, task, impl, difficulty, ev):
803
827
  prompt = (_RUN_CONSTITUTION + CODE_IMPL_PROMPT
804
828
  .replace("__GOAL__", task["goal"])
805
829
  .replace("__SUBTASK__", sub["detail"] if sub["detail"] else sub["title"])
830
+ .replace("__FILES__", _files_hint(sub))
806
831
  .replace("__CONTEXT__", task.get("context") or "(无)")
807
832
  .replace("__VERIFY_HINT__", _verify_hint(task)))
808
833
  res = _run_step(run_id, "race%d-impl" % k, agt_b, prompt, wt_path,
@@ -953,6 +978,7 @@ def _run_code(run, task, agents, ev, stats, mode):
953
978
  .replace("__GOAL__", task["goal"])
954
979
  .replace("__SUBTASK__",
955
980
  sub["detail"] if sub["detail"] else sub["title"])
981
+ .replace("__FILES__", _files_hint(sub))
956
982
  .replace("__CONTEXT__", task.get("context") or "(无)")
957
983
  .replace("__VERIFY_HINT__", _verify_hint(task))) + prog
958
984
  role = "implement" if len(subtasks) == 1 else "implement-%d/%d" % (i + 1, len(subtasks))
@@ -1396,7 +1422,7 @@ def _pick_reviewer_legacy(agents, impl):
1396
1422
 
1397
1423
  # ---------------------------------------------------------------- review 引擎(小说/文档/翻译/调研…通用)
1398
1424
 
1399
- NOVEL_DRAFT_PROMPT = """你是一名专业作者。请在当前工作目录中撰写/修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1425
+ NOVEL_DRAFT_PROMPT = """你是__ROLE__。请在当前工作目录中撰写/修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1400
1426
 
1401
1427
  ## 写作任务
1402
1428
  __GOAL__
@@ -1412,6 +1438,69 @@ __RUBRIC__
1412
1438
  - 只修改 `__FILE__` 这一个文件;保持 Markdown 结构。
1413
1439
  - 完成后用 3 句话说明本轮写了什么。"""
1414
1440
 
1441
+ # review 引擎共用执行骨架,但交付物不能只靠 rubric 猜格式。每个内置类型给出
1442
+ # 最小成品契约,起草和修订都注入;自定义流程继续使用通用回退,避免强加结构。
1443
+ CONTENT_DELIVERY_CONTRACTS = {
1444
+ "novel": ("小说作者", [
1445
+ "遵守用户给定的题材、篇幅、视角和风格;未给出的核心设定不要擅自扩张。",
1446
+ "用场景、行动和对话推进冲突,人物动机与前后因果保持一致。",
1447
+ ]),
1448
+ "article": ("平台内容主编", [
1449
+ "标题、开头钩子、正文层级和结尾行动建议要适配目标平台与读者。",
1450
+ "事实、数据和引语不得编造;缺少来源时明确标注待核实。",
1451
+ ]),
1452
+ "video_script": ("短视频编导", [
1453
+ "按镜头或时间段写清画面、口播、字幕/音效与预计时长,前 3 秒给出钩子。",
1454
+ "每个画面都应可实际拍摄或制作,结尾给出自然的互动或转化动作。",
1455
+ ]),
1456
+ "doc": ("技术文档编辑", [
1457
+ "先明确读者、目的和前置条件,再按可执行步骤组织正文。",
1458
+ "命令、参数、示例与限制必须一致;无法确认的内容明确标注。",
1459
+ ]),
1460
+ "translation": ("专业译者与审校", [
1461
+ "忠实保留原文含义、语气、数字、专名、占位符、链接和 Markdown 结构,不增译或漏译。",
1462
+ "术语译法全文一致;歧义或无法确认的专名保留原文并加简短译注。",
1463
+ ]),
1464
+ "research": ("研究分析师", [
1465
+ "围绕决策问题组织证据、对比、结论与可执行建议,避免资料堆砌。",
1466
+ "结论必须能回溯到来源;证据不足处明确写出不确定性和验证办法。",
1467
+ ]),
1468
+ "speech": ("演讲撰稿人", [
1469
+ "按场合、听众和时长控制篇幅,使用适合现场说出的短句与自然转场。",
1470
+ "开场建立关系,主体围绕一个核心信息展开,结尾给出清晰收束或号召。",
1471
+ ]),
1472
+ "weekly_report": ("业务汇报顾问", [
1473
+ "按成果与影响、关键数据、问题阻塞、下步行动(负责人/时间)组织内容。",
1474
+ "只使用用户提供或可核验的数据;缺失数字保留待补项,不虚构业绩。",
1475
+ ]),
1476
+ "email": ("商务沟通顾问", [
1477
+ "包含明确主题、称呼、来意、必要背景、请求/下一步和得体落款。",
1478
+ "语气匹配双方关系;日期、承诺、附件与联系人不得凭空补造。",
1479
+ ]),
1480
+ "tech_proposal": ("解决方案架构师", [
1481
+ "覆盖现状与目标、约束、候选方案对比、推荐架构、实施阶段、风险与回滚、验收指标。",
1482
+ "区分已知事实、假设和待验证项;成本收益给出计算口径而非虚构数字。",
1483
+ ]),
1484
+ "resume": ("招聘与简历顾问", [
1485
+ "围绕目标岗位提炼真实经历,用行动、结果和技能关键词表达岗位匹配度。",
1486
+ "不得虚构经历、公司、学历、指标或技术栈;缺少量化数据时保留待补提示。",
1487
+ ]),
1488
+ }
1489
+
1490
+
1491
+ def _content_role(task):
1492
+ """返回内置类型的专业角色;自定义 review 流程使用中性角色。"""
1493
+ spec = CONTENT_DELIVERY_CONTRACTS.get(str(task.get("type") or ""))
1494
+ return spec[0] if spec else "内容交付专家"
1495
+
1496
+
1497
+ def _content_contract(task):
1498
+ """把类型成品约束渲染为稳定提示块;无内置契约时不额外注入。"""
1499
+ spec = CONTENT_DELIVERY_CONTRACTS.get(str(task.get("type") or ""))
1500
+ if not spec:
1501
+ return ""
1502
+ return "\n\n## 本类型交付约束\n" + "\n".join("- " + item for item in spec[1])
1503
+
1415
1504
  # 调研报告类稿件的追加要求(借鉴 gpt-researcher 迭代深研):有网络/读文件工具时
1416
1505
  # 多源交叉验证,单源结论降权——调研的可信度来自证据链而非文采
1417
1506
  RESEARCH_APPENDIX = """
@@ -1421,9 +1510,10 @@ RESEARCH_APPENDIX = """
1421
1510
  单源信息要标注「仅单一来源」。
1422
1511
  - 引用来源在文中用行内链接或脚注标明(域名即可,不编造 URL)。
1423
1512
  - 区分「事实」与「观点」:数据/时间/版本号给来源,预测/评价标明是分析。
1424
- - 结构建议:结论先行 → 分层论证 → 风险与局限(说明哪些结论证据不足)。"""
1513
+ - 结构硬性要求:报告第一段必须是「**核心结论**」三行以内的要点摘要(结论先行),
1514
+ 之后才展开分层论证 → 风险与局限(说明哪些结论证据不足)。"""
1425
1515
 
1426
- NOVEL_REVISE_PROMPT = """你是一名专业作者。请根据下方汇总评审意见修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1516
+ NOVEL_REVISE_PROMPT = """你是__ROLE__。请根据下方汇总评审意见修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1427
1517
 
1428
1518
  ## 原始写作任务
1429
1519
  __GOAL__
@@ -1647,6 +1737,10 @@ def _critique_json(res, dims):
1647
1737
  if isinstance(gj, dict) and isinstance(gj.get("scores"), dict) and gj.get("scores"):
1648
1738
  return gj
1649
1739
  prose = runner.scores_from_prose(text, dims)
1740
+ if not prose:
1741
+ # 第四道网(BAML 借鉴):维度名没命中时按「X:N 分」模式泛化抓取——
1742
+ # 自定义 rubric 改了维度措辞而模型用了自己的说法时仍能救回
1743
+ prose = runner.extract_scores_from_text(text)
1650
1744
  if prose:
1651
1745
  return {"scores": prose, "issues": [], "summary": text[:400]}
1652
1746
  return {"scores": {}, "issues": [],
@@ -2016,9 +2110,12 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2016
2110
  use_prompt = prompt
2017
2111
  for draft_attempt in range(3):
2018
2112
  if draft_attempt:
2019
- if ev is not None and ev.is_set():
2020
- break
2021
- time.sleep(30 * draft_attempt) # 30s / 60s 退避
2113
+ # 30s / 60s 退避;ev.wait 睡等可被取消即刻唤醒
2114
+ if ev is not None:
2115
+ if ev.wait(30 * draft_attempt):
2116
+ break
2117
+ else:
2118
+ time.sleep(30 * draft_attempt)
2022
2119
  if draft_attempt and len(prompt) > 12000 and sk_block and sk_block in prompt:
2023
2120
  # 长提示词在容量受限通道(讯飞托管 35B 等)上会挂起/秒拒
2024
2121
  # ——分层降级:经验库→4K、模块库按模块边界、圣经按二级标题
@@ -2103,9 +2200,12 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2103
2200
  for race_round in range(2):
2104
2201
  # 全变体失败(网关突发限流)→ 60s 退避重赛一轮,别一章判死
2105
2202
  if race_round:
2106
- if ev is not None and ev.is_set():
2107
- break
2108
- time.sleep(60)
2203
+ # ev.wait 睡等可被取消即刻唤醒
2204
+ if ev is not None:
2205
+ if ev.wait(60):
2206
+ break
2207
+ else:
2208
+ time.sleep(60)
2109
2209
  for kk in range(n_variants):
2110
2210
  # 清上一轮残稿:防陈旧半成品被本轮评分误认成新成品
2111
2211
  try:
@@ -2664,13 +2764,15 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2664
2764
  impl, _ = _pick_implementer(agents, task.get("implementer"))
2665
2765
  critics = _pick_critics_manual(agents, task)
2666
2766
  else:
2667
- impl, route["author"] = router.pick(agents, "implement", "novel", stats)
2668
- critics, route["critics"] = router.pick_critics(agents, "novel", stats, impl=impl)
2767
+ task_type = task.get("type") or "novel"
2768
+ impl, route["author"] = router.pick(agents, "implement", task_type, stats)
2769
+ critics, route["critics"] = router.pick_critics(agents, task_type, stats, impl=impl)
2669
2770
  if impl is None:
2670
2771
  store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
2671
2772
  return
2672
2773
  if resume_ctx is not None and mode == "auto":
2673
- critics, route["critics"] = router.pick_critics(agents, "novel", stats, impl=impl)
2774
+ critics, route["critics"] = router.pick_critics(
2775
+ agents, task.get("type") or "novel", stats, impl=impl)
2674
2776
 
2675
2777
  # ---- 规划(小说为模板计划)
2676
2778
  _wait_gate(run_id, ev)
@@ -2714,6 +2816,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2714
2816
 
2715
2817
  def _draft_prompt_for(vfile):
2716
2818
  p = (_tpl(task, "draft_prompt", NOVEL_DRAFT_PROMPT).replace("__FILE__", vfile)
2819
+ .replace("__ROLE__", _content_role(task))
2717
2820
  .replace("__GOAL__", task["goal"])
2718
2821
  .replace("__CONTEXT__", task.get("context") or "(无)")
2719
2822
  .replace("__RUBRIC__", "、".join(dims) if dims else "(按流程默认维度)"))
@@ -2723,6 +2826,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2723
2826
  if is_research:
2724
2827
  # 调研报告追加证据链要求(gpt-researcher 借鉴)
2725
2828
  p += RESEARCH_APPENDIX
2829
+ p += _content_contract(task)
2726
2830
  return p
2727
2831
 
2728
2832
  best_of = max(1, min(3, int(task.get("best_of") or 1)))
@@ -2822,8 +2926,10 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2822
2926
  pass
2823
2927
  else:
2824
2928
  prompt = (NOVEL_REVISE_PROMPT.replace("__FILE__", ms_name)
2929
+ .replace("__ROLE__", _content_role(task))
2825
2930
  .replace("__GOAL__", task["goal"])
2826
2931
  .replace("__CRITIQUE__", "\n".join(crit_lines)))
2932
+ prompt += _content_contract(task)
2827
2933
  _run_step(run_id, "revise-r%d" % r, modelhub.bind_agent(impl, difficulty), prompt,
2828
2934
  workdir, readonly=False, ev=ev,
2829
2935
  resume=resume_ctx["session"] if resume_ctx else None)
@@ -3008,11 +3114,28 @@ def _write_task_evidence(run_id, task, workdir, summary_lines):
3008
3114
  for ln in summary_lines:
3009
3115
  f.write("- %s\n" % str(ln)[:300])
3010
3116
  f.write("\n")
3117
+ _archive_stamp(run_id, task, workdir, summary_lines)
3011
3118
  return path
3012
3119
  except Exception:
3013
3120
  return ""
3014
3121
 
3015
3122
 
3123
+ def _archive_stamp(run_id, task, workdir, summary_lines):
3124
+ """归档戳(借鉴 OpenSpec archive):evidence 落盘后向 spec.md 尾部追加
3125
+ 「已完成」快照行——spec 从「意图」升格为「意图+交付记录」的活档案。
3126
+ 失败静默。"""
3127
+ try:
3128
+ spec = os.path.join(workdir, ".codebee", "spec.md")
3129
+ if not os.path.isfile(spec):
3130
+ return
3131
+ with open(spec, "a", encoding="utf-8") as f:
3132
+ f.write("\n---\n**✅ 已交付** · %s · run %s\n%s\n" % (
3133
+ _now(), run_id,
3134
+ "\n".join("- %s" % str(ln)[:160] for ln in summary_lines[:5])))
3135
+ except Exception:
3136
+ return
3137
+
3138
+
3016
3139
  def _evidence_lines_from_run(run_id, task):
3017
3140
  """从 run 步骤与 verdict 提取证据行(确定性事实,不抄模型输出)。"""
3018
3141
  run = store.get_run(run_id) or {}
@@ -12,7 +12,7 @@ import os
12
12
  import re
13
13
  import time
14
14
 
15
- from . import modelhub, runner, skills, usage
15
+ from . import knowledge, modelhub, runner, skills, usage
16
16
 
17
17
  MAX_SUBTASKS = 4
18
18
  DEFAULT_OUTLINE_TIMEOUT = 900 # 8 章大纲 + 经验包注入是重生成任务,300s 实测不够
@@ -116,10 +116,16 @@ def _log_usage(source, role, task, res, agent=None, tool="", model="", provider=
116
116
 
117
117
  CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
118
118
  并判定任务难度。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
119
- {"difficulty": "easy 或 hard", "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令"}]}
119
+ {"difficulty": "easy 或 hard", "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
120
120
  难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
121
121
  子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
122
122
 
123
+ ## 先探索再计划(重要,借鉴 OpenSpec explore)
124
+ 拆解之前先用你的读文件/搜索工具**实际查看工作目录**,找到目标相关的真实文件与函数,再据此拆解。
125
+ detail 必须点名真实存在的文件路径;files 列出该子任务会改动的文件(新建的写目标路径)。
126
+ **没探索过就不要凭想象编路径**——计划的可执行性取决于它对真实代码库的贴合度。
127
+ 若工作目录为空(全新项目),files 写计划新建的文件路径。
128
+
123
129
  ## 开发目标
124
130
  __GOAL__
125
131
 
@@ -274,6 +280,9 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
274
280
  wpc = int(serial.get("words_per_chapter") or 2500)
275
281
  start = int(serial.get("start_chapter") or 1)
276
282
  sk_block, _ = skills.block_for(task)
283
+ kb_block = knowledge.block_for(task)
284
+ if kb_block:
285
+ sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
277
286
  prev_title = ""
278
287
  if start > 1:
279
288
  prev_lines, done, prev_title, prev_tail = _prev_serial_story(task)
@@ -367,7 +376,10 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
367
376
 
368
377
 
369
378
  def _norm_subtasks(data):
370
- """规范化 LLM 计划输出;不合规返回 None。"""
379
+ """规范化 LLM 计划输出;不合规返回 None。
380
+
381
+ files 字段(OpenSpec explore 借鉴):计划锚定的真实文件清单,透传给执行步
382
+ 让实现者知道改动面(缺失/非法时留空,向后兼容旧计划)。"""
371
383
  if not isinstance(data, dict):
372
384
  return None
373
385
  subs = data.get("subtasks")
@@ -381,7 +393,13 @@ def _norm_subtasks(data):
381
393
  detail = str(s.get("detail") or "").strip()
382
394
  if not title:
383
395
  continue
384
- steps.append({"title": title[:60], "detail": detail[:1500]})
396
+ step = {"title": title[:60], "detail": detail[:1500]}
397
+ files = s.get("files")
398
+ if isinstance(files, list):
399
+ clean = [str(f).strip()[:200] for f in files if str(f).strip()][:10]
400
+ if clean:
401
+ step["files"] = clean
402
+ steps.append(step)
385
403
  return steps or None
386
404
 
387
405
 
@@ -86,16 +86,56 @@ def view():
86
86
 
87
87
 
88
88
  def recover_orphans():
89
- """启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。"""
89
+ """启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。
90
+
91
+ 升级自愈:旧版「等扫码窗口一关就冤判超时」留下的 error(error 文案带
92
+ 「等待登录超时」)排队后台复核——profile 登录态还在的直接翻 connected,
93
+ 别让升级完还挂着旧冤案;attach 不到活实例就维持原样(用户点重连时
94
+ 新终审逻辑自会兜住)。"""
90
95
  _load()
91
96
  n = 0
97
+ stale = []
92
98
  for plat in PLATFORMS:
93
- if _st(plat).get("status") in ("waiting_login", "busy"):
99
+ s = _st(plat)
100
+ if s.get("status") in ("waiting_login", "busy"):
94
101
  _set(plat, status="error", error="上次操作随服务重启中断,请重试")
95
102
  n += 1
103
+ elif s.get("status") == "error" and "等待登录超时" in (s.get("error") or ""):
104
+ stale.append(plat)
105
+ if stale:
106
+ threading.Thread(target=_recheck_stale_errors, args=(stale,),
107
+ daemon=True, name="pub-stale-recheck").start()
96
108
  return n
97
109
 
98
110
 
111
+ def _recheck_stale_errors(plats):
112
+ """旧版假超时的后台复核:只 attach 还活着的浏览器实例(绝不 launch——
113
+ 升级重启时静默弹窗口吓人),登录态复核过了翻 connected;否则不动。"""
114
+ for plat in plats:
115
+ if _st(plat).get("status") != "error":
116
+ continue # 用户已先行操作(重连/断开),别覆盖
117
+ port = _st(plat).get("port") or 0
118
+ if not port:
119
+ continue
120
+ try:
121
+ b = Browser.attach(int(port))
122
+ except Exception:
123
+ continue # 实例已死:不 launch,维持原错误
124
+ with LOCK:
125
+ _browsers[plat] = b
126
+ page = b.first_page(create=False)
127
+ if page is None:
128
+ continue
129
+ try:
130
+ ok, _u = _check_login(plat, page)
131
+ except Exception:
132
+ continue
133
+ if ok and _st(plat).get("status") == "error":
134
+ _set(plat, status="connected", error="",
135
+ last_login=time.strftime("%m-%d %H:%M"))
136
+ ledger.record(plat, "connect", ok=True)
137
+
138
+
99
139
  # ---------------------------------------------------------------- 浏览器会话
100
140
  def _profiles_base():
101
141
  """profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
@@ -245,13 +285,31 @@ def connect(plat):
245
285
  except (BrowserError, Exception):
246
286
  pass # 页面被用户关掉等:继续等到超时
247
287
  time.sleep(5)
248
- _set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
288
+ _finalize_login_wait(plat)
249
289
 
250
290
  threading.Thread(target=wait_login, daemon=True,
251
291
  name="pub-login-%s" % plat).start()
252
292
  return True, ""
253
293
 
254
294
 
295
+ def _finalize_login_wait(plat):
296
+ """等扫码超时后的终审:用户可能已在本窗口登录后把窗口关了(profile
297
+ 里登录态还在),attach-or-launch 重拉页面再复核一次,别急着冤判
298
+ 超时——「明明登录着却报超时」是发布卡死感的最大来源。复核也过不了
299
+ 才落超时错误。返回 True 表示复核通过(已判 connected)。"""
300
+ try:
301
+ _b, page = _open_page(plat)
302
+ ok, _u = _check_login(plat, page)
303
+ except Exception:
304
+ ok = False
305
+ if ok:
306
+ _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
307
+ ledger.record(plat, "connect", ok=True)
308
+ else:
309
+ _set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
310
+ return ok
311
+
312
+
255
313
  def disconnect(plat):
256
314
  b = _browsers.pop(plat, None)
257
315
  if b:
@@ -357,6 +415,25 @@ def create_book_async(task_id, plat, auto_submit=False):
357
415
  return True, ""
358
416
 
359
417
 
418
+ # ---------------------------------------------------------------- 动作:登记已有作品
419
+ def register_book(task_id, plat, title, book_id=""):
420
+ """人工登记平台已有作品:建书流程没走通、或用户纯手工在平台上建的
421
+ 书,补进台账让卡片翻到「已建书·发一章」,避免再点「创建作品」造出
422
+ 重复书。只动本地台账不碰浏览器;发章按书名找书,title 必填,
423
+ book_id 选填(有直达 URL 时填)。已登记会覆盖更新(兼纠错口)。"""
424
+ from .. import store
425
+ if plat not in PLATFORMS:
426
+ return False, "未知平台"
427
+ if not store.get_task(task_id):
428
+ return False, "任务不存在"
429
+ title = (title or "").strip()
430
+ if not title:
431
+ return False, "作品名必填(发章按作品名在平台找书)"
432
+ ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
433
+ "title": title[:120]})
434
+ return True, ""
435
+
436
+
360
437
  def _with_tag_steps(steps, groups, values, mod=None):
361
438
  """标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
362
439
  "tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的