codebee 0.1.104 → 0.1.106

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.
@@ -68,6 +68,13 @@ def document():
68
68
  "CommandAuth": {"type": "object", "properties": {
69
69
  "mode": {"type": "string", "enum": ["legacy", "restricted"]},
70
70
  "pending": {"type": "array"}, "rules": {"type": "array"}, "audit": {"type": "array"}}},
71
+ "ReviewRequest": {"type": "object", "required": ["id", "run_id", "status"],
72
+ "properties": {
73
+ "id": {"type": "string"}, "run_id": {"type": "string"},
74
+ "summary": {"type": "string"}, "paths": {"type": "array", "items": {"type": "string"}},
75
+ "channel": {"type": "string"},
76
+ "status": {"type": "string", "enum": ["requested", "assigned", "approved", "changes_requested", "dismissed", "expired"]},
77
+ "reviewer": {"type": "string"}, "reason": {"type": "string"}}},
71
78
  }
72
79
 
73
80
  def body(schema):
@@ -146,6 +153,28 @@ def document():
146
153
  "/api/command-auth/rules/{rule_id}/revoke": {
147
154
  "post": {"summary": "撤销命令授权规则", "security": security,
148
155
  "responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
156
+ "/api/reviews": {
157
+ "get": {"summary": "读取外部人工评审请求", "security": security,
158
+ "responses": {"200": response("评审请求列表", {"type": "object", "properties": {
159
+ "reviews": {"type": "array", "items": ref("ReviewRequest")}}}), "401": errors["401"]}},
160
+ "post": {"summary": "创建外部人工评审请求", "security": security,
161
+ "requestBody": body({"type": "object", "required": ["run_id"], "properties": {
162
+ "run_id": {"type": "string", "maxLength": 100}, "summary": {"type": "string", "maxLength": 1000},
163
+ "paths": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string", "maxLength": 500}}]},
164
+ "channel": {"type": "string", "maxLength": 200}}}),
165
+ "responses": {"200": response("评审请求", {"type": "object", "properties": {"review": ref("ReviewRequest")}}), **errors}}},
166
+ "/api/reviews/{review_id}/transition": {
167
+ "post": {"summary": "推进外部人工评审状态", "security": security,
168
+ "requestBody": body({"type": "object", "required": ["action"], "properties": {
169
+ "action": {"type": "string", "enum": ["assign", "approve", "changes_requested", "dismiss", "expire"]},
170
+ "reviewer": {"type": "string", "maxLength": 200}, "reason": {"type": "string", "maxLength": 1000}}}),
171
+ "responses": {"200": response("评审请求", {"type": "object", "properties": {"review": ref("ReviewRequest")}}), **errors}}},
172
+ "/api/capabilities": {"get": {"summary": "读取智能体能力清单", "security": security,
173
+ "responses": {"200": response("能力清单", {"type": "object"}), "401": errors["401"]}}},
174
+ "/api/tasks/{task_id}/memory/preview": {"get": {"summary": "预览项目记忆注入内容", "security": security,
175
+ "parameters": [{"name": "q", "in": "query", "schema": {"type": "string", "maxLength": 500}},
176
+ {"name": "cap", "in": "query", "schema": {"type": "integer", "minimum": 0, "maximum": 20000}}],
177
+ "responses": {"200": response("记忆注入预览", {"type": "object"}), "401": errors["401"], "404": errors["404"], "409": errors["409"]}}},
149
178
  "/api/retrieval/search": {"get": {"summary": "本地检索", "security": security,
150
179
  "parameters": [{"name": "q", "in": "query", "required": True,
151
180
  "schema": {"type": "string", "maxLength": 500}},
@@ -445,11 +445,20 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
445
445
  model=agent.get("model") or "",
446
446
  provider=_binding_brief(agent))
447
447
  try:
448
+ checkpoint_effects = [] if readonly else ["workspace_files"]
449
+ if agent.get("mode") != "mock":
450
+ checkpoint_effects.append("external_agent")
451
+ if agent.get("kind") in ("acp", "mcp"):
452
+ checkpoint_effects.append("external_protocol")
448
453
  checkpoints.start(
449
454
  run_id, step["n"], role, prompt,
450
455
  input_data={"workdir": workdir, "readonly": readonly,
451
456
  "agent": agent.get("id")},
452
- attempt=len((store.get_run(run_id) or {}).get("steps") or []))
457
+ attempt=len((store.get_run(run_id) or {}).get("steps") or []),
458
+ tool_call={"name": "agent_step", "agent": agent.get("id"),
459
+ "role": role, "readonly": bool(readonly)},
460
+ conversation_ref=resume or "",
461
+ side_effects=checkpoint_effects)
453
462
  except Exception:
454
463
  pass
455
464
  start = time.time()
@@ -475,7 +484,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
475
484
  "tokens": 0, "usage": None, "error": sandbox_error,
476
485
  "raw": {"exit_code": None, "sandbox_blocked": True},
477
486
  "kind": agent.get("kind", "generic"), "model": agent.get("model")}
478
- _finish_step_result(run_id, step, res, role, agent, start)
487
+ _finish_step_result(run_id, step, res, role, agent, start,
488
+ readonly=readonly)
479
489
  return res
480
490
  if dead_binding:
481
491
  from .error_codes import ErrorCode
@@ -484,7 +494,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
484
494
  "error_code": ErrorCode.ENV_BLOCK,
485
495
  "raw": {"exit_code": None}, "kind": agent.get("kind", "generic"),
486
496
  "model": agent.get("model")}
487
- _finish_step_result(run_id, step, res, role, agent, start)
497
+ _finish_step_result(run_id, step, res, role, agent, start,
498
+ readonly=readonly)
488
499
  return res
489
500
  if agent.get("mode") == "mock":
490
501
  time.sleep(0.3)
@@ -507,7 +518,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
507
518
  "raw": {"exit_code": None, "repeat_stop": True},
508
519
  "kind": agent.get("kind", "generic"),
509
520
  "model": agent.get("model")}
510
- _finish_step_result(run_id, step, res, role, agent, start)
521
+ _finish_step_result(run_id, step, res, role, agent, start,
522
+ readonly=readonly)
511
523
  return res
512
524
  # 运行中指挥:drain 用户追加的指令/附件,注入本步(守门拦截时不 drain,
513
525
  # 消息留给下一个真实步骤,不空耗);role/step_n 作送达回执
@@ -529,7 +541,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
529
541
  "error": git_checkpoint.get("error") or "文件检查点准备失败",
530
542
  "raw": {"exit_code": None, "checkpoint_blocked": True},
531
543
  "kind": agent.get("kind", "generic"), "model": agent.get("model")}
532
- _finish_step_result(run_id, step, res, role, agent, start)
544
+ _finish_step_result(run_id, step, res, role, agent, start,
545
+ readonly=readonly)
533
546
  return res
534
547
  res = _spawn_step(session_run_id=run_id, role=role, agent=agent,
535
548
  prompt=effective_prompt, workdir=workdir, readonly=readonly,
@@ -561,7 +574,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
561
574
  pass
562
575
  # 先收尾再查取消:取消时进程已被 run_process 杀停,若先抛 Cancelled,
563
576
  # 步骤记录会永远停在「运行中」变僵尸(与 _run_verify 的顺序对齐)
564
- _finish_step_result(run_id, step, res, role, agent, start)
577
+ _finish_step_result(run_id, step, res, role, agent, start,
578
+ readonly=readonly)
565
579
  if (res.get("raw") or {}).get("deadline_exceeded"):
566
580
  store.update_run(run_id, expected_status="running", status="timeout",
567
581
  ended_at=_now(), error="任务总时限已到")
@@ -793,6 +807,18 @@ def _cost_gate_block():
793
807
  return None
794
808
 
795
809
 
810
+ def _run_sandbox(run_id):
811
+ """Return a run's persisted sandbox policy, or the unrestricted default hint.
812
+
813
+ Low-level callers such as budget-gate tests may use a run id before creating
814
+ a task. The runner still enforces any task policy when one is present.
815
+ """
816
+ run = store.get_run(run_id) or {}
817
+ task_id = run.get("task_id")
818
+ task = store.get_task(task_id) if task_id else None
819
+ return (task or {}).get("sandbox") or {}
820
+
821
+
796
822
  def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
797
823
  timeout, resume, step, log_abs, images=None, require_tools=False,
798
824
  deadline=None):
@@ -832,9 +858,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
832
858
  timeout=timeout, cancel_event=ev, log_path=str(log_abs),
833
859
  images=images, require_tools=require_tools, deadline=deadline,
834
860
  command_context={"run_id": session_run_id,
835
- "sandbox": (store.get_task(
836
- (store.get_run(session_run_id) or {}).get("task_id")
837
- ) or {}).get("sandbox") or {},
861
+ "sandbox": _run_sandbox(session_run_id),
838
862
  "allow_terminal": not readonly},
839
863
  checkpoint_run_id=session_run_id)
840
864
 
@@ -866,9 +890,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
866
890
  resume=resume, images=images, require_tools=require_tools,
867
891
  deadline=deadline,
868
892
  command_context={"run_id": session_run_id,
869
- "sandbox": (store.get_task(
870
- (store.get_run(session_run_id) or {}).get("task_id")
871
- ) or {}).get("sandbox") or {},
893
+ "sandbox": _run_sandbox(session_run_id),
872
894
  "allow_terminal": not readonly},
873
895
  checkpoint_run_id=session_run_id)
874
896
  # 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
@@ -883,7 +905,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
883
905
  return res
884
906
 
885
907
 
886
- def _finish_step_result(run_id, step, res, role, agent, start):
908
+ def _finish_step_result(run_id, step, res, role, agent, start, *, readonly=None):
887
909
  """step 收尾:记录 + 用量 + 运行时断言(5F)。"""
888
910
  # 被取消杀停的步骤如实记「已取消」;超时被杀记「超时」——都只是步骤
889
911
  # 显示层的细分,run 级仍是 failed,善后路径(重试/自动续跑)不变
@@ -924,7 +946,10 @@ def _finish_step_result(run_id, step, res, role, agent, start):
924
946
  checkpoints.finish(
925
947
  run_id, step["n"], checkpoint_status,
926
948
  error=res.get("error") or "", output=res.get("text") or "",
927
- metadata={"role": role, "agent": agent.get("id") or ""})
949
+ metadata={"role": role, "agent": agent.get("id") or "",
950
+ "repropose_required": True,
951
+ "side_effects": ([] if readonly is True else ["workspace_files"]) +
952
+ (["external_agent"] if agent.get("mode") != "mock" else [])})
928
953
  except Exception:
929
954
  pass
930
955
  # 错误台账:失败/超时各记一条结构化记录(遥测与诊断包的数据源)。
@@ -1040,6 +1065,7 @@ __FILES__
1040
1065
  __CONTEXT__
1041
1066
 
1042
1067
  ## 要求
1068
+ - 思考先行:动手前先读懂相关代码、明确验收标准再改;不读懂就动手是大改与返工的主因。
1043
1069
  - 修改要最小化、聚焦任务目标;不要无关重构。
1044
1070
  - 反过度防御(借鉴 HERO:只约束你「提出」的方案,不约束你「检查」的范围)——
1045
1071
  不要给没要求的数据加哈希/校验和;不要为内部脚本铺层层边界分支;
@@ -179,7 +179,7 @@ def _relevance(row, terms):
179
179
  def active_text(workdir, *, cap=4000, query="", now=None):
180
180
  stamp = float(now if now is not None else time.time())
181
181
  try:
182
- cap = max(0, int(cap))
182
+ cap = min(20000, max(0, int(cap)))
183
183
  except (TypeError, ValueError):
184
184
  cap = 4000
185
185
  heading = "## 已批准项目记忆(内容已校验,过期条目不注入)\n\n"
@@ -204,3 +204,49 @@ def active_text(workdir, *, cap=4000, query="", now=None):
204
204
  used += cost
205
205
  text = "\n\n".join(selected)
206
206
  return (heading + text + "\n\n") if text else ""
207
+
208
+
209
+ def injection_preview(workdir, *, cap=4000, query="", now=None):
210
+ """Explain exactly which approved memory entries would be injected.
211
+
212
+ The returned text is the same bounded content as ``active_text``; metadata
213
+ is deliberately separate so callers can render provenance without putting
214
+ extra bookkeeping into the model context.
215
+ """
216
+ stamp = float(now if now is not None else time.time())
217
+ try:
218
+ cap = min(20000, max(0, int(cap)))
219
+ except (TypeError, ValueError):
220
+ cap = 4000
221
+ heading = "## 已批准项目记忆(内容已校验,过期条目不注入)\n\n"
222
+ body_cap = max(0, cap - len(heading) - 2)
223
+ terms = _query_terms(query)
224
+ candidates = []
225
+ for row in list_entries(workdir, now=stamp):
226
+ if row.get("status") != "approved" or row.get("expired") or not row.get("verified"):
227
+ continue
228
+ block = "### %s\n%s" % (row.get("title") or "项目记忆", row.get("content") or "")
229
+ candidates.append((row, block))
230
+ candidates.sort(key=lambda item: (_relevance(item[0], terms),
231
+ item[0].get("feedback_score", 0),
232
+ item[0].get("created_at", 0)), reverse=True)
233
+ selected, used = [], 0
234
+ for row, block in candidates:
235
+ cost = len(block) + (2 if selected else 0)
236
+ if used + cost > body_cap:
237
+ continue
238
+ selected.append((row, block))
239
+ used += cost
240
+ return {
241
+ "text": (heading + "\n\n".join(block for _row, block in selected) + "\n\n"
242
+ if selected else ""),
243
+ "entries": [{"id": row.get("id"), "title": row.get("title"),
244
+ "source_run": row.get("source_run", ""),
245
+ "version": int(row.get("version") or 1),
246
+ "content_sha256": row.get("content_sha256", ""),
247
+ "feedback_score": int(row.get("feedback_score") or 0)}
248
+ for row, _block in selected],
249
+ "budget": {"cap": cap, "used": len((heading + "\n\n".join(
250
+ block for _row, block in selected) + "\n\n") if selected else "")},
251
+ "query": str(query or "")[:500],
252
+ }
@@ -215,6 +215,67 @@ def pending(task_id, platform):
215
215
  return out, ""
216
216
 
217
217
 
218
+ def chapter_review_states(task, items, quality, hard_ok, hard_reason="", direct_ok=True):
219
+ """逐章最新评审证据与实际发布门禁同时展示,不把单章通过当作放行。"""
220
+ from .. import store, quality_gate
221
+ try:
222
+ reviewed_through = int(quality.get("reviewed_through") or 0)
223
+ except (ValueError, TypeError):
224
+ reviewed_through = 0
225
+ runs = []
226
+ for tid in store.serial_chain_ids(task.get("id")):
227
+ runs.extend(store.task_runs(tid) or [])
228
+ evidence = {}
229
+ for run in sorted(runs, key=lambda r: str(r.get("id") or ""), reverse=True):
230
+ verdict = run.get("verdict") if run.get("status") == "done" else None
231
+ if not isinstance(verdict, dict):
232
+ continue
233
+ for score in verdict.get("chapter_scores") or []:
234
+ if not isinstance(score, dict):
235
+ continue
236
+ try:
237
+ n = int(score.get("chapter") or 0)
238
+ except (ValueError, TypeError):
239
+ continue
240
+ if n > 0 and n not in evidence:
241
+ evidence[n] = (score, run)
242
+ for item in items:
243
+ score, run = evidence.get(item["chapter_no"], ({}, {}))
244
+ state, reason = "pending", "尚无有效单章评审"
245
+ try:
246
+ valid = (score.get("passed") is True
247
+ and int(score.get("reviewer_count") or 0) >= quality_gate.MIN_REVIEWERS
248
+ and score.get("reviewer_consensus") is True)
249
+ except (ValueError, TypeError):
250
+ valid = False
251
+ if score.get("passed") is False:
252
+ state, reason = "failed", "单章质量评审未通过"
253
+ elif score and not valid:
254
+ reason = "有效评审人数不足或未形成共识"
255
+ elif valid:
256
+ state, reason = "passed_blocked", "单章已过审,发布门禁未过"
257
+ if not hard_ok:
258
+ reason = hard_reason
259
+ elif not quality.get("allowed"):
260
+ reason = ";".join(quality.get("blockers") or []) or reason
261
+ elif item["chapter_no"] > reviewed_through:
262
+ reason = "目标章节超出当前发布评审版本"
263
+ elif not direct_ok:
264
+ reason = "平台发布流程尚未校准,可人工确认发布,暂不可自动直发"
265
+ else:
266
+ state, reason = "ready", "单章及发布门禁均已通过"
267
+ try:
268
+ reviewed_at = time.mktime(time.strptime(run.get("ended_at") or "", "%Y-%m-%d %H:%M:%S"))
269
+ if os.path.getmtime(os.path.join(task.get("workdir") or "", item["file"])) > reviewed_at + 1:
270
+ state, reason = "stale", "正文在评审后修改,需要重新评审"
271
+ except (OSError, ValueError, TypeError):
272
+ pass
273
+ item.update(review_status=state, review_reason=reason,
274
+ review_passed=valid and state != "stale",
275
+ publish_ready=state == "ready", review_run_id=run.get("id") or "")
276
+ return items
277
+
278
+
218
279
  def status(task_id):
219
280
  """前端视图:待发清单 + 护栏状态 + 自动发布进度。
220
281
 
@@ -233,6 +294,7 @@ def status(task_id):
233
294
  if not _book_ready(info):
234
295
  hard_ok, hard_why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
235
296
  blockers = []
297
+ quality = {"allowed": False, "blockers": []}
236
298
  if task and _book_ready(info):
237
299
  quality = manager._quality_release_guard(task, plat, action="publish")
238
300
  if not quality.get("allowed"):
@@ -240,15 +302,24 @@ def status(task_id):
240
302
  ok, why = hard_ok, hard_why
241
303
  if ok and blockers:
242
304
  ok, why = False, "质量门禁拦截:%s" % ";".join(blockers)
305
+ direct_ok = calibrated(plat)
306
+ chapter_review_states(task or {}, pend, quality, hard_ok, hard_why, direct_ok)
307
+ published = ledger.published_chapters(task_id, plat)
308
+ drafted = ledger.drafted_chapters(task_id, plat) - published
243
309
  books.append({"platform": plat, "bound": True,
244
310
  "title": info.get("title") or "",
245
311
  "pending": len(pend),
246
- "items": pend[:200],
312
+ "items": pend,
313
+ "published_latest": max(published, default=0),
314
+ "next_chapter": pend[0]["chapter_no"] if pend else None,
315
+ "ready_count": sum(p["publish_ready"] for p in pend),
316
+ "review_passed_count": sum(p["review_passed"] for p in pend),
317
+ "drafted_nos": sorted(drafted),
247
318
  "guard_ok": ok, "guard_reason": why,
248
319
  "draft_ok": hard_ok, "draft_reason": hard_why,
249
320
  "quality_blockers": blockers,
250
- "drafted": len(ledger.drafted_chapters(task_id, plat)),
251
- "calibrated": calibrated(plat)})
321
+ "drafted": len(drafted),
322
+ "calibrated": direct_ok})
252
323
  run = _running.get(task_id) or None
253
324
  if run:
254
325
  run = dict(run)
@@ -102,6 +102,11 @@ def published_chapters(task_id, platform):
102
102
  out.add(int(n))
103
103
  try:
104
104
  ent = book_for(task_id, platform) or {}
105
+ # 用户确认的连续发布进度独立于平台数行结果;跨卷校准漏行不能
106
+ # 让已发布的旧章重新进入待发。它是章号边界,不是平台章节数量。
107
+ through = int(ent.get("confirmed_published_through") or 0)
108
+ if 0 < through <= 100000:
109
+ out.update(range(1, through + 1))
105
110
  for n in (ent.get("remote_published_nos") or []):
106
111
  n = int(n)
107
112
  if n > 0:
@@ -99,6 +99,7 @@ def _quality_release_guard(task, plat, action="publish", target_chapter=None,
99
99
  decision["version_snapshot"] = quality_gate.local_version(task.get("workdir") or "")
100
100
  decision["platform_snapshot"] = ledger.book_for(task.get("id"), plat) or {}
101
101
  if verdict:
102
+ decision["reviewed_through"] = verdict.get("end_chapter") or 0
102
103
  decision["quality_status"] = verdict.get("quality_status") or "continue_only"
103
104
  decision["review_run_id"] = run.get("id")
104
105
  return decision
@@ -720,7 +721,10 @@ def register_book(task_id, plat, title, book_id=""):
720
721
  or old.get("title") != title[:120]):
721
722
  ledger.update_book(task_id, plat, remote_total=None, remote_published=None,
722
723
  remote_review=None, remote_rejected=None,
723
- remote_synced_at=None, remote_error=None)
724
+ remote_synced_at=None, remote_error=None,
725
+ remote_published_nos=None,
726
+ confirmed_published_through=None,
727
+ confirmed_published_at=None)
724
728
  ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
725
729
  "title": title[:120], "source": "manual"})
726
730
  return True, ""
@@ -22,6 +22,41 @@ MOCK_AGENTS = [
22
22
  _LOCK = threading.RLock()
23
23
 
24
24
 
25
+ def capability_manifest(entry):
26
+ """Return one explicit, UI-safe capability contract for a backend.
27
+
28
+ The manifest is descriptive only: it never grants a backend extra access.
29
+ Execution remains governed by the task contract, sandbox policy and command
30
+ authorization gates.
31
+ """
32
+ entry = entry or {}
33
+ orch = entry.get("orch") or {}
34
+ kind = str(orch.get("kind") or "")
35
+ transport = "acp" if kind == "acp" or str(entry.get("id") or "").startswith("acp-") else "cli"
36
+ resume = bool(orch.get("resume_argv_template"))
37
+ if kind == "mock":
38
+ transport = "mock"
39
+ resume = False
40
+ platforms = entry.get("platforms")
41
+ if not isinstance(platforms, list) or not platforms:
42
+ platforms = ["windows", "linux", "macos"]
43
+ platforms = [str(x) for x in platforms if str(x) in ("windows", "linux", "macos")]
44
+ if not platforms:
45
+ platforms = ["windows", "linux", "macos"]
46
+ declared = entry.get("capabilities") if isinstance(entry.get("capabilities"), dict) else {}
47
+ return {
48
+ "id": str(entry.get("id") or ""),
49
+ "transport": transport,
50
+ "resume": resume,
51
+ "sandbox": str(declared.get("sandbox") or "task-policy"),
52
+ "approval": str(declared.get("approval") or "task-contract"),
53
+ "mcp": bool(declared.get("mcp", kind in ("acp", "mcp"))),
54
+ "platforms": platforms,
55
+ "model_tiers": [str(x) for x in (declared.get("model_tiers") or
56
+ ["budget", "standard", "premium"])[:8]],
57
+ }
58
+
59
+
25
60
  def load_enabled():
26
61
  try:
27
62
  return json.loads(paths.ENABLED_FILE.read_text(encoding="utf-8"))
@@ -0,0 +1,119 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Append-only external human-review requests with idempotent transitions."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import secrets
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from . import paths
12
+
13
+ _LOCK = threading.RLock()
14
+ _FILE = paths.DATA_DIR / "review-requests.jsonl"
15
+ _STATUSES = {"requested", "assigned", "approved", "changes_requested", "dismissed", "expired"}
16
+ _TRANSITIONS = {
17
+ "assign": ("requested", "assigned"),
18
+ "approve": ("assigned", "approved"),
19
+ "changes_requested": ("assigned", "changes_requested"),
20
+ "dismiss": (("requested", "assigned", "changes_requested"), "dismissed"),
21
+ "expire": (("requested", "assigned", "changes_requested"), "expired"),
22
+ }
23
+
24
+
25
+ def _normalize_paths(value):
26
+ if isinstance(value, str):
27
+ value = [value]
28
+ if not isinstance(value, (list, tuple, set)):
29
+ return []
30
+ return [str(item).strip()[:500] for item in value
31
+ if str(item or "").strip()][:200]
32
+
33
+
34
+ def _read():
35
+ latest = {}
36
+ try:
37
+ lines = Path(_FILE).read_text(encoding="utf-8").splitlines()[-5000:]
38
+ except OSError:
39
+ return latest
40
+ for line in lines:
41
+ try:
42
+ row = json.loads(line)
43
+ except (TypeError, ValueError):
44
+ continue
45
+ if isinstance(row, dict) and row.get("id"):
46
+ latest[row["id"]] = row
47
+ return latest
48
+
49
+
50
+ def list_requests():
51
+ """Return the latest version of each request in append-only storage."""
52
+ with _LOCK:
53
+ return list(_read().values())
54
+
55
+
56
+ def _append(row):
57
+ path = Path(_FILE)
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ with path.open("a", encoding="utf-8") as handle:
60
+ handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
61
+ handle.flush()
62
+
63
+
64
+ def route_channel(paths_changed, rules=None, default_channel=""):
65
+ """Select the channel with the most matching changed files."""
66
+ paths_changed = _normalize_paths(paths_changed)
67
+ rules = [r for r in (rules or []) if isinstance(r, dict)]
68
+ best, best_count = "", 0
69
+ import fnmatch
70
+ for rule in rules:
71
+ channel = str(rule.get("channel") or "").strip()
72
+ patterns = _normalize_paths(rule.get("paths"))
73
+ count = sum(1 for path in paths_changed if any(fnmatch.fnmatchcase(path, str(p)) for p in patterns))
74
+ if channel and count > best_count:
75
+ best, best_count = channel, count
76
+ return best or str(default_channel or "")[:200]
77
+
78
+
79
+ def create(run_id, *, summary="", paths=None, channel="", now=None):
80
+ if not str(run_id or "").strip():
81
+ raise ValueError("run_id 不能为空")
82
+ stamp = float(now if now is not None else time.time())
83
+ row = {"id": "review-" + secrets.token_hex(10), "run_id": str(run_id or "")[:100],
84
+ "summary": str(summary or "")[:1000],
85
+ "paths": _normalize_paths(paths),
86
+ "channel": str(channel or "")[:200], "status": "requested",
87
+ "version": 1, "created_at": stamp, "updated_at": stamp}
88
+ with _LOCK:
89
+ _append(row)
90
+ return row
91
+
92
+
93
+ def get(request_id):
94
+ with _LOCK:
95
+ return _read().get(str(request_id or ""))
96
+
97
+
98
+ def transition(request_id, action, *, reviewer="", reason="", now=None):
99
+ with _LOCK:
100
+ row = _read().get(str(request_id or ""))
101
+ if not row:
102
+ raise KeyError("评审请求不存在")
103
+ action = str(action or "").strip().lower()
104
+ if action not in _TRANSITIONS:
105
+ raise ValueError("不支持的评审状态操作")
106
+ allowed, target = _TRANSITIONS[action]
107
+ if row.get("status") == target:
108
+ return row
109
+ if row.get("status") not in allowed:
110
+ raise ValueError("当前评审状态不允许此操作")
111
+ stamp = float(now if now is not None else time.time())
112
+ updated = dict(row, status=target, version=int(row.get("version") or 1) + 1,
113
+ updated_at=stamp)
114
+ if reviewer:
115
+ updated["reviewer"] = str(reviewer)[:200]
116
+ if reason:
117
+ updated["reason"] = str(reason)[:1000]
118
+ _append(updated)
119
+ return updated
@@ -1775,7 +1775,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1775
1775
  bash = find_git_bash()
1776
1776
  if bash:
1777
1777
  base_env.setdefault("CLAUDE_CODE_GIT_BASH_PATH", bash)
1778
- base_env.setdefault("CLAUDE_CODE_MAX_OUTPUT_TOKENS", "16000")
1778
+ # 32000=claude CLI 官方默认(自定义网关模型常见上限):旧值 16000 在
1779
+ # 全局评审读 40 万字书稿时被输出顶爆——CLI 按 16000/轮反复续烧 23.5
1780
+ # 分钟、$3 后报 "response exceeded the 16000 output token maximum"
1781
+ # (2026-10-11 实案×3);setdefault 保留:用户显式配置不覆盖
1782
+ base_env.setdefault("CLAUDE_CODE_MAX_OUTPUT_TOKENS", "32000")
1779
1783
  sid = (resume or "").strip() if agent.get("mode") == "real" else ""
1780
1784
  if sid and kind == "generic" and not agent.get("resume_argv_template"):
1781
1785
  return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
@@ -0,0 +1,75 @@
1
+ """One service per data directory, shared by CLI and desktop launchers."""
2
+ from __future__ import annotations
3
+
4
+ import atexit
5
+ import os
6
+ from pathlib import Path
7
+ from typing import IO, Optional
8
+
9
+
10
+ class ServiceAlreadyRunning(RuntimeError):
11
+ """Another process owns this data directory."""
12
+
13
+
14
+ class ServiceLease:
15
+ """OS-backed lock; a crashed process releases it automatically."""
16
+
17
+ def __init__(self, directory: Path):
18
+ self.directory = Path(directory)
19
+ self._file: Optional[IO[bytes]] = None
20
+
21
+ def acquire(self) -> None:
22
+ if self._file is not None:
23
+ return
24
+ self.directory.mkdir(parents=True, exist_ok=True)
25
+ # Never unlink this file: replacing its inode defeats POSIX file locks.
26
+ handle = (self.directory / "service.lock").open("a+b")
27
+ try:
28
+ if os.name == "nt":
29
+ import msvcrt
30
+ handle.seek(0, os.SEEK_END)
31
+ if handle.tell() == 0:
32
+ handle.write(b"0")
33
+ handle.flush()
34
+ handle.seek(0)
35
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
36
+ else:
37
+ import fcntl
38
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
39
+ except OSError as exc:
40
+ handle.close()
41
+ raise ServiceAlreadyRunning(
42
+ "CodeBee 已在使用此数据目录,请先退出已有服务:%s" % self.directory
43
+ ) from exc
44
+ self._file = handle
45
+
46
+ def release(self) -> None:
47
+ handle, self._file = self._file, None
48
+ if handle is not None:
49
+ # Closing the handle releases both flock and Windows byte locks.
50
+ handle.close()
51
+ key = str(self.directory.resolve())
52
+ if _LEASES.get(key) is self:
53
+ _LEASES.pop(key, None)
54
+
55
+ def __enter__(self) -> "ServiceLease":
56
+ self.acquire()
57
+ return self
58
+
59
+ def __exit__(self, *args: object) -> None:
60
+ self.release()
61
+
62
+
63
+ _LEASES = {}
64
+
65
+
66
+ def acquire_service(directory: Path) -> ServiceLease:
67
+ """Keep a service lease until process exit; repeated startup calls reuse it."""
68
+ key = str(Path(directory).resolve())
69
+ lease = _LEASES.get(key)
70
+ if lease is None or lease._file is None:
71
+ lease = ServiceLease(Path(key))
72
+ lease.acquire()
73
+ _LEASES[key] = lease
74
+ atexit.register(lease.release)
75
+ return lease