codebee 0.1.7 → 0.1.9
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 +20 -0
- package/README.md +236 -252
- package/app/core/catalog.py +24 -5
- package/app/core/errorlog.py +179 -0
- package/app/core/flows.py +6 -2
- package/app/core/gitmod.py +45 -1
- package/app/core/health.py +48 -11
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +21 -0
- package/app/core/modelhub.py +80 -11
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +420 -88
- package/app/core/runner.py +122 -10
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +180 -139
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/main.py +162 -16
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +1012 -604
- package/app/ui/i18n.js +79 -6
- package/app/ui/index.html +161 -80
- package/app/ui/style.css +838 -67
- package/package.json +1 -1
package/app/core/store.py
CHANGED
|
@@ -61,30 +61,30 @@ def _safe_name(s):
|
|
|
61
61
|
|
|
62
62
|
# ---------------------------------------------------------------- 任务
|
|
63
63
|
|
|
64
|
-
def create_task(payload):
|
|
64
|
+
def create_task(payload):
|
|
65
65
|
"""校验并创建任务。payload 至少含 type/goal/workdir。
|
|
66
66
|
|
|
67
67
|
type 必须是 flows.py 里的有效流程 ID;流程参数(引擎/维度/阈值/轮数/产出
|
|
68
68
|
文件/提示词覆盖)在创建时固化到任务上,之后修改流程定义不影响已建任务。
|
|
69
69
|
"""
|
|
70
|
-
if not isinstance(payload, dict):
|
|
71
|
-
raise ValueError("任务参数必须是 JSON 对象")
|
|
72
|
-
|
|
73
|
-
def _text(value, field):
|
|
74
|
-
if value is None:
|
|
75
|
-
return ""
|
|
76
|
-
if not isinstance(value, str):
|
|
77
|
-
raise ValueError("%s 必须是文本" % field)
|
|
78
|
-
return value.strip()
|
|
79
|
-
|
|
80
|
-
from . import flows as flows_mod
|
|
70
|
+
if not isinstance(payload, dict):
|
|
71
|
+
raise ValueError("任务参数必须是 JSON 对象")
|
|
72
|
+
|
|
73
|
+
def _text(value, field):
|
|
74
|
+
if value is None:
|
|
75
|
+
return ""
|
|
76
|
+
if not isinstance(value, str):
|
|
77
|
+
raise ValueError("%s 必须是文本" % field)
|
|
78
|
+
return value.strip()
|
|
79
|
+
|
|
80
|
+
from . import flows as flows_mod
|
|
81
81
|
flow = flows_mod.get_flow(payload.get("type"))
|
|
82
82
|
if flow is None:
|
|
83
83
|
raise ValueError("未知任务类型:%s(可选:%s)"
|
|
84
84
|
% (payload.get("type"), "、".join(f["id"] for f in flows_mod.list_flows())))
|
|
85
|
-
title = _text(payload.get("title"), "title")
|
|
86
|
-
goal = _text(payload.get("goal"), "goal")
|
|
87
|
-
workdir = _text(payload.get("workdir"), "workdir")
|
|
85
|
+
title = _text(payload.get("title"), "title")
|
|
86
|
+
goal = _text(payload.get("goal"), "goal")
|
|
87
|
+
workdir = _text(payload.get("workdir"), "workdir")
|
|
88
88
|
if not goal:
|
|
89
89
|
raise ValueError("目标描述不能为空")
|
|
90
90
|
title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
|
|
@@ -112,7 +112,7 @@ def create_task(payload):
|
|
|
112
112
|
task = {
|
|
113
113
|
"id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
|
|
114
114
|
"title": title, "goal": goal,
|
|
115
|
-
"context": _text(payload.get("context"), "context"),
|
|
115
|
+
"context": _text(payload.get("context"), "context"),
|
|
116
116
|
"workdir": str(wd),
|
|
117
117
|
"mode": mode,
|
|
118
118
|
"difficulty": difficulty,
|
|
@@ -123,7 +123,7 @@ def create_task(payload):
|
|
|
123
123
|
}
|
|
124
124
|
# 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
|
|
125
125
|
from . import gitmod
|
|
126
|
-
git_rev = _text(payload.get("git_rev"), "git_rev")
|
|
126
|
+
git_rev = _text(payload.get("git_rev"), "git_rev")
|
|
127
127
|
if git_rev:
|
|
128
128
|
# 前端下拉值带 kind 前缀(branch:main / tag:v1 / commit:abc),此处归一为纯 rev;
|
|
129
129
|
# git 分支/标签名本身允许含冒号(罕见),前缀剥离只认这三种已知 kind
|
|
@@ -132,12 +132,12 @@ def create_task(payload):
|
|
|
132
132
|
raise ValueError("非法的代码版本引用:%s" % git_rev[:40])
|
|
133
133
|
task["git_rev"] = git_rev
|
|
134
134
|
if flow["engine"] == "code":
|
|
135
|
-
task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
|
|
135
|
+
task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
|
|
136
136
|
elif flow["engine"] == "direct":
|
|
137
137
|
pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
|
|
138
138
|
else:
|
|
139
|
-
ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
|
|
140
|
-
"manuscript")
|
|
139
|
+
ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
|
|
140
|
+
"manuscript")
|
|
141
141
|
ms = re.sub(r"[\\/]", "_", ms) # 只允许工作目录内的相对文件名
|
|
142
142
|
ms = re.sub(r"\.{2,}", "_", ms).lstrip(".") # 顺带清掉残留的 ..
|
|
143
143
|
task["manuscript"] = ms
|
|
@@ -145,6 +145,11 @@ def create_task(payload):
|
|
|
145
145
|
task["rounds"] = max(1, min(5, int(payload.get("rounds") or flow.get("rounds") or 2)))
|
|
146
146
|
except Exception:
|
|
147
147
|
task["rounds"] = 2
|
|
148
|
+
try:
|
|
149
|
+
# Best-of-N 赛马候选数(非连载单稿;连载走 serial.variants 的同章赛马)
|
|
150
|
+
task["best_of"] = max(1, min(3, int(payload.get("best_of") or flow.get("best_of") or 1)))
|
|
151
|
+
except Exception:
|
|
152
|
+
task["best_of"] = 1
|
|
148
153
|
try:
|
|
149
154
|
task["threshold"] = max(1.0, min(10.0,
|
|
150
155
|
float(payload.get("threshold") or flow.get("threshold") or 7.0)))
|
|
@@ -158,18 +163,18 @@ def create_task(payload):
|
|
|
158
163
|
for key in ("draft_prompt", "critique_prompt"): # 自定义流程的提示词覆盖
|
|
159
164
|
if flow.get(key):
|
|
160
165
|
task[key] = flow[key]
|
|
161
|
-
# 连载模式:逐章起草/评审/修订(任务级 serial 覆盖流程默认)。
|
|
162
|
-
# payload 中显式传 null 表示关闭流程默认连载;字段缺失才沿用流程默认,
|
|
163
|
-
# 这样前端把章节清空时不会被 serial_novel 的默认值悄悄重新打开。
|
|
164
|
-
serial_unset = object()
|
|
165
|
-
serial_value = payload.get("serial", serial_unset)
|
|
166
|
-
if serial_value is None or (isinstance(serial_value, dict) and
|
|
167
|
-
serial_value.get("enabled") is False):
|
|
168
|
-
serial = None
|
|
169
|
-
elif isinstance(serial_value, dict):
|
|
170
|
-
serial = serial_value
|
|
171
|
-
else:
|
|
172
|
-
serial = flow.get("serial")
|
|
166
|
+
# 连载模式:逐章起草/评审/修订(任务级 serial 覆盖流程默认)。
|
|
167
|
+
# payload 中显式传 null 表示关闭流程默认连载;字段缺失才沿用流程默认,
|
|
168
|
+
# 这样前端把章节清空时不会被 serial_novel 的默认值悄悄重新打开。
|
|
169
|
+
serial_unset = object()
|
|
170
|
+
serial_value = payload.get("serial", serial_unset)
|
|
171
|
+
if serial_value is None or (isinstance(serial_value, dict) and
|
|
172
|
+
serial_value.get("enabled") is False):
|
|
173
|
+
serial = None
|
|
174
|
+
elif isinstance(serial_value, dict):
|
|
175
|
+
serial = serial_value
|
|
176
|
+
else:
|
|
177
|
+
serial = flow.get("serial")
|
|
173
178
|
if isinstance(serial, dict) and serial.get("chapters"):
|
|
174
179
|
try:
|
|
175
180
|
s = {
|
|
@@ -201,17 +206,17 @@ def create_task(payload):
|
|
|
201
206
|
s["variants"] = v
|
|
202
207
|
task["serial"] = s
|
|
203
208
|
critics = payload.get("critics")
|
|
204
|
-
if isinstance(critics, list) and critics:
|
|
205
|
-
task["critics"] = [str(c) for c in critics]
|
|
206
|
-
# 初始故事圣经必须在任务入队前落盘,保证首个章节步骤就能读到设定。
|
|
207
|
-
# 只对带连载引擎的任务接收;已有不同内容的圣经拒绝覆盖,避免新任务误伤旧书设定。
|
|
208
|
-
initial_bible = str(payload.get("story_bible") or "").strip()
|
|
209
|
-
if initial_bible:
|
|
210
|
-
if not task.get("serial"):
|
|
211
|
-
raise ValueError("初始故事圣经仅适用于连载小说任务")
|
|
212
|
-
if len(initial_bible) > BIBLE_MAX_CHARS:
|
|
213
|
-
raise ValueError("故事圣经超长(最大 %d 字符,当前 %d 字符)" %
|
|
214
|
-
(BIBLE_MAX_CHARS, len(initial_bible)))
|
|
209
|
+
if isinstance(critics, list) and critics:
|
|
210
|
+
task["critics"] = [str(c) for c in critics]
|
|
211
|
+
# 初始故事圣经必须在任务入队前落盘,保证首个章节步骤就能读到设定。
|
|
212
|
+
# 只对带连载引擎的任务接收;已有不同内容的圣经拒绝覆盖,避免新任务误伤旧书设定。
|
|
213
|
+
initial_bible = str(payload.get("story_bible") or "").strip()
|
|
214
|
+
if initial_bible:
|
|
215
|
+
if not task.get("serial"):
|
|
216
|
+
raise ValueError("初始故事圣经仅适用于连载小说任务")
|
|
217
|
+
if len(initial_bible) > BIBLE_MAX_CHARS:
|
|
218
|
+
raise ValueError("故事圣经超长(最大 %d 字符,当前 %d 字符)" %
|
|
219
|
+
(BIBLE_MAX_CHARS, len(initial_bible)))
|
|
215
220
|
resume = payload.get("resume")
|
|
216
221
|
if isinstance(resume, dict) and resume.get("agent") and resume.get("session"):
|
|
217
222
|
task["resume"] = {"agent": str(resume["agent"])[:40],
|
|
@@ -221,16 +226,16 @@ def create_task(payload):
|
|
|
221
226
|
proj = str(resume.get("project") or "")[:260]
|
|
222
227
|
if proj:
|
|
223
228
|
task["resume"]["project"] = proj
|
|
224
|
-
# 初始圣经先于附件提交:如果目录已有设定,尽早拒绝,避免附件已移动却
|
|
225
|
-
# 因故事圣经冲突导致任务创建失败。相同内容的重试是幂等的(例如附件
|
|
226
|
-
# 提交中断后重试),不会覆盖已有设定。
|
|
227
|
-
if initial_bible:
|
|
228
|
-
_write_initial_story_bible(str(wd), initial_bible)
|
|
229
|
-
# 附件:把待提交文件移入工作目录 _attachments/,清单注入 context(__CONTEXT__ 全链路可见)。
|
|
229
|
+
# 初始圣经先于附件提交:如果目录已有设定,尽早拒绝,避免附件已移动却
|
|
230
|
+
# 因故事圣经冲突导致任务创建失败。相同内容的重试是幂等的(例如附件
|
|
231
|
+
# 提交中断后重试),不会覆盖已有设定。
|
|
232
|
+
if initial_bible:
|
|
233
|
+
_write_initial_story_bible(str(wd), initial_bible)
|
|
234
|
+
# 附件:把待提交文件移入工作目录 _attachments/,清单注入 context(__CONTEXT__ 全链路可见)。
|
|
230
235
|
# 两种形态:字符串 id = 待提交区文件(新建任务);dict 清单 = 已落盘的附件
|
|
231
236
|
# (继续连载/重试沿用同目录同文件,直接复制清单,不再移文件)。
|
|
232
237
|
att_ids = payload.get("attachments")
|
|
233
|
-
if isinstance(att_ids, list) and att_ids:
|
|
238
|
+
if isinstance(att_ids, list) and att_ids:
|
|
234
239
|
from . import attachments as att_mod
|
|
235
240
|
items = [a for a in att_ids if isinstance(a, dict) and a.get("path")]
|
|
236
241
|
if not items: # 纯 id 形态 → 从待提交区移入工作目录
|
|
@@ -243,9 +248,9 @@ def create_task(payload):
|
|
|
243
248
|
task["attachments"] = items
|
|
244
249
|
blk = att_mod.context_block(items)
|
|
245
250
|
# 复制清单场景下 context 已含附件块(随旧任务沿用),别重复追加
|
|
246
|
-
if blk and "## 附件材料" not in task["context"]:
|
|
247
|
-
task["context"] = (task["context"] + blk).strip()
|
|
248
|
-
with LOCK:
|
|
251
|
+
if blk and "## 附件材料" not in task["context"]:
|
|
252
|
+
task["context"] = (task["context"] + blk).strip()
|
|
253
|
+
with LOCK:
|
|
249
254
|
_TASKS[task["id"]] = task
|
|
250
255
|
_save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
|
|
251
256
|
return task
|
|
@@ -419,20 +424,20 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
|
419
424
|
"started_at": None, "ended_at": None,
|
|
420
425
|
"cost_usd": 0.0, "tokens": 0, "error": "",
|
|
421
426
|
"verdict": None, "summary": "",
|
|
422
|
-
}
|
|
423
|
-
rdir = paths.RUNS_DIR / run["id"]
|
|
424
|
-
(rdir / "steps").mkdir(parents=True, exist_ok=True)
|
|
425
|
-
with LOCK:
|
|
426
|
-
# 先完成原子落盘,再发布到内存索引。旧顺序在 _save_json 失败时会
|
|
427
|
-
# 留下只存在于 _RUNS 的“幽灵 run”,后续 UI 看到排队记录却永远无法
|
|
428
|
-
# 读取/恢复其 run.json。
|
|
429
|
-
try:
|
|
430
|
-
_save_json(rdir / "run.json", run)
|
|
431
|
-
except Exception:
|
|
432
|
-
_RUNS.pop(run["id"], None)
|
|
433
|
-
raise
|
|
434
|
-
_RUNS[run["id"]] = run
|
|
435
|
-
return run
|
|
427
|
+
}
|
|
428
|
+
rdir = paths.RUNS_DIR / run["id"]
|
|
429
|
+
(rdir / "steps").mkdir(parents=True, exist_ok=True)
|
|
430
|
+
with LOCK:
|
|
431
|
+
# 先完成原子落盘,再发布到内存索引。旧顺序在 _save_json 失败时会
|
|
432
|
+
# 留下只存在于 _RUNS 的“幽灵 run”,后续 UI 看到排队记录却永远无法
|
|
433
|
+
# 读取/恢复其 run.json。
|
|
434
|
+
try:
|
|
435
|
+
_save_json(rdir / "run.json", run)
|
|
436
|
+
except Exception:
|
|
437
|
+
_RUNS.pop(run["id"], None)
|
|
438
|
+
raise
|
|
439
|
+
_RUNS[run["id"]] = run
|
|
440
|
+
return run
|
|
436
441
|
|
|
437
442
|
|
|
438
443
|
def get_run(run_id):
|
|
@@ -657,6 +662,39 @@ def recover_orphaned_runs():
|
|
|
657
662
|
return recovered
|
|
658
663
|
|
|
659
664
|
|
|
665
|
+
def recover_interrupted_mgmt():
|
|
666
|
+
"""启动时调用:把崩溃遗留的 queued 管理操作 run 标记为 failed。
|
|
667
|
+
|
|
668
|
+
running 的 mgmt run 已由 recover_orphaned_runs 统一收尸;queued 不在
|
|
669
|
+
其候选里——通用恢复故意留着 queued 给连载任务的 resume_interrupted
|
|
670
|
+
复活,但 mgmt job 只存在于内存队列,重启后永远没人认领,留着还会
|
|
671
|
+
堵住同条目的去重闸门。workers 尚未启动,此时 queued 必是遗留。
|
|
672
|
+
"""
|
|
673
|
+
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
674
|
+
with LOCK:
|
|
675
|
+
candidates = [r["id"] for r in _RUNS.values()
|
|
676
|
+
if r.get("kind") == "mgmt" and r.get("status") == "queued"]
|
|
677
|
+
for run_id in candidates:
|
|
678
|
+
update_run(run_id, status="failed", ended_at=now,
|
|
679
|
+
error="interrupted at startup (mgmt auto-recovered)")
|
|
680
|
+
return len(candidates)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def active_mgmt_run(entry_id):
|
|
684
|
+
"""该目录条目当前进行中(queued/running)的管理操作 run;没有则 None。
|
|
685
|
+
|
|
686
|
+
供「同条目同时只跑一个安装/升级/卸载」去重闸使用:两个同名全局 npm
|
|
687
|
+
并发装同一包会互锁(2026-09-18 codex 双开案)。内存索引即真源——
|
|
688
|
+
重启后 recover_* 已把遗留 run 收成终态。
|
|
689
|
+
"""
|
|
690
|
+
with LOCK:
|
|
691
|
+
for r in _RUNS.values():
|
|
692
|
+
if r.get("kind") == "mgmt" and r.get("entry_id") == entry_id \
|
|
693
|
+
and r.get("status") in ("queued", "running"):
|
|
694
|
+
return dict(r)
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
|
|
660
698
|
def run_workdir(run_id):
|
|
661
699
|
"""该 run 的工作目录(经其 task 关联);无任务的 run(如管理操作)返回空串。"""
|
|
662
700
|
run = get_run(run_id)
|
|
@@ -758,11 +796,11 @@ def task_step_count(task_id):
|
|
|
758
796
|
|
|
759
797
|
# ---------------------------------------------------------------- 故事圣经(story-bible.md)
|
|
760
798
|
|
|
761
|
-
BIBLE_FILE = "story-bible.md"
|
|
762
|
-
BIBLE_MAX_CHARS = 20000
|
|
799
|
+
BIBLE_FILE = "story-bible.md"
|
|
800
|
+
BIBLE_MAX_CHARS = 20000
|
|
763
801
|
|
|
764
802
|
|
|
765
|
-
def _bible_path(workdir):
|
|
803
|
+
def _bible_path(workdir):
|
|
766
804
|
"""工作目录内圣经文件绝对路径;目录穿越直接返回 None(不读外面任何东西)。"""
|
|
767
805
|
wd = str(workdir or "").strip()
|
|
768
806
|
if not wd or not os.path.isdir(wd):
|
|
@@ -771,53 +809,53 @@ def _bible_path(workdir):
|
|
|
771
809
|
p = (root / BIBLE_FILE).resolve()
|
|
772
810
|
if root != p and root not in p.parents:
|
|
773
811
|
return None
|
|
774
|
-
return p
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
def _write_initial_story_bible(workdir, text):
|
|
778
|
-
"""创建任务时安全播种故事圣经。
|
|
779
|
-
|
|
780
|
-
初始圣经写入发生在任务入队之前,多个请求可能同时指向同一个工作目录。
|
|
781
|
-
旧逻辑先在锁外检查、再在锁外写入,两个请求都能通过检查,后写请求会
|
|
782
|
-
覆盖先写的设定。这里把检查和写入放进同一进程锁,并在文件不存在时用
|
|
783
|
-
``O_EXCL`` 做最后一道独占创建;已有不同内容的文件始终拒绝覆盖。
|
|
784
|
-
"""
|
|
785
|
-
p = _bible_path(workdir)
|
|
786
|
-
if p is None:
|
|
787
|
-
raise ValueError("故事圣经写入失败:工作目录不可用")
|
|
788
|
-
with LOCK:
|
|
789
|
-
try:
|
|
790
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
791
|
-
if p.exists():
|
|
792
|
-
if not p.is_file():
|
|
793
|
-
raise ValueError("故事圣经写入失败:目标路径不是文件")
|
|
794
|
-
existing = runner.read_text_any_enc(p).strip()
|
|
795
|
-
if existing:
|
|
796
|
-
if existing == text:
|
|
797
|
-
return
|
|
798
|
-
raise ValueError("工作目录已有 story-bible.md,请清空初始圣经输入或先编辑已有设定")
|
|
799
|
-
# 空文件是合法的旧占位文件;在锁内覆盖,避免本服务内的并发写入。
|
|
800
|
-
p.write_text(text, encoding="utf-8")
|
|
801
|
-
return
|
|
802
|
-
try:
|
|
803
|
-
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
804
|
-
except FileExistsError:
|
|
805
|
-
# 其他进程可能刚创建了文件。相同内容可幂等返回;空文件仍可
|
|
806
|
-
# 完成播种,非空不同内容绝不静默覆盖。
|
|
807
|
-
if p.is_file():
|
|
808
|
-
existing = runner.read_text_any_enc(p).strip()
|
|
809
|
-
if existing == text:
|
|
810
|
-
return
|
|
811
|
-
if not existing:
|
|
812
|
-
p.write_text(text, encoding="utf-8")
|
|
813
|
-
return
|
|
814
|
-
raise ValueError("工作目录已有 story-bible.md,请清空初始圣经输入或先编辑已有设定")
|
|
815
|
-
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
816
|
-
fh.write(text)
|
|
817
|
-
except ValueError:
|
|
818
|
-
raise
|
|
819
|
-
except OSError as e:
|
|
820
|
-
raise ValueError("故事圣经写入失败:%s" % e)
|
|
812
|
+
return p
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _write_initial_story_bible(workdir, text):
|
|
816
|
+
"""创建任务时安全播种故事圣经。
|
|
817
|
+
|
|
818
|
+
初始圣经写入发生在任务入队之前,多个请求可能同时指向同一个工作目录。
|
|
819
|
+
旧逻辑先在锁外检查、再在锁外写入,两个请求都能通过检查,后写请求会
|
|
820
|
+
覆盖先写的设定。这里把检查和写入放进同一进程锁,并在文件不存在时用
|
|
821
|
+
``O_EXCL`` 做最后一道独占创建;已有不同内容的文件始终拒绝覆盖。
|
|
822
|
+
"""
|
|
823
|
+
p = _bible_path(workdir)
|
|
824
|
+
if p is None:
|
|
825
|
+
raise ValueError("故事圣经写入失败:工作目录不可用")
|
|
826
|
+
with LOCK:
|
|
827
|
+
try:
|
|
828
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
829
|
+
if p.exists():
|
|
830
|
+
if not p.is_file():
|
|
831
|
+
raise ValueError("故事圣经写入失败:目标路径不是文件")
|
|
832
|
+
existing = runner.read_text_any_enc(p).strip()
|
|
833
|
+
if existing:
|
|
834
|
+
if existing == text:
|
|
835
|
+
return
|
|
836
|
+
raise ValueError("工作目录已有 story-bible.md,请清空初始圣经输入或先编辑已有设定")
|
|
837
|
+
# 空文件是合法的旧占位文件;在锁内覆盖,避免本服务内的并发写入。
|
|
838
|
+
p.write_text(text, encoding="utf-8")
|
|
839
|
+
return
|
|
840
|
+
try:
|
|
841
|
+
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
842
|
+
except FileExistsError:
|
|
843
|
+
# 其他进程可能刚创建了文件。相同内容可幂等返回;空文件仍可
|
|
844
|
+
# 完成播种,非空不同内容绝不静默覆盖。
|
|
845
|
+
if p.is_file():
|
|
846
|
+
existing = runner.read_text_any_enc(p).strip()
|
|
847
|
+
if existing == text:
|
|
848
|
+
return
|
|
849
|
+
if not existing:
|
|
850
|
+
p.write_text(text, encoding="utf-8")
|
|
851
|
+
return
|
|
852
|
+
raise ValueError("工作目录已有 story-bible.md,请清空初始圣经输入或先编辑已有设定")
|
|
853
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
854
|
+
fh.write(text)
|
|
855
|
+
except ValueError:
|
|
856
|
+
raise
|
|
857
|
+
except OSError as e:
|
|
858
|
+
raise ValueError("故事圣经写入失败:%s" % e)
|
|
821
859
|
|
|
822
860
|
|
|
823
861
|
def read_story_bible(task_id):
|
|
@@ -838,33 +876,33 @@ def read_story_bible(task_id):
|
|
|
838
876
|
return None, None, "读取失败: %s" % e
|
|
839
877
|
|
|
840
878
|
|
|
841
|
-
def write_story_bible(task_id, text):
|
|
879
|
+
def write_story_bible(task_id, text):
|
|
842
880
|
"""写入故事圣经到任务工作目录。守卫:
|
|
843
881
|
- 任务不存在/工作目录越界 → 拒绝;
|
|
844
882
|
- 任务正在运行(queued/running)→ 拒绝(圣经是中流砥柱,运行中不能换骨架);
|
|
845
883
|
- 文本超长(BIBLE_MAX_CHARS)→ 拒绝;
|
|
846
884
|
- 写入失败 → 报错。
|
|
847
885
|
返回 (ok, 错误信息)。"""
|
|
848
|
-
with LOCK:
|
|
849
|
-
task = get_task(task_id)
|
|
850
|
-
if not task:
|
|
851
|
-
return False, "任务不存在"
|
|
852
|
-
if task.get("status") in ("queued", "running"):
|
|
853
|
-
return False, "任务正在运行,不能修改故事圣经(请等运行结束后再编辑)"
|
|
854
|
-
p = _bible_path(task.get("workdir"))
|
|
855
|
-
if p is None:
|
|
856
|
-
return False, "工作目录不存在或路径越界"
|
|
857
|
-
text = (text or "").strip()
|
|
858
|
-
if len(text) > BIBLE_MAX_CHARS:
|
|
859
|
-
return False, "故事圣经超长(最大 %d 字符,当前 %d 字符)" % (BIBLE_MAX_CHARS, len(text))
|
|
860
|
-
try:
|
|
861
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
862
|
-
p.write_text(text, encoding="utf-8")
|
|
863
|
-
except OSError as e:
|
|
864
|
-
return False, "写入失败: %s" % e
|
|
865
|
-
# 故事圣经是提示词输入,写入后让 SSE/轮询端尽快看到新状态。
|
|
866
|
-
bump_state()
|
|
867
|
-
return True, ""
|
|
886
|
+
with LOCK:
|
|
887
|
+
task = get_task(task_id)
|
|
888
|
+
if not task:
|
|
889
|
+
return False, "任务不存在"
|
|
890
|
+
if task.get("status") in ("queued", "running"):
|
|
891
|
+
return False, "任务正在运行,不能修改故事圣经(请等运行结束后再编辑)"
|
|
892
|
+
p = _bible_path(task.get("workdir"))
|
|
893
|
+
if p is None:
|
|
894
|
+
return False, "工作目录不存在或路径越界"
|
|
895
|
+
text = (text or "").strip()
|
|
896
|
+
if len(text) > BIBLE_MAX_CHARS:
|
|
897
|
+
return False, "故事圣经超长(最大 %d 字符,当前 %d 字符)" % (BIBLE_MAX_CHARS, len(text))
|
|
898
|
+
try:
|
|
899
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
900
|
+
p.write_text(text, encoding="utf-8")
|
|
901
|
+
except OSError as e:
|
|
902
|
+
return False, "写入失败: %s" % e
|
|
903
|
+
# 故事圣经是提示词输入,写入后让 SSE/轮询端尽快看到新状态。
|
|
904
|
+
bump_state()
|
|
905
|
+
return True, ""
|
|
868
906
|
|
|
869
907
|
|
|
870
908
|
def read_run_file(run_id, rel):
|
|
@@ -1235,7 +1273,8 @@ def add_step(run_id, role, agent_id, agent_label, note=""):
|
|
|
1235
1273
|
|
|
1236
1274
|
|
|
1237
1275
|
def finish_step(run_id, n, status, summary="", exit_code=None,
|
|
1238
|
-
cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None
|
|
1276
|
+
cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
|
|
1277
|
+
followups=None):
|
|
1239
1278
|
with LOCK:
|
|
1240
1279
|
run = _RUNS.get(run_id)
|
|
1241
1280
|
if not run:
|
|
@@ -1254,6 +1293,8 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
|
|
|
1254
1293
|
s["duration_s"] = round(duration_s, 1)
|
|
1255
1294
|
if output is not None:
|
|
1256
1295
|
s["output"] = str(output)[:6000]
|
|
1296
|
+
if followups:
|
|
1297
|
+
s["followups"] = list(followups)[:3]
|
|
1257
1298
|
break
|
|
1258
1299
|
run["cost_usd"] = round(run.get("cost_usd", 0.0) + cost_usd, 4)
|
|
1259
1300
|
run["tokens"] = run.get("tokens", 0) + tokens
|