codebee 0.1.5 → 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.
@@ -14,9 +14,10 @@ MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
14
 
15
15
 
16
16
  def _binding_bonus(agent_id):
17
- """绑定链可用性加分/减分:链上有可用条目 +8,解析为空(回落 CLI 本机默认)
18
- -25。2026-09-16 实测:静态能力基线让配额烧干的 codex 永远压过健康备用 CLI
19
- 绑定空的 CLI 更是连用户配置的模型都没用上——先按「能不能按配置跑起来」校准。"""
17
+ """绑定链可用性加分/减分:链上有可用条目 +8,解析为空 -25。2026-09-16 实测:
18
+ 静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,绑定空的 CLI 更是连
19
+ 用户配置的模型都没用上——先按「能不能按配置跑起来」校准。2026-09-17
20
+ 空链步骤在 pipeline 直接判失败(不再静默回落本机默认),此处只管排序。"""
20
21
  try:
21
22
  from . import modelhub
22
23
  b = modelhub.resolve_binding(agent_id)
@@ -49,7 +50,7 @@ def score(agent, role, ttype, stats=None):
49
50
  if bb > 0:
50
51
  btxt = ",绑定链可用(+%s)" % bb
51
52
  elif bb < 0:
52
- btxt = ",绑定链为空将回落 CLI 本机默认配置(%s)" % bb
53
+ btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
53
54
  hb = _history_bonus(stats, agent.get("id"), ttype)
54
55
  total = base + bb + hb
55
56
  hs = (stats.get(agent.get("id")) or {}).get(ttype)
@@ -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,
@@ -824,9 +847,18 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
824
847
  if orch_timeout_ms:
825
848
  timeout = float(orch_timeout_ms) / 1000.0
826
849
  kind = agent.get("kind", "generic")
827
- stall_t = (_stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600) if kind == "codex"
828
- else _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
829
- if kind == "claude" else 0)
850
+ if kind == "codex":
851
+ stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
852
+ elif kind == "claude":
853
+ stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
854
+ else:
855
+ # 数据驱动:catalog orch.stall_timeout_s——给 kimi 这类边写边吐进度行的
856
+ # CLI 配置后,静默挂死 10 分钟即杀(不必耗满总超时);qwen/mimo 这类
857
+ # 结束才一次性输出的留 0(开了必误杀),靠总超时兜底
858
+ try:
859
+ stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
860
+ except Exception:
861
+ stall_t = 0
830
862
  # 5G:approval NEVER 一线(无人值守不静默降级)
831
863
  ok, reason = _check_approval(agent)
832
864
  if not ok:
@@ -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,13 +76,21 @@ 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
 
78
89
  relnotes 来自 registry 元数据里的 README(与查新同一条 npm 通道,不依赖
79
- GitHub 连通性),展示「新版本更新内容」用。"""
90
+ GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
91
+ readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
80
92
  r = runner.run_process(
81
- argv=["cmd", "/c", "npm", "view", _PKG_NAME, "--json"], timeout=60)
93
+ argv=_npm_argv("view", _PKG_NAME, "--json"), timeout=60)
82
94
  if not r["ok"]:
83
95
  return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
84
96
  ver, notes = "", ""
@@ -92,6 +104,11 @@ def _npm_meta():
92
104
  if not ver: # 旧 npm --json 失败时退回纯文本解析
93
105
  m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
94
106
  ver = m.group(0) if m else ""
107
+ if ver and not notes:
108
+ r2 = runner.run_process(
109
+ argv=_npm_argv("view", _PKG_NAME, "readme"), timeout=60)
110
+ if r2["ok"]:
111
+ notes = _relnotes(r2["stdout"] or "")
95
112
  return ver, notes, ("" if ver else "npm 输出无法解析")
96
113
 
97
114
 
@@ -144,22 +161,41 @@ def check(force=False):
144
161
  return out
145
162
 
146
163
 
147
- def apply_upgrade():
164
+ def apply_upgrade():
148
165
  """发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。"""
149
166
  if install_mode() != "npm":
150
167
  return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
151
168
  from . import store, jobs
152
- run = store.create_run("mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
153
- entry_id="__self__", op="selfupgrade")
154
- jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
155
- 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"]}
156
190
 
157
191
 
158
192
  def run_upgrade(run_id, log_path):
159
193
  """worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。"""
160
194
  res = runner.run_process(
161
- argv=["cmd", "/c", "npm", "install", "-g", _PKG_NAME + "@latest"],
162
- 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)
163
199
  if res["ok"]:
164
200
  with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
165
201
  _CHECK_CACHE["result"] = None
@@ -194,9 +230,10 @@ def relaunch(port):
194
230
  if port < 1 or port > 65535:
195
231
  return False
196
232
  subprocess.Popen(
197
- [sys.executable, "main.py", "--port", str(port),
233
+ [sys.executable, str(paths.APP_DIR / "main.py"), "--port", str(port),
198
234
  "--wait-port", "--no-browser"],
199
- cwd=str(paths.APP_DIR),
235
+ # 新实例 CWD 同样不得落在包内,否则下次升级 npm 改名包目录再撞 EBUSY
236
+ cwd=str(Path.home()),
200
237
  stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
201
238
  close_fds=True,
202
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
+ }