codebee 0.1.68 → 0.1.69

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 (106) hide show
  1. package/CHANGELOG.md +543 -543
  2. package/LICENSE +21 -21
  3. package/README.md +10 -0
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/aiflavor.py +197 -197
  6. package/app/core/artifacts.py +148 -148
  7. package/app/core/assets.py +110 -110
  8. package/app/core/attachments.py +623 -623
  9. package/app/core/automation.py +758 -758
  10. package/app/core/backup.py +470 -470
  11. package/app/core/beekeeper.py +183 -183
  12. package/app/core/board.py +209 -209
  13. package/app/core/bookmeta.py +930 -930
  14. package/app/core/bookmeta_catalog.py +180 -180
  15. package/app/core/branching.py +137 -137
  16. package/app/core/builtin_agent.py +1234 -1234
  17. package/app/core/capability.py +144 -144
  18. package/app/core/chaptersafe.py +32 -32
  19. package/app/core/cleanup.py +303 -303
  20. package/app/core/compaction.py +201 -201
  21. package/app/core/covergen.py +356 -356
  22. package/app/core/defectretro.py +43 -43
  23. package/app/core/diagnostics.py +114 -114
  24. package/app/core/dispatch.py +173 -173
  25. package/app/core/dispatch_log.py +128 -128
  26. package/app/core/env_scrub.py +85 -85
  27. package/app/core/error_codes.py +23 -23
  28. package/app/core/errorlog.py +137 -137
  29. package/app/core/evaluation.py +174 -174
  30. package/app/core/failure_notify.py +290 -290
  31. package/app/core/goal_service.py +167 -167
  32. package/app/core/health.py +374 -374
  33. package/app/core/history.py +38 -38
  34. package/app/core/hooks.py +162 -162
  35. package/app/core/jobs.py +29 -29
  36. package/app/core/manager.py +2122 -2122
  37. package/app/core/market.py +362 -362
  38. package/app/core/mocks.py +64 -64
  39. package/app/core/modelhub.py +8 -8
  40. package/app/core/notify.py +106 -106
  41. package/app/core/operations.py +228 -228
  42. package/app/core/paihang.py +138 -138
  43. package/app/core/paths.py +2 -2
  44. package/app/core/portguard.py +111 -111
  45. package/app/core/preview.py +293 -293
  46. package/app/core/publish/__init__.py +7 -7
  47. package/app/core/publish/auto.py +367 -367
  48. package/app/core/publish/browser.py +539 -539
  49. package/app/core/publish/fanqie.py +159 -159
  50. package/app/core/publish/flow.py +440 -440
  51. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  52. package/app/core/publish/ledger.py +203 -203
  53. package/app/core/publish/manager.py +692 -692
  54. package/app/core/publish/qimao.py +141 -141
  55. package/app/core/publish/ws.py +139 -139
  56. package/app/core/redact.py +38 -38
  57. package/app/core/repeat_guard.py +129 -129
  58. package/app/core/router.py +398 -398
  59. package/app/core/runner.py +2067 -2067
  60. package/app/core/selfupdate.py +384 -384
  61. package/app/core/session_log.py +161 -161
  62. package/app/core/sessions.py +312 -312
  63. package/app/core/settings.py +149 -149
  64. package/app/core/settings_schema.py +262 -262
  65. package/app/core/share_page.py +114 -114
  66. package/app/core/skill_scan.py +122 -122
  67. package/app/core/skillpacks/fanqie-novel.md +80 -80
  68. package/app/core/skillpacks/market/character-bible.md +66 -66
  69. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  70. package/app/core/skillpacks/market/git-workflow.md +57 -57
  71. package/app/core/skillpacks/market/release-notes.md +72 -72
  72. package/app/core/skillpacks/market/weekly-report.md +71 -71
  73. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  74. package/app/core/step_runner.py +132 -132
  75. package/app/core/store.py +75 -75
  76. package/app/core/task_compile.py +199 -199
  77. package/app/core/telemetry.py +292 -292
  78. package/app/core/tlsctx.py +64 -64
  79. package/app/core/token_meter.py +147 -147
  80. package/app/core/tracing.py +144 -144
  81. package/app/core/upstream.py +41 -41
  82. package/app/core/usage.py +862 -862
  83. package/app/core/volumes.py +389 -389
  84. package/app/core/wxdigest.py +858 -858
  85. package/app/core/zentao.py +2465 -2465
  86. package/app/main.py +2981 -2981
  87. package/app/pick_dialog.py +110 -110
  88. package/app/ui/app.js +14399 -14399
  89. package/app/ui/artdiff.js +74 -74
  90. package/app/ui/board.html +162 -162
  91. package/app/ui/board.js +305 -305
  92. package/app/ui/i18n.js +2770 -2770
  93. package/app/ui/icons/bee.svg +79 -79
  94. package/app/ui/manifest.json +16 -16
  95. package/app/ui/qrcode.js +2297 -2297
  96. package/app/ui/style.css +16 -16
  97. package/bin/tutti.js +147 -147
  98. package/package.json +46 -46
  99. package/tools/wx_reader.py +261 -261
  100. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  101. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  102. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  103. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  104. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  105. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  106. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -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,26 +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
- # 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:
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:
205
205
  threading.Thread(target=_run_job, args=(dict(job),),
206
206
  name="job-direct-%d" % seq, daemon=True).start()
207
207
  except Exception:
@@ -339,7 +339,7 @@ def _requeue_await_slot(job, waits_left=None):
339
339
  # 带着全新重试额度重新入队,封顶永远打不中)。仍满载则续下一拍。
340
340
  with _timer_lock:
341
341
  _deferred_timers.pop(run_id, None)
342
- try:
342
+ try:
343
343
  started = _try_start_once(job)
344
344
  except DuplicateJobError:
345
345
  return # 已被取消/接管/收口:补跑链到此为止
@@ -582,8 +582,8 @@ def _maybe_auto_resume(run_id):
582
582
 
583
583
  真实长篇单次运行常因供应商拥堵超时中断;这里在执行线程收尾时自动续跑一次
584
584
  续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
585
- 2026-09-24 戍边骑奴 9-20 案:「任务总时限已到」终态此前不续跑,
586
- 长篇在慢链天仍可能超过单次预算;超时也是应自动接着写的一种中断。
585
+ 2026-09-24 戍边骑奴 9-20 案:「任务总时限已到」终态此前不续跑,
586
+ 长篇在慢链天仍可能超过单次预算;超时也是应自动接着写的一种中断。
587
587
  同因连撞止损:续跑副本再失败时与本次失败的错误签名比对(2026-09-18
588
588
  重写任务 kimi 403 欠费案),一模一样说明退避没换来不同结果,直接落
589
589
  终态写明死因,不再烧剩余的退避次数。超时例外:错误签名恒为同一句