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.
- package/CHANGELOG.md +17 -0
- package/README.md +10 -4
- package/app/core/activity.py +36 -0
- package/app/core/builtin_agent.py +6 -1
- package/app/core/checkpoints.py +48 -5
- package/app/core/jobs.py +7 -0
- package/app/core/manager.py +1 -0
- package/app/core/market_remote.py +19 -7
- package/app/core/mcp_client.py +102 -63
- package/app/core/openapi.py +29 -0
- package/app/core/pipeline.py +40 -14
- package/app/core/project_memory.py +47 -1
- package/app/core/publish/auto.py +74 -3
- package/app/core/publish/ledger.py +5 -0
- package/app/core/publish/manager.py +5 -1
- package/app/core/registry.py +35 -0
- package/app/core/review_requests.py +119 -0
- package/app/core/runner.py +5 -1
- package/app/core/runtime_lock.py +75 -0
- package/app/core/selfupdate.py +8 -0
- package/app/core/store.py +32 -2
- package/app/core/usage.py +31 -26
- package/app/core/webhooks.py +17 -13
- package/app/desktop_host.py +190 -0
- package/app/main.py +131 -4
- package/app/pet.py +1 -1
- package/app/ui/activity.css +34 -0
- package/app/ui/activity.js +199 -0
- package/app/ui/app.js +813 -66
- package/app/ui/hive3d.js +214 -72
- package/app/ui/i18n.js +162 -2
- package/app/ui/index.html +155 -75
- package/app/ui/style.css +583 -26
- package/package.json +1 -1
package/app/core/openapi.py
CHANGED
|
@@ -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}},
|
package/app/core/pipeline.py
CHANGED
|
@@ -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": (
|
|
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": (
|
|
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
|
+
}
|
package/app/core/publish/auto.py
CHANGED
|
@@ -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
|
|
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(
|
|
251
|
-
"calibrated":
|
|
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, ""
|
package/app/core/registry.py
CHANGED
|
@@ -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
|
package/app/core/runner.py
CHANGED
|
@@ -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
|
-
|
|
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
|