codebee 0.1.66 → 0.1.68

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 (116) hide show
  1. package/CHANGELOG.md +543 -520
  2. package/LICENSE +21 -21
  3. package/README.md +11 -7
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -0
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/artifacts.py +148 -148
  14. package/app/core/assets.py +110 -0
  15. package/app/core/attachments.py +623 -532
  16. package/app/core/automation.py +758 -758
  17. package/app/core/backup.py +470 -470
  18. package/app/core/beekeeper.py +183 -183
  19. package/app/core/board.py +209 -209
  20. package/app/core/bookmeta.py +930 -796
  21. package/app/core/bookmeta_catalog.py +180 -180
  22. package/app/core/branching.py +137 -137
  23. package/app/core/builtin_agent.py +1234 -1234
  24. package/app/core/capability.py +144 -144
  25. package/app/core/catalog.py +42 -0
  26. package/app/core/chaptersafe.py +32 -32
  27. package/app/core/cleanup.py +303 -303
  28. package/app/core/compaction.py +201 -201
  29. package/app/core/covergen.py +356 -356
  30. package/app/core/defectretro.py +43 -0
  31. package/app/core/diagnostics.py +114 -114
  32. package/app/core/dispatch.py +173 -173
  33. package/app/core/dispatch_log.py +128 -113
  34. package/app/core/env_scrub.py +85 -85
  35. package/app/core/error_codes.py +23 -23
  36. package/app/core/errorlog.py +137 -137
  37. package/app/core/evaluation.py +174 -174
  38. package/app/core/failure_notify.py +290 -290
  39. package/app/core/flows.py +48 -2
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +374 -374
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +46 -26
  45. package/app/core/knowledge.py +57 -3
  46. package/app/core/manager.py +2122 -2115
  47. package/app/core/market.py +362 -362
  48. package/app/core/market_remote.py +124 -26
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/modelhub.py +51 -3
  51. package/app/core/notify.py +106 -106
  52. package/app/core/operations.py +228 -0
  53. package/app/core/paihang.py +138 -109
  54. package/app/core/paths.py +2 -2
  55. package/app/core/pipeline.py +221 -12
  56. package/app/core/planner.py +6 -1
  57. package/app/core/portguard.py +111 -111
  58. package/app/core/preview.py +293 -293
  59. package/app/core/publish/__init__.py +7 -7
  60. package/app/core/publish/auto.py +367 -367
  61. package/app/core/publish/browser.py +539 -533
  62. package/app/core/publish/fanqie.py +159 -128
  63. package/app/core/publish/flow.py +440 -432
  64. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  65. package/app/core/publish/flows-qimao-calibrated.json +7 -3
  66. package/app/core/publish/ledger.py +203 -198
  67. package/app/core/publish/manager.py +692 -623
  68. package/app/core/publish/qimao.py +141 -141
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/revisions.py +51 -0
  73. package/app/core/router.py +398 -398
  74. package/app/core/runner.py +2067 -2050
  75. package/app/core/selfupdate.py +384 -384
  76. package/app/core/session_log.py +161 -161
  77. package/app/core/sessions.py +312 -312
  78. package/app/core/settings.py +149 -149
  79. package/app/core/settings_schema.py +262 -262
  80. package/app/core/share_page.py +114 -114
  81. package/app/core/skill_scan.py +122 -114
  82. package/app/core/skillpacks/fanqie-novel.md +80 -80
  83. package/app/core/skillpacks/market/character-bible.md +66 -66
  84. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  85. package/app/core/skillpacks/market/git-workflow.md +57 -57
  86. package/app/core/skillpacks/market/release-notes.md +72 -72
  87. package/app/core/skillpacks/market/weekly-report.md +71 -71
  88. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  89. package/app/core/skills.py +109 -2
  90. package/app/core/step_runner.py +132 -132
  91. package/app/core/store.py +278 -41
  92. package/app/core/task_compile.py +199 -199
  93. package/app/core/telemetry.py +292 -292
  94. package/app/core/tlsctx.py +64 -64
  95. package/app/core/token_meter.py +147 -147
  96. package/app/core/tracing.py +144 -0
  97. package/app/core/upstream.py +41 -41
  98. package/app/core/usage.py +862 -858
  99. package/app/core/volumes.py +389 -355
  100. package/app/core/wxdigest.py +858 -858
  101. package/app/core/zentao.py +2465 -2447
  102. package/app/main.py +2981 -2838
  103. package/app/pick_dialog.py +110 -110
  104. package/app/ui/app.js +14399 -13994
  105. package/app/ui/artdiff.js +74 -0
  106. package/app/ui/board.html +162 -162
  107. package/app/ui/board.js +305 -305
  108. package/app/ui/i18n.js +2770 -2716
  109. package/app/ui/icons/bee.svg +79 -79
  110. package/app/ui/index.html +6 -0
  111. package/app/ui/manifest.json +16 -16
  112. package/app/ui/qrcode.js +2297 -2297
  113. package/app/ui/style.css +96 -24
  114. package/bin/tutti.js +147 -147
  115. package/package.json +46 -46
  116. package/tools/wx_reader.py +261 -261
@@ -1,38 +1,38 @@
1
- # -*- coding: utf-8 -*-
2
- """历史统计:从过往运行记录提取每个智能体在每种任务类型上的胜率,反哺路由。"""
3
- from __future__ import annotations
4
-
5
- from . import store
6
-
7
-
8
- def agent_stats(limit=200):
9
- """返回 {agent_id: {task_type: {"runs": n, "wins": w}}}。"""
10
- stats = {}
11
-
12
- def bump(aid, ttype, win):
13
- if not aid:
14
- return
15
- s = stats.setdefault(aid, {}).setdefault(ttype, {"runs": 0, "wins": 0})
16
- s["runs"] += 1
17
- if win:
18
- s["wins"] += 1
19
-
20
- for run in store.list_runs(limit):
21
- if run.get("kind") != "orchestration":
22
- continue
23
- # 失败运行也要进历史(记为全负):以前只数 done——常挂的 CLI 永远
24
- # 「无历史记录」满血参选,2026-09-17 opencode 当日秒挂 3 次仍被换将
25
- # 选中。cancelled 不算(用户取消非智能体之过)。
26
- if run.get("status") not in ("done", "failed"):
27
- continue
28
- verdict = run.get("verdict") or {}
29
- task = store.get_task(run.get("task_id")) if run.get("task_id") else None
30
- ttype = (task or {}).get("type") or verdict.get("type")
31
- if not ttype:
32
- continue
33
- win = run.get("status") == "done" and bool(
34
- verdict.get("pass") or verdict.get("publishable"))
35
- for s in run.get("steps") or []:
36
- if s.get("role") in ("implement", "draft"):
37
- bump(s.get("agent"), ttype, win)
38
- return stats
1
+ # -*- coding: utf-8 -*-
2
+ """历史统计:从过往运行记录提取每个智能体在每种任务类型上的胜率,反哺路由。"""
3
+ from __future__ import annotations
4
+
5
+ from . import store
6
+
7
+
8
+ def agent_stats(limit=200):
9
+ """返回 {agent_id: {task_type: {"runs": n, "wins": w}}}。"""
10
+ stats = {}
11
+
12
+ def bump(aid, ttype, win):
13
+ if not aid:
14
+ return
15
+ s = stats.setdefault(aid, {}).setdefault(ttype, {"runs": 0, "wins": 0})
16
+ s["runs"] += 1
17
+ if win:
18
+ s["wins"] += 1
19
+
20
+ for run in store.list_runs(limit):
21
+ if run.get("kind") != "orchestration":
22
+ continue
23
+ # 失败运行也要进历史(记为全负):以前只数 done——常挂的 CLI 永远
24
+ # 「无历史记录」满血参选,2026-09-17 opencode 当日秒挂 3 次仍被换将
25
+ # 选中。cancelled 不算(用户取消非智能体之过)。
26
+ if run.get("status") not in ("done", "failed"):
27
+ continue
28
+ verdict = run.get("verdict") or {}
29
+ task = store.get_task(run.get("task_id")) if run.get("task_id") else None
30
+ ttype = (task or {}).get("type") or verdict.get("type")
31
+ if not ttype:
32
+ continue
33
+ win = run.get("status") == "done" and bool(
34
+ verdict.get("pass") or verdict.get("publishable"))
35
+ for s in run.get("steps") or []:
36
+ if s.get("role") in ("implement", "draft"):
37
+ bump(s.get("agent"), ttype, win)
38
+ return stats
package/app/core/hooks.py CHANGED
@@ -1,162 +1,162 @@
1
- # -*- coding: utf-8 -*-
2
- """任务生命周期钩子(借鉴 ZCode hooks,收窄为三个事件——2026-09-22 用户立项):
3
-
4
- - task_start 任务开跑前(编排/直连都触发):stdout 可注入文本,追加进任务上下文
5
- - message_submit 用户消息送达前:stdout 可注入文本,随消息进提示词头
6
- - run_end 每轮运行结束:纯副作用型(回写知识库等),输出被忽略
7
-
8
- 协议:钩子命令从 stdin 收 JSON(事件+上下文);stdout 若为 {"inject": "..."}
9
- 取该字段为注入文本,否则把非空 stdout 整体当注入文本。
10
- 失败/超时一律静默跳过(外部脚本不稳绝不拖死编排主流程)。
11
-
12
- 命令执行:shlex 分词(保 Windows 反斜杠)后走参数列表,不经 shell——
13
- 不支持管道/重定向等 shell 语法,需要时写 .py/.bat 包装脚本。
14
- 配置:DATA_DIR/hooks.json,纯本机设置。"""
15
- from __future__ import annotations
16
-
17
- import json
18
- import logging
19
- import os
20
- import shlex
21
- import subprocess
22
-
23
- from . import paths
24
-
25
- log = logging.getLogger(__name__)
26
-
27
- EVENTS = ("task_start", "message_submit", "run_end")
28
- _DEFAULT_TIMEOUT = 10
29
- _FILE = None # 测试重绑(同 modelhub._FILE 惯例)
30
-
31
- _CREATE_NO_WINDOW = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
32
-
33
-
34
- def _path():
35
- return _FILE or (paths.DATA_DIR / "hooks.json")
36
-
37
-
38
- def _default_list():
39
- return []
40
-
41
-
42
- def load_list():
43
- """读钩子配置列表;损坏/缺省回落空表。"""
44
- try:
45
- data = json.loads(_path().read_text(encoding="utf-8"))
46
- return data if isinstance(data, list) else _default_list()
47
- except Exception:
48
- return _default_list()
49
-
50
-
51
- def save_list(items):
52
- """整表保存(UI 全量提交)。字段归一 + 事件白名单。"""
53
- if not isinstance(items, list):
54
- raise ValueError("hooks 必须是数组")
55
- out = []
56
- for i, h in enumerate(items):
57
- if not isinstance(h, dict):
58
- continue
59
- name = str(h.get("name") or "").strip()[:60]
60
- event = str(h.get("event") or "").strip()
61
- cmd = str(h.get("cmd") or "").strip()
62
- if not cmd:
63
- continue
64
- if event not in EVENTS:
65
- raise ValueError("第 %d 条钩子 event 非法(%s)" % (i + 1, event))
66
- try:
67
- timeout = max(1, min(120, int(h.get("timeout_s") or _DEFAULT_TIMEOUT)))
68
- except (TypeError, ValueError):
69
- timeout = _DEFAULT_TIMEOUT
70
- out.append({"id": str(h.get("id") or ("hk-%d" % (i + 1)))[:40],
71
- "name": name or ("钩子 %d" % (i + 1)),
72
- "event": event, "cmd": cmd,
73
- "enabled": bool(h.get("enabled", True)),
74
- "timeout_s": timeout})
75
- tmp = _path().with_suffix(".tmp")
76
- tmp.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
77
- tmp.replace(_path())
78
- return out
79
-
80
-
81
- def _argv(cmd):
82
- """命令串 → 参数列表。Windows 反斜杠路径保真(posix=False),支持引号分组;
83
- 分词后剥掉 token 首尾引号(CreateProcess 不认字面引号路径)。"""
84
- toks = shlex.split(str(cmd or ""), posix=False)
85
- out = []
86
- for t in toks:
87
- if len(t) >= 2 and t[0] == t[-1] and t[0] in ('"', "'"):
88
- t = t[1:-1]
89
- if t:
90
- out.append(t)
91
- return out
92
-
93
-
94
- def _run_one(h, payload):
95
- """跑单条钩子。返回注入文本或 None(失败/超时/空输出)。"""
96
- try:
97
- argv = _argv(h.get("cmd"))
98
- except ValueError as e:
99
- log.warning("hook[%s %s] 命令解析失败:%s", h.get("name"), h.get("event"), str(e)[:120])
100
- return None
101
- if not argv:
102
- return None
103
- try:
104
- # Hook input/output is a UTF-8 JSON/text protocol. Python subprocesses on
105
- # Windows otherwise inherit the OEM code page and their Chinese stdout is
106
- # decoded as mojibake below. Non-Python hooks ignore this variable.
107
- env = os.environ.copy()
108
- env["PYTHONIOENCODING"] = "utf-8"
109
- proc = subprocess.run(
110
- argv, shell=False,
111
- input=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
112
- capture_output=True, timeout=int(h.get("timeout_s") or _DEFAULT_TIMEOUT),
113
- creationflags=_CREATE_NO_WINDOW, env=env)
114
- except subprocess.TimeoutExpired:
115
- log.warning("hook[%s %s] 超时,已跳过", h.get("name"), h.get("event"))
116
- return None
117
- except Exception as e:
118
- log.warning("hook[%s %s] 执行失败:%s", h.get("name"), h.get("event"), str(e)[:120])
119
- return None
120
- out = (proc.stdout or b"").decode("utf-8", "replace").strip()
121
- if not out:
122
- return None
123
- try:
124
- data = json.loads(out)
125
- if isinstance(data, dict):
126
- return str(data.get("inject") or "") or None
127
- except Exception:
128
- pass
129
- return out
130
-
131
-
132
- def run_event(event, ctx=None, task_id=None, run_id=None, text=None):
133
- """触发事件:依次跑启用的钩子,合并注入文本。任何异常不外抛。
134
-
135
- ctx 附带任务/运行摘要;text 仅 message_submit 时传用户消息原文。
136
- 返回拼接好的注入文本(可为空串)。"""
137
- if event not in EVENTS:
138
- return ""
139
- try:
140
- items = [h for h in load_list() if h.get("enabled") and h.get("event") == event]
141
- except Exception:
142
- return ""
143
- if not items:
144
- return ""
145
- payload = {"event": event, "task_id": task_id or "", "run_id": run_id or "",
146
- "text": text or ""}
147
- if ctx is not None:
148
- payload["ctx"] = ctx
149
- outs = []
150
- for h in items:
151
- piece = _run_one(h, payload)
152
- if piece:
153
- outs.append(piece.strip())
154
- return "\n\n".join(outs)
155
-
156
-
157
- def test_one(cmd, event="task_start", timeout_s=None):
158
- """设置页「测试」:手动跑一条钩子,返回 (ok, 输出/错误人话)。"""
159
- h = {"name": "test", "event": event if event in EVENTS else "task_start",
160
- "cmd": cmd, "timeout_s": timeout_s or _DEFAULT_TIMEOUT}
161
- piece = _run_one(h, {"event": h["event"], "task_id": "", "run_id": "", "text": ""})
162
- return True, piece if piece else "(无输出,未注入)"
1
+ # -*- coding: utf-8 -*-
2
+ """任务生命周期钩子(借鉴 ZCode hooks,收窄为三个事件——2026-09-22 用户立项):
3
+
4
+ - task_start 任务开跑前(编排/直连都触发):stdout 可注入文本,追加进任务上下文
5
+ - message_submit 用户消息送达前:stdout 可注入文本,随消息进提示词头
6
+ - run_end 每轮运行结束:纯副作用型(回写知识库等),输出被忽略
7
+
8
+ 协议:钩子命令从 stdin 收 JSON(事件+上下文);stdout 若为 {"inject": "..."}
9
+ 取该字段为注入文本,否则把非空 stdout 整体当注入文本。
10
+ 失败/超时一律静默跳过(外部脚本不稳绝不拖死编排主流程)。
11
+
12
+ 命令执行:shlex 分词(保 Windows 反斜杠)后走参数列表,不经 shell——
13
+ 不支持管道/重定向等 shell 语法,需要时写 .py/.bat 包装脚本。
14
+ 配置:DATA_DIR/hooks.json,纯本机设置。"""
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import shlex
21
+ import subprocess
22
+
23
+ from . import paths
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ EVENTS = ("task_start", "message_submit", "run_end")
28
+ _DEFAULT_TIMEOUT = 10
29
+ _FILE = None # 测试重绑(同 modelhub._FILE 惯例)
30
+
31
+ _CREATE_NO_WINDOW = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
32
+
33
+
34
+ def _path():
35
+ return _FILE or (paths.DATA_DIR / "hooks.json")
36
+
37
+
38
+ def _default_list():
39
+ return []
40
+
41
+
42
+ def load_list():
43
+ """读钩子配置列表;损坏/缺省回落空表。"""
44
+ try:
45
+ data = json.loads(_path().read_text(encoding="utf-8"))
46
+ return data if isinstance(data, list) else _default_list()
47
+ except Exception:
48
+ return _default_list()
49
+
50
+
51
+ def save_list(items):
52
+ """整表保存(UI 全量提交)。字段归一 + 事件白名单。"""
53
+ if not isinstance(items, list):
54
+ raise ValueError("hooks 必须是数组")
55
+ out = []
56
+ for i, h in enumerate(items):
57
+ if not isinstance(h, dict):
58
+ continue
59
+ name = str(h.get("name") or "").strip()[:60]
60
+ event = str(h.get("event") or "").strip()
61
+ cmd = str(h.get("cmd") or "").strip()
62
+ if not cmd:
63
+ continue
64
+ if event not in EVENTS:
65
+ raise ValueError("第 %d 条钩子 event 非法(%s)" % (i + 1, event))
66
+ try:
67
+ timeout = max(1, min(120, int(h.get("timeout_s") or _DEFAULT_TIMEOUT)))
68
+ except (TypeError, ValueError):
69
+ timeout = _DEFAULT_TIMEOUT
70
+ out.append({"id": str(h.get("id") or ("hk-%d" % (i + 1)))[:40],
71
+ "name": name or ("钩子 %d" % (i + 1)),
72
+ "event": event, "cmd": cmd,
73
+ "enabled": bool(h.get("enabled", True)),
74
+ "timeout_s": timeout})
75
+ tmp = _path().with_suffix(".tmp")
76
+ tmp.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
77
+ tmp.replace(_path())
78
+ return out
79
+
80
+
81
+ def _argv(cmd):
82
+ """命令串 → 参数列表。Windows 反斜杠路径保真(posix=False),支持引号分组;
83
+ 分词后剥掉 token 首尾引号(CreateProcess 不认字面引号路径)。"""
84
+ toks = shlex.split(str(cmd or ""), posix=False)
85
+ out = []
86
+ for t in toks:
87
+ if len(t) >= 2 and t[0] == t[-1] and t[0] in ('"', "'"):
88
+ t = t[1:-1]
89
+ if t:
90
+ out.append(t)
91
+ return out
92
+
93
+
94
+ def _run_one(h, payload):
95
+ """跑单条钩子。返回注入文本或 None(失败/超时/空输出)。"""
96
+ try:
97
+ argv = _argv(h.get("cmd"))
98
+ except ValueError as e:
99
+ log.warning("hook[%s %s] 命令解析失败:%s", h.get("name"), h.get("event"), str(e)[:120])
100
+ return None
101
+ if not argv:
102
+ return None
103
+ try:
104
+ # Hook input/output is a UTF-8 JSON/text protocol. Python subprocesses on
105
+ # Windows otherwise inherit the OEM code page and their Chinese stdout is
106
+ # decoded as mojibake below. Non-Python hooks ignore this variable.
107
+ env = os.environ.copy()
108
+ env["PYTHONIOENCODING"] = "utf-8"
109
+ proc = subprocess.run(
110
+ argv, shell=False,
111
+ input=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
112
+ capture_output=True, timeout=int(h.get("timeout_s") or _DEFAULT_TIMEOUT),
113
+ creationflags=_CREATE_NO_WINDOW, env=env)
114
+ except subprocess.TimeoutExpired:
115
+ log.warning("hook[%s %s] 超时,已跳过", h.get("name"), h.get("event"))
116
+ return None
117
+ except Exception as e:
118
+ log.warning("hook[%s %s] 执行失败:%s", h.get("name"), h.get("event"), str(e)[:120])
119
+ return None
120
+ out = (proc.stdout or b"").decode("utf-8", "replace").strip()
121
+ if not out:
122
+ return None
123
+ try:
124
+ data = json.loads(out)
125
+ if isinstance(data, dict):
126
+ return str(data.get("inject") or "") or None
127
+ except Exception:
128
+ pass
129
+ return out
130
+
131
+
132
+ def run_event(event, ctx=None, task_id=None, run_id=None, text=None):
133
+ """触发事件:依次跑启用的钩子,合并注入文本。任何异常不外抛。
134
+
135
+ ctx 附带任务/运行摘要;text 仅 message_submit 时传用户消息原文。
136
+ 返回拼接好的注入文本(可为空串)。"""
137
+ if event not in EVENTS:
138
+ return ""
139
+ try:
140
+ items = [h for h in load_list() if h.get("enabled") and h.get("event") == event]
141
+ except Exception:
142
+ return ""
143
+ if not items:
144
+ return ""
145
+ payload = {"event": event, "task_id": task_id or "", "run_id": run_id or "",
146
+ "text": text or ""}
147
+ if ctx is not None:
148
+ payload["ctx"] = ctx
149
+ outs = []
150
+ for h in items:
151
+ piece = _run_one(h, payload)
152
+ if piece:
153
+ outs.append(piece.strip())
154
+ return "\n\n".join(outs)
155
+
156
+
157
+ def test_one(cmd, event="task_start", timeout_s=None):
158
+ """设置页「测试」:手动跑一条钩子,返回 (ok, 输出/错误人话)。"""
159
+ h = {"name": "test", "event": event if event in EVENTS else "task_start",
160
+ "cmd": cmd, "timeout_s": timeout_s or _DEFAULT_TIMEOUT}
161
+ piece = _run_one(h, {"event": h["event"], "task_id": "", "run_id": "", "text": ""})
162
+ return True, piece if piece else "(无输出,未注入)"
package/app/core/jobs.py CHANGED
@@ -150,13 +150,13 @@ def _try_start_once(job):
150
150
  global _alive, _chat_alive, _seq
151
151
  from . import store
152
152
  run_id = job.get("run_id")
153
- current = store.get_run(run_id)
154
- if current:
155
- # 先 CAS 认领持久化 run,再碰并发位。若先占位,两个调用可能分别看到
153
+ current = store.get_run(run_id)
154
+ if current:
155
+ # 先 CAS 认领持久化 run,再碰并发位。若先占位,两个调用可能分别看到
156
156
  # “容量已满”和“状态已变化”,把唯一 run 误收口为 failed 且无人执行。
157
- changed = store.update_run(run_id, expected_status="queued",
158
- status="running", started_at=_now(),
159
- dispatch_mode="direct")
157
+ changed = store.update_run(run_id, expected_status="queued",
158
+ status="running", started_at=_now(),
159
+ dispatch_mode="direct")
160
160
  if changed is None:
161
161
  latest = store.get_run(run_id) or current
162
162
  raise DuplicateJobError("运行 %s 当前状态为 %s,拒绝重复启动" %
@@ -182,11 +182,26 @@ def _try_start_once(job):
182
182
  if not busy:
183
183
  _seq += 1
184
184
  seq = _seq
185
- if busy:
186
- CANCELS.pop(run_id, None)
187
- return False
188
-
189
- try:
185
+ if busy:
186
+ CANCELS.pop(run_id, None)
187
+ return False
188
+
189
+ # A queued run may have spent minutes waiting for a slot or resume timer.
190
+ # Only the successful slot owner starts the execution clock. The CAS
191
+ # prevents a cancellation between slot claim and launch from being revived.
192
+ if current and current.get("task_id"):
193
+ task = store.get_task(current["task_id"])
194
+ if task:
195
+ budget_s = store._task_timeout_s(current.get("timeout_budget_s") or
196
+ task.get("timeout_s"))
197
+ if store.update_run(run_id, expected_status="running",
198
+ timeout_budget_s=budget_s,
199
+ deadline_at=time.time() + budget_s) is None:
200
+ _release_slot(job)
201
+ CANCELS.pop(run_id, None)
202
+ raise DuplicateJobError("运行 %s 已在启动前结束" % run_id)
203
+
204
+ try:
190
205
  threading.Thread(target=_run_job, args=(dict(job),),
191
206
  name="job-direct-%d" % seq, daemon=True).start()
192
207
  except Exception:
@@ -324,17 +339,7 @@ def _requeue_await_slot(job, waits_left=None):
324
339
  # 带着全新重试额度重新入队,封顶永远打不中)。仍满载则续下一拍。
325
340
  with _timer_lock:
326
341
  _deferred_timers.pop(run_id, None)
327
- try:
328
- from . import store
329
- run = store.get_run(run_id) or {}
330
- deadline_at = run.get("deadline_at")
331
- if deadline_at is not None and float(deadline_at) <= time.time():
332
- store.update_run(run_id, expected_status="queued", status="timeout",
333
- ended_at=_now(), error="任务总时限已到(排队等待期间)")
334
- return
335
- except (TypeError, ValueError, AttributeError):
336
- pass
337
- try:
342
+ try:
338
343
  started = _try_start_once(job)
339
344
  except DuplicateJobError:
340
345
  return # 已被取消/接管/收口:补跑链到此为止
@@ -564,19 +569,31 @@ def _err_signature(err):
564
569
  return re.sub(r"\s+", " ", s).strip()[:400]
565
570
 
566
571
 
572
+ def _serial_progress(run):
573
+ """连载运行的已过审章数——超时续跑的进度锚点(chapter_scores 长度)。"""
574
+ try:
575
+ return len(run.get("chapter_scores") or [])
576
+ except Exception:
577
+ return 0
578
+
579
+
567
580
  def _maybe_auto_resume(run_id):
568
- """连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
581
+ """连载任务失败/超时自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
569
582
 
570
583
  真实长篇单次运行常因供应商拥堵超时中断;这里在执行线程收尾时自动续跑一次
571
584
  续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
585
+ 2026-09-24 戍边骑奴 9-20 案:「任务总时限已到」终态此前不续跑,
586
+ 长篇在慢链天仍可能超过单次预算;超时也是应自动接着写的一种中断。
572
587
  同因连撞止损:续跑副本再失败时与本次失败的错误签名比对(2026-09-18
573
588
  重写任务 kimi 403 欠费案),一模一样说明退避没换来不同结果,直接落
574
- 终态写明死因,不再烧剩余的退避次数。
589
+ 终态写明死因,不再烧剩余的退避次数。超时例外:错误签名恒为同一句
590
+ 「任务总时限已到」,但每轮都在落章——本轮比上轮多过审了章就不算
591
+ 「同因无效重试」,续跑交给次数上限兜底;两轮一章未进则照常止损。
575
592
  """
576
593
  try:
577
594
  from . import store
578
595
  run = store.get_run(run_id)
579
- if not run or run.get("status") not in ("failed", "cancelled"):
596
+ if not run or run.get("status") not in ("failed", "cancelled", "timeout"):
580
597
  return False
581
598
  task = store.get_task(run.get("task_id")) if run.get("task_id") else None
582
599
  if not task or not task.get("serial"):
@@ -589,8 +606,11 @@ def _maybe_auto_resume(run_id):
589
606
  if prev_id:
590
607
  prev = store.get_run(prev_id)
591
608
  sig_now = _err_signature(run.get("error"))
609
+ timeout_progressed = (run.get("status") == "timeout"
610
+ and _serial_progress(run) > _serial_progress(prev or {}))
592
611
  if (prev and sig_now and prev.get("error")
593
- and sig_now == _err_signature(prev.get("error"))):
612
+ and sig_now == _err_signature(prev.get("error"))
613
+ and not timeout_progressed):
594
614
  store.update_run(run_id, auto_resume_stopped="same_cause",
595
615
  error=(run.get("error") or "")
596
616
  + "|自动续跑止损:连续两次失败原因相同,不再重试")
@@ -29,7 +29,7 @@ import threading
29
29
  import time
30
30
  from pathlib import Path
31
31
 
32
- from . import paths, skills
32
+ from . import paths, revisions, skills
33
33
 
34
34
  _LOCK = threading.RLock()
35
35
  _FILE = paths.DATA_DIR / "knowledge.json"
@@ -82,6 +82,29 @@ def list_entries(scope=None, status=None, tag=None, only_enabled=False):
82
82
  return items
83
83
 
84
84
 
85
+ def revision_for_task(task) -> dict:
86
+ """Return approved knowledge references selected by task scope."""
87
+ scope = str((task or {}).get("type") or "*")
88
+ entries = list_entries(scope=scope, status="approved", only_enabled=True)
89
+ refs = []
90
+ for entry in entries:
91
+ body = entry.get("body") or ""
92
+ digest = revisions.content_sha256(body)
93
+ stored_digest = str(entry.get("content_sha256") or "")
94
+ drift = bool(stored_digest and stored_digest != digest)
95
+ refs.append({"id": entry.get("id") or "",
96
+ "revision_id": (entry.get("revision_id")
97
+ if stored_digest == digest else
98
+ revisions.revision_id("kbrev", digest)) or
99
+ revisions.revision_id("kbrev", digest),
100
+ "content_sha256": digest,
101
+ "revision_drift": drift})
102
+ payload = {"scope": scope, "entries": refs}
103
+ digest = revisions.stable_digest(payload)
104
+ return {"revision_id": revisions.revision_id("knowledge", digest),
105
+ "content_sha256": digest, "scope": scope, "entries": refs}
106
+
107
+
85
108
  def _title_sim(a, b):
86
109
  """标题近似度:字符 bigram 的包含度(交集 / 较短者,中文友好零分词)。
87
110
  用包含度而非 Jaccard:「X」vs「X 指南」的较短者完全被包含 →1.0,
@@ -131,13 +154,32 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
131
154
  if hit is not None:
132
155
  it = hit
133
156
  it["seen"] = int(it.get("seen") or 1) + 1
157
+ # 合并不吞变体标题(与教训库同款记忆卫生):近似题吸收留痕——
158
+ # 溯源可见、变体措辞的后续检索有据;去重+封顶 8。
159
+ if title != str(it.get("title") or ""):
160
+ mt = it.setdefault("merged_titles", [])
161
+ if title not in mt:
162
+ mt.append(title)
163
+ del mt[:-8]
164
+ old_body = str(it.get("body") or "")
165
+ old_rev = it.get("revision_id") or ""
166
+ new_rev = revisions.make_revision("kbrev", body, source=source)
134
167
  if status == "approved" or it.get("status") != "approved":
168
+ if old_body and old_body != body:
169
+ it.setdefault("revisions", []).append(
170
+ revisions.revision_record(
171
+ "kbrev", old_body, source=it.get("source") or "",
172
+ extra={"body": old_body, "revision_id": old_rev or
173
+ revisions.revision_id("kbrev", revisions.content_sha256(old_body))}))
174
+ it["revisions"] = it["revisions"][-5:]
135
175
  it["body"] = body
176
+ it.update({k: new_rev[k] for k in ("revision_id", "content_sha256")})
136
177
  if status == "approved":
137
178
  it["status"] = "approved"
138
179
  else:
139
180
  it.setdefault("revisions", []).append(
140
- {"at": _now(), "body": body, "source": source})
181
+ revisions.revision_record("kbrev", body, source=source,
182
+ extra={"body": body}))
141
183
  it["revisions"] = it["revisions"][-5:] # 最多留 5 条修订候选
142
184
  for tg in tags:
143
185
  if tg not in (it.get("tags") or []):
@@ -154,6 +196,7 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
154
196
  it["updated_at"] = _now()
155
197
  _save(data)
156
198
  return it
199
+ rev = revisions.make_revision("kbrev", body, source=source)
157
200
  it = {"id": kid, "scope": scope, "title": title, "body": body,
158
201
  "tags": tags, "status": status, "enabled": True,
159
202
  "source": source, "source_file": source_file,
@@ -161,6 +204,7 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
161
204
  "confidence": confidence or "medium",
162
205
  "revisions": [], "hits": 0, "seen": 1,
163
206
  "created_at": _now(), "updated_at": _now(), "kind": "knowledge"}
207
+ it.update({k: rev[k] for k in ("revision_id", "content_sha256")})
164
208
  items.append(it)
165
209
  _save(data)
166
210
  return it
@@ -197,7 +241,17 @@ def entry_op(entry_id, op, fields=None):
197
241
  if str(f.get("title") or "").strip():
198
242
  hit["title"] = str(f["title"]).strip()[:80]
199
243
  if str(f.get("body") or "").strip():
200
- hit["body"] = str(f["body"]).strip()[:1500]
244
+ new_body = str(f["body"]).strip()[:1500]
245
+ old_body = str(hit.get("body") or "")
246
+ if old_body != new_body:
247
+ hit.setdefault("revisions", []).append(
248
+ revisions.revision_record(
249
+ "kbrev", old_body, source=hit.get("source") or "",
250
+ extra={"body": old_body}))
251
+ hit["revisions"] = hit["revisions"][-5:]
252
+ hit["body"] = new_body
253
+ hit.update({k: revisions.make_revision("kbrev", new_body)[k]
254
+ for k in ("revision_id", "content_sha256")})
201
255
  if "tags" in f:
202
256
  hit["tags"] = [str(t).strip()[:20]
203
257
  for t in (f.get("tags") or []) if str(t).strip()][:8]