codebee 0.1.73 → 0.1.74

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.
@@ -74,6 +74,9 @@ _PROTOCOL_AUTO = "auto"
74
74
  _PROTOCOL_CHOICES = _PROTOCOLS + (_PROTOCOL_AUTO,)
75
75
  # auto 挑主协议时的偏好顺序:只挑「实测过的」wire(wire_caps),不猜。
76
76
  _WIRE_PREFERENCE = ("anthropic", "openai")
77
+ # 新鲜失败(fresh + failed)评测条目的重探间隔:失败探针会刷新 evaluated_at,
78
+ # 实际效果=固定 15 分钟退避一次,而不是把链锁死到 24h TTL(0930 公司OpenAI 案)。
79
+ _FAILED_REPROBE_AFTER = 15 * 60
77
80
 
78
81
 
79
82
  def _load():
@@ -264,7 +267,12 @@ def _model_payload_ok(protocol, data, wire_api=""):
264
267
 
265
268
 
266
269
  def _evaluation_is_usable(provider_id, model=""):
267
- """Unknown evidence is allowed; stale evidence is never a routing green light."""
270
+ """Unknown evidence is allowed; stale evidence is never a routing green light.
271
+
272
+ 新鲜失败(fresh + failed)不再硬卡到 24h TTL:隔 ``_FAILED_REPROBE_AFTER``
273
+ 秒就地重探一次。失败的探针会刷新 evaluated_at,自然形成固定间隔的退避
274
+ 节流——2026-09-30 公司OpenAI 案:一条 10:39 的失败探针把 codex 链锁到
275
+ 次日同时刻,网关恢复后也没有任何自动回探路径(重探只对 stale 触发)。"""
268
276
  try:
269
277
  from . import evaluation
270
278
  for kind, name in (("provider", ""), ("model", model)):
@@ -280,6 +288,14 @@ def _evaluation_is_usable(provider_id, model=""):
280
288
  return bool(probe and probe.get("ok") and probe.get("fresh"))
281
289
  if entry and (not entry.get("ok") or entry.get("status") not in
282
290
  ("passed", "reachable", "reachable_unverified")):
291
+ try:
292
+ age = time.time() - float(entry.get("evaluated_at") or 0)
293
+ except (TypeError, ValueError):
294
+ age = 0.0
295
+ if age >= _FAILED_REPROBE_AFTER:
296
+ probe = (test_model(provider_id, model) if kind == "model" and model
297
+ else test_provider(provider_id))
298
+ return bool(probe and probe.get("ok") and probe.get("fresh"))
283
299
  return False
284
300
  except Exception:
285
301
  return True
@@ -2433,9 +2449,43 @@ def bindable_protocols(agent_kind_or_id):
2433
2449
  _LOCAL_CRED_ONLY_TARGETS = ("gemini-cli", "codebuddy", "trae-agent")
2434
2450
 
2435
2451
 
2452
+ def _dead(reasons, text):
2453
+ """死因速记去重追加(同供应商多条链目只留一份,防文案刷屏)。"""
2454
+ if text not in reasons:
2455
+ reasons.append(text)
2456
+
2457
+
2458
+ # 死因速记(进程内):resolve_binding 判空链时按 CLI 记最近一次逐条死因,
2459
+ # binding_dead_msg 据此把「真实死因」钉进失败/告警文案——0930 假死链案:
2460
+ # 文案只列「停用/删除/无密钥/模型停用」四种配置猜测,真凶(健康事件作废、
2461
+ # 探针失败、协议不匹配)一个都不在文案里,用户看着「明明有可用模型」无从对账。
2462
+ _DEAD_REASONS = {}
2463
+ _DEAD_REASONS_TTL = 15 * 60.0
2464
+
2465
+
2466
+ def _note_dead_reasons(cli_id, items):
2467
+ with _LOCK:
2468
+ _DEAD_REASONS[str(cli_id or "")] = {
2469
+ "at": time.time(),
2470
+ "items": [str(x)[:90] for x in (items or []) if str(x).strip()][:6],
2471
+ }
2472
+
2473
+
2474
+ def last_dead_reasons(cli_id):
2475
+ """该 CLI 最近一次链解析死因(超过 TTL 视为过期不复述,防陈旧误导)。"""
2476
+ try:
2477
+ with _LOCK:
2478
+ rec = _DEAD_REASONS.get(str(cli_id or ""))
2479
+ fresh = (rec is not None
2480
+ and time.time() - float(rec.get("at") or 0) <= _DEAD_REASONS_TTL)
2481
+ return list(rec.get("items") or []) if fresh else []
2482
+ except Exception:
2483
+ return []
2484
+
2485
+
2436
2486
  def binding_dead_msg(cli_id):
2437
2487
  """死链失败/告警文案(pipeline 死链闸门与本模块 sync 共用):说明为什么
2438
- 不回落本机默认 + 该 CLI 需要什么协议的供应商。"""
2488
+ 不回落本机默认 + 该 CLI 需要什么协议的供应商 + 最近一次解析的真实死因。"""
2439
2489
  try:
2440
2490
  protos = bindable_protocols(cli_id)
2441
2491
  except Exception:
@@ -2444,8 +2494,12 @@ def binding_dead_msg(cli_id):
2444
2494
  return ("该 CLI 暂不支持绑定链(凭据注入尚未适配,使用 CLI 本机登录态与"
2445
2495
  "默认模型);请勿为其配置供应商链")
2446
2496
  hint = "该 CLI 仅接受 %s 协议的已启用供应商;" % "、".join(protos)
2447
- return ("绑定链全部失效(链上供应商已停用/删除/无密钥,或模型已停用),"
2448
- "本步判失败、不回落 CLI 本机默认——%s请在「模型调度(可选)」页为该 CLI 指定已启用的供应商" % hint)
2497
+ msg = ("绑定链全部失效(链上供应商已停用/删除/无密钥,或模型已停用),"
2498
+ "本步判失败、不回落 CLI 本机默认——%s请在「模型调度(可选)」页为该 CLI 指定已启用的供应商" % hint)
2499
+ reasons = last_dead_reasons(cli_id)
2500
+ if reasons:
2501
+ msg += "。最近一次解析死因:" + ";".join(reasons)
2502
+ return msg
2449
2503
 
2450
2504
 
2451
2505
  def resolve_binding(agent_kind_or_id, difficulty="default"):
@@ -2485,6 +2539,7 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2485
2539
  down_set = health.down_names()
2486
2540
  except Exception:
2487
2541
  down_set = set()
2542
+ dead_reasons = [] # 逐条死因速记:解析为空时喂给 binding_dead_msg(0930 假死链案)
2488
2543
  for item in chain:
2489
2544
  model = (item.get("model") or "").strip()
2490
2545
  pid = (item.get("provider_id") or "").strip()
@@ -2497,20 +2552,38 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2497
2552
  "no_cred": True})
2498
2553
  continue
2499
2554
  prov = provs.get(pid)
2500
- if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
2501
- continue # 该条失效:跳过(降级链的语义就是逐条顶上)
2555
+ label = (prov or {}).get("name") or pid
2556
+ if not prov:
2557
+ _dead(dead_reasons, "%s:供应商已删除" % label)
2558
+ continue
2559
+ if not prov.get("enabled", True):
2560
+ _dead(dead_reasons, "%s:供应商已停用" % label)
2561
+ continue
2562
+ if not prov.get("api_key"):
2563
+ _dead(dead_reasons, "%s:无可用密钥" % label)
2564
+ continue
2502
2565
  ep = _entry_endpoint(prov, allowed)
2503
2566
  if not ep:
2504
- continue # 原生协议与适配过的 wire 都不匹配:跳过
2567
+ proto = prov.get("protocol") or "auto"
2568
+ _dead(dead_reasons, "%s:协议不匹配(%s,该 CLI 仅接受 %s)"
2569
+ % (label, proto if proto != "auto" else "auto 未探出可用 wire",
2570
+ "、".join(allowed)))
2571
+ continue
2505
2572
  if prov.get("name") in down_set:
2573
+ _dead(dead_reasons, "%s:健康监测判定 down" % label)
2506
2574
  continue # 健康监测判定 down:跳过,省掉无效等待
2507
2575
  if not _evaluation_is_usable(pid, model):
2576
+ _dead(dead_reasons, "%s:评测结论不可用(健康事件作废或探针失败)"
2577
+ % label)
2508
2578
  continue # 旧评测结论已过期/被健康事件作废,不能冒充绿灯
2509
2579
  if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
2580
+ _dead(dead_reasons, "%s:chat wire 被 codex 剔除(codex 仅讲 responses)"
2581
+ % label)
2510
2582
  continue # codex 0.154+ 只讲 responses wire:chat-only 供应商在起跑前
2511
2583
  # 就剔除(此前撞了才冷却 30 分钟,每轮白烧一次注定失败的
2512
2584
  # 尝试——2026-09-17 续4 连载 c35 实测)
2513
2585
  if model and not _model_bindable(prov, model):
2586
+ _dead(dead_reasons, "%s:模型 %s 已停用/删除" % (label, model))
2514
2587
  continue # 模型被停用/删除:该条跳过(2026-09-15 告警弹框「禁用该模型」)
2515
2588
  # 多 KEY:同一厂商按 KEY 展开成多条,顺序即调用顺序。欠费的 KEY 被
2516
2589
  # 冷却跳过(切备用),全冷却时仍留一条顶上——降级复用既有尝试循环。
@@ -2524,6 +2597,7 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2524
2597
  if len(entries) >= MAX_CHAIN_ATTEMPTS:
2525
2598
  break
2526
2599
  if not entries:
2600
+ _note_dead_reasons(agent_kind_or_id, dead_reasons)
2527
2601
  return None
2528
2602
  # 显式难度路由开启时,解析层就是唯一排序真源;关闭时保持手工链顺序。
2529
2603
  if routing and tier in ("easy", "hard"):
@@ -2553,16 +2627,37 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2553
2627
  if not pid:
2554
2628
  return None
2555
2629
  prov = provs.get(pid)
2556
- if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
2630
+ single_reasons = []
2631
+ label = (prov or {}).get("name") or pid
2632
+ if not prov:
2633
+ _dead(single_reasons, "%s:供应商已删除" % label)
2634
+ _note_dead_reasons(agent_kind_or_id, single_reasons)
2635
+ return None
2636
+ if not prov.get("enabled", True):
2637
+ _dead(single_reasons, "%s:供应商已停用" % label)
2638
+ _note_dead_reasons(agent_kind_or_id, single_reasons)
2639
+ return None
2640
+ if not prov.get("api_key"):
2641
+ _dead(single_reasons, "%s:无可用密钥" % label)
2642
+ _note_dead_reasons(agent_kind_or_id, single_reasons)
2557
2643
  return None
2558
2644
  ep = _entry_endpoint(prov, allowed)
2559
2645
  if not ep:
2646
+ proto = prov.get("protocol") or "auto"
2647
+ _dead(single_reasons, "%s:协议不匹配(%s,该 CLI 仅接受 %s)"
2648
+ % (label, proto if proto != "auto" else "auto 未探出可用 wire",
2649
+ "、".join(allowed)))
2650
+ _note_dead_reasons(agent_kind_or_id, single_reasons)
2560
2651
  return None # google 只登记;dsh 只接受 OpenAI 兼容端点;未适配的不硬塞
2561
2652
  if not _evaluation_is_usable(pid):
2653
+ _dead(single_reasons, "%s:评测结论不可用(健康事件作废或探针失败)" % label)
2654
+ _note_dead_reasons(agent_kind_or_id, single_reasons)
2562
2655
  return None
2563
2656
  if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
2657
+ _dead(single_reasons, "%s:chat wire 被 codex 剔除(codex 仅讲 responses)" % label)
2658
+ _note_dead_reasons(agent_kind_or_id, single_reasons)
2564
2659
  return None # codex 0.154+ 只讲 responses:chat-only 供应商直接判不可绑
2565
- # (解析为空 → 死链闸门/路由降权接手,不浪费 CLI 尝试)
2660
+ # (解析为空 → 死链闸门/路由降权接手,不浪费 CLI 尝试)
2566
2661
  names = [m["name"] for m in _enabled_models(prov)]
2567
2662
  model = prov.get("model_" + tier) or "" if (routing and tier) else ""
2568
2663
  if not model:
@@ -0,0 +1,154 @@
1
+ """OpenAPI 3 contract for the stable headless CodeBee integrations."""
2
+
3
+
4
+ def document():
5
+ schemas = {
6
+ "Error": {"type": "object", "required": ["error"], "properties": {
7
+ "error": {"type": "string"}, "control": {"$ref": "#/components/schemas/Control"}}},
8
+ "Control": {"type": "object", "properties": {
9
+ "mode": {"type": "string", "enum": ["free", "held"]},
10
+ "mine": {"type": "boolean"}, "holder": {"type": "string"},
11
+ "expires_in": {"type": "integer", "minimum": 0}}},
12
+ "TaskCreate": {"type": "object", "required": ["goal"], "properties": {
13
+ "type": {"type": "string"}, "title": {"type": "string"},
14
+ "goal": {"type": "string", "maxLength": 4000}, "workdir": {"type": "string"},
15
+ "acceptance_criteria": {"type": "array", "maxItems": 40,
16
+ "items": {"type": "string", "maxLength": 500}},
17
+ "approval_required": {"type": "boolean"}}},
18
+ "TaskResult": {"type": "object", "required": ["task_id", "run_id"], "properties": {
19
+ "task_id": {"type": "string"}, "run_id": {"type": "string"}}},
20
+ "Evidence": {"type": "object", "required": ["status"], "properties": {
21
+ "id": {"type": "string"}, "criterion": {"type": "string", "maxLength": 500},
22
+ "status": {"type": "string", "enum": ["passed", "failed", "unknown", "not_evaluated"]},
23
+ "summary": {"type": "string", "maxLength": 2000},
24
+ "source": {"type": "string", "maxLength": 80}, "created_at": {"type": "string"}}},
25
+ "Contract": {"type": "object", "properties": {
26
+ "task_id": {"type": "string"},
27
+ "acceptance_criteria": {"type": "array", "items": {"type": "string"}},
28
+ "evidence": {"type": "array", "items": {"$ref": "#/components/schemas/Evidence"}},
29
+ "approval_required": {"type": "boolean"}, "approval": {"type": "object"},
30
+ "blocked_reason": {"type": "string"}, "completion_receipt": {"type": "object"}}},
31
+ "StoryChapter": {"type": "object", "properties": {
32
+ "op": {"type": "string"},
33
+ "chapter": {"type": "integer", "minimum": 1, "maximum": 5000},
34
+ "outline": {"type": "string", "maxLength": 12000}, "prose": {"type": "string"},
35
+ "facts": {"type": "array", "items": {"type": "string"}},
36
+ "characters": {"type": "array", "items": {"type": "string"}},
37
+ "timeline": {"type": "array", "items": {"type": "string"}},
38
+ "foreshadowing": {"type": "array", "items": {"type": "object"}},
39
+ "next_promises": {"type": "array", "items": {"type": "string"}},
40
+ "author_truth": {"type": "array", "items": {"type": "string"}},
41
+ "reader_known": {"type": "array", "items": {"type": "string"}}},
42
+ "oneOf": [
43
+ {"required": ["op"], "properties": {"op": {"const": "init"}}},
44
+ {"required": ["chapter", "outline", "prose"]},
45
+ ]},
46
+ "StoryTrackingResult": {"type": "object", "properties": {
47
+ "state": {"type": "object"}, "check": {"type": "object"}}},
48
+ "Presence": {"type": "object", "required": ["agent_id"], "properties": {
49
+ "agent_id": {"type": "string", "maxLength": 100}, "label": {"type": "string", "maxLength": 120},
50
+ "capabilities": {"type": "array", "maxItems": 30,
51
+ "items": {"type": "string", "maxLength": 80}},
52
+ "task_id": {"type": "string", "maxLength": 100}, "status": {"type": "string", "maxLength": 30},
53
+ "metadata": {"type": "object"}}},
54
+ }
55
+
56
+ def body(schema):
57
+ return {"required": True, "content": {"application/json": {"schema": schema}}}
58
+
59
+ def response(description, schema=None):
60
+ out = {"description": description}
61
+ if schema is not None:
62
+ out["content"] = {"application/json": {"schema": schema}}
63
+ return out
64
+
65
+ ref = lambda name: {"$ref": "#/components/schemas/" + name}
66
+ security = [{"codebeeToken": []}, {"codebeeQueryToken": []}]
67
+ errors = {code: response(desc, ref("Error")) for code, desc in (
68
+ ("400", "请求无效"), ("401", "缺少或错误的访问令牌"),
69
+ ("404", "资源不存在"), ("409", "状态冲突或审批未完成"),
70
+ ("423", "其他设备持有控制权"))}
71
+ ok = response("成功", {"type": "object", "properties": {"ok": {"type": "boolean"}}})
72
+
73
+ paths = {
74
+ "/api/tasks": {"post": {"summary": "创建并启动任务", "security": security,
75
+ "requestBody": body(ref("TaskCreate")),
76
+ "responses": {"200": response("已排队", ref("TaskResult")),
77
+ "503": response("运行记录或入队失败", ref("Error")), **errors}}},
78
+ "/api/a2a/tasks": {"post": {"summary": "A2A-lite 创建任务", "security": security,
79
+ "requestBody": body(ref("TaskCreate")),
80
+ "responses": {"200": response("已排队", ref("TaskResult")), "400": errors["400"],
81
+ "401": errors["401"], "423": errors["423"]}}},
82
+ "/api/a2a/tasks/{task_id}": {"get": {"summary": "A2A-lite 读取任务状态", "security": security,
83
+ "responses": {"200": response("任务、运行和契约", {"type": "object"}),
84
+ "401": errors["401"], "404": errors["404"]}}},
85
+ "/api/tasks/{task_id}/detail": {"get": {"summary": "读取任务详情和契约", "security": security,
86
+ "responses": {"200": response("任务详情", {"type": "object"}), "401": errors["401"], "404": errors["404"]}}},
87
+ "/api/tasks/{task_id}/contract": {
88
+ "get": {"summary": "读取任务契约", "security": security,
89
+ "responses": {"200": response("契约", ref("Contract")), "401": errors["401"], "404": errors["404"]}},
90
+ "post": {"summary": "更新契约、追加证据或审批任务", "security": security,
91
+ "requestBody": body({"type": "object", "properties": {
92
+ "op": {"type": "string", "enum": ["update", "create", "evidence", "approve", "reject", "blocked"]},
93
+ "acceptance_criteria": {"type": "array", "items": {"type": "string"}},
94
+ "approval_required": {"type": "boolean"}, "evidence": ref("Evidence"),
95
+ "note": {"type": "string", "maxLength": 1000}, "reason": {"type": "string", "maxLength": 1000}}}),
96
+ "responses": {"200": response("契约已更新", {"type": "object", "properties": {"contract": ref("Contract")}}), **errors}}},
97
+ "/api/tasks/{task_id}/story-tracking": {
98
+ "get": {"summary": "读取小说追踪状态", "security": security,
99
+ "responses": {"200": response("追踪状态", ref("StoryTrackingResult")), "401": errors["401"],
100
+ "404": errors["404"], "409": errors["409"]}},
101
+ "post": {"summary": "提交章节结构化状态", "security": security, "requestBody": body(ref("StoryChapter")),
102
+ "responses": {"200": response("追踪状态已更新", ref("StoryTrackingResult")), **errors}}},
103
+ "/api/agents/presence": {
104
+ "get": {"summary": "Agent 在线状态", "security": security,
105
+ "responses": {"200": response("在线 Agent 列表", {"type": "object"}), "401": errors["401"]}},
106
+ "post": {"summary": "Agent 心跳", "security": security, "requestBody": body(ref("Presence")),
107
+ "responses": {"200": response("心跳已记录", {"type": "object"}), "400": errors["400"],
108
+ "401": errors["401"], "423": errors["423"]}}},
109
+ "/api/retrieval/search": {"get": {"summary": "本地检索", "security": security,
110
+ "parameters": [{"name": "q", "in": "query", "required": True,
111
+ "schema": {"type": "string", "maxLength": 500}},
112
+ {"name": "limit", "in": "query",
113
+ "schema": {"type": "integer", "minimum": 1, "maximum": 50}}],
114
+ "responses": {"200": response("检索结果", {"type": "object"}), "400": errors["400"], "401": errors["401"]}}},
115
+ "/api/retrieval/index": {"post": {"summary": "为配置工作区/任务目录建立本地检索索引", "security": security,
116
+ "requestBody": body({"type": "object", "properties": {
117
+ "source": {"type": "string"}, "text": {"type": "string"}, "metadata": {"type": "object"},
118
+ "doc_id": {"type": "string"}, "root": {"type": "string"}, "task_id": {"type": "string"},
119
+ "limit": {"type": "integer", "minimum": 1, "maximum": 500}}}),
120
+ "responses": {"200": response("索引已更新", {"type": "object"}), "400": errors["400"],
121
+ "401": errors["401"], "404": errors["404"], "423": errors["423"]}}},
122
+ "/api/analytics": {"get": {"summary": "任务成本质量分析", "security": security,
123
+ "responses": {"200": response("分析摘要", {"type": "object"}), "401": errors["401"]}}},
124
+ "/api/runs/{run_id}": {"get": {"summary": "读取运行状态", "security": security,
125
+ "responses": {"200": response("运行状态", {"type": "object"}), "401": errors["401"], "404": errors["404"]}}},
126
+ "/api/runs/{run_id}/resume_now": {"post": {"summary": "立即重试运行", "security": security,
127
+ "responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"], "423": errors["423"]}}},
128
+ "/api/control": {
129
+ "get": {"summary": "读取当前控制权", "security": security,
130
+ "responses": {"200": response("控制权状态", {"type": "object"}), "401": errors["401"]}},
131
+ "post": {"summary": "接管或释放控制权", "security": security,
132
+ "requestBody": body({"type": "object", "required": ["action"], "properties": {
133
+ "action": {"type": "string", "enum": ["acquire", "release"]},
134
+ "force": {"type": "boolean"}}}),
135
+ "responses": {"200": response("控制权已更新", {"type": "object"}),
136
+ "400": errors["400"], "401": errors["401"], "423": errors["423"]}}},
137
+ "/api/control/heartbeat": {
138
+ "get": {"summary": "续期当前控制权", "security": security,
139
+ "responses": {"200": response("控制权状态", {"type": "object"}), "401": errors["401"]}},
140
+ "post": {"summary": "续期当前控制权", "security": security,
141
+ "responses": {"200": response("控制权状态", {"type": "object"}), "401": errors["401"]}}},
142
+ "/api/openapi.json": {"get": {"summary": "读取 OpenAPI 文档", "security": security,
143
+ "responses": {"200": response("OpenAPI 文档", {"type": "object"}), "401": errors["401"]}}},
144
+ "/api/health": {"get": {"summary": "读取服务健康状态", "security": security,
145
+ "responses": {"200": response("健康状态", {"type": "object"}), "401": errors["401"]}}},
146
+ "/api/state": {"get": {"summary": "读取任务状态聚合", "security": security,
147
+ "responses": {"200": response("状态聚合", {"type": "object"}), "401": errors["401"]}}},
148
+ }
149
+ return {"openapi": "3.0.3", "info": {"title": "CodeBee API", "version": "1.0",
150
+ "description": "本机/局域网 API。远程请求需 X-CodeBee-Token 或 token 查询参数;本机 loopback 默认免令牌。"},
151
+ "servers": [{"url": "/"}], "components": {"securitySchemes": {
152
+ "codebeeToken": {"type": "apiKey", "in": "header", "name": "X-CodeBee-Token"},
153
+ "codebeeQueryToken": {"type": "apiKey", "in": "query", "name": "token"}},
154
+ "schemas": schemas}, "paths": paths}
@@ -2862,6 +2862,27 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2862
2862
 
2863
2863
  refresh_actual_route(impl)
2864
2864
  workdir = task["workdir"]
2865
+ # Long-form state is a deterministic input to every chapter. Bootstrap
2866
+ # legacy serial tasks once, then refuse to write over manually edited
2867
+ # derived views until the author reconciles the source JSON.
2868
+ from . import story_tracking
2869
+ try:
2870
+ tracking_state = story_tracking.load(workdir)
2871
+ if tracking_state is None:
2872
+ story_tracking.init(workdir, task.get("id"), task.get("title"),
2873
+ premise=task.get("goal") or "")
2874
+ tracking_check = story_tracking.check(workdir)
2875
+ except Exception as exc:
2876
+ store.update_run(run_id, expected_status="running", status="failed",
2877
+ error="故事追踪状态不可用,请先修复:%s" % str(exc)[:500],
2878
+ ended_at=_now())
2879
+ return
2880
+ if not tracking_check.get("ok"):
2881
+ store.update_run(run_id, expected_status="running", status="failed",
2882
+ error="故事追踪状态存在漂移,请先修复:%s" %
2883
+ ";".join(tracking_check.get("errors") or [])[:500],
2884
+ ended_at=_now())
2885
+ return
2865
2886
  # 续会话步骤的 CLI 启动目录(稿件读写仍用 workdir)
2866
2887
  step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
2867
2888
  serial = task.get("serial") or {}
@@ -3840,6 +3861,26 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3840
3861
  chapter_scores.append(cs_new)
3841
3862
  # 每章即时持久化:长篇中断/超时后可断点续跑,不丢已完成章的分数
3842
3863
  store.update_run(run_id, chapter_scores=chapter_scores)
3864
+ # Structured story state is committed alongside the chapter. The
3865
+ # pipeline remains compatible with legacy workdirs: a tracking failure
3866
+ # is reported in the run evidence but never deletes a valid chapter.
3867
+ try:
3868
+ from . import story_tracking
3869
+ story_tracking.commit_chapter(
3870
+ workdir, i, outline_txt, _read_chapter(workdir, i),
3871
+ facts=[str(x.get("note") or "") for x in issues_all
3872
+ if isinstance(x, dict) and x.get("chapter") == i and x.get("note")],
3873
+ foreshadowing=[{"id": "F-%04d-%02d" % (i, n + 1),
3874
+ "text": str(line)[:400], "status": "active",
3875
+ "planted_chapter": i}
3876
+ for n, line in enumerate(ledger_lines[:20])],
3877
+ next_promises=[ch.get("hook") or ch.get("highlight") or ""],
3878
+ )
3879
+ except Exception as exc:
3880
+ try:
3881
+ store.update_run(run_id, story_tracking_warning=str(exc)[:300])
3882
+ except Exception:
3883
+ pass
3843
3884
  # 资源账本(借鉴角色资源账本):评审提出的道具/伤情/承诺/伏笔增量
3844
3885
  # 追加到 .codebee/resource-ledger.md,下一章起草时注入,防跨章穿帮。
3845
3886
  # 失败静默——账本是增强不是硬依赖。
@@ -0,0 +1,77 @@
1
+ """Small local agent presence registry for the operator dashboard."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import threading
6
+ import time
7
+
8
+ from . import paths
9
+
10
+ _LOCK = threading.RLock()
11
+ _FILE = paths.DATA_DIR / "agent_presence.json"
12
+ _STALE_S = 180
13
+
14
+
15
+ def _load():
16
+ try:
17
+ value = json.loads(_FILE.read_text(encoding="utf-8"))
18
+ return value if isinstance(value, dict) else {}
19
+ except Exception:
20
+ return {}
21
+
22
+
23
+ def _save(value):
24
+ _FILE.parent.mkdir(parents=True, exist_ok=True)
25
+ tmp = _FILE.with_suffix(".tmp")
26
+ tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
27
+ tmp.replace(_FILE)
28
+
29
+
30
+ def heartbeat(agent_id, label="", capabilities=None, task_id="", status="idle", metadata=None):
31
+ aid = str(agent_id or "").strip()[:100]
32
+ if not aid:
33
+ raise ValueError("agent_id 必填")
34
+ if capabilities is not None and not isinstance(capabilities, list):
35
+ raise ValueError("capabilities 必须是列表")
36
+ if status is not None and not isinstance(status, str):
37
+ raise ValueError("status 必须是文本")
38
+ now = time.time()
39
+ with _LOCK:
40
+ data = _load()
41
+ safe_meta = metadata if isinstance(metadata, dict) else {}
42
+ # Presence is a hot, user-controlled endpoint; keep the durable
43
+ # registry bounded even when an agent sends oversized diagnostics.
44
+ safe_meta = {str(k)[:80]: str(v)[:500] for k, v in list(safe_meta.items())[:30]}
45
+ data[aid] = {"agent_id": aid, "label": str(label or aid)[:120],
46
+ "capabilities": [str(x)[:80] for x in (capabilities or [])][:30],
47
+ "task_id": str(task_id or "")[:100], "status": str(status or "idle")[:30],
48
+ "metadata": safe_meta,
49
+ "last_seen": now}
50
+ _save(data)
51
+ return data[aid]
52
+
53
+
54
+ def list_agents():
55
+ now = time.time()
56
+ with _LOCK:
57
+ rows = []
58
+ for row in _load().values():
59
+ item = dict(row)
60
+ try:
61
+ last_seen = float(item.get("last_seen") or 0)
62
+ except (TypeError, ValueError):
63
+ last_seen = 0.0
64
+ age = max(0, now - last_seen)
65
+ item["age_s"] = int(age)
66
+ item["online"] = age <= _STALE_S
67
+ rows.append(item)
68
+ return sorted(rows, key=lambda x: (not x["online"], x.get("label") or ""))
69
+
70
+
71
+ def remove(agent_id):
72
+ with _LOCK:
73
+ data = _load()
74
+ existed = str(agent_id or "") in data
75
+ data.pop(str(agent_id or ""), None)
76
+ _save(data)
77
+ return existed
@@ -186,6 +186,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, force=False,
186
186
  task = store.get_task(task_id)
187
187
  if not task:
188
188
  return False, "任务不存在"
189
+ from .. import contracts
190
+ contract = contracts.get(task_id)
191
+ if ((contract and contract.get("approval_required")) or task.get("approval_required")) \
192
+ and not contracts.release_allowed(task_id):
193
+ return False, "任务需要先完成审批,才能发布产物"
189
194
  book = ledger.book_for(task_id, platform)
190
195
  if not book:
191
196
  return False, "该任务尚未在此平台建书,请先「创建作品」"
@@ -359,6 +364,12 @@ def due_tasks(now=None):
359
364
  ap = task.get("auto_publish")
360
365
  if not isinstance(ap, dict) or not ap.get("enabled"):
361
366
  continue
367
+ if ap.get("auto_submit"):
368
+ from .. import contracts
369
+ contract = contracts.get(task["id"])
370
+ if ((contract and contract.get("approval_required")) or
371
+ task.get("approval_required")) and not contracts.release_allowed(task["id"]):
372
+ continue
362
373
  if str(ap.get("time") or "") <= hhmm_now and fired.get(task["id"]) != today:
363
374
  plat = ap.get("platform")
364
375
  if plat and _book_ready(ledger.book_for(task["id"], plat)):
@@ -0,0 +1,137 @@
1
+ """Dependency-free retrieval index with traceable keyword scoring.
2
+
3
+ This is intentionally a RAG-lite layer: SQLite/vector backends can be added
4
+ later without changing the API contract. Every hit carries its source and the
5
+ terms that caused the match.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import threading
13
+ from pathlib import Path
14
+
15
+ from . import paths
16
+
17
+ _LOCK = threading.RLock()
18
+ _FILE = paths.DATA_DIR / "retrieval_index.json"
19
+ _WORD_RE = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]")
20
+ _MAX_FILES = 500
21
+ _MAX_BYTES = 20 * 1024 * 1024
22
+ _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
23
+
24
+
25
+ def _terms(text):
26
+ return [x.lower() for x in _WORD_RE.findall(str(text or ""))]
27
+
28
+
29
+ def _load():
30
+ try:
31
+ value = json.loads(_FILE.read_text(encoding="utf-8"))
32
+ return value if isinstance(value, dict) else {"documents": {}}
33
+ except Exception:
34
+ return {"version": 1, "documents": {}}
35
+
36
+
37
+ def _save(value):
38
+ _FILE.parent.mkdir(parents=True, exist_ok=True)
39
+ tmp = _FILE.with_suffix(".tmp")
40
+ tmp.write_text(json.dumps(value, ensure_ascii=False, indent=1), encoding="utf-8")
41
+ tmp.replace(_FILE)
42
+
43
+
44
+ def upsert(source, text, metadata=None, doc_id=""):
45
+ source = str(source or "").strip()[:500]
46
+ text = str(text or "").strip()
47
+ if not source or not text:
48
+ raise ValueError("source 和 text 不能为空")
49
+ root_hint = ""
50
+ if isinstance(metadata, dict):
51
+ root_hint = str(metadata.get("root") or "")[:500]
52
+ did = str(doc_id or hashlib.sha256((root_hint + "\0" + source).encode("utf-8")).hexdigest()[:20])[:100]
53
+ meta = metadata if isinstance(metadata, dict) else {}
54
+ meta = {str(k)[:80]: str(v)[:1000] for k, v in list(meta.items())[:30]}
55
+ row = {"id": did, "source": source, "text": text[:200000],
56
+ "terms": sorted(set(_terms(text))),
57
+ "metadata": meta}
58
+ with _LOCK:
59
+ data = _load()
60
+ data.setdefault("version", 1)
61
+ documents = data.setdefault("documents", {})
62
+ # Explicit IDs are client supplied; avoid silently replacing an
63
+ # unrelated document when two sources reuse the same short ID.
64
+ if did in documents and documents[did].get("source") != source:
65
+ suffix = hashlib.sha256((source + "\0" + text).encode("utf-8")).hexdigest()[:12]
66
+ did = (did[:87] + "-" + suffix)[:100]
67
+ row["id"] = did
68
+ documents[did] = row
69
+ _save(data)
70
+ return row
71
+
72
+
73
+ def index_directory(root, extensions=(".md", ".txt", ".json"), limit=500,
74
+ allowed_roots=None, max_bytes=_MAX_BYTES):
75
+ root = Path(root).expanduser().resolve()
76
+ if not root.is_dir():
77
+ raise ValueError("目录不存在")
78
+ if allowed_roots is not None:
79
+ roots = [Path(p).expanduser().resolve() for p in allowed_roots if p]
80
+ if not any(root == p or p in root.parents for p in roots):
81
+ raise ValueError("只能索引任务工作目录范围内的文件")
82
+ try:
83
+ file_limit = max(1, min(_MAX_FILES, int(limit)))
84
+ except (TypeError, ValueError):
85
+ raise ValueError("limit 必须是整数")
86
+ try:
87
+ byte_limit = max(1, min(_MAX_BYTES, int(max_bytes)))
88
+ except (TypeError, ValueError):
89
+ raise ValueError("max_bytes 必须是整数")
90
+ count = 0
91
+ total_bytes = 0
92
+ for path in root.rglob("*"):
93
+ if count >= file_limit or total_bytes >= byte_limit:
94
+ break
95
+ # Never follow a symlink while indexing user-controlled trees.
96
+ if path.is_symlink() or any(part in _SKIP_DIRS for part in path.relative_to(root).parts):
97
+ continue
98
+ if path.is_file() and path.suffix.lower() in extensions:
99
+ try:
100
+ resolved = path.resolve()
101
+ if resolved != root and root not in resolved.parents:
102
+ continue
103
+ size = path.stat().st_size
104
+ if size > byte_limit - total_bytes:
105
+ continue
106
+ upsert(str(path.relative_to(root)), path.read_text(encoding="utf-8", errors="replace"),
107
+ {"root": str(root)})
108
+ count += 1
109
+ total_bytes += size
110
+ except OSError:
111
+ pass
112
+ return {"indexed": count, "bytes": total_bytes,
113
+ "total": len(_load().get("documents") or {})}
114
+
115
+
116
+ def search(query, limit=10):
117
+ try:
118
+ n = max(1, min(50, int(limit or 10)))
119
+ except (TypeError, ValueError):
120
+ raise ValueError("limit 必须是整数")
121
+ terms = set(_terms(str(query or "")[:500]))
122
+ if not terms:
123
+ return []
124
+ with _LOCK:
125
+ docs = list((_load().get("documents") or {}).values())
126
+ rows = []
127
+ for doc in docs:
128
+ matched = sorted(terms.intersection(doc.get("terms") or []))
129
+ if not matched:
130
+ continue
131
+ score = len(matched) / max(1, len(terms))
132
+ rows.append({"id": doc.get("id"), "source": doc.get("source"),
133
+ "score": round(score, 4), "matched_terms": matched,
134
+ "snippet": str(doc.get("text") or "")[:600],
135
+ "metadata": doc.get("metadata") or {}})
136
+ rows.sort(key=lambda x: (-x["score"], x["source"] or ""))
137
+ return rows[:n]