codebee 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
6
6
 
7
7
  ## 未发布
8
8
 
9
+ ## v0.1.21(2026-09-21)
10
+
11
+ - 任务默认直接启动,不再进入容量等待队列;达到并发保护上限立即失败并明确提示稍后重试,历史 queued 记录启动时自动接管或收口。
12
+ - 修复取消竞态:编排、管理、自升级、禅道修复在取消后不会复活;自动续跑重启恢复不再受隐藏数量上限影响,并显示明确的续跑时间。
13
+ - 定时任务、重试、续写、答疑和禅道修复统一使用同一套启动失败收口逻辑,避免任务长期停留在待启动状态。
14
+
9
15
  ## v0.1.20(2026-09-20)
10
16
 
11
17
  - 全类型交付契约:小说、文章、短视频、文档、翻译、调研、演讲、汇报、邮件、技术方案、简历按真实类型路由,并注入各自角色、成品结构和真实性约束
package/README.md CHANGED
@@ -21,8 +21,10 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
21
21
  不会把你的任务内容交给任何第三方。
22
22
 
23
23
  <!-- relnotes:start -->
24
- ### 最新版更新内容(v0.1.20
24
+ ### 最新版更新内容(v0.1.21
25
25
 
26
+ - 任务默认直接启动,不进入容量等待队列;满载立即失败并提示稍后重试,自动续跑退避会显示预定时间
27
+ - 取消竞态修复:编排、管理、自升级、禅道修复取消后不会复活,重启可恢复全部定时续跑
26
28
  - 11 类内容任务按真实类型路由,并带各自角色、成品结构和真实性约束
27
29
  - token 预算恢复有效:默认路径与续会话均累计真实 usage,压缩重试不重复记账
28
30
  - 15 种流程成本预估全覆盖;修复 rubric 串值、预置流程误建副本和 direct 标签
@@ -108,7 +110,7 @@ OpenCode / Continue / Cursor / Trae 十种工具的配置,一次带入全部
108
110
  跑完整代码链,修完自动合并;双端问题修完我方部分后带修复报告转派另一端负责人,
109
111
  非我方转回报告人;测试指错人也按排查结论改派;修复失败自动升级转派;群机器人
110
112
  全程播报(设置页「禅道」子页配置);
111
- - **多任务并发**:worker 池默认 3(1-6 可调),互不打扰;连载任务超时/中断自动
113
+ - **多任务并发**:任务默认立即启动、无等待队列,并发保护上限默认 12;连载任务超时/中断自动
112
114
  断点续跑;
113
115
  - **远程访问**:手机同一 WiFi 直开、Tailscale 外网可达、Cloudflare Tunnel 固定
114
116
  域名——8 位访问令牌保护,多端实时同步(SSE),单设备控制权防互踩;
@@ -316,10 +318,10 @@ start-public.bat
316
318
 
317
319
  ## 多任务并发
318
320
 
319
- 任务队列是可并发 worker 池(默认 3,设置页「编排设置」可调 1-6):多个任务同时跑、
320
- 互不打扰——每个任务独立线程,运行数据按 run 隔离,互不等待。并发数调小后多余线程
321
- 在空闲检查点自行退出,调大即时补齐。串行模式(=1)也可用。连载任务超时/中断自动
322
- 断点续跑(延迟退避入队,防在网关限流墙上连续撞死)。
321
+ 任务执行器采用直接启动模式(并发保护上限默认 12,设置页「编排设置」可调 1-12):多个任务同时跑、
322
+ 互不打扰——每个任务独立线程,运行数据按 run 隔离,不进入等待队列。达到保护上限时本次请求明确失败并提示稍后重试;并发数调小只影响后续任务,已有任务继续完成。
323
+ 连载任务超时/中断会自动断点续跑;重试前按明确时间退避,防止在网关限流墙上连续撞死,
324
+ 这段等待不占执行位,也不属于任务容量排队。
323
325
 
324
326
  ## 编排设置(编排者模型)
325
327
 
@@ -292,7 +292,7 @@ def _recover_after_restart():
292
292
 
293
293
  def _launch_run(t):
294
294
  """拉起一次真实编排运行:与 main.py 的 /api/tasks 走同一条链路
295
- (store.create_task → store.create_run → jobs 入队),返回 run_id。
295
+ (store.create_task → store.create_run → jobs 直接启动),返回 run_id。
296
296
  测试可把本函数打成 stub,绝不真调 LLM/CLI。"""
297
297
  payload = {"type": t.get("flow") or DEFAULT_FLOW,
298
298
  "title": ("%s %s" % (t.get("name") or "定时任务",
@@ -309,15 +309,15 @@ def _launch_run(t):
309
309
  "task_id": task["id"]})
310
310
  return run["id"]
311
311
  except Exception:
312
- # A scheduler failure must not leave a task/run that looks queued forever.
312
+ # 启动失败必须收口,不能留下看似仍在启动的任务/run
313
313
  # Keep the public error generic; the detailed traceback stays in the
314
314
  # service log and the run record remains useful for diagnostics.
315
- log.exception("automation: 运行入队失败 task=%s run=%s",
315
+ log.exception("automation: 运行启动失败 task=%s run=%s",
316
316
  (task or {}).get("id"), (run or {}).get("id"))
317
317
  if run:
318
318
  try:
319
319
  store.update_run(run["id"], status="failed",
320
- error="任务入队失败,请稍后重试",
320
+ error="任务启动失败,本次未排队,请稍后重试",
321
321
  ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
322
322
  except Exception:
323
323
  log.exception("automation: 运行失败收口失败 run=%s", run.get("id"))
@@ -334,7 +334,7 @@ def _launch_run(t):
334
334
  def _fire(snapshot, now):
335
335
  """触发一个到期任务:拉起运行并推进 last_run/next_run/run_count/last_status。
336
336
  once 触发完自动停用。拉起失败只记 error,next_run 照常推进(下个周期重试)。"""
337
- status = "queued"
337
+ status = "started"
338
338
  try:
339
339
  _launch_run(snapshot)
340
340
  except Exception as e:
@@ -527,7 +527,7 @@ def run_now(tid):
527
527
  return None, None
528
528
  now = datetime.now()
529
529
  run_id = ""
530
- status = "queued"
530
+ status = "started"
531
531
  try:
532
532
  with _LOCK:
533
533
  snapshot = dict(_TASKS[tid])
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  import re
15
15
 
16
+ from .dispatch import TYPE_DIMENSIONS
17
+
16
18
  # 能力维度(先固化清单,schema 见设计稿)
17
19
  DIMS = ("writing", "coding", "reasoning", "vision")
18
20
 
@@ -33,6 +35,9 @@ def classify_task_type(task) -> str:
33
35
  explicit = str((task or {}).get("task_type") or "").strip().lower()
34
36
  if explicit in DIMS:
35
37
  return explicit
38
+ preset = str((task or {}).get("type") or "").strip().lower()
39
+ if preset in TYPE_DIMENSIONS:
40
+ return TYPE_DIMENSIONS[preset]
36
41
  text = "%s %s" % ((task or {}).get("type") or "",
37
42
  (task or {}).get("goal") or "")
38
43
  scores = {}
@@ -92,7 +97,8 @@ def resolve_binding_by_task(task, providers, bindings):
92
97
  TIER_ORDER = {"budget": 0, "standard": 1, "premium": 2}
93
98
 
94
99
 
95
- def cascade_reorder(agent, tier_of):
100
+ def cascade_reorder(agent, tier_of, *, providers=None, pricing=None,
101
+ difficulty="easy", task_type="", role=""):
96
102
  """FrugalGPT 式便宜优先:easy 任务把 call_chain 按 tier 升序稳定重排。
97
103
 
98
104
  agent 来自 modelhub.bind_agent(可能带 call_chain);tier_of(chain_entry)
@@ -102,6 +108,14 @@ def cascade_reorder(agent, tier_of):
102
108
  chain = (agent or {}).get("call_chain") or []
103
109
  if len(chain) < 2:
104
110
  return agent
111
+ if providers is not None and (task_type or role):
112
+ from .dispatch import rank_model_entries
113
+ ranked, _decisions = rank_model_entries(
114
+ chain, {p.get("id"): p for p in providers or []},
115
+ pricing or {}, difficulty, task_type, role)
116
+ out = dict(agent)
117
+ out["call_chain"] = ranked
118
+ return out
105
119
 
106
120
  def key(entry):
107
121
  return TIER_ORDER.get(tier_of(entry) or "standard", 1)
@@ -127,4 +141,4 @@ def make_tier_lookup(providers):
127
141
  if isinstance(m, dict) and m.get("name") == (entry or {}).get("model"):
128
142
  return m.get("tier") or p.get("tier")
129
143
  return p.get("tier")
130
- return tier_of
144
+ return tier_of
@@ -154,7 +154,7 @@ DEFAULT_CATALOG = [
154
154
  "note": "DeepSeek 官方 agent harness(dsh,profile 插件架构);无头是「一次性任务」——"
155
155
  "答完即退、无交互后续、不支持会话恢复;任务只走位置参数(超长提示词受 Windows "
156
156
  "命令行上限约 32k 约束);模型写进 ~/.dsh/settings.yaml 的 agent-default-model.model;"
157
- "密钥由「CLI 绑定」注入 DEEPSEEK_API_KEY(优先级最高);端点注入 DEEPSEEK_BASE_URL,"
157
+ "密钥由运行时自动调度或「模型调度(可选)」注入 DEEPSEEK_API_KEY(优先级最高);端点注入 DEEPSEEK_BASE_URL,"
158
158
  "但若 settings.yaml 已固定 llm-deepseek.baseURL,则以它为准(settings 高于 env)",
159
159
  "detect": {"cli": "dsh"},
160
160
  "orch": {"kind": "generic", "command": "dsh",
@@ -42,17 +42,53 @@ _IMAGE_MODEL_KEYWORDS = ("cogview", "dall-e", "gpt-image", "flux", "kolors",
42
42
  _SIZES = ("768x1344", "1024x1024") # 竖版优先,方图兜底
43
43
 
44
44
 
45
+ def _chapter_facts(workdir, limit=3):
46
+ """从最新章节抽取视觉事实(借鉴 poster-v2 的 novel fact extraction):
47
+ 人物/场景/关键意象的原文片段——封面元素来自真实剧情而非干瘪题材词。
48
+ 读不到章节(单稿/未开写)返回空串,提示词回落题材+简介。"""
49
+ if not workdir:
50
+ return ""
51
+ import glob as _glob
52
+ try:
53
+ chapters = sorted(_glob.glob(str(workdir) + "/chapter-*.md"))
54
+ if not chapters:
55
+ return ""
56
+ facts = []
57
+ for p in chapters[-limit:]:
58
+ try:
59
+ with open(p, encoding="utf-8", errors="replace") as f:
60
+ txt = f.read(4000)
61
+ # 取开头(多为场景/人物动作)+ 关键句粗筛
62
+ head = " ".join(txt.split("\n")[1:6])
63
+ if head.strip():
64
+ facts.append(head.strip()[:200])
65
+ except OSError:
66
+ continue
67
+ return "\n".join(facts)[:900]
68
+ except Exception:
69
+ return ""
70
+
71
+
45
72
  def _cover_prompt(task):
46
- """从任务与建书资料拼图像提示词:场景氛围向,不要文字(平台会自行压字)。"""
73
+ """从任务与建书资料拼图像提示词:场景氛围向,不要文字(平台会自行压字)。
74
+
75
+ 素材优先级:章节事实抽取(真实剧情的人物/场景/意象)> 题材+简介。
76
+ poster-v2 借鉴:视觉元素来自 novel facts 才有「这是这本书的封面」的辨识度。"""
47
77
  bm = ((task.get("book_meta") or {}).get("fanqie") or {}).get("data") or {}
48
78
  if not isinstance(bm, dict):
49
79
  bm = {}
50
80
  title = bm.get("书名") or task.get("title") or ""
51
81
  genre = bm.get("类型") or bm.get("分类") or ""
52
82
  brief = (bm.get("一句话简介") or bm.get("简介") or task.get("goal") or "")
53
- return ("竖版小说封面插画,画面中不要出现任何文字。题材:%s %s。故事梗概:%s。"
54
- "商业网文封面质感:主体人物或核心场景突出,色彩浓郁有冲击力,"
55
- "构图上方留白便于后期压标题。" % (genre, title, str(brief)[:300]))
83
+ facts = _chapter_facts(task.get("workdir") or "")
84
+ base = ("竖版小说封面插画,画面中不要出现任何文字。题材:%s %s。故事梗概:%s。"
85
+ % (genre, title, str(brief)[:300]))
86
+ if facts:
87
+ base += ("\n\n以下是最新的剧情片段(从中提取主要人物形象、标志性场景与核心意象"
88
+ "作为画面主体——让封面一眼可辨「这是这本书」):\n%s" % facts)
89
+ base += ("\n商业网文封面质感:主体人物或核心场景突出,色彩浓郁有冲击力,"
90
+ "构图上方留白便于后期压标题。")
91
+ return base
56
92
 
57
93
 
58
94
  def _pick_key(prov):
@@ -0,0 +1,141 @@
1
+ # -*- coding: utf-8 -*-
2
+ """统一调度评分:任务画像、CLI 亲和度与模型链排序。
3
+
4
+ 本模块只做纯计算,不读写配置和运行状态。调用方先完成协议、启停、健康、
5
+ 密钥等硬约束过滤,再把可用候选交给这里评分;相同分数保持用户原顺序。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+
12
+ TYPE_DIMENSIONS = {
13
+ "direct": "reasoning",
14
+ "code": "coding",
15
+ "novel": "writing",
16
+ "serial_novel": "writing",
17
+ "article": "writing",
18
+ "video_script": "writing",
19
+ "doc": "writing",
20
+ "translation": "writing",
21
+ "rank_scan": "reasoning",
22
+ "research": "reasoning",
23
+ "speech": "writing",
24
+ "weekly_report": "writing",
25
+ "email": "writing",
26
+ "tech_proposal": "reasoning",
27
+ "resume": "writing",
28
+ "zentao": "coding",
29
+ }
30
+
31
+ _KIND_AFFINITY = {
32
+ "coding": {"codex": 10, "aider": 8, "opencode": 7, "qwen": 6,
33
+ "claude": 5, "generic": 2},
34
+ "writing": {"claude": 10, "qwen": 7, "generic": 5, "codex": 3,
35
+ "opencode": 2, "aider": 0},
36
+ "reasoning": {"claude": 9, "codex": 8, "qwen": 6, "opencode": 4,
37
+ "generic": 3, "aider": 1},
38
+ "vision": {"codex": 10, "claude": 8, "qwen": 5, "opencode": 2,
39
+ "generic": 1, "aider": 0},
40
+ }
41
+
42
+ _TIER_SCORE = {
43
+ "easy": {"budget": 18.0, "standard": 8.0, "premium": -6.0},
44
+ "default": {"budget": 2.0, "standard": 6.0, "premium": 5.0},
45
+ "hard": {"budget": -8.0, "standard": 4.0, "premium": 12.0},
46
+ }
47
+
48
+
49
+ def task_dimension(task_type, role=""):
50
+ """把预置任务与步骤角色归一为 writing/coding/reasoning/vision。"""
51
+ ttype = str(task_type or "").strip().lower()
52
+ role = str(role or "").strip().lower()
53
+ if ttype in ("writing", "coding", "reasoning", "vision"):
54
+ return ttype
55
+ if role in ("plan", "review", "critique", "selector", "qa") \
56
+ or "review" in role or "critique" in role:
57
+ return "reasoning"
58
+ return TYPE_DIMENSIONS.get(ttype, "reasoning")
59
+
60
+
61
+ def agent_affinity(kind, task_type, role=""):
62
+ """CLI 类型对任务维度的温和偏好;只作加分,不覆盖健康与历史信号。"""
63
+ dim = task_dimension(task_type, role)
64
+ score = float((_KIND_AFFINITY.get(dim) or {}).get(kind, 0))
65
+ if role == "review" or "critique" in str(role or ""):
66
+ score += {"claude": 4, "codex": 3, "qwen": 2}.get(kind, 0)
67
+ return score, "%s 匹配 %+.1f" % (dim, score)
68
+
69
+
70
+ def _model_meta(provider, model):
71
+ for item in (provider or {}).get("models") or []:
72
+ if isinstance(item, dict) and item.get("name") == model:
73
+ return item
74
+ return {}
75
+
76
+
77
+ def _price_score(pricing, model, difficulty):
78
+ price = (pricing or {}).get(model) or {}
79
+ try:
80
+ # 输出通常比输入贵且更影响整步成本,按 2 倍权重估算。
81
+ blended = max(0.0, float(price.get("in") or 0.0)
82
+ + 2.0 * float(price.get("out") or 0.0))
83
+ except (TypeError, ValueError):
84
+ return 0.0, "价格未知"
85
+ if blended <= 0:
86
+ return 0.0, "价格未知"
87
+ magnitude = max(0.0, math.log10(blended + 1.0))
88
+ score = -min(18.0, magnitude * (7.0 if difficulty == "easy" else 2.0))
89
+ return score, "估算价 %.3g(%+.1f)" % (blended, score)
90
+
91
+
92
+ def score_model_entry(entry, providers, pricing, difficulty, task_type="", role=""):
93
+ """给已通过硬约束的模型链条目评分,返回 (score, explanation)。"""
94
+ provider = (providers or {}).get(entry.get("provider_id")) \
95
+ or entry.get("provider") or {}
96
+ model = entry.get("model") or ""
97
+ meta = _model_meta(provider, model)
98
+ try:
99
+ priority = max(1, int(meta.get("priority") or 99))
100
+ except (TypeError, ValueError):
101
+ priority = 99
102
+ quality = 0.0 if priority == 99 else max(-8.0, 20.0 - (priority - 1) * 4.0)
103
+ tier = meta.get("tier") or provider.get("tier") or "standard"
104
+ tier_score = (_TIER_SCORE.get(difficulty) or {}).get(tier, 0.0)
105
+ price_score, price_reason = _price_score(pricing, model, difficulty)
106
+ dim = task_dimension(task_type, role)
107
+ strengths = provider.get("strengths") if isinstance(provider.get("strengths"), list) else []
108
+ strength_score = 10.0 if dim in strengths else 0.0
109
+ if difficulty == "easy":
110
+ quality *= 0.25
111
+ elif difficulty != "hard":
112
+ # 普通任务兼顾质量、成本与能力匹配;是否真的重排由调用方决定,
113
+ # 因而手工链仍可保持原顺序,自动推荐则能使用这组平衡分。
114
+ quality *= 0.65
115
+ vision_score = 0.0
116
+ if dim == "vision":
117
+ vision_score = 24.0 if meta.get("image_in") else -24.0
118
+ total = quality + tier_score + price_score + strength_score + vision_score
119
+ reason = ("质量 %+.1f,档位 %s %+.1f,%s,能力 %s %+.1f"
120
+ % (quality, tier, tier_score, price_reason, dim,
121
+ strength_score + vision_score))
122
+ return round(total, 2), reason
123
+
124
+
125
+ def rank_model_entries(entries, providers, pricing, difficulty,
126
+ task_type="", role="", force=False):
127
+ """稳定排序模型链并返回脱敏决策明细;default 难度保持人工顺序。"""
128
+ rows = []
129
+ for index, entry in enumerate(entries or []):
130
+ score, reason = score_model_entry(
131
+ entry, providers, pricing, difficulty, task_type, role)
132
+ rows.append((score, index, entry, reason))
133
+ if difficulty in ("easy", "hard") or force:
134
+ rows.sort(key=lambda row: (-row[0], row[1]))
135
+ ranked = [row[2] for row in rows]
136
+ decisions = [{"provider_id": row[2].get("provider_id") or "",
137
+ "provider": (row[2].get("provider") or {}).get("name") or "",
138
+ "model": row[2].get("model") or "",
139
+ "score": row[0], "reason": row[3]}
140
+ for row in rows]
141
+ return ranked, decisions
package/app/core/flows.py CHANGED
@@ -187,6 +187,11 @@ def _apply_overrides(base, ov):
187
187
  f["rounds"] = max(1, min(5, int(v)))
188
188
  except Exception:
189
189
  pass
190
+ elif k == "best_of":
191
+ try:
192
+ f["best_of"] = max(1, min(3, int(v)))
193
+ except Exception:
194
+ pass
190
195
  elif k in ("manuscript", "verify_command"):
191
196
  s = re.sub(r"[\\/]+", "_", str(v or "")).strip()
192
197
  s = re.sub(r"\.{2,}", "_", s).lstrip(".")