codebee 0.1.71 → 0.1.73

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 (118) hide show
  1. package/CHANGELOG.md +601 -589
  2. package/LICENSE +21 -21
  3. package/README.md +508 -509
  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/audit.py +30 -30
  10. package/app/core/backup.py +470 -470
  11. package/app/core/beekeeper.py +183 -183
  12. package/app/core/benchstore.py +95 -95
  13. package/app/core/board.py +209 -209
  14. package/app/core/bookmeta.py +935 -935
  15. package/app/core/bookmeta_catalog.py +180 -180
  16. package/app/core/branching.py +137 -137
  17. package/app/core/builtin_agent.py +1680 -1673
  18. package/app/core/capability.py +144 -144
  19. package/app/core/chaptersafe.py +32 -32
  20. package/app/core/cleanup.py +303 -303
  21. package/app/core/compaction.py +201 -201
  22. package/app/core/covergen.py +356 -356
  23. package/app/core/defectretro.py +43 -43
  24. package/app/core/diagnostics.py +114 -114
  25. package/app/core/dispatch.py +190 -190
  26. package/app/core/dispatch_log.py +128 -128
  27. package/app/core/env_scrub.py +85 -85
  28. package/app/core/errorlog.py +137 -137
  29. package/app/core/evalbench.py +836 -720
  30. package/app/core/evaluation.py +174 -174
  31. package/app/core/failure_notify.py +290 -290
  32. package/app/core/flows.py +8 -8
  33. package/app/core/goal_service.py +167 -167
  34. package/app/core/health.py +374 -374
  35. package/app/core/history.py +38 -38
  36. package/app/core/hooks.py +162 -162
  37. package/app/core/jobs.py +25 -1
  38. package/app/core/manager.py +2208 -2143
  39. package/app/core/market.py +90 -4
  40. package/app/core/market_remote.py +16 -0
  41. package/app/core/mocks.py +64 -64
  42. package/app/core/modelhub.py +3 -2
  43. package/app/core/notify.py +201 -201
  44. package/app/core/novel_quality.py +189 -189
  45. package/app/core/operations.py +228 -228
  46. package/app/core/paihang.py +145 -138
  47. package/app/core/pipeline.py +102 -30
  48. package/app/core/planner.py +2 -0
  49. package/app/core/plugins.py +34 -0
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/preview.py +293 -293
  52. package/app/core/prompt_guard.py +78 -0
  53. package/app/core/publish/__init__.py +7 -7
  54. package/app/core/publish/auto.py +396 -367
  55. package/app/core/publish/browser.py +655 -655
  56. package/app/core/publish/fanqie.py +165 -165
  57. package/app/core/publish/flow.py +640 -589
  58. package/app/core/publish/flows-fanqie-calibrated.json +336 -336
  59. package/app/core/publish/ledger.py +307 -229
  60. package/app/core/publish/manager.py +1118 -824
  61. package/app/core/publish/qimao.py +176 -146
  62. package/app/core/publish/ws.py +139 -139
  63. package/app/core/quality_gate.py +194 -0
  64. package/app/core/redact.py +38 -38
  65. package/app/core/repeat_guard.py +129 -129
  66. package/app/core/router.py +398 -398
  67. package/app/core/runner.py +2142 -2142
  68. package/app/core/selfupdate.py +384 -384
  69. package/app/core/session_log.py +161 -161
  70. package/app/core/sessions.py +312 -312
  71. package/app/core/settings_schema.py +270 -270
  72. package/app/core/share_page.py +114 -114
  73. package/app/core/skill_scan.py +139 -122
  74. package/app/core/skillpacks/fanqie-novel.md +98 -80
  75. package/app/core/skillpacks/market/character-bible.md +66 -66
  76. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  77. package/app/core/skillpacks/market/git-workflow.md +57 -57
  78. package/app/core/skillpacks/market/release-notes.md +72 -72
  79. package/app/core/skillpacks/market/weekly-report.md +71 -71
  80. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  81. package/app/core/skillpacks/qimao-signing.md +70 -59
  82. package/app/core/skills.py +58 -8
  83. package/app/core/step_runner.py +132 -132
  84. package/app/core/store.py +51 -0
  85. package/app/core/task_compile.py +199 -199
  86. package/app/core/telemetry.py +292 -292
  87. package/app/core/tlsctx.py +64 -64
  88. package/app/core/token_meter.py +147 -147
  89. package/app/core/tracing.py +144 -144
  90. package/app/core/upstream.py +41 -41
  91. package/app/core/usage.py +1035 -1035
  92. package/app/core/volumes.py +389 -389
  93. package/app/core/wxdigest.py +858 -858
  94. package/app/core/zentao.py +2692 -2614
  95. package/app/main.py +110 -39
  96. package/app/mcp_server.py +175 -175
  97. package/app/pick_dialog.py +110 -110
  98. package/app/ui/app.js +509 -114
  99. package/app/ui/artdiff.js +74 -74
  100. package/app/ui/board.html +162 -162
  101. package/app/ui/board.js +305 -305
  102. package/app/ui/i18n.js +41 -2
  103. package/app/ui/icons/bee.svg +79 -79
  104. package/app/ui/index.html +8 -3
  105. package/app/ui/manifest.json +16 -16
  106. package/app/ui/qrcode.js +2297 -2297
  107. package/app/ui/style.css +74 -26
  108. package/bin/tutti.js +147 -147
  109. package/package.json +48 -47
  110. package/tools/wx_reader.py +261 -261
  111. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  112. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  113. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  114. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  115. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  116. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  117. package/app/.playwright-cli/run-detail-after.png +0 -0
  118. package/app//344/275/234/345/223/201/344/277/241/346/201/257-/344/270/203/347/214/253.md +0 -18
@@ -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
@@ -39,6 +39,8 @@ MAX_POOL = 24
39
39
  CHAT_POOL = 4 # 轻量池上限:direct 对话直连 API 本机开销极小,4 个足够
40
40
  BUSY_WAIT_S = 15.0 # 满载排队后的补跑轮询间隔(秒);测试可调小
41
41
  BUSY_WAIT_MAX = 40 # 补跑拍数封顶:15s × 40 = 10 分钟,超时才判失败
42
+ QUEUED_SWEEP_INTERVAL_S = 60 # 运行期 queued 巡检周期:续跑 Timer 断链的最后防线
43
+ QUEUED_SWEEP_MIN_AGE_S = 120 # 只接管卡了超过该秒数的 queued run(正常排队/刚建的不掺和)
42
44
 
43
45
 
44
46
  class JobsBusyError(RuntimeError):
@@ -114,6 +116,8 @@ def start_worker():
114
116
  if _started:
115
117
  return
116
118
  _started = True
119
+ threading.Thread(target=_queued_sweep_loop,
120
+ name="queued-sweep", daemon=True).start()
117
121
  try:
118
122
  from . import settings
119
123
  configure(settings.load()["max_concurrent_jobs"])
@@ -123,6 +127,23 @@ def start_worker():
123
127
  configure(12)
124
128
 
125
129
 
130
+ def _queued_sweep_loop():
131
+ """运行期 queued 巡检:requeue_pending 只在启动时跑一次,而续跑 Timer
132
+ 随进程死亡——resume_enqueue_at 到点时若进程已换血,副本就永远躺平在
133
+ queued(2026-09-29 实案:连载续跑副本躺了 4 小时无人接)。这里周期兜底
134
+ 捞起超龄 queued:到点的退避副本、蒸发的满载排队都走 requeue_pending
135
+ 统一收口。防双跑靠三层既有闸:_task_active_run 单飞 + enqueue CAS 认领
136
+ + 重复 job 跳过;退避未到点/max_age 内的新鲜记录 requeue_pending 自己跳。"""
137
+ while True:
138
+ time.sleep(QUEUED_SWEEP_INTERVAL_S)
139
+ if _restart_drain:
140
+ continue
141
+ try:
142
+ requeue_pending(limit=10, max_age_s=QUEUED_SWEEP_MIN_AGE_S)
143
+ except Exception:
144
+ pass
145
+
146
+
126
147
  def _job_is_light(run):
127
148
  """direct 对话走轻量池:内置智能体直连模型 API,本机无 CLI 子进程,
128
149
  开销与编排任务(每步起一个 CLI)差几个量级,不该抢全局槽。判定按
@@ -903,7 +924,10 @@ def _do_mgmt(job, ev):
903
924
  summary = ("失败:安装文件被占用(可能有同名程序在运行,或杀毒软件正在扫描),"
904
925
  "请关闭占用该文件的程序后重试。完整输出见日志。")
905
926
  else:
906
- summary = ("完成" if ok else "失败") + (": " + res["error"][:300] if res.get("error") else "")
927
+ # note 优先:良性收口(如 winget「已装无可升级」复检在装)给用户
928
+ # 看原因,别让「完成」两个字孤零零不带解释
929
+ extra = str(res.get("note") or res.get("error") or "")[:300]
930
+ summary = ("完成" if ok else "失败") + (": " + extra if extra else "")
907
931
  store.finish_step(run_id, step["n"],
908
932
  "done" if ok else "failed",
909
933
  summary=summary,