codebee 0.1.63 → 0.1.65

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.
@@ -2037,6 +2037,35 @@ def _model_image_in(prov, model):
2037
2037
  return False
2038
2038
 
2039
2039
 
2040
+ def vision_candidates(prefer_provider_id="", prefer_model=""):
2041
+ """全库搜「能看图」的候选模型,供图类调用自动降级(编排模型不支持看图时
2042
+ 自己换将,不甩人工)。返回有序 [(prov, model_name, inferred)]:
2043
+ 声明 image_in 的在先;仅名字推断(_auto_image_in)的在后(inferred=True,
2044
+ 调用方实测成功后应 set_model_caps 落标记)。编排者供应商排最前,
2045
+ 编排模型本身若可看图排整个列表第一。"""
2046
+ out, seen = [], set()
2047
+ provs = [p for p in providers()
2048
+ if isinstance(p, dict) and p.get("enabled", True) and p.get("api_key")]
2049
+ provs.sort(key=lambda p: 0 if p.get("id") == prefer_provider_id else 1)
2050
+ for p in provs:
2051
+ for m in _enabled_models(p):
2052
+ name = str(m.get("name") or "")
2053
+ if not name or (p.get("id"), name) in seen:
2054
+ continue
2055
+ if bool(m.get("image_in")):
2056
+ inferred = False
2057
+ elif _auto_image_in(name):
2058
+ inferred = True
2059
+ else:
2060
+ continue
2061
+ seen.add((p.get("id"), name))
2062
+ out.append((p, name, inferred))
2063
+ if prefer_model:
2064
+ out.sort(key=lambda x: 0 if (x[0].get("id") == prefer_provider_id
2065
+ and x[1] == prefer_model) else 1)
2066
+ return out
2067
+
2068
+
2040
2069
  def bind_agent(agent, difficulty="default", task_type="", role=""):
2041
2070
  """按绑定生成应用了供应商/模型覆盖的 agent 副本;无绑定时原样返回。
2042
2071
 
@@ -3096,32 +3125,68 @@ def resolve_orchestrator():
3096
3125
  return prov, model
3097
3126
 
3098
3127
 
3099
- def _chat_cache_path(provider_id, model_name, prompt, max_tokens, reasoning_effort=""):
3100
- """§07 T2.2:精确匹配响应缓存的落盘路径(只缓存 ok 的幂等调用)。"""
3128
+ def _chat_user_content(proto, prompt, images):
3129
+ """images=[(mime, b64)] 时构造多模态 user content;无图返回原样文本。
3130
+
3131
+ openai/anthropic 的 content 变数组块,google 走 parts;形状与
3132
+ builtin_agent 的对话消息一致(图只进 user 消息)。"""
3133
+ if not images:
3134
+ return [{"text": prompt}] if proto == "google" else prompt
3135
+ if proto == "google":
3136
+ parts = [{"text": prompt}]
3137
+ for mime, b64 in images:
3138
+ parts.append({"inline_data": {"mime_type": mime, "data": b64}})
3139
+ return parts
3140
+ blocks = [{"type": "text", "text": prompt}]
3141
+ for mime, b64 in images:
3142
+ if proto == "anthropic":
3143
+ blocks.append({"type": "image",
3144
+ "source": {"type": "base64", "media_type": mime,
3145
+ "data": b64}})
3146
+ else:
3147
+ blocks.append({"type": "image_url",
3148
+ "image_url": {"url": "data:%s;base64,%s" % (mime, b64)}})
3149
+ return blocks
3150
+
3151
+
3152
+ def _chat_cache_path(provider_id, model_name, prompt, max_tokens, reasoning_effort="",
3153
+ images=None):
3154
+ """§07 T2.2:精确匹配响应缓存的落盘路径(只缓存 ok 的幂等调用)。
3155
+ images 参与键(同 prompt 不同图的响应可能不同)。"""
3101
3156
  import hashlib as _h
3157
+ img_sig = ""
3158
+ if images:
3159
+ try:
3160
+ img_sig = _h.sha256("|".join("%s|%s" % (m, b) for m, b in images)
3161
+ .encode("utf-8")).hexdigest()[:16]
3162
+ except Exception:
3163
+ img_sig = "err"
3102
3164
  key = "|".join([str(provider_id), str(model_name), str(max_tokens),
3103
- str(reasoning_effort or ""), str(prompt)])
3165
+ str(reasoning_effort or ""), str(prompt), img_sig])
3104
3166
  name = _h.sha256(key.encode("utf-8")).hexdigest()[:24]
3105
3167
  return paths.DATA_DIR / "chat_cache" / (name + ".json")
3106
3168
 
3107
3169
 
3108
3170
  def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_ttl=0,
3109
- on_delta=None, reasoning_effort=""):
3171
+ on_delta=None, reasoning_effort="", images=None):
3110
3172
  """直连供应商 API 做一次对话(编排者规划 / 连通性测试)。
3111
3173
 
3112
3174
  支持 anthropic / openai / google 三种协议;复用 SSRF 防护。
3175
+ images=[(mime, b64)] 时随 user 消息传图(三协议形状见 _chat_user_content;
3176
+ 图只在非流式路径验证过,流式 on_delta 与图并用未验证)。
3113
3177
  返回 {ok, text, tokens, usage, error};usage 为细分 {input, output, cached, reasoning, total}。
3114
- cache_ttl>0 启用精确匹配响应缓存(key=供应商+模型+prompt+max_tokens,只缓存
3115
- ok 结果)——仅限幂等调用(连通性测试等);创作类调用不要开,否则同一 prompt
3178
+ cache_ttl>0 启用精确匹配响应缓存(key=供应商+模型+prompt+max_tokens+图签名,
3179
+ 只缓存 ok 结果)——仅限幂等调用(连通性测试等);创作类调用不要开,否则同一 prompt
3116
3180
  的二次请求会屏蔽模型的新输出。
3117
3181
  on_delta 给定时走 SSE 流式:每收到一段增量文本回调一次。编排者直连调用
3118
3182
  不经 run_process、原本生成全程日志只有一行标题,靠它把「正在吐字」实时
3119
3183
  写进步骤日志(planner._log_streamer 节流落盘)。
3120
3184
  """
3185
+ images = [(str(m), str(b)) for m, b in (images or [])] or None
3121
3186
  if cache_ttl > 0:
3122
3187
  try:
3123
3188
  cache_path = _chat_cache_path(provider_id, model_name, prompt, max_tokens,
3124
- reasoning_effort)
3189
+ reasoning_effort, images)
3125
3190
  if cache_path.is_file():
3126
3191
  age = time.time() - cache_path.stat().st_mtime
3127
3192
  if age <= cache_ttl:
@@ -3157,7 +3222,7 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
3157
3222
  else:
3158
3223
  url = base + "/v1beta/models/%s:generateContent" % model_name
3159
3224
  headers = {"x-goog-api-key": use_key}
3160
- body = {"contents": [{"parts": [{"text": prompt}]}],
3225
+ body = {"contents": [{"parts": _chat_user_content(proto, prompt, images)}],
3161
3226
  "generationConfig": {"maxOutputTokens": max_tokens}}
3162
3227
  else:
3163
3228
  path = "/messages" if proto == "anthropic" else "/chat/completions"
@@ -3167,7 +3232,8 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
3167
3232
  else:
3168
3233
  headers = {"Authorization": "Bearer " + use_key}
3169
3234
  body = {"model": model_name, "max_tokens": max_tokens,
3170
- "messages": [{"role": "user", "content": prompt}]}
3235
+ "messages": [{"role": "user",
3236
+ "content": _chat_user_content(proto, prompt, images)}]}
3171
3237
  effort = str(reasoning_effort or "").strip().lower()
3172
3238
  if proto == "openai" and effort in ("low", "medium", "high"):
3173
3239
  body["reasoning_effort"] = effort
@@ -3255,7 +3321,7 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
3255
3321
  if cache_ttl > 0:
3256
3322
  try:
3257
3323
  cache_path = _chat_cache_path(provider_id, model_name, prompt, max_tokens,
3258
- reasoning_effort)
3324
+ reasoning_effort, images)
3259
3325
  cache_path.parent.mkdir(parents=True, exist_ok=True)
3260
3326
  tmp = cache_path.with_suffix(".tmp")
3261
3327
  import json as _json
@@ -1060,7 +1060,9 @@ def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
1060
1060
  failures = ["%s:%s" % (impl.get("id") or "CLI",
1061
1061
  (draft_res.get("error") or "起草失败")[:180])]
1062
1062
  dead_upstreams = []
1063
- first_upstreams = router.agent_upstreams(impl.get("id"))
1063
+ first_upstreams = router.agent_upstreams(
1064
+ impl.get("id"), difficulty=difficulty,
1065
+ task_type=task.get("type") or "novel", role="implement")
1064
1066
  if first_upstreams:
1065
1067
  dead_upstreams.append(first_upstreams)
1066
1068
  current = impl
@@ -1072,7 +1074,7 @@ def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
1072
1074
  _check_cancel(ev)
1073
1075
  candidate, reason = router.pick_switch_candidate(
1074
1076
  agents, "implement", task.get("type") or "novel", stats,
1075
- exclude=tried, dead_upstreams=dead_upstreams)
1077
+ exclude=tried, dead_upstreams=dead_upstreams, difficulty=difficulty)
1076
1078
  if candidate is None or candidate.get("id") in tried:
1077
1079
  break
1078
1080
  tried.add(candidate.get("id"))
@@ -1097,7 +1099,9 @@ def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
1097
1099
  (last_result.get("error") or "起草失败")[:180]))
1098
1100
  if (last_result.get("raw") or {}).get("cancelled"):
1099
1101
  break
1100
- ups = router.agent_upstreams(candidate.get("id"))
1102
+ ups = router.agent_upstreams(
1103
+ candidate.get("id"), difficulty=difficulty,
1104
+ task_type=task.get("type") or "novel", role="implement")
1101
1105
  if ups:
1102
1106
  dead_upstreams.append(ups)
1103
1107
  current = candidate
@@ -1309,8 +1313,11 @@ def _run_code(run, task, agents, ev, stats, mode):
1309
1313
  # 原实现者若死于配额,其上游(网关/账号)也一并判入死池——否则第一棒
1310
1314
  # 换将不知道同网关候选已死,评审者重选更会把「与死者同上游」的候选
1311
1315
  # 继续当活口,评审撞同一条失效网关白烧一轮后误报「评审器故障」。
1312
- if runner._quota_error(res.get("error") or ""):
1313
- ups0 = router.agent_upstreams(impl_agent["id"])
1316
+ if (runner._quota_error(res.get("error") or "")
1317
+ or runner._permission_error(res.get("error") or "")):
1318
+ ups0 = router.agent_upstreams(
1319
+ impl_agent["id"], difficulty=difficulty,
1320
+ task_type="code", role="implement")
1314
1321
  if ups0:
1315
1322
  dead_ups.append(ups0)
1316
1323
  prev_id = impl_agent["id"]
@@ -1318,7 +1325,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1318
1325
  while True:
1319
1326
  other, other_reason = router.pick_switch_candidate(
1320
1327
  agents, "implement", "code", stats,
1321
- exclude=tried, dead_upstreams=dead_ups)
1328
+ exclude=tried, dead_upstreams=dead_ups,
1329
+ difficulty=difficulty)
1322
1330
  if other is None or other.get("mode") != "real":
1323
1331
  other = None
1324
1332
  break
@@ -1348,8 +1356,11 @@ def _run_code(run, task, agents, ev, stats, mode):
1348
1356
  tried.add(other["id"])
1349
1357
  dead_ids.add(other["id"])
1350
1358
  prev_id = other["id"]
1351
- if runner._quota_error(res.get("error") or ""):
1352
- ups = router.agent_upstreams(other["id"])
1359
+ if (runner._quota_error(res.get("error") or "")
1360
+ or runner._permission_error(res.get("error") or "")):
1361
+ ups = router.agent_upstreams(
1362
+ other["id"], difficulty=difficulty,
1363
+ task_type="code", role="implement")
1353
1364
  if ups:
1354
1365
  dead_ups.append(ups)
1355
1366
  else:
@@ -1541,13 +1552,27 @@ def _run_code(run, task, agents, ev, stats, mode):
1541
1552
  _write_project_memory(task, workdir, _mem_lines)
1542
1553
  except Exception:
1543
1554
  pass
1555
+ # Stop gate(planning-with-files):收工时活计划若有未勾选项,摘要与 verdict
1556
+ # 如实点名——「计划没走完就报完成」属于静默降级(赛马/异常路径不回写勾选时
1557
+ # 此前无任何可见信号)。只保可见不改判:勾选是文件事实,质量判据仍是评审。
1558
+ try:
1559
+ gate_total, gate_left = plan_stopgate(workdir)
1560
+ except Exception:
1561
+ gate_total, gate_left = 0, []
1562
+ gate_note = ""
1563
+ if gate_left:
1564
+ verdict["plan_gate"] = "活计划 %d 项中 %d 项未勾选完成:%s" % (
1565
+ gate_total, len(gate_left),
1566
+ "、".join("#%d[%s]%s" % (n, mk, t) for n, mk, t in gate_left[:3]))
1567
+ gate_note = ";⚠ 活计划 %d/%d 项完成" % (gate_total - len(gate_left), gate_total)
1544
1568
  store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
1545
- summary="代码任务%s(验证%s / 评审%s%s)" % (
1569
+ summary="代码任务%s(验证%s / 评审%s%s%s)" % (
1546
1570
  "通过" if overall_pass else "未通过",
1547
1571
  "通过" if verify_pass else "未通过",
1548
1572
  (("通过" if review_json.get("pass") else "未通过")
1549
1573
  if workflow["review_required"] else "按策略省略"),
1550
- ",%d 轮修复" % (len(repairs) - 1) if len(repairs) > 1 else ""),
1574
+ ",%d 轮修复" % (len(repairs) - 1) if len(repairs) > 1 else "",
1575
+ gate_note),
1551
1576
  ended_at=_now())
1552
1577
 
1553
1578
 
@@ -4145,6 +4170,31 @@ def _write_task_plan(task, workdir, plan):
4145
4170
  _PLAN_MARK = {"done": "x", "fail": "!", "run": ">"}
4146
4171
 
4147
4172
 
4173
+ def plan_stopgate(workdir):
4174
+ """Stop gate(planning-with-files 第四件):收工前校验活计划勾选完整性。
4175
+ 返回 (总项数, [(序号, 标记, 标题)]);计划文件缺失/异常 → (0, [])(无计划不拦)。
4176
+ 未完成 = 勾选框不是 [x](含 [ ] 未跑、[!] 失败、[>] 中断在跑)。"""
4177
+ try:
4178
+ from pathlib import Path as _P
4179
+ import re as _re
4180
+ root = _P(workdir).resolve()
4181
+ target = (root / ".codebee" / "task_plan.md").resolve()
4182
+ if root not in target.parents or not target.is_file():
4183
+ return 0, []
4184
+ pat = _re.compile(r"^(\d+)\. \[([ x!>])\] ")
4185
+ total, unfinished = 0, []
4186
+ for ln in target.read_text("utf-8").splitlines():
4187
+ m = pat.match(ln)
4188
+ if not m:
4189
+ continue
4190
+ total += 1
4191
+ if m.group(2) != "x":
4192
+ unfinished.append((int(m.group(1)), m.group(2), ln[m.end():][:60]))
4193
+ return total, unfinished
4194
+ except Exception:
4195
+ return 0, []
4196
+
4197
+
4148
4198
  def mark_task_plan(workdir, index, status):
4149
4199
  """活计划回写:把 .codebee/task_plan.md 第 index 项(1 基)的勾选框
4150
4200
  翻成 [x](完成)/[!](失败)/[>](进行中)。planning-with-files 的断点
@@ -4211,7 +4261,7 @@ def execute_run(run_id):
4211
4261
  if initial_status not in ("queued", "running"):
4212
4262
  return
4213
4263
  if store.update_run(run_id, expected_status=initial_status, status="running",
4214
- started_at=_now()) is None:
4264
+ started_at=_now(), resume_enqueue_at="", warnings=[]) is None:
4215
4265
  return
4216
4266
  if task is None:
4217
4267
  store.update_run(run_id, expected_status="running", status="failed",
@@ -2,8 +2,9 @@
2
2
  """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
3
  from __future__ import annotations
4
4
 
5
- import os
6
- import re
5
+ import os
6
+ import re
7
+ import json
7
8
 
8
9
  from . import dispatch, history
9
10
 
@@ -144,14 +145,16 @@ def pick(agents, role, ttype, stats=None, exclude=()):
144
145
  # 空链 CLI 的真实上游在各自本机配置里(注册表里的 provider_id 是 UI 残留,
145
146
  # 2026-09-22 实测 qwencode 注册表指 prov-36 本机却指公司网关)。正则探针
146
147
  # 只为换将时识别「同上游」,读不到就算未知——未知不参与剔除,宁白试不误杀。
147
- _LOCAL_ENDPOINT_PROBES = (
148
+ _LOCAL_ENDPOINT_PROBES = (
148
149
  ("kimi-code", "~/.kimi-code/config.toml", r'baseUrl\s*=\s*"([^"]+)"'),
149
150
  ("claude-code", "~/.claude/settings.json", r'"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"'),
150
151
  ("qwencode", "~/.qwen/settings.json", r'"OPENAI_BASE_URL"\s*:\s*"([^"]+)"'),
151
152
  ("opencode", "~/.config/opencode/opencode.json", r'"baseURL"\s*:\s*"([^"]+)"'),
152
153
  ("opencode", "~/.config/opencode/opencode.jsonc", r'"baseURL"\s*:\s*"([^"]+)"'),
153
154
  ("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
154
- )
155
+ )
156
+
157
+ _PI_SETTINGS_PATH = "~/.pi/agent/settings.json"
155
158
 
156
159
 
157
160
  def _host_of(url):
@@ -160,27 +163,59 @@ def _host_of(url):
160
163
  return m.group(1).lower() if m else ""
161
164
 
162
165
 
163
- def agent_upstreams(agent_id):
164
- """候选实际会用到的上游集合(host:port 归一)。显式链 > CLI 本机配置
165
- 端点;两者都拿不到返回空集 = 未知。"""
166
- ups = set()
167
- try:
168
- from . import modelhub
169
- pref = modelhub._binding_for(agent_id)
170
- chain = modelhub._binding_chain(pref)
171
- provs = {p.get("id"): p for p in modelhub.providers()}
172
- if chain:
173
- for item in chain:
174
- pid = (item.get("provider_id") or "").strip()
175
- h = _host_of((provs.get(pid) or {}).get("base_url"))
176
- if h:
177
- ups.add(h)
178
- except Exception:
179
- pass
180
- if not ups:
181
- for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
182
- if aid != agent_id:
183
- continue
166
+ def agent_upstreams(agent_id, difficulty="default", task_type="", role=""):
167
+ """候选实际会用到的上游集合(host:port 归一)。显式链 > CLI 本机配置
168
+ 端点;无显式绑定时按运行期自动推荐链判断;都拿不到返回空集 = 未知。"""
169
+ ups = set()
170
+ try:
171
+ from . import modelhub
172
+ pref = modelhub._binding_for(agent_id)
173
+ provs = {p.get("id"): p for p in modelhub.providers()}
174
+
175
+ def _add_chain(chain):
176
+ for item in chain or ():
177
+ pid = (item.get("provider_id") or "").strip()
178
+ provider = item.get("provider") or provs.get(pid) or {}
179
+ h = _host_of(provider.get("base_url"))
180
+ if h:
181
+ ups.add(h)
182
+
183
+ configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
184
+ # 保留原始显式链中的 endpoint,即使某条已禁用/失效;同一个死上游仍
185
+ # 不值得在另一个 CLI 上立刻再撞一次。
186
+ if configured:
187
+ chain = modelhub._binding_chain(pref)
188
+ _add_chain(chain)
189
+ resolved = modelhub.resolve_binding(agent_id, difficulty) or {}
190
+ _add_chain(resolved.get("call_chain"))
191
+ else:
192
+ # bind_agent 在空绑定时会自动推荐供应商链。换将选路必须观察同一
193
+ # 生效链,否则“空绑定”的多个 CLI 可能被排成不同候选却共用一个 403 网关。
194
+ recommended = modelhub.recommend_binding(
195
+ agent_id, difficulty, task_type=task_type, role=role) or {}
196
+ _add_chain(recommended.get("call_chain"))
197
+ except Exception:
198
+ pass
199
+ if not ups:
200
+ if agent_id == "pi":
201
+ # Pi 将 endpoint 放在 providers.<defaultProvider>.baseUrl,不能只扫
202
+ # 所有 baseUrl(那会把未选中的 provider 也误算成当前上游)。
203
+ try:
204
+ with open(os.path.expanduser(_PI_SETTINGS_PATH), "r",
205
+ encoding="utf-8") as fh:
206
+ cfg = json.load(fh)
207
+ selected = (cfg.get("providers") or {}).get(
208
+ cfg.get("defaultProvider") or "") or {}
209
+ h = _host_of(selected.get("baseUrl") or selected.get("baseURL") or
210
+ selected.get("base_url"))
211
+ if h:
212
+ ups.add(h)
213
+ except (OSError, ValueError, TypeError):
214
+ pass
215
+ if not ups:
216
+ for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
217
+ if aid != agent_id:
218
+ continue
184
219
  try:
185
220
  with open(os.path.expanduser(path), "r", encoding="utf-8",
186
221
  errors="ignore") as fh:
@@ -194,8 +229,8 @@ def agent_upstreams(agent_id):
194
229
  return ups
195
230
 
196
231
 
197
- def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
198
- dead_upstreams=()):
232
+ def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
233
+ dead_upstreams=(), difficulty="default"):
199
234
  """换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
200
235
  异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
201
236
  之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
@@ -204,9 +239,10 @@ def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
204
239
  best, reason = pick(agents, role, ttype, stats, exclude=exclude)
205
240
  if best is None or not dead_upstreams:
206
241
  return best, reason
207
- deferred = []
208
- while best is not None:
209
- ups = agent_upstreams(best.get("id"))
242
+ deferred = []
243
+ while best is not None:
244
+ ups = agent_upstreams(best.get("id"), difficulty=difficulty,
245
+ task_type=ttype, role=role)
210
246
  if not (ups and any(ups & dead for dead in dead_upstreams)):
211
247
  break # 异上游或上游未知:就用它
212
248
  deferred.append((best, reason))
@@ -981,7 +981,7 @@ def _model_flag(kind, model):
981
981
  return ["--model", model] # claude / opencode / aider
982
982
 
983
983
 
984
- _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
984
+ _TRANSIENT_DEFAULT = ("503", "502", "529", "429", "no available channel", "temporarily",
985
985
  "unavailable", "overloaded", "rate limit", "timeout", "timed out",
986
986
  "输出停滞",
987
987
  # 2026-09-15 连载验收实测:网关故障形态远不止 HTTP 5xx——
@@ -1007,10 +1007,15 @@ def _model_flag(kind, model):
1007
1007
  "enotfound", "getaddrinfo", "name or service not known",
1008
1008
  "name resolution")
1009
1009
 
1010
+ # 保留兼容别名:旧 worker 热重载时可能短暂丢失模块级变量,判断函数不能因此
1011
+ # 把一次普通上游故障升级成 NameError。
1012
+ _TRANSIENT = _TRANSIENT_DEFAULT
1010
1013
 
1011
- def _transient_error(err):
1014
+
1015
+ def _transient_error(err, _default_markers=_TRANSIENT_DEFAULT):
1012
1016
  err = (err or "").lower()
1013
- return any(k in err for k in _TRANSIENT)
1017
+ markers = globals().get("_TRANSIENT") or _default_markers
1018
+ return any(k in err for k in markers)
1014
1019
 
1015
1020
 
1016
1021
  # 欠费/配额/限流耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
@@ -1038,6 +1043,17 @@ def _auth_error(err):
1038
1043
  return any(k in err for k in _AUTH)
1039
1044
 
1040
1045
 
1046
+ _FORBIDDEN_HINTS = ("forbidden", "request not allowed", "permission denied",
1047
+ "access denied")
1048
+
1049
+
1050
+ def _permission_error(err):
1051
+ """识别明确的 HTTP 403 权限拒绝;它是上游故障,不等同于无效 API Key。"""
1052
+ err = (err or "").lower()
1053
+ return bool(re.search(r"(?<!\d)403(?!\d)", err) and
1054
+ any(marker in err for marker in _FORBIDDEN_HINTS))
1055
+
1056
+
1041
1057
  def _attempt_credential(att):
1042
1058
  """绑定链中凭据的非敏感身份;不把密钥值写入日志或尝试记录。"""
1043
1059
  return (str((att or {}).get("provider_id") or ""),
@@ -1616,6 +1632,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1616
1632
  grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
1617
1633
  timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
1618
1634
  skipped_upstreams = set()
1635
+ forbidden_upstreams = set()
1619
1636
  auth_failed_credentials = set()
1620
1637
  for ai, att in enumerate(attempts):
1621
1638
  credential = _attempt_credential(att)
@@ -1633,7 +1650,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1633
1650
  out["attempts"] = list(attempt_history)
1634
1651
  continue
1635
1652
  upstream = _attempt_upstream(att)
1636
- if upstream in skipped_upstreams:
1653
+ if upstream in skipped_upstreams or upstream in forbidden_upstreams:
1637
1654
  continue
1638
1655
  # 有任务 deadline 时,所有候选共享同一个截止时刻;否则每个候选
1639
1656
  # 使用 catalog 配置或 caller 提供的单模型时限。
@@ -1758,7 +1775,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1758
1775
  except Exception:
1759
1776
  pass
1760
1777
  if not (_quota_error(out["error"]) or _transient_error(out["error"])
1761
- or _rate_limited(out["error"])):
1778
+ or _rate_limited(out["error"])
1779
+ or _permission_error(out["error"])):
1762
1780
  # 尾段清洗后没有错误信号而流式审计日志里有:补日志错误行。
1763
1781
  # 2026-09-22 实测 kimi 撞 429 时 stderr/stdout 只剩启动横幅
1764
1782
  # (真错误只走了流式管道),路由层看不到配额关键词,换将
@@ -1862,6 +1880,15 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1862
1880
  for candidate in attempts[ai + 1:]):
1863
1881
  return out
1864
1882
  continue
1883
+ if _permission_error(out.get("error")):
1884
+ # 403 通常是该网关/上游拒绝当前请求;同一 host 换模型只会重撞,
1885
+ # 但不同 host 的候选仍可能有权限,应继续尝试异上游链项。
1886
+ if upstream != "unknown":
1887
+ forbidden_upstreams.add(upstream)
1888
+ if not any(_attempt_upstream(candidate) not in forbidden_upstreams
1889
+ for candidate in attempts[ai + 1:]):
1890
+ return out
1891
+ continue
1865
1892
  # 换将闸门:什么失败值得烧链上下一个候选。
1866
1893
  # · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
1867
1894
  # · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
@@ -1874,6 +1901,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1874
1901
  if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
1875
1902
  or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
1876
1903
  or _refusal_error(out.get("error"))
1904
+ or _permission_error(out.get("error"))
1877
1905
  or raw.get("timed_out"))):
1878
1906
  return out # 取消/解析失败等真终态不降级
1879
1907
  # 同一上游的模型连续两次超时就收手——
package/app/core/store.py CHANGED
@@ -18,7 +18,7 @@ import threading
18
18
  import time
19
19
  from pathlib import Path
20
20
 
21
- from . import paths, runner, volumes
21
+ from . import artifacts, paths, runner, volumes
22
22
 
23
23
  LOCK = threading.RLock()
24
24
  _TASKS = {}
@@ -746,7 +746,7 @@ def task_side(task_id):
746
746
  total_steps = sum(len(r.get("steps") or []) for r in runs)
747
747
  # 成品口径闸:任务一步都没跑出来过(如历次都在检出前失败)→ 工作目录里的
748
748
  # 文件变动全是并行活动的噪音,不算这个任务的成品
749
- wd, arts = run_artifacts(latest["id"], limit=50) if (latest and total_steps > 0) else ("", [])
749
+ wd, arts = run_artifacts(latest["id"], limit=50, cache=True) if (latest and total_steps > 0) else ("", [])
750
750
  return {
751
751
  "task": {k: task.get(k) for k in ("id", "title", "status", "workdir", "git_state",
752
752
  "git_rev")},
@@ -966,41 +966,6 @@ def run_workdir(run_id):
966
966
  return str(task.get("workdir") or "") if task else ""
967
967
 
968
968
 
969
- _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
970
-
971
- # 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
972
- _BUILD_DIRS = {
973
- "target", "build", "dist", "out", "bin", "obj", # 通用构建输出
974
- "surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
975
- ".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
976
- "__MACOSX",
977
- }
978
-
979
- # CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
980
- # 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
981
- _PROCESS_ARTIFACT_NAMES = {
982
- ".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
983
- ".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
984
- }
985
- _PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
986
- _PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
987
-
988
-
989
- def _is_process_artifact(rel_name):
990
- """判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
991
- name = str(rel_name or "").replace("\\", "/")
992
- base = name.rsplit("/", 1)[-1].lower()
993
- if base in _PROCESS_ARTIFACT_NAMES:
994
- return True
995
- if base.startswith(".") and base.startswith(".aider"):
996
- return True
997
- if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
998
- return True
999
- if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
1000
- return True
1001
- return False
1002
-
1003
-
1004
969
  def task_first_start(task_id, fallback=""):
1005
970
  """该任务最早一次运行的开始时间(含回退:任务创建时间 → 指定回退值)。"""
1006
971
  stamps = []
@@ -1019,13 +984,15 @@ def task_first_start(task_id, fallback=""):
1019
984
  return best
1020
985
 
1021
986
 
1022
- def run_artifacts(run_id, limit=200):
987
+ def run_artifacts(run_id, limit=200, cache=False):
1023
988
  """列一次运行的「成品文件」:工作目录里自该任务首次运行以来新产生/修改的文件。
1024
989
 
1025
990
  断点续跑会拆成多条 run,只按本 run 过滤会漏掉早期章节;这里以「任务首跑」
1026
991
  为起点。返回 (workdir, files);files 按 mtime 新→旧,name 为工作目录内
1027
- 相对路径,已跳过 .git / 隐藏目录 / node_modules / target 等构建产物目录,
1028
- 最多 limit 个。
992
+ 相对路径,已跳过 .git / 隐藏目录 / node_modules / target 等构建产物目录。
993
+ cache=True 供 UI 轮询热路径(详情/侧栏/状态快照)复用短 TTL 缓存与在飞
994
+ 合并;发布/预览/知识库等正确性敏感方保持默认,永远现扫。
995
+ 剪枝遍历与过滤实现在 artifacts.py(几万文件目录从秒级降到百毫秒级)。
1029
996
  """
1030
997
  wd = run_workdir(run_id)
1031
998
  if not wd:
@@ -1040,36 +1007,12 @@ def run_artifacts(run_id, limit=200):
1040
1007
  root = Path(wd)
1041
1008
  if not root.is_dir():
1042
1009
  return wd, []
1043
- files = []
1044
- try:
1045
- for p in root.rglob("*"):
1046
- if not p.is_file():
1047
- continue
1048
- if any(part in _SKIP_DIRS for part in p.parts):
1049
- continue
1050
- if any(part in _BUILD_DIRS for part in p.parts[:-1]):
1051
- continue
1052
- # 隐藏目录一律是工具过程文件(.mimocode/.zcode/.claude/.codex…),
1053
- # 不是成品;按前缀通排,免得每来一个新 agent CLI 就补一次白名单
1054
- if any(part.startswith(".") for part in p.parts[:-1]):
1055
- continue
1056
- try:
1057
- st = p.stat()
1058
- except OSError:
1059
- continue
1060
- if st.st_mtime < t0:
1061
- continue
1062
- rel_name = str(p.relative_to(root)).replace("\\", "/")
1063
- if _is_process_artifact(rel_name):
1064
- continue
1065
- files.append({"name": rel_name,
1066
- "size": st.st_size, "mtime": int(st.st_mtime)})
1067
- if len(files) >= 800: # 防超大目录拖垮接口;截断后再排序取最新
1068
- break
1069
- except OSError:
1070
- pass
1071
- files.sort(key=lambda f: -f["mtime"])
1072
- return wd, files[:limit]
1010
+ running = False
1011
+ if cache:
1012
+ task = get_task(run.get("task_id") or "") if run.get("task_id") else None
1013
+ running = bool(task) and task.get("status") in ("queued", "running")
1014
+ return wd, artifacts.scan(str(root), t0, limit=limit,
1015
+ running=running, cache=cache)
1073
1016
 
1074
1017
 
1075
1018
  def task_step_count(task_id):