codebee 0.1.0

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/app/__init__.py +0 -0
  4. package/app/core/__init__.py +0 -0
  5. package/app/core/attachments.py +322 -0
  6. package/app/core/automation.py +585 -0
  7. package/app/core/bookmeta.py +296 -0
  8. package/app/core/capability.py +130 -0
  9. package/app/core/catalog.py +319 -0
  10. package/app/core/compaction.py +186 -0
  11. package/app/core/diagnostics.py +115 -0
  12. package/app/core/env_scrub.py +84 -0
  13. package/app/core/error_codes.py +65 -0
  14. package/app/core/flows.py +328 -0
  15. package/app/core/gitmod.py +949 -0
  16. package/app/core/goal_service.py +159 -0
  17. package/app/core/health.py +294 -0
  18. package/app/core/history.py +32 -0
  19. package/app/core/jobs.py +424 -0
  20. package/app/core/manager.py +1415 -0
  21. package/app/core/market.py +299 -0
  22. package/app/core/market_remote.py +896 -0
  23. package/app/core/mocks.py +64 -0
  24. package/app/core/modelhub.py +2750 -0
  25. package/app/core/paths.py +60 -0
  26. package/app/core/pipeline.py +2161 -0
  27. package/app/core/planner.py +493 -0
  28. package/app/core/registry.py +105 -0
  29. package/app/core/remote.py +303 -0
  30. package/app/core/repeat_guard.py +124 -0
  31. package/app/core/router.py +120 -0
  32. package/app/core/runner.py +856 -0
  33. package/app/core/selfupdate.py +170 -0
  34. package/app/core/session_log.py +162 -0
  35. package/app/core/sessions.py +312 -0
  36. package/app/core/settings.py +85 -0
  37. package/app/core/settings_schema.py +250 -0
  38. package/app/core/skillpacks/fanqie-novel.md +80 -0
  39. package/app/core/skillpacks/market/character-bible.md +66 -0
  40. package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
  41. package/app/core/skillpacks/market/git-workflow.md +57 -0
  42. package/app/core/skillpacks/market/release-notes.md +72 -0
  43. package/app/core/skillpacks/market/weekly-report.md +71 -0
  44. package/app/core/skillpacks/market/worldview-consistency.md +70 -0
  45. package/app/core/skillpacks/qimao-signing.md +105 -0
  46. package/app/core/skills.py +649 -0
  47. package/app/core/step_runner.py +61 -0
  48. package/app/core/store.py +1321 -0
  49. package/app/core/token_meter.py +130 -0
  50. package/app/core/usage.py +450 -0
  51. package/app/main.py +1448 -0
  52. package/app/ui/app.js +8021 -0
  53. package/app/ui/i18n.js +1709 -0
  54. package/app/ui/icons/brand-horizontal.png +0 -0
  55. package/app/ui/icons/brand-square.png +0 -0
  56. package/app/ui/icons/icon-192.png +0 -0
  57. package/app/ui/icons/icon-512.png +0 -0
  58. package/app/ui/icons/logo-horizontal.png +0 -0
  59. package/app/ui/icons/logo-mark.png +0 -0
  60. package/app/ui/index.html +864 -0
  61. package/app/ui/manifest.json +16 -0
  62. package/app/ui/qrcode.js +2297 -0
  63. package/app/ui/style.css +2733 -0
  64. package/bin/tutti.js +121 -0
  65. package/package.json +39 -0
@@ -0,0 +1,1321 @@
1
+ # -*- coding: utf-8 -*-
2
+ """存储层:任务与运行(含管理操作)全部落盘,可回放。
3
+
4
+ 目录约定:
5
+ data/tasks/<task_id>.json
6
+ data/runs/<run_id>/run.json
7
+ data/runs/<run_id>/steps/<NN>-<role>-<agent>.log
8
+ data/runs/<run_id>/report.md
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import secrets
16
+ import shutil
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from . import paths, runner
22
+
23
+ LOCK = threading.RLock()
24
+ _TASKS = {}
25
+ _RUNS = {}
26
+
27
+ # ---------------------------------------------------------------- 状态版本(SSE 事件驱动)
28
+ # 任何落盘写都算状态变化:SSE 连接等版本号变化才构建/推送全量状态,
29
+ # 空闲时连接零开销(此前每连接每 0.8s 盲构建全量 payload,多端并发会放大成假死)
30
+ _VER_CV = threading.Condition()
31
+ _STATE_VER = 1
32
+
33
+
34
+ def bump_state():
35
+ global _STATE_VER
36
+ with _VER_CV:
37
+ _STATE_VER += 1
38
+ _VER_CV.notify_all()
39
+
40
+
41
+ def state_version():
42
+ return _STATE_VER
43
+
44
+
45
+ def wait_state_change(last_ver, timeout):
46
+ """阻塞直到版本号超过 last_ver 或超时。返回当前版本号。"""
47
+ with _VER_CV:
48
+ if _STATE_VER != last_ver:
49
+ return _STATE_VER
50
+ _VER_CV.wait(timeout)
51
+ return _STATE_VER
52
+
53
+
54
+ def _new_id(prefix):
55
+ return "%s-%s-%04d" % (prefix, time.strftime("%Y%m%d-%H%M%S"), secrets.randbelow(10000))
56
+
57
+
58
+ def _safe_name(s):
59
+ return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
60
+
61
+
62
+ # ---------------------------------------------------------------- 任务
63
+
64
+ def create_task(payload):
65
+ """校验并创建任务。payload 至少含 type/goal/workdir。
66
+
67
+ type 必须是 flows.py 里的有效流程 ID;流程参数(引擎/维度/阈值/轮数/产出
68
+ 文件/提示词覆盖)在创建时固化到任务上,之后修改流程定义不影响已建任务。
69
+ """
70
+ from . import flows as flows_mod
71
+ flow = flows_mod.get_flow(payload.get("type"))
72
+ if flow is None:
73
+ raise ValueError("未知任务类型:%s(可选:%s)"
74
+ % (payload.get("type"), "、".join(f["id"] for f in flows_mod.list_flows())))
75
+ title = (payload.get("title") or "").strip()
76
+ goal = (payload.get("goal") or "").strip()
77
+ workdir = (payload.get("workdir") or "").strip()
78
+ if not goal:
79
+ raise ValueError("目标描述不能为空")
80
+ title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
81
+ if not workdir:
82
+ # 未指定目录 → 用「默认保存路径」(设置里可改;内置回落 <data 同级>/workspace)。
83
+ # 默认路径允许自动创建;用户显式给的目录仍必须已存在。
84
+ from . import settings as settings_mod
85
+ workdir = settings_mod.default_workdir()
86
+ wd = Path(workdir)
87
+ try:
88
+ wd.mkdir(parents=True, exist_ok=True)
89
+ except Exception as e:
90
+ raise ValueError("默认保存路径不可用: %s(%s)" % (workdir, e))
91
+ wd = Path(workdir)
92
+ if not wd.is_absolute():
93
+ raise ValueError("工作目录必须是绝对路径")
94
+ if not wd.is_dir():
95
+ raise ValueError("工作目录不存在: %s" % workdir)
96
+ mode = payload.get("mode")
97
+ if mode not in ("auto", "manual"):
98
+ mode = "manual" if payload.get("implementer") else "auto"
99
+ difficulty = payload.get("difficulty")
100
+ if difficulty not in ("auto", "easy", "hard", "default"):
101
+ difficulty = "auto"
102
+ task = {
103
+ "id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
104
+ "title": title, "goal": goal,
105
+ "context": (payload.get("context") or "").strip(),
106
+ "workdir": str(wd),
107
+ "mode": mode,
108
+ "difficulty": difficulty,
109
+ "implementer": payload.get("implementer") or "",
110
+ "attachments": [],
111
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
112
+ "status": "created",
113
+ }
114
+ # 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
115
+ from . import gitmod
116
+ git_rev = (payload.get("git_rev") or "").strip()
117
+ if git_rev:
118
+ # 前端下拉值带 kind 前缀(branch:main / tag:v1 / commit:abc),此处归一为纯 rev;
119
+ # git 分支/标签名本身允许含冒号(罕见),前缀剥离只认这三种已知 kind
120
+ git_rev = re.sub(r"^(branch|tag|commit):", "", git_rev)
121
+ if not gitmod.valid_rev(git_rev):
122
+ raise ValueError("非法的代码版本引用:%s" % git_rev[:40])
123
+ task["git_rev"] = git_rev
124
+ if flow["engine"] == "code":
125
+ task["verify_command"] = (payload.get("verify_command") or "").strip()
126
+ elif flow["engine"] == "direct":
127
+ pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
128
+ else:
129
+ ms = (payload.get("manuscript") or flow.get("manuscript") or "manuscript.md").strip()
130
+ ms = re.sub(r"[\\/]", "_", ms) # 只允许工作目录内的相对文件名
131
+ ms = re.sub(r"\.{2,}", "_", ms).lstrip(".") # 顺带清掉残留的 ..
132
+ task["manuscript"] = ms
133
+ try:
134
+ task["rounds"] = max(1, min(5, int(payload.get("rounds") or flow.get("rounds") or 2)))
135
+ except Exception:
136
+ task["rounds"] = 2
137
+ try:
138
+ task["threshold"] = max(1.0, min(10.0,
139
+ float(payload.get("threshold") or flow.get("threshold") or 7.0)))
140
+ except Exception:
141
+ task["threshold"] = 7.0
142
+ dims = payload.get("rubric")
143
+ if not (isinstance(dims, list) and dims):
144
+ dims = flow.get("rubric")
145
+ if isinstance(dims, list) and dims:
146
+ task["rubric"] = [str(d).strip() for d in dims if str(d).strip()][:8]
147
+ for key in ("draft_prompt", "critique_prompt"): # 自定义流程的提示词覆盖
148
+ if flow.get(key):
149
+ task[key] = flow[key]
150
+ # 连载模式:逐章起草/评审/修订(任务级 serial 覆盖流程默认)
151
+ serial = payload.get("serial") if isinstance(payload.get("serial"), dict) else flow.get("serial")
152
+ if isinstance(serial, dict) and serial.get("chapters"):
153
+ try:
154
+ s = {
155
+ # 续写批次允许只续 1 章,下限放宽到 1(全新连载仍由前端约束 ≥2)
156
+ "chapters": max(1, min(20, int(serial["chapters"]))),
157
+ "words_per_chapter": max(500, min(8000,
158
+ int(serial.get("words_per_chapter") or 2500))),
159
+ }
160
+ except Exception:
161
+ s = None
162
+ if s:
163
+ # 续写:从 start_chapter 章接着写(章节文件/步骤/评分都用全书章号);
164
+ # continues 指向上一批任务,生成大纲时回溯前情保证剧情衔接
165
+ try:
166
+ sc = max(1, min(500, int(serial.get("start_chapter") or 1)))
167
+ except Exception:
168
+ sc = 1
169
+ if sc > 1:
170
+ s["start_chapter"] = sc
171
+ cont = str(serial.get("continues") or "").strip()
172
+ if re.match(r"^[A-Za-z][0-9A-Za-z_-]*$", cont) and get_task(cont):
173
+ s["continues"] = cont
174
+ # 同章多稿赛马(dev-3.0):1=关;2-3 = 每章并行起草 N 稿评审择优
175
+ try:
176
+ v = max(1, min(3, int(serial.get("variants") or 1)))
177
+ except Exception:
178
+ v = 1
179
+ if v > 1:
180
+ s["variants"] = v
181
+ task["serial"] = s
182
+ critics = payload.get("critics")
183
+ if isinstance(critics, list) and critics:
184
+ task["critics"] = [str(c) for c in critics]
185
+ resume = payload.get("resume")
186
+ if isinstance(resume, dict) and resume.get("agent") and resume.get("session"):
187
+ task["resume"] = {"agent": str(resume["agent"])[:40],
188
+ "session": str(resume["session"])[:80],
189
+ "preview": str(resume.get("preview") or "")[:140]}
190
+ # 会话所属项目目录:续会话时 CLI 必须在该目录下启动(opencode/qwen 按 cwd 定位会话)
191
+ proj = str(resume.get("project") or "")[:260]
192
+ if proj:
193
+ task["resume"]["project"] = proj
194
+ # 附件:把待提交文件移入工作目录 _attachments/,清单注入 context(__CONTEXT__ 全链路可见)。
195
+ # 两种形态:字符串 id = 待提交区文件(新建任务);dict 清单 = 已落盘的附件
196
+ # (继续连载/重试沿用同目录同文件,直接复制清单,不再移文件)。
197
+ att_ids = payload.get("attachments")
198
+ if isinstance(att_ids, list) and att_ids:
199
+ from . import attachments as att_mod
200
+ items = [a for a in att_ids if isinstance(a, dict) and a.get("path")]
201
+ if not items: # 纯 id 形态 → 从待提交区移入工作目录
202
+ try:
203
+ items = att_mod.commit_to_workdir(
204
+ str(wd), [str(x) for x in att_ids][:att_mod.MAX_FILES])
205
+ except Exception as e:
206
+ raise ValueError("附件落盘失败: %s" % e)
207
+ if items:
208
+ task["attachments"] = items
209
+ blk = att_mod.context_block(items)
210
+ # 复制清单场景下 context 已含附件块(随旧任务沿用),别重复追加
211
+ if blk and "## 附件材料" not in task["context"]:
212
+ task["context"] = (task["context"] + blk).strip()
213
+ with LOCK:
214
+ _TASKS[task["id"]] = task
215
+ _save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
216
+ return task
217
+
218
+
219
+ def _save_json(path, data):
220
+ path.parent.mkdir(parents=True, exist_ok=True)
221
+ tmp = path.with_suffix(".tmp")
222
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
223
+ tmp.replace(path)
224
+ bump_state()
225
+
226
+
227
+ def update_task_status(task_id, status):
228
+ with LOCK:
229
+ task = _TASKS.get(task_id)
230
+ if task:
231
+ task["status"] = status
232
+ _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
233
+
234
+
235
+ def _valid_id(task_id):
236
+ """task_id 白名单:字母开头,仅字母/数字/下划线/连字符。
237
+
238
+ id 会被拼进磁盘路径(tasks/<id>.json 等)与 URL,`../` 这类穿越序列必须
239
+ 在入口处拒掉;store 里所有「id 直拼路径」的读写都要先过这道闸。
240
+ """
241
+ return bool(re.match(r"^[A-Za-z][0-9A-Za-z_-]*$", str(task_id)))
242
+
243
+
244
+ def set_book_meta(task_id, platform, entry):
245
+ """写任务的作品信息状态(book_meta[platform] = {status, data?, error?, at})。
246
+
247
+ 一键生成是后台线程跑的,前端靠任务 JSON 里的这个字段看进度(SSE 推送)。
248
+ 任务不存在返回 False。"""
249
+ if not _valid_id(task_id):
250
+ return False
251
+ with LOCK:
252
+ task = _TASKS.get(task_id)
253
+ if not task:
254
+ return False
255
+ task.setdefault("book_meta", {})[platform] = entry
256
+ _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
257
+ return True
258
+
259
+
260
+ def get_task(task_id):
261
+ if not _valid_id(task_id):
262
+ return None
263
+ with LOCK:
264
+ if task_id in _TASKS:
265
+ return _TASKS[task_id]
266
+ p = paths.TASKS_DIR / (task_id + ".json")
267
+ if p.is_file():
268
+ try:
269
+ t = json.loads(p.read_text(encoding="utf-8"))
270
+ _TASKS[task_id] = t
271
+ return t
272
+ except Exception:
273
+ return None
274
+ return None
275
+
276
+
277
+ def list_tasks(limit=100, archived=None):
278
+ """archived=None 返回全部;False 仅未归档;True 仅已归档。按 id(含时间戳)倒序。"""
279
+ with LOCK:
280
+ ids = sorted(_TASKS.keys(), reverse=True)
281
+ out = []
282
+ for i in ids:
283
+ t = _TASKS[i]
284
+ if archived is not None and bool(t.get("archived")) != archived:
285
+ continue
286
+ out.append(t)
287
+ if len(out) >= limit:
288
+ break
289
+ return out
290
+
291
+
292
+ def migrate_task_workdirs(old_root, new_root):
293
+ """把「旧默认保存路径」下的任务目录搬到新默认路径下,并更新任务记录。
294
+
295
+ 只动位于 old_root 内部的任务目录(当初由默认路径自动放置的);用户显式
296
+ 指定的其他目录一律不碰。运行中/排队中的任务跳过(工作目录正被使用)。
297
+ 返回 (移动数, 跳过数)。
298
+ """
299
+ oldp = Path(old_root).resolve()
300
+ newp = Path(new_root).resolve()
301
+ if oldp == newp:
302
+ return 0, 0
303
+ moved = skipped = 0
304
+ with LOCK:
305
+ for tid in sorted(_TASKS.keys()):
306
+ t = _TASKS[tid]
307
+ wd = str(t.get("workdir") or "").strip()
308
+ if not wd:
309
+ continue
310
+ try:
311
+ wdp = Path(wd).resolve()
312
+ rel = wdp.relative_to(oldp)
313
+ except (ValueError, OSError):
314
+ continue # 不在旧默认路径下:不碰
315
+ if t.get("status") in ("queued", "running"):
316
+ skipped += 1
317
+ continue
318
+ if not wdp.is_dir():
319
+ continue
320
+ target = newp / rel
321
+ if target.exists():
322
+ target = newp / (rel.name + "_migrated_" + tid[-4:])
323
+ try:
324
+ target.parent.mkdir(parents=True, exist_ok=True)
325
+ shutil.move(str(wdp), str(target))
326
+ except Exception:
327
+ skipped += 1
328
+ continue
329
+ t["workdir"] = str(target)
330
+ _save_json(paths.TASKS_DIR / (tid + ".json"), t)
331
+ moved += 1
332
+ return moved, skipped
333
+
334
+
335
+ def load_all():
336
+ with LOCK:
337
+ for p in paths.TASKS_DIR.glob("*.json"):
338
+ try:
339
+ t = json.loads(p.read_text(encoding="utf-8"))
340
+ _TASKS[t["id"]] = t
341
+ except Exception:
342
+ pass
343
+ for p in paths.RUNS_DIR.glob("*/run.json"):
344
+ try:
345
+ r = json.loads(p.read_text(encoding="utf-8"))
346
+ r.pop("cancel_event", None)
347
+ # 队列不跨进程持久化:磁盘上仍是 queued/running 的运行必是上次进程中断的残骸
348
+ if r.get("status") in ("queued", "running"):
349
+ r["status"] = "failed"
350
+ r["error"] = r.get("error") or "服务重启中断,可重试"
351
+ _save_json(p, r)
352
+ _RUNS[r["id"]] = r
353
+ except Exception:
354
+ pass
355
+ # 回填历史遗留:运行已终态而任务仍停在 created/queued/running 的脏状态
356
+ for t in _TASKS.values():
357
+ if t.get("status") not in ("created", "queued", "running"):
358
+ continue
359
+ runs = [r for r in _RUNS.values() if r.get("task_id") == t["id"]]
360
+ if not runs:
361
+ # 卡在排队却从未有运行:创建流程被中断,标记失败允许重试
362
+ t["status"] = "failed"
363
+ t["error"] = "没有运行记录(创建可能被中断),可重试"
364
+ _save_json(paths.TASKS_DIR / (t["id"] + ".json"), t)
365
+ continue
366
+ latest = max(runs, key=lambda r: r["id"])
367
+ if latest.get("status") in ("done", "failed", "cancelled"):
368
+ t["status"] = latest["status"]
369
+ _save_json(paths.TASKS_DIR / (t["id"] + ".json"), t)
370
+
371
+
372
+ # ---------------------------------------------------------------- 运行(含管理操作)
373
+
374
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
375
+ run = {
376
+ "id": _new_id("r" if kind == "orchestration" else "m"),
377
+ "kind": kind, # orchestration | mgmt
378
+ "title": title,
379
+ "task_id": task_id, "entry_id": entry_id, "op": op,
380
+ "status": "queued", "steps": [], "messages": [],
381
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
382
+ "started_at": None, "ended_at": None,
383
+ "cost_usd": 0.0, "tokens": 0, "error": "",
384
+ "verdict": None, "summary": "",
385
+ }
386
+ rdir = paths.RUNS_DIR / run["id"]
387
+ (rdir / "steps").mkdir(parents=True, exist_ok=True)
388
+ with LOCK:
389
+ _RUNS[run["id"]] = run
390
+ _save_json(rdir / "run.json", run)
391
+ return run
392
+
393
+
394
+ def get_run(run_id):
395
+ with LOCK:
396
+ return _RUNS.get(run_id)
397
+
398
+
399
+ def list_runs(limit=60):
400
+ with LOCK:
401
+ ids = sorted(_RUNS.keys(), reverse=True)
402
+ return [_RUNS[i] for i in ids[:limit]]
403
+
404
+
405
+ def latest_run_by_task():
406
+ """每个任务的最近一次运行(含 steps),全量扫描不受 list_runs 窗口限制。
407
+
408
+ 侧栏靠它给每个任务展示真实近况;从未运行过的任务不在返回值里。
409
+ run id 含时间戳,字典序即新旧序。
410
+ """
411
+ with LOCK:
412
+ best = {}
413
+ for r in _RUNS.values():
414
+ tid = r.get("task_id")
415
+ if not tid:
416
+ continue
417
+ cur = best.get(tid)
418
+ if cur is None or r["id"] > cur["id"]:
419
+ best[tid] = r
420
+ return {tid: dict(r) for tid, r in best.items()}
421
+
422
+
423
+ def task_run_stats():
424
+ """每个任务的运行次数与步骤总数(全量扫描,不受 run 窗口限制)。
425
+
426
+ 侧栏「查看全部 N 次运行 · M 步」的计数来源——从窗口里数会因刷屏/窗口滑动而算错。
427
+ """
428
+ with LOCK:
429
+ stats = {}
430
+ for r in _RUNS.values():
431
+ tid = r.get("task_id")
432
+ if not tid:
433
+ continue
434
+ s = stats.setdefault(tid, {"runs": 0, "steps": 0})
435
+ s["runs"] += 1
436
+ s["steps"] += len(r.get("steps") or [])
437
+ return stats
438
+
439
+
440
+ def task_runs(task_id):
441
+ """某任务的全部运行(新→旧,含 steps)。任务级详情视图用,按需拉全量。"""
442
+ with LOCK:
443
+ runs = [dict(r) for r in _RUNS.values() if r.get("task_id") == task_id]
444
+ runs.sort(key=lambda r: r["id"], reverse=True)
445
+ return runs
446
+
447
+
448
+ def task_side(task_id):
449
+ """任务检查器(右缘停靠列)的轻量聚合:最新 run 摘要 + 进度步骤 +
450
+ git 分支/裁决状态 + 变更行级统计 + 成品文件。
451
+
452
+ 刻意不带 diff 文本与消息历史——面板每 2s 轮询一次,响应必须保持 KB 级;
453
+ diff 按需走 /api/runs/<id> 单拉。任务不存在返回 None。
454
+
455
+ 变更统计取双路径:任务运行中 → 实时 numstat(工作区正检出在任务分支,
456
+ 未提交改动即产物);已结束 → 读最新 run 的 changes 快照(finalize 已把
457
+ 产物提交进任务分支并切回,实时统计恒为 0,只能用落盘快照)。
458
+ """
459
+ task = get_task(task_id)
460
+ if not task:
461
+ return None
462
+ runs = task_runs(task_id)
463
+ latest = runs[0] if runs else None
464
+ steps = (latest or {}).get("steps") or []
465
+ done = sum(1 for s in steps if s.get("status") == "done")
466
+ current = next((s for s in steps if s.get("status") == "running"), None)
467
+ active = bool(latest) and latest.get("status") in ("queued", "running")
468
+ # 分支上下文取最近一次带 git 信息的 run(重试/续跑会带出同一任务分支)
469
+ git = {}
470
+ for r in runs:
471
+ if r.get("git"):
472
+ git = {k: r["git"].get(k) for k in
473
+ ("rev", "branch", "commit", "from_branch", "base_commit",
474
+ "restored", "restore_error")}
475
+ break
476
+ git["state"] = task.get("git_state") or ""
477
+ changes = {"count": 0, "add_total": None, "del_total": None, "files": []}
478
+ if active:
479
+ from . import gitmod
480
+ live = gitmod.collect_changes(task.get("workdir"))
481
+ changes["count"] = len(live.get("files") or [])
482
+ changes["add_total"] = live.get("add_total") or 0
483
+ changes["del_total"] = live.get("del_total") or 0
484
+ changes["files"] = [
485
+ {"status": f.get("status"), "path": f.get("path"),
486
+ "add": f.get("add") or 0, "del": f.get("del") or 0}
487
+ for f in (live.get("files") or [])[:50]]
488
+ git["branch"] = git.get("branch") or gitmod.branch_name(task_id)
489
+ elif latest is not None:
490
+ snap = latest.get("changes") or {}
491
+ snap_files = snap.get("files") or []
492
+ changes["count"] = len(snap_files)
493
+ changes["add_total"] = snap.get("add_total")
494
+ changes["del_total"] = snap.get("del_total")
495
+ changes["files"] = [
496
+ {"status": f.get("status"), "path": f.get("path"),
497
+ "add": f.get("add"), "del": f.get("del")}
498
+ for f in snap_files[:50]]
499
+ total_steps = sum(len(r.get("steps") or []) for r in runs)
500
+ # 成品口径闸:任务一步都没跑出来过(如历次都在检出前失败)→ 工作目录里的
501
+ # 文件变动全是并行活动的噪音,不算这个任务的成品
502
+ wd, arts = run_artifacts(latest["id"], limit=50) if (latest and total_steps > 0) else ("", [])
503
+ return {
504
+ "task": {k: task.get(k) for k in ("id", "title", "status", "workdir", "git_state",
505
+ "git_rev")},
506
+ "run": ({k: latest.get(k) for k in ("id", "status", "cost_usd", "tokens", "error",
507
+ "created_at", "started_at", "ended_at")}
508
+ if latest else None),
509
+ "progress": {"total": len(steps), "done": done,
510
+ "current": ({k: current.get(k) for k in
511
+ ("n", "role", "agent_label", "summary", "status")}
512
+ if current else None)},
513
+ "steps": [{k: s.get(k) for k in ("n", "role", "agent_label", "summary",
514
+ "status", "duration_s", "log")}
515
+ for s in steps[:20]],
516
+ "git": git,
517
+ "changes": changes,
518
+ "workdir": wd,
519
+ "files": arts,
520
+ "stats": {"runs": len(runs),
521
+ "steps": total_steps,
522
+ "cost_usd": sum(float(r.get("cost_usd") or 0) for r in runs),
523
+ "tokens": sum(int(r.get("tokens") or 0) for r in runs)},
524
+ }
525
+
526
+
527
+ def update_run(run_id, expected_status=None, **fields):
528
+ """更新运行字段。expected_status 非 None 时做 CAS(§2C):
529
+ 当前状态不等于 expected_status 则拒绝写入并返回 None,
530
+ 防止陈旧执行方(被取消的 worker、崩溃恢复前的旧线程)覆盖新状态
531
+ ——防御模式「异步状态不是同步状态」。不传则保持原行为。"""
532
+ with LOCK:
533
+ run = _RUNS.get(run_id)
534
+ if not run:
535
+ return None
536
+ if expected_status is not None and run.get("status") != expected_status:
537
+ return None
538
+ run.update(fields)
539
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
540
+ # 终态回填:运行结束(成功/失败/取消)时同步任务状态,否则任务永远停在 queued
541
+ st = fields.get("status")
542
+ if st in ("done", "failed", "cancelled"):
543
+ tid = run.get("task_id")
544
+ task = _TASKS.get(tid) if tid else None
545
+ if task:
546
+ task["status"] = st
547
+ _save_json(paths.TASKS_DIR / (tid + ".json"), task)
548
+ return run
549
+
550
+
551
+ def run_dir(run_id):
552
+ return paths.RUNS_DIR / run_id
553
+
554
+
555
+ def delete_run(run_id):
556
+ """删除一条运行记录(内存 + 磁盘目录)。返回 (ok, 错误信息)。"""
557
+ if not _valid_id(run_id):
558
+ return False, "非法的记录 ID"
559
+ with LOCK:
560
+ run = _RUNS.get(run_id)
561
+ if not run:
562
+ return False, "记录不存在"
563
+ if run.get("status") in ("queued", "running"):
564
+ return False, "运行中的记录不能删除,请先取消"
565
+ del _RUNS[run_id]
566
+ shutil.rmtree(paths.RUNS_DIR / run_id, ignore_errors=True)
567
+ bump_state()
568
+ return True, ""
569
+
570
+
571
+ def recover_orphaned_runs():
572
+ """启动时调用:把上一轮进程崩溃遗留的 status='running' run 标记为 failed。
573
+
574
+ 设计稿:docs/migration/01-defense-patterns.md §1E。
575
+ 参考 dsh docs/subsystems/persistence.zh.md:108-114 interruptedTurnClosers。
576
+ 调用时机:load_all 之后,启动 HTTP 服务之前。
577
+ 返回恢复的 run 数。
578
+ """
579
+ now = time.strftime("%Y-%m-%d %H:%M:%S")
580
+ with LOCK:
581
+ # status=running 视为崩溃遗留(包括 ended_at 已设的异常状态,统一兜底)
582
+ candidates = [r["id"] for r in _RUNS.values() if r.get("status") == "running"]
583
+ recovered = 0
584
+ for run_id in candidates:
585
+ # update_run 内部再次加 LOCK(RLock 允许重入),并把终态回填到 task
586
+ update_run(run_id,
587
+ status="failed",
588
+ ended_at=now,
589
+ error="interrupted at startup (auto-recovered)")
590
+ recovered += 1
591
+ # 步骤级兜底:终态 run 里卡在 queued/running 的步骤落「已取消」。
592
+ # 既清崩溃遗留,也清取消收尾修复前的僵尸步骤(运行已取消、格子永转);
593
+ # 进行中的 run 不碰——其步骤是活流程,收尾由 pipeline._run_step 负责。
594
+ healed = 0
595
+ step_now = time.strftime("%H:%M:%S")
596
+ with LOCK:
597
+ for r in _RUNS.values():
598
+ if r.get("status") not in ("done", "failed", "cancelled"):
599
+ continue
600
+ changed = False
601
+ for s in r.get("steps") or []:
602
+ if s.get("status") in ("queued", "running"):
603
+ s["status"] = "cancelled"
604
+ s["ended_at"] = s.get("ended_at") or step_now
605
+ if not s.get("summary"):
606
+ s["summary"] = "步骤未正常收尾(取消/中断自动恢复)"
607
+ changed = True
608
+ if changed:
609
+ _save_json(paths.RUNS_DIR / r["id"] / "run.json", r)
610
+ healed += 1
611
+ if healed:
612
+ bump_state()
613
+ return recovered
614
+
615
+
616
+ def run_workdir(run_id):
617
+ """该 run 的工作目录(经其 task 关联);无任务的 run(如管理操作)返回空串。"""
618
+ run = get_run(run_id)
619
+ if not run:
620
+ return ""
621
+ task = get_task(run.get("task_id") or "") if run.get("task_id") else None
622
+ return str(task.get("workdir") or "") if task else ""
623
+
624
+
625
+ _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
626
+
627
+ # 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
628
+ _BUILD_DIRS = {
629
+ "target", "build", "dist", "out", "bin", "obj", # 通用构建输出
630
+ "surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
631
+ ".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
632
+ "__MACOSX",
633
+ }
634
+
635
+
636
+ def task_first_start(task_id, fallback=""):
637
+ """该任务最早一次运行的开始时间(含回退:任务创建时间 → 指定回退值)。"""
638
+ stamps = []
639
+ with LOCK:
640
+ for r in _RUNS.values():
641
+ if r.get("task_id") == task_id:
642
+ stamps.append(str(r.get("started_at") or r.get("created_at") or ""))
643
+ task = get_task(task_id) if task_id else None
644
+ if task:
645
+ stamps.append(str(task.get("created_at") or ""))
646
+ stamps.append(str(fallback or ""))
647
+ best = ""
648
+ for s in stamps:
649
+ if s and (not best or s < best):
650
+ best = s
651
+ return best
652
+
653
+
654
+ def run_artifacts(run_id, limit=200):
655
+ """列一次运行的「成品文件」:工作目录里自该任务首次运行以来新产生/修改的文件。
656
+
657
+ 断点续跑会拆成多条 run,只按本 run 过滤会漏掉早期章节;这里以「任务首跑」
658
+ 为起点。返回 (workdir, files);files 按 mtime 新→旧,name 为工作目录内
659
+ 相对路径,已跳过 .git / 隐藏目录 / node_modules / target 等构建产物目录,
660
+ 最多 limit 个。
661
+ """
662
+ wd = run_workdir(run_id)
663
+ if not wd:
664
+ return "", []
665
+ run = get_run(run_id) or {}
666
+ t0 = task_first_start(run.get("task_id") or "",
667
+ run.get("started_at") or run.get("created_at") or "")
668
+ try:
669
+ t0 = time.mktime(time.strptime(t0, "%Y-%m-%d %H:%M:%S")) - 1
670
+ except Exception:
671
+ t0 = 0
672
+ root = Path(wd)
673
+ if not root.is_dir():
674
+ return wd, []
675
+ files = []
676
+ try:
677
+ for p in root.rglob("*"):
678
+ if not p.is_file():
679
+ continue
680
+ if any(part in _SKIP_DIRS for part in p.parts):
681
+ continue
682
+ if any(part in _BUILD_DIRS for part in p.parts[:-1]):
683
+ continue
684
+ # 隐藏目录一律是工具过程文件(.mimocode/.zcode/.claude/.codex…),
685
+ # 不是成品;按前缀通排,免得每来一个新 agent CLI 就补一次白名单
686
+ if any(part.startswith(".") for part in p.parts[:-1]):
687
+ continue
688
+ try:
689
+ st = p.stat()
690
+ except OSError:
691
+ continue
692
+ if st.st_mtime < t0:
693
+ continue
694
+ files.append({"name": str(p.relative_to(root)).replace("\\", "/"),
695
+ "size": st.st_size, "mtime": int(st.st_mtime)})
696
+ if len(files) >= 800: # 防超大目录拖垮接口;截断后再排序取最新
697
+ break
698
+ except OSError:
699
+ pass
700
+ files.sort(key=lambda f: -f["mtime"])
701
+ return wd, files[:limit]
702
+
703
+
704
+ def task_step_count(task_id):
705
+ """该任务所有 run 的步骤总数(含进行中)。
706
+
707
+ 成品口径的闸:一步都没跑出来过的任务(历次都在检出等前置环节失败)
708
+ 没有成品可言,工作目录里的文件变动全是并行活动的噪音。
709
+ """
710
+ if not task_id:
711
+ return 0
712
+ return sum(len(r.get("steps") or []) for r in task_runs(task_id))
713
+
714
+
715
+ # ---------------------------------------------------------------- 故事圣经(story-bible.md)
716
+
717
+ BIBLE_FILE = "story-bible.md"
718
+ BIBLE_MAX_CHARS = 20000
719
+
720
+
721
+ def _bible_path(workdir):
722
+ """工作目录内圣经文件绝对路径;目录穿越直接返回 None(不读外面任何东西)。"""
723
+ wd = str(workdir or "").strip()
724
+ if not wd or not os.path.isdir(wd):
725
+ return None
726
+ root = Path(wd).resolve()
727
+ p = (root / BIBLE_FILE).resolve()
728
+ if root != p and root not in p.parents:
729
+ return None
730
+ return p
731
+
732
+
733
+ def read_story_bible(task_id):
734
+ """读取任务工作目录里的故事圣经。返回 (文件路径, 文本内容, None) 或
735
+ (None, None, 错误信息)。"""
736
+ from . import store as _self
737
+ task = get_task(task_id)
738
+ if not task:
739
+ return None, None, "任务不存在"
740
+ p = _bible_path(task.get("workdir"))
741
+ if p is None:
742
+ return None, None, "工作目录不存在或路径越界"
743
+ try:
744
+ if p.is_file():
745
+ return str(p), runner.read_text_any_enc(p), None
746
+ return str(p), "", None # 文件未创建:空内容
747
+ except OSError as e:
748
+ return None, None, "读取失败: %s" % e
749
+
750
+
751
+ def write_story_bible(task_id, text):
752
+ """写入故事圣经到任务工作目录。守卫:
753
+ - 任务不存在/工作目录越界 → 拒绝;
754
+ - 任务正在运行(queued/running)→ 拒绝(圣经是中流砥柱,运行中不能换骨架);
755
+ - 文本超长(BIBLE_MAX_CHARS)→ 拒绝;
756
+ - 写入失败 → 报错。
757
+ 返回 (ok, 错误信息)。"""
758
+ task = get_task(task_id)
759
+ if not task:
760
+ return False, "任务不存在"
761
+ if task.get("status") in ("queued", "running"):
762
+ return False, "任务正在运行,不能修改故事圣经(请等运行结束后再编辑)"
763
+ p = _bible_path(task.get("workdir"))
764
+ if p is None:
765
+ return False, "工作目录不存在或路径越界"
766
+ text = (text or "").strip()
767
+ if len(text) > BIBLE_MAX_CHARS:
768
+ return False, "故事圣经超长(最大 %d 字符,当前 %d 字符)" % (BIBLE_MAX_CHARS, len(text))
769
+ try:
770
+ p.parent.mkdir(parents=True, exist_ok=True)
771
+ p.write_text(text, encoding="utf-8")
772
+ return True, ""
773
+ except OSError as e:
774
+ return False, "写入失败: %s" % e
775
+
776
+
777
+ def read_run_file(run_id, rel):
778
+ """读取 run 工作目录内的一个文件(防目录穿越)。返回 (bytes, 错误)。"""
779
+ wd = run_workdir(run_id)
780
+ if not wd:
781
+ return b"", "该运行没有关联的工作目录"
782
+ rel = (rel or "").replace("\\", "/").lstrip("/")
783
+ if not rel:
784
+ return b"", "缺少文件名"
785
+ root = Path(wd).resolve()
786
+ p = (root / rel).resolve()
787
+ if root != p and root not in p.parents:
788
+ return b"", "非法路径"
789
+ if not p.is_file():
790
+ return b"", "文件不存在"
791
+ try:
792
+ return p.read_bytes(), None
793
+ except OSError as e:
794
+ return b"", "读取失败: %s" % e
795
+
796
+
797
+ def delete_runs(run_ids):
798
+ """批量删除运行记录。跳过排队中/运行中的记录。
799
+
800
+ 返回 (删除数, 跳过数, 错误信息):非法 ID 与运行中的记录都计入跳过,
801
+ 合法且已结束的记录照常删除,不因个别非法 ID 整体失败。
802
+ """
803
+ if not isinstance(run_ids, (list, tuple)):
804
+ return 0, 0, "ids 必须是数组"
805
+ deleted, skipped, bad = 0, 0, 0
806
+ for run_id in run_ids:
807
+ rid = str(run_id)
808
+ if not _valid_id(rid):
809
+ bad += 1
810
+ continue
811
+ with LOCK:
812
+ run = _RUNS.get(rid)
813
+ if not run or run.get("status") in ("queued", "running"):
814
+ skipped += 1
815
+ continue
816
+ del _RUNS[rid]
817
+ shutil.rmtree(paths.RUNS_DIR / rid, ignore_errors=True)
818
+ deleted += 1
819
+ if deleted:
820
+ bump_state()
821
+ return deleted, skipped, ("有 %d 条非法记录 ID" % bad) if bad else ""
822
+
823
+
824
+ def clear_runs():
825
+ """一键清除全部运行记录(跳过排队中/运行中的)。返回 (删除数, 跳过数)。"""
826
+ with LOCK:
827
+ targets, skipped = [], 0
828
+ for rid, r in _RUNS.items():
829
+ if r.get("status") in ("queued", "running"):
830
+ skipped += 1
831
+ else:
832
+ targets.append(rid)
833
+ for rid in targets:
834
+ del _RUNS[rid]
835
+ for rid in targets:
836
+ shutil.rmtree(paths.RUNS_DIR / rid, ignore_errors=True)
837
+ bump_state()
838
+ return len(targets), skipped
839
+
840
+
841
+ def archive_task(task_id, archived=True):
842
+ """归档/取消归档:归档后从默认列表与侧栏隐藏,数据保留,可随时恢复。
843
+ 运行中/排队也允许归档——归档只是隐藏,运行照常继续,不删任何东西。"""
844
+ if not _valid_id(task_id):
845
+ return False, "非法的任务 ID"
846
+ with LOCK:
847
+ task = _TASKS.get(task_id)
848
+ if not task:
849
+ return False, "任务不存在"
850
+ task["archived"] = bool(archived)
851
+ _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
852
+ return True, ""
853
+
854
+
855
+ def delete_task(task_id):
856
+ """删除任务及其全部运行记录(含日志与报告目录)。返回 (ok, 错误信息)。"""
857
+ if not _valid_id(task_id):
858
+ return False, "非法的任务 ID"
859
+ with LOCK:
860
+ task = _TASKS.get(task_id)
861
+ if not task:
862
+ return False, "任务不存在"
863
+ if task.get("status") in ("queued", "running"):
864
+ return False, "运行中的任务不能删除,请先取消"
865
+ run_ids = []
866
+ for rid, r in _RUNS.items():
867
+ if r.get("task_id") != task_id:
868
+ continue
869
+ if r.get("status") in ("queued", "running"):
870
+ return False, "有运行中的记录,请先取消"
871
+ run_ids.append(rid)
872
+ del _TASKS[task_id]
873
+ for rid in run_ids:
874
+ _RUNS.pop(rid, None)
875
+ for rid in run_ids:
876
+ shutil.rmtree(paths.RUNS_DIR / rid, ignore_errors=True)
877
+ (paths.TASKS_DIR / (task_id + ".json")).unlink(missing_ok=True)
878
+ bump_state()
879
+ return True, ""
880
+
881
+
882
+ def retry_task(task_id):
883
+ """手动重试:为失败/已取消的任务再创建一次新运行。返回 (ok, 错误, run)。
884
+
885
+ 连载任务断点续跑:若上一次运行已产出大纲与部分章节成稿,新运行继承
886
+ (inherit)大纲与已完成章号——流水线跳过这些章的起草(成稿/评审分数
887
+ 直接复用或重评),避免几十万字长篇因一次超时全部重来。
888
+ """
889
+ if not _valid_id(task_id):
890
+ return False, "非法的任务 ID", None
891
+ with LOCK:
892
+ task = _TASKS.get(task_id)
893
+ if not task:
894
+ return False, "任务不存在", None
895
+ for r in _RUNS.values():
896
+ if r.get("task_id") == task_id and r.get("status") in ("queued", "running"):
897
+ return False, "任务仍在运行中,不能重试", None
898
+ run = create_run("orchestration", task.get("title") or task_id, task_id=task_id)
899
+ # 运行中指挥继承:旧 run 里未消费的用户纠偏指令带入新 run——
900
+ # 指令是针对目标的意图,不因一次超时/失败而丢(自动续跑同享)。
901
+ # 旧 run 的消息保留原样(历史回放可见),新 run 里是未消费副本。
902
+ inherited = []
903
+ for prev in _RUNS.values():
904
+ if prev.get("task_id") != task_id or prev["id"] == run["id"]:
905
+ continue
906
+ for m in prev.get("messages") or []:
907
+ if not m.get("consumed"):
908
+ inherited.append(dict(m))
909
+ if inherited:
910
+ run["messages"] = inherited[-50:] # 封顶防多轮重试滚雪球
911
+ if task.get("serial"):
912
+ prev_runs = sorted((r for r in _RUNS.values()
913
+ if r.get("task_id") == task_id and r["id"] != run["id"]),
914
+ key=lambda r: r["id"], reverse=True)
915
+ for prev in prev_runs:
916
+ outline = prev.get("outline")
917
+ # 降级大纲没有真实情节,继承只会让每一遍都按空模板写废——
918
+ # 跳过它,让新运行重新生成(编排者恢复后即可拿到真大纲)。
919
+ # mock 模板的 outline 无 degraded 标记,正常继承不受影响。
920
+ if not outline or outline.get("degraded"):
921
+ continue
922
+ done = sorted({int(s["role"].split("c")[-1])
923
+ for s in (prev.get("steps") or [])
924
+ if s.get("status") == "done"
925
+ and (s.get("role") or "").startswith("draft-c")
926
+ and str(s.get("role")).split("c")[-1].isdigit()})
927
+ if done:
928
+ run["inherit"] = {
929
+ "outline": outline,
930
+ "done_chapters": done,
931
+ # 分数优先取 verdict(整轮成功时的最终账);
932
+ # failed/cancelled 的 run 没有 verdict,退回每章实时
933
+ # 落账的 chapter_scores——否则多轮失败恢复会把全部
934
+ # 已过线章节重新评审(实测一晚白烧数百万 token)
935
+ "chapter_scores": ((prev.get("verdict") or {}).get("chapter_scores")
936
+ or prev.get("chapter_scores") or []),
937
+ }
938
+ break
939
+ task["status"] = "queued"
940
+ _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
941
+ _save_json(paths.RUNS_DIR / run["id"] / "run.json", run)
942
+ return True, "", run
943
+
944
+
945
+ _CH_FILE_RE = re.compile(r"^chapter-(\d{1,4})\.md$")
946
+
947
+
948
+ def serial_book_progress(task):
949
+ """这本书已经写到第几章:工作目录里的 chapter-*.md 是事实标准(续写批次
950
+ 共用同一目录,天然包含全部历史章);文件缺失时沿 continues 链推算兜底。"""
951
+ last = 0
952
+ wd = str(task.get("workdir") or "")
953
+ if wd:
954
+ try:
955
+ for p in Path(wd).glob("chapter-*.md"):
956
+ m = _CH_FILE_RE.match(p.name)
957
+ if m:
958
+ last = max(last, int(m.group(1)))
959
+ except OSError:
960
+ pass
961
+ if last:
962
+ return last
963
+ seen = set()
964
+ cur = task
965
+ while cur and cur.get("serial") and cur["id"] not in seen and len(seen) < 20:
966
+ seen.add(cur["id"])
967
+ s = cur["serial"]
968
+ try:
969
+ start = int(s.get("start_chapter") or 1)
970
+ except Exception:
971
+ start = 1
972
+ runs = task_runs(cur["id"])
973
+ batch = 0
974
+ for r in runs:
975
+ o = r.get("outline")
976
+ if o and o.get("chapters") and not o.get("degraded"):
977
+ batch = len(o["chapters"])
978
+ break
979
+ # 从没跑过的任务不计入进度:计划章数 ≠ 已写成章数
980
+ if runs:
981
+ batch = batch or int(s.get("chapters") or 0)
982
+ if batch:
983
+ last = max(last, start + batch - 1)
984
+ cur = get_task(str(s.get("continues") or ""))
985
+ return last
986
+
987
+
988
+ def continue_info(task_id):
989
+ """「继续连载」弹框数据:能否续、已写到第几章、默认续几章。任务不存在返回 None。"""
990
+ task = get_task(task_id)
991
+ if not task:
992
+ return None
993
+ serial = task.get("serial") or {}
994
+ try:
995
+ default_ch = int(serial.get("chapters") or 8)
996
+ except Exception:
997
+ default_ch = 8
998
+ info = {"task_id": task_id, "serial": bool(serial),
999
+ "last_chapter": 0, "default_chapters": default_ch,
1000
+ "words_per_chapter": int(serial.get("words_per_chapter") or 2500),
1001
+ "can": False, "reason": ""}
1002
+ if not serial:
1003
+ info["reason"] = "只有连载任务支持继续连载"
1004
+ return info
1005
+ if task.get("status") in ("queued", "running"):
1006
+ info["reason"] = "任务还在运行中,等结束或取消后再续写"
1007
+ return info
1008
+ for r in _RUNS.values():
1009
+ if r.get("task_id") == task_id and r.get("status") in ("queued", "running"):
1010
+ info["reason"] = "有运行中的记录,请先取消"
1011
+ return info
1012
+ last = serial_book_progress(task)
1013
+ info["last_chapter"] = last
1014
+ if last < 1:
1015
+ info["reason"] = "还没写成任何一章(工作目录里没有 chapter-*.md),先跑完一次连载"
1016
+ return info
1017
+ info["can"] = True
1018
+ return info
1019
+
1020
+
1021
+ def continue_task(task_id, chapters=None):
1022
+ """在此基础上新建任务继续连载:沿用目标/上下文/目录/评审设置,从已写到
1023
+ 的下一章接着写(章节文件与成书合并按全书章号衔接,旧章不动)。
1024
+ 返回 (ok, 错误, 新任务)。"""
1025
+ task = get_task(task_id)
1026
+ if not task:
1027
+ return False, "任务不存在", None
1028
+ info = continue_info(task_id)
1029
+ if not info.get("can"):
1030
+ return False, info.get("reason") or "当前不能继续连载", None
1031
+ serial = task["serial"]
1032
+ try:
1033
+ batch = max(1, min(20, int(chapters or serial.get("chapters") or 8)))
1034
+ except Exception:
1035
+ batch = int(serial.get("chapters") or 8)
1036
+ # 标题:去掉历史「·续N」后缀取根名,按链条代数标 ·续 / ·续2 / ·续3…
1037
+ base_title = re.sub(r"·续\d*$", "", task.get("title") or "").strip() or task_id
1038
+ gen, cur, seen = 0, task, set()
1039
+ while cur and cur["id"] not in seen and len(seen) < 20:
1040
+ seen.add(cur["id"])
1041
+ gen += 1
1042
+ cur = get_task(str((cur.get("serial") or {}).get("continues") or ""))
1043
+ payload = {
1044
+ "type": task["type"],
1045
+ "title": base_title + ("·续" if gen <= 1 else "·续%d" % gen),
1046
+ "goal": task.get("goal") or "",
1047
+ "context": task.get("context") or "",
1048
+ "workdir": task.get("workdir") or "",
1049
+ "mode": task.get("mode") or "auto",
1050
+ "difficulty": task.get("difficulty") or "auto",
1051
+ "implementer": task.get("implementer") or "",
1052
+ "critics": task.get("critics") or [],
1053
+ "manuscript": task.get("manuscript") or "manuscript.md",
1054
+ "threshold": task.get("threshold") or 7.0,
1055
+ "serial": {"chapters": batch,
1056
+ "words_per_chapter": int(serial.get("words_per_chapter") or 2500),
1057
+ "start_chapter": info["last_chapter"] + 1,
1058
+ "continues": task_id},
1059
+ }
1060
+ if serial.get("variants"): # 赛马配置随链条沿用
1061
+ payload["serial"]["variants"] = serial["variants"]
1062
+ for key in ("draft_prompt", "critique_prompt"): # 自定义提示词覆盖一并沿用
1063
+ if task.get(key):
1064
+ payload[key] = task[key]
1065
+ try:
1066
+ new = create_task(payload)
1067
+ except ValueError as e:
1068
+ return False, str(e), None
1069
+ return True, "", new
1070
+
1071
+
1072
+ def set_task_git_state(task_id, state):
1073
+ """任务分支裁决状态:isolated(有分支待裁决)/ merged / discarded / None(清除)。
1074
+
1075
+ run 检出任务分支时置 isolated,人审合并/丢弃后置终态,新一轮 run 又会
1076
+ 重置回 isolated。返回 (ok, 错误信息)。
1077
+ """
1078
+ if not _valid_id(task_id):
1079
+ return False, "非法的任务 ID"
1080
+ with LOCK:
1081
+ task = _TASKS.get(task_id)
1082
+ if not task:
1083
+ return False, "任务不存在"
1084
+ if state:
1085
+ task["git_state"] = state
1086
+ else:
1087
+ task.pop("git_state", None)
1088
+ _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
1089
+ bump_state()
1090
+ return True, ""
1091
+
1092
+
1093
+ def rename_task(task_id, title):
1094
+ """重命名任务:同步更新任务与全部运行记录的标题。
1095
+
1096
+ 侧栏任务树按 run.title 显示组名,只改任务会让历史运行仍顶着旧名,
1097
+ 所以两者一起改(运行 json 逐个回写)。
1098
+ """
1099
+ if not _valid_id(task_id):
1100
+ return False, "非法的任务 ID"
1101
+ title = str(title or "").strip()
1102
+ if not title:
1103
+ return False, "标题不能为空"
1104
+ if len(title) > 120:
1105
+ return False, "标题过长(最多 120 字)"
1106
+ with LOCK:
1107
+ task = _TASKS.get(task_id)
1108
+ if not task:
1109
+ return False, "任务不存在"
1110
+ if task.get("title") == title:
1111
+ return True, ""
1112
+ task["title"] = title
1113
+ _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
1114
+ for r in _RUNS.values():
1115
+ if r.get("task_id") == task_id:
1116
+ r["title"] = title
1117
+ _save_json(paths.RUNS_DIR / r["id"] / "run.json", r)
1118
+ bump_state()
1119
+ return True, ""
1120
+
1121
+
1122
+ def add_step(run_id, role, agent_id, agent_label, note=""):
1123
+ with LOCK:
1124
+ run = _RUNS.get(run_id)
1125
+ if not run:
1126
+ return None, None
1127
+ n = len(run["steps"]) + 1
1128
+ step = {
1129
+ "n": n, "role": role, "agent": agent_id, "agent_label": agent_label,
1130
+ "note": note,
1131
+ "status": "running", "started_at": time.strftime("%H:%M:%S"),
1132
+ "ended_at": None, "duration_s": None, "exit_code": None,
1133
+ "summary": "", "log": None, "cost_usd": 0.0, "tokens": 0,
1134
+ }
1135
+ run["steps"].append(step)
1136
+ log_rel = "steps/%02d-%s-%s.log" % (n, _safe_name(role), _safe_name(agent_id))
1137
+ step["log"] = log_rel
1138
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1139
+ log_abs = paths.RUNS_DIR / run_id / log_rel
1140
+ log_abs.parent.mkdir(parents=True, exist_ok=True)
1141
+ return step, log_abs
1142
+
1143
+
1144
+ def finish_step(run_id, n, status, summary="", exit_code=None,
1145
+ cost_usd=0.0, tokens=0.0, duration_s=None, model=None):
1146
+ with LOCK:
1147
+ run = _RUNS.get(run_id)
1148
+ if not run:
1149
+ return
1150
+ for s in run["steps"]:
1151
+ if s["n"] == n:
1152
+ s["status"] = status
1153
+ s["ended_at"] = time.strftime("%H:%M:%S")
1154
+ s["summary"] = summary
1155
+ s["exit_code"] = exit_code
1156
+ s["cost_usd"] = round(cost_usd, 4)
1157
+ s["tokens"] = tokens
1158
+ if model:
1159
+ s["model"] = str(model)[:80]
1160
+ if duration_s is not None:
1161
+ s["duration_s"] = round(duration_s, 1)
1162
+ break
1163
+ run["cost_usd"] = round(run.get("cost_usd", 0.0) + cost_usd, 4)
1164
+ run["tokens"] = run.get("tokens", 0) + tokens
1165
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1166
+
1167
+
1168
+ # ---------------------------------------------------------------- 运行中指挥(消息信箱)
1169
+ # 用户可在任务运行中往编排者「递话」:文字 + 附件(截图/文件)。消息先进信箱,
1170
+ # 下一个智能体步骤开始前由 pipeline drain 出来注入 prompt——不插进正在跑的进程
1171
+ # (无头 CLI 没有交互 stdin),而是在最近的轮间安全点生效。consumed 标记防重复注入。
1172
+
1173
+ def _ensure_messages(run):
1174
+ if "messages" not in run or not isinstance(run.get("messages"), list):
1175
+ run["messages"] = []
1176
+ return run["messages"]
1177
+
1178
+
1179
+ def add_message(run_id, text, sender="本机", attachments=None):
1180
+ """往运行信箱追加一条用户指令。attachments 为已落盘到 workdir 的
1181
+ 附件相对路径清单(由 main.py 提交后传入)。返回消息 dict 或 None。"""
1182
+ text = str(text or "").strip()
1183
+ atts = [str(a) for a in (attachments or []) if a][:12]
1184
+ if not text and not atts:
1185
+ return None
1186
+ if len(text) > 4000:
1187
+ text = text[:4000]
1188
+ with LOCK:
1189
+ run = _RUNS.get(run_id)
1190
+ if not run:
1191
+ return None
1192
+ msgs = _ensure_messages(run)
1193
+ if len(msgs) >= 200: # 信箱封顶:只保留最近 200 条,防 run.json 无限膨胀
1194
+ del msgs[:len(msgs) - 199]
1195
+ # id 取「现存最大 +1」而非 len+1:撤回/封顶删除后 len 会回落,
1196
+ # 用 len+1 会和存活消息撞号,前端按 id 撤回就错杀。
1197
+ nxt = 1
1198
+ for m in msgs:
1199
+ try:
1200
+ nxt = max(nxt, int(m.get("id") or 0) + 1)
1201
+ except (TypeError, ValueError):
1202
+ pass
1203
+ msg = {
1204
+ "id": "%06d" % nxt,
1205
+ "text": text, "sender": str(sender or "")[:24],
1206
+ "attachments": atts,
1207
+ "created_at": time.strftime("%H:%M:%S"),
1208
+ "consumed": False,
1209
+ }
1210
+ msgs.append(msg)
1211
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1212
+ return msg
1213
+
1214
+
1215
+ def drain_messages(run_id, consumed_by=None):
1216
+ """取出并标记全部未消费消息(运行中指挥注入点调用)。
1217
+
1218
+ consumed_by:{"step": 步号, "role": 角色}——送达回执,记录指令最终进了
1219
+ 哪个步骤,详情页「✓已下达」据此显示去向。返回 [{text,attachments,...}]。"""
1220
+ with LOCK:
1221
+ run = _RUNS.get(run_id)
1222
+ if not run:
1223
+ return []
1224
+ pend = [m for m in _ensure_messages(run) if not m.get("consumed")]
1225
+ if not pend:
1226
+ return []
1227
+ receipt = None
1228
+ if consumed_by:
1229
+ try:
1230
+ receipt = {"step": int(consumed_by.get("step") or 0),
1231
+ "role": str(consumed_by.get("role") or "")[:40]}
1232
+ except Exception:
1233
+ receipt = None
1234
+ for m in pend:
1235
+ m["consumed"] = True
1236
+ if receipt:
1237
+ m["consumed_by"] = dict(receipt)
1238
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1239
+ return [{"text": m.get("text", ""), "attachments": m.get("attachments", []),
1240
+ "sender": m.get("sender", ""), "created_at": m.get("created_at", "")}
1241
+ for m in pend]
1242
+
1243
+
1244
+ def peek_messages(run_id):
1245
+ """只读未消费消息(不标记):规划步骤合入上下文用,执行步骤仍会 drain 注入。"""
1246
+ with LOCK:
1247
+ run = _RUNS.get(run_id)
1248
+ if not run:
1249
+ return []
1250
+ return [{"text": m.get("text", ""), "attachments": m.get("attachments", []),
1251
+ "sender": m.get("sender", ""), "created_at": m.get("created_at", "")}
1252
+ for m in _ensure_messages(run) if not m.get("consumed")]
1253
+
1254
+
1255
+ def retract_message(run_id, msg_id):
1256
+ """撤回一条尚未下达(consumed=False)的指令:从信箱删除。
1257
+
1258
+ 只删未消费的——已随步骤注入执行的删不掉(那是审计事实,且 prompt 已喂出)。
1259
+ 返回 (ok, err):ok=False 时 err 说明原因,供前端 toast。"""
1260
+ msg_id = str(msg_id or "").strip()
1261
+ if not msg_id:
1262
+ return False, "缺少消息 id"
1263
+ with LOCK:
1264
+ run = _RUNS.get(run_id)
1265
+ if not run:
1266
+ return False, "运行不存在"
1267
+ msgs = _ensure_messages(run)
1268
+ for i, m in enumerate(msgs):
1269
+ if str(m.get("id")) == msg_id:
1270
+ if m.get("consumed"):
1271
+ cb = m.get("consumed_by") or {}
1272
+ where = ("已随步骤 #" + str(cb.get("step")) + " 送达") if cb.get("step") \
1273
+ else "已送达执行"
1274
+ return False, "指令" + where + ",无法撤回"
1275
+ del msgs[i]
1276
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1277
+ return True, ""
1278
+ return False, "消息不在信箱中(可能已被撤回)"
1279
+
1280
+
1281
+ def set_paused(run_id, paused):
1282
+ """暂停/放行:标志位挂在下一个步骤开始前(pipeline 轮间闸门读取)。"""
1283
+ with LOCK:
1284
+ run = _RUNS.get(run_id)
1285
+ if not run:
1286
+ return False
1287
+ update_run(run_id, paused=bool(paused))
1288
+ return True
1289
+
1290
+
1291
+ def write_report(run_id, markdown):
1292
+ p = paths.RUNS_DIR / run_id / "report.md"
1293
+ p.parent.mkdir(parents=True, exist_ok=True)
1294
+ p.write_text(markdown, encoding="utf-8")
1295
+ with LOCK:
1296
+ run = _RUNS.get(run_id)
1297
+ if run:
1298
+ run["report"] = "report.md"
1299
+ bump_state()
1300
+ return p
1301
+
1302
+
1303
+ def read_step_log(run_id, rel_path, tail=paths.LOG_TAIL_CHARS, pretty=False):
1304
+ p = (paths.RUNS_DIR / run_id / rel_path).resolve()
1305
+ try:
1306
+ # 防目录穿越:必须落在本 run 目录内
1307
+ if paths.RUNS_DIR.resolve() not in p.parents:
1308
+ return ""
1309
+ data = p.read_bytes()
1310
+ if len(data) > tail:
1311
+ text = "...(已截断)...\n" + runner.tail_decoded(data, tail)
1312
+ else:
1313
+ text = runner.decode_output(data)
1314
+ # 折叠遥测刷屏(时间戳不同的重复 WARN);pretty 再把 codex JSONL
1315
+ # 事件流翻译成【消息】【命令】等可读行,日志抽屉直读"蜂在干什么"
1316
+ text = runner.collapse_dup_lines(text)
1317
+ if pretty:
1318
+ text = runner.pretty_cli_log(text)
1319
+ return text
1320
+ except Exception:
1321
+ return ""