codebee 0.1.6 → 0.1.7

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.
@@ -16,6 +16,7 @@ import json
16
16
  import os
17
17
  import re
18
18
  import shutil
19
+ import signal
19
20
  import subprocess
20
21
  import threading
21
22
  import time
@@ -23,7 +24,9 @@ import time
23
24
  from .env_scrub import scrub_env
24
25
  from .error_codes import ErrorCode
25
26
 
26
- CREATE_NO_WINDOW = 0x08000000
27
+ # Windows 必须置 0:POSIX 的 Popen 对非零 creationflags 直接抛 ValueError,
28
+ # 置 0 则两边通用(remote.py 同款守卫)。
29
+ CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
27
30
  DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
28
31
 
29
32
  _BASH_CANDIDATES = [
@@ -35,6 +38,11 @@ _bash_cache = {"path": None, "done": False}
35
38
 
36
39
 
37
40
  def find_git_bash():
41
+ """Windows 专供:claude 原生 exe 找不到 bash 会拒绝启动,指给 Git Bash。
42
+ macOS/Linux 有系统 bash,claude 自会找到;强行设 CLAUDE_CODE_GIT_BASH_PATH
43
+ 反而可能指错(/bin/bash 与 Git Bash 行为有差异),故非 Windows 一律 None。"""
44
+ if os.name != "nt":
45
+ return None
38
46
  if _bash_cache["done"]:
39
47
  return _bash_cache["path"]
40
48
  _bash_cache["done"] = True
@@ -81,6 +89,19 @@ def _npm_shim_bypass(argv):
81
89
 
82
90
 
83
91
  def _kill_tree(pid):
92
+ """杀整棵进程树。Windows 用 taskkill /T;POSIX 靠 spawn 时的
93
+ start_new_session(子进程自成一个进程组,pgid==pid)用 killpg 连孙带杀。"""
94
+ if os.name != "nt":
95
+ try:
96
+ os.killpg(pid, signal.SIGKILL)
97
+ return
98
+ except Exception:
99
+ pass
100
+ try:
101
+ os.kill(pid, signal.SIGKILL)
102
+ except Exception:
103
+ pass
104
+ return
84
105
  try:
85
106
  subprocess.run(
86
107
  ["taskkill", "/F", "/T", "/PID", str(pid)],
@@ -295,7 +316,8 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
295
316
  返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
296
317
  """
297
318
  if shell_cmd:
298
- argv = ["cmd", "/c", shell_cmd]
319
+ # shell 串的解析器随平台:重定向/引号语法两边通用,只是解释器不同
320
+ argv = ["cmd", "/c", shell_cmd] if os.name == "nt" else ["/bin/sh", "-c", shell_cmd]
299
321
  if argv is None:
300
322
  return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
301
323
  "duration": 0.0, "cancelled": False, "timed_out": False, "stalled": False}
@@ -327,7 +349,8 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
327
349
  [str(a) for a in argv], cwd=cwd, env=full_env,
328
350
  stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
329
351
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
330
- creationflags=CREATE_NO_WINDOW)
352
+ creationflags=CREATE_NO_WINDOW,
353
+ start_new_session=(os.name != "nt")) # POSIX 需独立进程组供 killpg 杀树;Windows 忽略该参数
331
354
  except Exception as e:
332
355
  return {"ok": False, "exit_code": None, "stdout": "",
333
356
  "stderr": "启动失败: %r" % e, "duration": 0.0,
@@ -18,18 +18,22 @@ SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进
18
18
  """
19
19
  from __future__ import annotations
20
20
 
21
- import json
22
- import os
21
+ import json
22
+ import logging
23
+ import os
23
24
  import re
24
25
  import socket
25
26
  import subprocess
26
27
  import sys
27
28
  import threading
28
29
  import time
30
+ from pathlib import Path
29
31
 
30
- from . import paths, runner
31
-
32
- _PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
32
+ from . import paths, runner
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ _PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
33
37
  _UPDATE_TTL = 600 # 查新结果缓存(秒)
34
38
  _LOCK = threading.Lock()
35
39
  _CHECK_CACHE = {"ts": 0.0, "result": None}
@@ -72,6 +76,13 @@ def _relnotes(readme):
72
76
  return m.group(1).strip() if m else ""
73
77
 
74
78
 
79
+ def _npm_argv(*args):
80
+ """npm 命令 argv:Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑。"""
81
+ if os.name == "nt":
82
+ return ["cmd", "/c", "npm"] + list(args)
83
+ return ["npm"] + list(args)
84
+
85
+
75
86
  def _npm_meta():
76
87
  """npm view <pkg> --json;返回 (latest, relnotes, err)。
77
88
 
@@ -79,7 +90,7 @@ def _npm_meta():
79
90
  GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
80
91
  readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
81
92
  r = runner.run_process(
82
- argv=["cmd", "/c", "npm", "view", _PKG_NAME, "--json"], timeout=60)
93
+ argv=_npm_argv("view", _PKG_NAME, "--json"), timeout=60)
83
94
  if not r["ok"]:
84
95
  return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
85
96
  ver, notes = "", ""
@@ -95,7 +106,7 @@ def _npm_meta():
95
106
  ver = m.group(0) if m else ""
96
107
  if ver and not notes:
97
108
  r2 = runner.run_process(
98
- argv=["cmd", "/c", "npm", "view", _PKG_NAME, "readme"], timeout=60)
109
+ argv=_npm_argv("view", _PKG_NAME, "readme"), timeout=60)
99
110
  if r2["ok"]:
100
111
  notes = _relnotes(r2["stdout"] or "")
101
112
  return ver, notes, ("" if ver else "npm 输出无法解析")
@@ -150,22 +161,41 @@ def check(force=False):
150
161
  return out
151
162
 
152
163
 
153
- def apply_upgrade():
164
+ def apply_upgrade():
154
165
  """发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。"""
155
166
  if install_mode() != "npm":
156
167
  return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
157
168
  from . import store, jobs
158
- run = store.create_run("mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
159
- entry_id="__self__", op="selfupgrade")
160
- jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
161
- return {"run_id": run["id"]}
169
+ try:
170
+ run = store.create_run(
171
+ "mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
172
+ entry_id="__self__", op="selfupgrade")
173
+ except Exception:
174
+ log.exception("selfupdate: 创建升级运行记录失败")
175
+ return {"error": "升级任务创建失败,请稍后重试"}
176
+ try:
177
+ jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
178
+ except Exception:
179
+ # The run is already durable when enqueue fails. Close it explicitly so
180
+ # the upgrade panel cannot remain in a misleading queued state.
181
+ log.exception("selfupdate: 升级任务入队失败 run=%s", run["id"])
182
+ try:
183
+ store.update_run(run["id"], status="failed",
184
+ error="升级任务入队失败,请稍后重试",
185
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
186
+ except Exception:
187
+ log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
188
+ return {"error": "升级任务入队失败,请稍后重试", "run_id": run["id"]}
189
+ return {"run_id": run["id"]}
162
190
 
163
191
 
164
192
  def run_upgrade(run_id, log_path):
165
193
  """worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。"""
166
194
  res = runner.run_process(
167
- argv=["cmd", "/c", "npm", "install", "-g", _PKG_NAME + "@latest"],
168
- cwd=str(paths.ROOT), timeout=900, log_path=log_path)
195
+ argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
196
+ # Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
197
+ # cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
198
+ cwd=str(Path.home()), timeout=900, log_path=log_path)
169
199
  if res["ok"]:
170
200
  with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
171
201
  _CHECK_CACHE["result"] = None
@@ -200,9 +230,10 @@ def relaunch(port):
200
230
  if port < 1 or port > 65535:
201
231
  return False
202
232
  subprocess.Popen(
203
- [sys.executable, "main.py", "--port", str(port),
233
+ [sys.executable, str(paths.APP_DIR / "main.py"), "--port", str(port),
204
234
  "--wait-port", "--no-browser"],
205
- cwd=str(paths.APP_DIR),
235
+ # 新实例 CWD 同样不得落在包内,否则下次升级 npm 改名包目录再撞 EBUSY
236
+ cwd=str(Path.home()),
206
237
  stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
207
238
  close_fds=True,
208
239
  creationflags=(0x00000008 | 0x00000200) if os.name == "nt" else 0)
package/app/core/store.py CHANGED
@@ -61,20 +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
- 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
71
81
  flow = flows_mod.get_flow(payload.get("type"))
72
82
  if flow is None:
73
83
  raise ValueError("未知任务类型:%s(可选:%s)"
74
84
  % (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()
85
+ title = _text(payload.get("title"), "title")
86
+ goal = _text(payload.get("goal"), "goal")
87
+ workdir = _text(payload.get("workdir"), "workdir")
78
88
  if not goal:
79
89
  raise ValueError("目标描述不能为空")
80
90
  title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
@@ -102,7 +112,7 @@ def create_task(payload):
102
112
  task = {
103
113
  "id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
104
114
  "title": title, "goal": goal,
105
- "context": (payload.get("context") or "").strip(),
115
+ "context": _text(payload.get("context"), "context"),
106
116
  "workdir": str(wd),
107
117
  "mode": mode,
108
118
  "difficulty": difficulty,
@@ -113,7 +123,7 @@ def create_task(payload):
113
123
  }
114
124
  # 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
115
125
  from . import gitmod
116
- git_rev = (payload.get("git_rev") or "").strip()
126
+ git_rev = _text(payload.get("git_rev"), "git_rev")
117
127
  if git_rev:
118
128
  # 前端下拉值带 kind 前缀(branch:main / tag:v1 / commit:abc),此处归一为纯 rev;
119
129
  # git 分支/标签名本身允许含冒号(罕见),前缀剥离只认这三种已知 kind
@@ -122,11 +132,12 @@ def create_task(payload):
122
132
  raise ValueError("非法的代码版本引用:%s" % git_rev[:40])
123
133
  task["git_rev"] = git_rev
124
134
  if flow["engine"] == "code":
125
- task["verify_command"] = (payload.get("verify_command") or "").strip()
135
+ task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
126
136
  elif flow["engine"] == "direct":
127
137
  pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
128
138
  else:
129
- ms = (payload.get("manuscript") or flow.get("manuscript") or "manuscript.md").strip()
139
+ ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
140
+ "manuscript")
130
141
  ms = re.sub(r"[\\/]", "_", ms) # 只允许工作目录内的相对文件名
131
142
  ms = re.sub(r"\.{2,}", "_", ms).lstrip(".") # 顺带清掉残留的 ..
132
143
  task["manuscript"] = ms
@@ -147,8 +158,18 @@ def create_task(payload):
147
158
  for key in ("draft_prompt", "critique_prompt"): # 自定义流程的提示词覆盖
148
159
  if flow.get(key):
149
160
  task[key] = flow[key]
150
- # 连载模式:逐章起草/评审/修订(任务级 serial 覆盖流程默认)
151
- serial = payload.get("serial") if isinstance(payload.get("serial"), dict) else flow.get("serial")
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")
152
173
  if isinstance(serial, dict) and serial.get("chapters"):
153
174
  try:
154
175
  s = {
@@ -180,8 +201,17 @@ def create_task(payload):
180
201
  s["variants"] = v
181
202
  task["serial"] = s
182
203
  critics = payload.get("critics")
183
- if isinstance(critics, list) and critics:
184
- task["critics"] = [str(c) for c in 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)))
185
215
  resume = payload.get("resume")
186
216
  if isinstance(resume, dict) and resume.get("agent") and resume.get("session"):
187
217
  task["resume"] = {"agent": str(resume["agent"])[:40],
@@ -191,11 +221,16 @@ def create_task(payload):
191
221
  proj = str(resume.get("project") or "")[:260]
192
222
  if proj:
193
223
  task["resume"]["project"] = proj
194
- # 附件:把待提交文件移入工作目录 _attachments/,清单注入 context(__CONTEXT__ 全链路可见)。
224
+ # 初始圣经先于附件提交:如果目录已有设定,尽早拒绝,避免附件已移动却
225
+ # 因故事圣经冲突导致任务创建失败。相同内容的重试是幂等的(例如附件
226
+ # 提交中断后重试),不会覆盖已有设定。
227
+ if initial_bible:
228
+ _write_initial_story_bible(str(wd), initial_bible)
229
+ # 附件:把待提交文件移入工作目录 _attachments/,清单注入 context(__CONTEXT__ 全链路可见)。
195
230
  # 两种形态:字符串 id = 待提交区文件(新建任务);dict 清单 = 已落盘的附件
196
231
  # (继续连载/重试沿用同目录同文件,直接复制清单,不再移文件)。
197
232
  att_ids = payload.get("attachments")
198
- if isinstance(att_ids, list) and att_ids:
233
+ if isinstance(att_ids, list) and att_ids:
199
234
  from . import attachments as att_mod
200
235
  items = [a for a in att_ids if isinstance(a, dict) and a.get("path")]
201
236
  if not items: # 纯 id 形态 → 从待提交区移入工作目录
@@ -208,9 +243,9 @@ def create_task(payload):
208
243
  task["attachments"] = items
209
244
  blk = att_mod.context_block(items)
210
245
  # 复制清单场景下 context 已含附件块(随旧任务沿用),别重复追加
211
- if blk and "## 附件材料" not in task["context"]:
212
- task["context"] = (task["context"] + blk).strip()
213
- with LOCK:
246
+ if blk and "## 附件材料" not in task["context"]:
247
+ task["context"] = (task["context"] + blk).strip()
248
+ with LOCK:
214
249
  _TASKS[task["id"]] = task
215
250
  _save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
216
251
  return task
@@ -384,13 +419,20 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
384
419
  "started_at": None, "ended_at": None,
385
420
  "cost_usd": 0.0, "tokens": 0, "error": "",
386
421
  "verdict": None, "summary": "",
387
- }
388
- rdir = paths.RUNS_DIR / run["id"]
389
- (rdir / "steps").mkdir(parents=True, exist_ok=True)
390
- with LOCK:
391
- _RUNS[run["id"]] = run
392
- _save_json(rdir / "run.json", run)
393
- return run
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
394
436
 
395
437
 
396
438
  def get_run(run_id):
@@ -716,11 +758,11 @@ def task_step_count(task_id):
716
758
 
717
759
  # ---------------------------------------------------------------- 故事圣经(story-bible.md)
718
760
 
719
- BIBLE_FILE = "story-bible.md"
720
- BIBLE_MAX_CHARS = 20000
761
+ BIBLE_FILE = "story-bible.md"
762
+ BIBLE_MAX_CHARS = 20000
721
763
 
722
764
 
723
- def _bible_path(workdir):
765
+ def _bible_path(workdir):
724
766
  """工作目录内圣经文件绝对路径;目录穿越直接返回 None(不读外面任何东西)。"""
725
767
  wd = str(workdir or "").strip()
726
768
  if not wd or not os.path.isdir(wd):
@@ -729,7 +771,53 @@ def _bible_path(workdir):
729
771
  p = (root / BIBLE_FILE).resolve()
730
772
  if root != p and root not in p.parents:
731
773
  return None
732
- return p
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)
733
821
 
734
822
 
735
823
  def read_story_bible(task_id):
@@ -750,30 +838,33 @@ def read_story_bible(task_id):
750
838
  return None, None, "读取失败: %s" % e
751
839
 
752
840
 
753
- def write_story_bible(task_id, text):
841
+ def write_story_bible(task_id, text):
754
842
  """写入故事圣经到任务工作目录。守卫:
755
843
  - 任务不存在/工作目录越界 → 拒绝;
756
844
  - 任务正在运行(queued/running)→ 拒绝(圣经是中流砥柱,运行中不能换骨架);
757
845
  - 文本超长(BIBLE_MAX_CHARS)→ 拒绝;
758
846
  - 写入失败 → 报错。
759
847
  返回 (ok, 错误信息)。"""
760
- task = get_task(task_id)
761
- if not task:
762
- return False, "任务不存在"
763
- if task.get("status") in ("queued", "running"):
764
- return False, "任务正在运行,不能修改故事圣经(请等运行结束后再编辑)"
765
- p = _bible_path(task.get("workdir"))
766
- if p is None:
767
- return False, "工作目录不存在或路径越界"
768
- text = (text or "").strip()
769
- if len(text) > BIBLE_MAX_CHARS:
770
- return False, "故事圣经超长(最大 %d 字符,当前 %d 字符)" % (BIBLE_MAX_CHARS, len(text))
771
- try:
772
- p.parent.mkdir(parents=True, exist_ok=True)
773
- p.write_text(text, encoding="utf-8")
774
- return True, ""
775
- except OSError as e:
776
- return False, "写入失败: %s" % e
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, ""
777
868
 
778
869
 
779
870
  def read_run_file(run_id, rel):
package/app/core/usage.py CHANGED
@@ -214,7 +214,10 @@ def agent_tokens_recent(agent, hours=1):
214
214
  if str(r.get("ts") or "") < bound:
215
215
  continue
216
216
  a = str(r.get("agent") or "")
217
- total[a] = total.get(a, 0) + max(0, _parse_int((r.get("usage") or {}).get("total")))
217
+ # record() 将规范化后的 token 总数落在顶层 ``total``;旧实现
218
+ # 误读不存在的嵌套 usage 字段,导致配额路由永远认为本小时
219
+ # 用量为 0,超过供应商额度也不会降权。
220
+ total[a] = total.get(a, 0) + max(0, _parse_int(r.get("total")))
218
221
  _HOURLY_CACHE.update(ts=now, val=total)
219
222
  return int(_HOURLY_CACHE["val"].get(agent) or 0)
220
223
  except Exception:
@@ -448,3 +451,50 @@ def summary(days=30, recent_limit=30):
448
451
  "by_task_type": _dim_rows(records, "task_type"),
449
452
  "recent": recent,
450
453
  }
454
+
455
+
456
+ def estimate(task_type="", days=90):
457
+ """同类任务开跑前成本预估(借鉴 omnigent 的 pre-run estimate;数据源就是本台账)。
458
+
459
+ 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
460
+ task_type 为空=全类型。无样本时 samples=0,前端不展示。"""
461
+ span = max(1, min(3650, int(days or 90)))
462
+ with LOCK:
463
+ records = _iter_records(span)
464
+ runs = {}
465
+ for r in records:
466
+ tt = str(r.get("task_type") or "unknown")
467
+ if task_type and tt != task_type:
468
+ continue
469
+ rid = str(r.get("run_id") or "")
470
+ if not rid:
471
+ continue
472
+ g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0, "ok": False})
473
+ g["tokens"] += _num(r, "total")
474
+ g["cost"] += _parse_float(r.get("cost_usd"))
475
+ if r.get("ok"):
476
+ g["ok"] = True
477
+ ok_runs = [g for g in runs.values() if g["ok"]]
478
+ basis = ok_runs or list(runs.values())
479
+ if not basis:
480
+ return {"task_type": task_type or "", "days": span, "samples": 0}
481
+ toks = sorted(g["tokens"] for g in basis)
482
+ costs = sorted(g["cost"] for g in basis)
483
+ n = len(toks)
484
+ mid = n // 2
485
+ med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
486
+ med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
487
+ import datetime
488
+ import math
489
+ return {
490
+ "task_type": task_type or "",
491
+ "days": span,
492
+ "samples": n,
493
+ "ok_samples": len(ok_runs),
494
+ "avg_tokens": int(sum(toks) / n),
495
+ "median_tokens": int(med),
496
+ "p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
497
+ "avg_cost_usd": round(sum(costs) / n, 4),
498
+ "median_cost_usd": round(med_c, 4),
499
+ "since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
500
+ }