codebee 0.1.66 → 0.1.67
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 +13 -1
- package/app/core/acceptance.py +100 -0
- package/app/core/assets.py +110 -0
- package/app/core/attachments.py +92 -1
- package/app/core/bookmeta.py +160 -26
- package/app/core/defectretro.py +43 -0
- package/app/core/flows.py +47 -1
- package/app/core/jobs.py +20 -4
- package/app/core/knowledge.py +50 -3
- package/app/core/manager.py +7 -0
- package/app/core/market_remote.py +124 -26
- package/app/core/modelhub.py +29 -0
- package/app/core/operations.py +228 -0
- package/app/core/paihang.py +58 -29
- package/app/core/pipeline.py +205 -11
- package/app/core/planner.py +4 -1
- package/app/core/publish/browser.py +6 -0
- package/app/core/publish/fanqie.py +31 -0
- package/app/core/publish/flow.py +9 -1
- package/app/core/publish/ledger.py +7 -2
- package/app/core/publish/manager.py +91 -22
- package/app/core/revisions.py +51 -0
- package/app/core/runner.py +27 -12
- package/app/core/skills.py +94 -1
- package/app/core/store.py +207 -11
- package/app/core/volumes.py +34 -0
- package/app/core/zentao.py +21 -3
- package/app/main.py +140 -21
- package/app/ui/app.js +407 -17
- package/app/ui/artdiff.js +74 -0
- package/app/ui/i18n.js +51 -1
- package/app/ui/index.html +6 -0
- package/app/ui/style.css +96 -24
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Small, content-addressed revision helpers.
|
|
3
|
+
|
|
4
|
+
The execution layer stores revision identifiers and digests instead of copying
|
|
5
|
+
skill/knowledge bodies into every run. A digest is deliberately based on the
|
|
6
|
+
semantic payload supplied by the caller, so formatting-only metadata changes do
|
|
7
|
+
not silently change the meaning of an existing run.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonical_json(value) -> str:
|
|
17
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True,
|
|
18
|
+
separators=(",", ":"), default=str)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def content_sha256(value) -> str:
|
|
22
|
+
if not isinstance(value, str):
|
|
23
|
+
value = canonical_json(value)
|
|
24
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def revision_id(prefix: str, digest: str) -> str:
|
|
28
|
+
return "%s-%s" % (str(prefix or "rev"), str(digest or "")[:16])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_revision(prefix: str, content, *, source="", created_at=None) -> dict:
|
|
32
|
+
digest = content_sha256(content)
|
|
33
|
+
return {
|
|
34
|
+
"revision_id": revision_id(prefix, digest),
|
|
35
|
+
"content_sha256": digest,
|
|
36
|
+
"source": str(source or "")[:160],
|
|
37
|
+
"created_at": created_at or time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def revision_record(prefix: str, content, *, source="", created_at=None,
|
|
42
|
+
extra=None) -> dict:
|
|
43
|
+
out = make_revision(prefix, content, source=source, created_at=created_at)
|
|
44
|
+
if isinstance(extra, dict):
|
|
45
|
+
out.update(extra)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def stable_digest(items) -> str:
|
|
50
|
+
"""Digest a list/dict of revision metadata without embedding content."""
|
|
51
|
+
return content_sha256(items)
|
package/app/core/runner.py
CHANGED
|
@@ -184,18 +184,23 @@ def _kill_tree(pid):
|
|
|
184
184
|
某些机器状态下 taskkill /F /T 对普通进程也挂死 30s+,等满 15s 超时会让每次
|
|
185
185
|
超时杀进程都卡 20s,看门狗/超时全被拖死;漏杀的孙进程由启动孤儿清扫兜底)。
|
|
186
186
|
POSIX 靠 spawn 时的 start_new_session(子进程自成一个进程组,pgid==pid)
|
|
187
|
-
用 killpg 连孙带杀。
|
|
187
|
+
用 killpg 连孙带杀。
|
|
188
|
+
|
|
189
|
+
返回是否确认整树清空(借鉴 WorkDSH 取消结算分层:kill 动作完成 ≠ 停稳):
|
|
190
|
+
Windows 以 taskkill /T 在 1.2s 内退出且返回 0 为准;POSIX 以 killpg 成功
|
|
191
|
+
为准。返回 False = 停稳未知(孙进程可能仍在),调用方据此落
|
|
192
|
+
quiescence_unknown 诊断,不改变任何终态语义。"""
|
|
188
193
|
if os.name != "nt":
|
|
189
194
|
try:
|
|
190
195
|
os.killpg(pid, signal.SIGKILL)
|
|
191
|
-
return
|
|
196
|
+
return True
|
|
192
197
|
except Exception:
|
|
193
198
|
pass
|
|
194
199
|
try:
|
|
195
|
-
os.kill(pid, signal.SIGKILL)
|
|
200
|
+
os.kill(pid, signal.SIGKILL) # 组杀失败退而杀根:子孙已不可确认
|
|
196
201
|
except Exception:
|
|
197
202
|
pass
|
|
198
|
-
return
|
|
203
|
+
return False
|
|
199
204
|
# Windows 没有 signal.SIGKILL(直接引用会 AttributeError);os.kill 带任意
|
|
200
205
|
# 信号值都走 TerminateProcess,用 SIGTERM
|
|
201
206
|
try:
|
|
@@ -211,8 +216,9 @@ def _kill_tree(pid):
|
|
|
211
216
|
stderr=subprocess.DEVNULL, close_fds=True, # 不继承被杀进程的管道句柄,否则 EOF 永不来、drain 烧满超时
|
|
212
217
|
creationflags=CREATE_NO_WINDOW)
|
|
213
218
|
tk.wait(timeout=1.2) # 健康机器 <1s;卡死就不再陪等(后台自行结束)
|
|
219
|
+
return tk.returncode == 0
|
|
214
220
|
except Exception:
|
|
215
|
-
|
|
221
|
+
return False
|
|
216
222
|
|
|
217
223
|
|
|
218
224
|
def _drain_streams(proc, t_out, t_err, *, timeout=10):
|
|
@@ -647,7 +653,8 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
647
653
|
为 timeout,而不是把它误记成普通供应商失败。
|
|
648
654
|
|
|
649
655
|
返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled,
|
|
650
|
-
deadline_exceeded, abort_marker}
|
|
656
|
+
deadline_exceeded, abort_marker};杀树后未能确认整树清空时额外带
|
|
657
|
+
quiescence_unknown=True(孙进程可能仍在,诊断用,不改变终态语义)。
|
|
651
658
|
"""
|
|
652
659
|
if shell_cmd:
|
|
653
660
|
# shell 串的解析器随平台:重定向/引号语法两边通用,只是解释器不同
|
|
@@ -736,6 +743,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
736
743
|
cancelled = timed_out = stalled = repeat_aborted = False
|
|
737
744
|
deadline_exceeded = False
|
|
738
745
|
abort_marker = None
|
|
746
|
+
tree_quiet = None # 杀树结果:None=未杀,True=确认清空,False=停稳未知
|
|
739
747
|
while True:
|
|
740
748
|
try:
|
|
741
749
|
proc.wait(timeout=0.4)
|
|
@@ -744,13 +752,13 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
744
752
|
pass
|
|
745
753
|
if cancel_event is not None and cancel_event.is_set():
|
|
746
754
|
cancelled = True
|
|
747
|
-
_kill_tree(proc.pid)
|
|
755
|
+
tree_quiet = _kill_tree(proc.pid)
|
|
748
756
|
_drain_streams(proc, t_out, t_err, timeout=10)
|
|
749
757
|
break
|
|
750
758
|
if deadline is not None and time.monotonic() >= deadline:
|
|
751
759
|
deadline_exceeded = True
|
|
752
760
|
timed_out = True
|
|
753
|
-
_kill_tree(proc.pid)
|
|
761
|
+
tree_quiet = _kill_tree(proc.pid)
|
|
754
762
|
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
755
763
|
break
|
|
756
764
|
elapsed = time.monotonic() - start_mono
|
|
@@ -763,7 +771,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
763
771
|
and elapsed < timeout + activity_timeout)
|
|
764
772
|
if not active:
|
|
765
773
|
timed_out = True
|
|
766
|
-
_kill_tree(proc.pid)
|
|
774
|
+
tree_quiet = _kill_tree(proc.pid)
|
|
767
775
|
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
768
776
|
break
|
|
769
777
|
if stall_timeout and time.time() - stamp[0] > stall_timeout:
|
|
@@ -771,7 +779,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
771
779
|
# 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
|
|
772
780
|
stalled = True
|
|
773
781
|
timed_out = True
|
|
774
|
-
_kill_tree(proc.pid)
|
|
782
|
+
tree_quiet = _kill_tree(proc.pid)
|
|
775
783
|
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
776
784
|
break
|
|
777
785
|
if aborts:
|
|
@@ -783,7 +791,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
783
791
|
repeat_aborted = True
|
|
784
792
|
abort_marker = marker
|
|
785
793
|
timed_out = True
|
|
786
|
-
_kill_tree(proc.pid)
|
|
794
|
+
tree_quiet = _kill_tree(proc.pid)
|
|
787
795
|
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
788
796
|
break
|
|
789
797
|
if repeat_aborted:
|
|
@@ -821,7 +829,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
821
829
|
stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
|
|
822
830
|
elif timed_out:
|
|
823
831
|
stderr += "\n[超时 %ss,已终止进程树]" % timeout
|
|
824
|
-
|
|
832
|
+
result = {
|
|
825
833
|
"ok": exit_code == 0 and not cancelled and not timed_out,
|
|
826
834
|
"exit_code": exit_code, "stdout": stdout, "stderr": stderr,
|
|
827
835
|
"duration": duration, "cancelled": cancelled, "timed_out": timed_out,
|
|
@@ -832,6 +840,13 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
832
840
|
"activity_timeout": activity_timeout,
|
|
833
841
|
"last_output_age": max(0.0, time.time() - stamp[0]),
|
|
834
842
|
}
|
|
843
|
+
if tree_quiet is False:
|
|
844
|
+
# 杀树完成但未确认整树清空(taskkill 卡死放弃 / killpg 失手只杀了
|
|
845
|
+
# 根):孙进程可能仍在运行。只落诊断标记,不改变 cancelled/timed_out
|
|
846
|
+
# 等任何终态语义——KEY 冷却豁免、健康惩罚仍按结构化取消标记判断。
|
|
847
|
+
result["quiescence_unknown"] = True
|
|
848
|
+
result["stderr"] += "\n[进程树未确认清空:孙进程可能仍在运行]"
|
|
849
|
+
return result
|
|
835
850
|
finally:
|
|
836
851
|
if log_fh:
|
|
837
852
|
try:
|
package/app/core/skills.py
CHANGED
|
@@ -25,7 +25,7 @@ import threading
|
|
|
25
25
|
import time
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
|
|
28
|
-
from . import paths
|
|
28
|
+
from . import paths, revisions
|
|
29
29
|
|
|
30
30
|
_LOCK = threading.RLock()
|
|
31
31
|
_FILE = paths.DATA_DIR / "skills.json"
|
|
@@ -189,6 +189,8 @@ def _load_user_pack(path):
|
|
|
189
189
|
"user": True,
|
|
190
190
|
}
|
|
191
191
|
pack["body"] = body
|
|
192
|
+
pack.update({k: revisions.make_revision("packrev", body, source=key)[k]
|
|
193
|
+
for k in ("revision_id", "content_sha256")})
|
|
192
194
|
with _LOCK:
|
|
193
195
|
_user_pack_cache[key] = (mt, pack)
|
|
194
196
|
return pack
|
|
@@ -265,6 +267,38 @@ def list_packs():
|
|
|
265
267
|
return out
|
|
266
268
|
|
|
267
269
|
|
|
270
|
+
def revision_for_task(task) -> dict:
|
|
271
|
+
"""Return the skill inputs selected for a task as a compact revision set."""
|
|
272
|
+
scope = str((task or {}).get("type") or "*")
|
|
273
|
+
packs = []
|
|
274
|
+
for pack in all_packs():
|
|
275
|
+
if scope not in (pack.get("scopes") or ["*"]) and "*" not in (pack.get("scopes") or []):
|
|
276
|
+
continue
|
|
277
|
+
if not _pack_enabled(pack.get("id")):
|
|
278
|
+
continue
|
|
279
|
+
body = pack_text(pack)
|
|
280
|
+
rev = revisions.make_revision("packrev", body, source=pack.get("id") or "")
|
|
281
|
+
packs.append({"id": pack.get("id") or "", "revision_id": rev["revision_id"],
|
|
282
|
+
"content_sha256": rev["content_sha256"]})
|
|
283
|
+
lessons = []
|
|
284
|
+
for lesson in list_lessons(scope=scope, only_enabled=True):
|
|
285
|
+
current_digest = revisions.content_sha256(lesson.get("content") or "")
|
|
286
|
+
stored_digest = str(lesson.get("content_sha256") or "")
|
|
287
|
+
drift = bool(stored_digest and stored_digest != current_digest)
|
|
288
|
+
lessons.append({"id": lesson.get("id") or "",
|
|
289
|
+
"revision_id": (lesson.get("revision_id")
|
|
290
|
+
if stored_digest == current_digest else
|
|
291
|
+
revisions.revision_id("skrev", current_digest)) or
|
|
292
|
+
revisions.revision_id("skrev", current_digest),
|
|
293
|
+
"content_sha256": current_digest,
|
|
294
|
+
"revision_drift": drift})
|
|
295
|
+
payload = {"scope": scope, "packs": packs, "lessons": lessons}
|
|
296
|
+
digest = revisions.stable_digest(payload)
|
|
297
|
+
return {"revision_id": revisions.revision_id("skills", digest),
|
|
298
|
+
"content_sha256": digest, "scope": scope,
|
|
299
|
+
"packs": packs, "lessons": lessons}
|
|
300
|
+
|
|
301
|
+
|
|
268
302
|
def _pack_enabled(pid):
|
|
269
303
|
with _LOCK:
|
|
270
304
|
st = (_load().get("packs") or {}).get(pid) or {}
|
|
@@ -364,7 +398,16 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
|
|
|
364
398
|
break
|
|
365
399
|
if hit is not None:
|
|
366
400
|
it = hit
|
|
401
|
+
old_content = str(it.get("content") or "")
|
|
402
|
+
new_rev = revisions.make_revision("skrev", content, source=source)
|
|
403
|
+
if old_content and old_content != content:
|
|
404
|
+
it.setdefault("revisions", []).append(
|
|
405
|
+
revisions.revision_record(
|
|
406
|
+
"skrev", old_content, source=it.get("source") or "",
|
|
407
|
+
extra={"content": old_content}))
|
|
408
|
+
it["revisions"] = it["revisions"][-5:]
|
|
367
409
|
it["content"] = content
|
|
410
|
+
it.update({k: new_rev[k] for k in ("revision_id", "content_sha256")})
|
|
368
411
|
it["seen"] = int(it.get("seen") or 1) + 1
|
|
369
412
|
it["updated_at"] = _now()
|
|
370
413
|
# 合并时不降级已有分类:除非本次归到了明确类别,或该条原本没有分类
|
|
@@ -374,6 +417,7 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
|
|
|
374
417
|
it["source"] = source
|
|
375
418
|
_save(data)
|
|
376
419
|
return it
|
|
420
|
+
rev = revisions.make_revision("skrev", content, source=source)
|
|
377
421
|
it = {"id": lid, "scope": scope, "title": title, "content": content,
|
|
378
422
|
"source": source, "hits": 0, "seen": 1, "enabled": True,
|
|
379
423
|
"category": cat,
|
|
@@ -381,6 +425,7 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
|
|
|
381
425
|
# 稳定 token(procedure=程序性做法 / lesson=规避性教训);
|
|
382
426
|
# 展示层 view() 翻译成「做法/教训」,持久层不用中文防改文案伤数据
|
|
383
427
|
"kind": ("procedure" if title.startswith("做法:") else "lesson")}
|
|
428
|
+
it.update({k: rev[k] for k in ("revision_id", "content_sha256")})
|
|
384
429
|
items.append(it)
|
|
385
430
|
_save(data)
|
|
386
431
|
return it
|
|
@@ -452,6 +497,54 @@ def relevance_top(lessons, task, limit):
|
|
|
452
497
|
return sorted(lessons, key=rank)[:limit]
|
|
453
498
|
|
|
454
499
|
|
|
500
|
+
def evolve_lessons(min_hits=6, min_karma=0.75, min_won=1, max_items=10):
|
|
501
|
+
"""教训半自动晋升技能包(ECC /evolve 借鉴):把被任务面反复验证的教训
|
|
502
|
+
(注入 ≥min_hits、可信度下界 ≥min_karma、成功归因 ≥min_won)聚成一份
|
|
503
|
+
用户包**草稿**写入 data/skillpacks/evolve-<时间戳>.md。
|
|
504
|
+
|
|
505
|
+
草稿默认停用(packs 状态写 enabled=False)——晋升必须过人工审阅这道闸:
|
|
506
|
+
技能库页审后启用/改 scopes/编辑正文;不自动生效,避免聚合噪音直接进
|
|
507
|
+
全量任务注入。返回 (文件名, 收录条数);无可晋升教训返回 (None, 0)。"""
|
|
508
|
+
with _LOCK:
|
|
509
|
+
items = list((_load().get("lessons") or []))
|
|
510
|
+
picked = [x for x in items
|
|
511
|
+
if x.get("enabled", True)
|
|
512
|
+
and int(x.get("hits") or 0) >= min_hits
|
|
513
|
+
and int(x.get("won") or 0) >= min_won
|
|
514
|
+
and _karma(x) >= min_karma]
|
|
515
|
+
picked.sort(key=lambda x: (-_karma(x), -int(x.get("hits") or 0)))
|
|
516
|
+
picked = picked[:max_items]
|
|
517
|
+
if not picked:
|
|
518
|
+
return None, 0
|
|
519
|
+
scopes = sorted({str(x.get("scope") or "*") for x in picked})
|
|
520
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
521
|
+
fname = "evolve-%s.md" % stamp
|
|
522
|
+
lines = ["# 晋升自项目教训(evolve 草稿,待人工审阅启用)", "",
|
|
523
|
+
"以下教训经任务面反复验证聚合而成;逐条确认后可编辑删改,"
|
|
524
|
+
"确认无误再在技能库页启用本包。", ""]
|
|
525
|
+
for x in picked:
|
|
526
|
+
lines.append("- **%s**(可信度 %.0f%%,注入 %d 次/成功归因 %d 次):%s"
|
|
527
|
+
% (x.get("title") or x.get("id"),
|
|
528
|
+
_karma(x) * 100, int(x.get("hits") or 0),
|
|
529
|
+
int(x.get("won") or 0), x.get("content") or ""))
|
|
530
|
+
fm = ["---",
|
|
531
|
+
"name: 教训晋升草稿·%s" % time.strftime("%Y-%m-%d %H:%M"),
|
|
532
|
+
"scopes:"] + [" - %s" % s for s in scopes] + [
|
|
533
|
+
"note: evolve 自动聚合的高可信教训草稿(默认停用,审阅后启用)",
|
|
534
|
+
"---", ""]
|
|
535
|
+
d = _user_pack_dir()
|
|
536
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
537
|
+
(d / fname).write_text("\n".join(fm + lines) + "\n", encoding="utf-8")
|
|
538
|
+
# 草稿停用态:pid 与 _load_user_pack 的 path.stem 哈希口径一致
|
|
539
|
+
stem = fname[:-3] if fname.endswith(".md") else fname
|
|
540
|
+
pid = "user-" + hashlib.sha256(stem.encode("utf-8")).hexdigest()[:10]
|
|
541
|
+
with _LOCK:
|
|
542
|
+
data = _load()
|
|
543
|
+
data.setdefault("packs", {})[pid] = {"enabled": False, "evolved": True}
|
|
544
|
+
_save(data)
|
|
545
|
+
return fname, len(picked)
|
|
546
|
+
|
|
547
|
+
|
|
455
548
|
# 运行级注入登记(outcome 加权用):run_id → 注入的教训 id 列表。
|
|
456
549
|
# 容量有界防泄漏;run 收尾 learn_from_run 时消费清除。
|
|
457
550
|
_INJECTED = {}
|
package/app/core/store.py
CHANGED
|
@@ -160,6 +160,19 @@ def create_task(payload):
|
|
|
160
160
|
thinking = str(payload.get("thinking") or "standard").strip().lower()
|
|
161
161
|
task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
|
|
162
162
|
task["timeout_s"] = _task_timeout_s(payload.get("timeout_s"))
|
|
163
|
+
# 流程修订出处(借鉴 WorkDSH ADR-0010):创建时钉住当时流程定义的指纹。
|
|
164
|
+
# 固化参数已保证「改流程不影响本任务」,digest 再补上可对账的一面——
|
|
165
|
+
# 续跑/重试时若流程已改,flows.flow_drift 能给出明确诊断而非静默换新。
|
|
166
|
+
flow_content_sha = flows_mod.flow_content_sha256(flow)
|
|
167
|
+
task["flow_snapshot"] = {"id": flow["id"], "name": (flow.get("name") or "")[:40],
|
|
168
|
+
"engine": flow["engine"],
|
|
169
|
+
"edited": bool(flow.get("edited")),
|
|
170
|
+
"digest": flow_content_sha[:16],
|
|
171
|
+
"revision_id": "flowrev-" + flow_content_sha[:16],
|
|
172
|
+
"content_sha256": flow_content_sha}
|
|
173
|
+
# 参数修订号(借鉴 WorkDSH 契约纪律 expectedRevision):写接口据此做过期
|
|
174
|
+
# 写入检测。老任务无此键按 1 处理;不带 expected_rev 的调用不受影响。
|
|
175
|
+
task["rev"] = 1
|
|
163
176
|
if flow["engine"] == "direct":
|
|
164
177
|
task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
|
|
165
178
|
"direct_provider_id")[:80]
|
|
@@ -312,8 +325,20 @@ def create_task(payload):
|
|
|
312
325
|
att_ids = payload.get("attachments")
|
|
313
326
|
if isinstance(att_ids, list) and att_ids:
|
|
314
327
|
from . import attachments as att_mod
|
|
315
|
-
|
|
316
|
-
if
|
|
328
|
+
dict_items = [a for a in att_ids if isinstance(a, dict) and a.get("path")]
|
|
329
|
+
if dict_items:
|
|
330
|
+
root = Path(str(wd)).resolve()
|
|
331
|
+
items = []
|
|
332
|
+
for raw in dict_items[:att_mod.MAX_FILES]:
|
|
333
|
+
rel = att_mod.norm_rel(raw)
|
|
334
|
+
fp = (root / rel).resolve() if rel else root / "__invalid__"
|
|
335
|
+
if rel and root in fp.parents and fp.is_file():
|
|
336
|
+
item = dict(raw)
|
|
337
|
+
item["path"] = rel
|
|
338
|
+
items.append(item)
|
|
339
|
+
if not items:
|
|
340
|
+
raise ValueError("附件路径必须位于工作目录的 _attachments/ 内")
|
|
341
|
+
else: # 纯 id 形态 → 从待提交区移入工作目录
|
|
317
342
|
try:
|
|
318
343
|
items = att_mod.commit_to_workdir(
|
|
319
344
|
str(wd), [str(x) for x in att_ids][:att_mod.MAX_FILES])
|
|
@@ -321,6 +346,18 @@ def create_task(payload):
|
|
|
321
346
|
raise ValueError("附件落盘失败: %s" % e)
|
|
322
347
|
if items:
|
|
323
348
|
task["attachments"] = items
|
|
349
|
+
task["asset_refs"] = att_mod.asset_refs(str(wd), items)
|
|
350
|
+
try:
|
|
351
|
+
from . import assets
|
|
352
|
+
for ref in task["asset_refs"]:
|
|
353
|
+
saved = assets.register(str(wd), ref.get("path"),
|
|
354
|
+
owner_type="task", owner_id=task["id"],
|
|
355
|
+
name=ref.get("name"), mime=ref.get("mime"))
|
|
356
|
+
if saved:
|
|
357
|
+
ref.update({k: saved[k] for k in ("asset_id", "revision_id",
|
|
358
|
+
"content_sha256")})
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
324
361
|
task["context"] = att_mod.merge_context(
|
|
325
362
|
task["context"], items, workdir=str(wd))
|
|
326
363
|
with LOCK:
|
|
@@ -569,6 +606,100 @@ def set_run_auditor(auditor):
|
|
|
569
606
|
_RUN_AUDITOR = auditor if callable(auditor) else None
|
|
570
607
|
|
|
571
608
|
|
|
609
|
+
def _execution_snapshot(task):
|
|
610
|
+
"""Capture the redacted execution inputs at run creation time.
|
|
611
|
+
|
|
612
|
+
This is intentionally a snapshot of identities and revisions, never API
|
|
613
|
+
keys or task bodies. Later binding/skill/knowledge changes therefore do
|
|
614
|
+
not rewrite the explanation of an existing run.
|
|
615
|
+
"""
|
|
616
|
+
task = task or {}
|
|
617
|
+
flow_snapshot = dict(task.get("flow_snapshot") or {})
|
|
618
|
+
flow_digest = str(flow_snapshot.get("digest") or "")
|
|
619
|
+
flow_content_sha = str(flow_snapshot.get("content_sha256") or "")
|
|
620
|
+
flow_revision_id = str(flow_snapshot.get("revision_id") or "")
|
|
621
|
+
if not flow_content_sha and len(flow_digest) == 64:
|
|
622
|
+
flow_content_sha = flow_digest
|
|
623
|
+
if not flow_revision_id and flow_digest:
|
|
624
|
+
flow_revision_id = "flowrev-" + flow_digest[:16]
|
|
625
|
+
|
|
626
|
+
def _redact_command(command):
|
|
627
|
+
value = str(command or "")[:300]
|
|
628
|
+
if not value:
|
|
629
|
+
return ""
|
|
630
|
+
pattern = re.compile(
|
|
631
|
+
r"(?i)(api[_-]?key|access[_-]?token|auth(?:orization)?|password|passwd|secret|token)"
|
|
632
|
+
r"(\s*[:=]\s*|\s+)([^\s]+)")
|
|
633
|
+
return pattern.sub(lambda m: m.group(1) + m.group(2) + "<redacted>", value)
|
|
634
|
+
|
|
635
|
+
snapshot = {
|
|
636
|
+
"captured_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
637
|
+
"task_id": task.get("id") or "",
|
|
638
|
+
"flow_revision": flow_digest,
|
|
639
|
+
"flow_revision_id": flow_revision_id,
|
|
640
|
+
"flow_content_sha256": flow_content_sha,
|
|
641
|
+
"flow_snapshot": flow_snapshot,
|
|
642
|
+
"workdir": str(task.get("workdir") or "")[:500],
|
|
643
|
+
"git_rev": str(task.get("git_rev") or "")[:160],
|
|
644
|
+
"verify_command": _redact_command(task.get("verify_command") or ""),
|
|
645
|
+
"quality_gate": {
|
|
646
|
+
"threshold": task.get("threshold"),
|
|
647
|
+
"rubric": list(task.get("rubric") or [])[:8],
|
|
648
|
+
"rounds": task.get("rounds"),
|
|
649
|
+
"best_of": task.get("best_of"),
|
|
650
|
+
},
|
|
651
|
+
}
|
|
652
|
+
try:
|
|
653
|
+
from . import knowledge, modelhub, skills
|
|
654
|
+
snapshot["skills"] = skills.revision_for_task(task)
|
|
655
|
+
snapshot["knowledge"] = knowledge.revision_for_task(task)
|
|
656
|
+
providers = {str(p.get("id") or ""): p for p in modelhub.providers()}
|
|
657
|
+
bindings = {}
|
|
658
|
+
for agent_id, binding in (modelhub.bindings() or {}).items():
|
|
659
|
+
chain = []
|
|
660
|
+
for item in (binding or {}).get("chain") or []:
|
|
661
|
+
pid = str(item.get("provider_id") or "")
|
|
662
|
+
prov = providers.get(pid) or {}
|
|
663
|
+
try:
|
|
664
|
+
key_ids = [str(k.get("id") or "") for k in modelhub._provider_keys(prov)
|
|
665
|
+
if k.get("id")]
|
|
666
|
+
except Exception:
|
|
667
|
+
key_ids = []
|
|
668
|
+
entry = {
|
|
669
|
+
"provider_id": pid,
|
|
670
|
+
"model": str(item.get("model") or "")[:160],
|
|
671
|
+
"key_ids": key_ids[:8],
|
|
672
|
+
}
|
|
673
|
+
if pid:
|
|
674
|
+
entry.update(modelhub.connection_identity(pid, key_ids[0] if key_ids else ""))
|
|
675
|
+
chain.append(entry)
|
|
676
|
+
bindings[str(agent_id)[:80]] = {
|
|
677
|
+
"difficulty_routing": bool((binding or {}).get("difficulty_routing")),
|
|
678
|
+
"chain": chain,
|
|
679
|
+
}
|
|
680
|
+
snapshot["agent_bindings"] = bindings
|
|
681
|
+
if task.get("direct_provider_id") or task.get("direct_model"):
|
|
682
|
+
direct = {
|
|
683
|
+
"provider_id": str(task.get("direct_provider_id") or "")[:80],
|
|
684
|
+
"model": str(task.get("direct_model") or "")[:160],
|
|
685
|
+
"agent": str(task.get("direct_agent") or "")[:80],
|
|
686
|
+
}
|
|
687
|
+
direct_pid = str(task.get("direct_provider_id") or "")
|
|
688
|
+
direct_prov = providers.get(direct_pid) or {}
|
|
689
|
+
direct_keys = modelhub._provider_keys(direct_prov)
|
|
690
|
+
if direct_pid:
|
|
691
|
+
direct.update(modelhub.connection_identity(
|
|
692
|
+
direct_pid, str((direct_keys[0] if direct_keys else {}).get("id") or "")))
|
|
693
|
+
snapshot["direct"] = direct
|
|
694
|
+
except Exception:
|
|
695
|
+
# A snapshot is diagnostic metadata. A broken optional catalog must
|
|
696
|
+
# never prevent the task from being created.
|
|
697
|
+
snapshot.setdefault("skills", {})
|
|
698
|
+
snapshot.setdefault("knowledge", {})
|
|
699
|
+
snapshot.setdefault("agent_bindings", {})
|
|
700
|
+
return snapshot
|
|
701
|
+
|
|
702
|
+
|
|
572
703
|
# run 终态集合:与 CAS 收口、状态回填共用同一份定义
|
|
573
704
|
TERMINAL_STATUSES = ("done", "failed", "cancelled", "timeout")
|
|
574
705
|
|
|
@@ -589,6 +720,7 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
|
589
720
|
if kind == "orchestration" and task_id:
|
|
590
721
|
task = get_task(task_id) or {}
|
|
591
722
|
run["deadline_at"] = time.time() + _task_timeout_s(task.get("timeout_s"))
|
|
723
|
+
run["execution_snapshot"] = _execution_snapshot(task)
|
|
592
724
|
try:
|
|
593
725
|
eta = _RUN_ESTIMATOR(
|
|
594
726
|
task_type=task.get("type") or "", mode=task.get("mode") or "auto",
|
|
@@ -816,7 +948,6 @@ def update_run(run_id, expected_status=None, **fields):
|
|
|
816
948
|
s["summary"] = ("步骤未正常收尾(任务总时限已到)"
|
|
817
949
|
if st == "timeout" else
|
|
818
950
|
"步骤未正常收尾(终态自动恢复)")
|
|
819
|
-
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
820
951
|
# 状态回填:起跑与结束都同步任务状态。只回填终态的话,run 在跑、
|
|
821
952
|
# 任务永远显示「排队中」(2026-09-18 实案:「# 重写·续」run 已 running
|
|
822
953
|
# 写了一小时,任务卡 queued,用户以为卡死连点重试)。
|
|
@@ -826,6 +957,16 @@ def update_run(run_id, expected_status=None, **fields):
|
|
|
826
957
|
if task:
|
|
827
958
|
task["status"] = st
|
|
828
959
|
_save_json(paths.TASKS_DIR / (tid + ".json"), task)
|
|
960
|
+
if st in TERMINAL_STATUSES:
|
|
961
|
+
try:
|
|
962
|
+
from . import acceptance
|
|
963
|
+
run["acceptance"] = acceptance.evaluate_run(
|
|
964
|
+
run, (_TASKS.get(run.get("task_id")) if run.get("task_id") else None) or {})
|
|
965
|
+
except Exception:
|
|
966
|
+
# Acceptance reporting is evidence enrichment; it must not
|
|
967
|
+
# prevent the primary run from reaching a terminal state.
|
|
968
|
+
pass
|
|
969
|
+
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
829
970
|
if st in TERMINAL_STATUSES and prev_status not in TERMINAL_STATUSES:
|
|
830
971
|
# 事后对账:只有「非终态 → 终态」这一次翻转才记账,重复写终态
|
|
831
972
|
# (UI 补写、迟到写手)不得二次入账。快照在锁内浅拷,锁外调用
|
|
@@ -1038,6 +1179,29 @@ def run_artifacts(run_id, limit=200, cache=False):
|
|
|
1038
1179
|
running=running, cache=cache)
|
|
1039
1180
|
|
|
1040
1181
|
|
|
1182
|
+
def run_asset_refs(run_id, limit=200):
|
|
1183
|
+
"""Return content-addressed references for this run's output files."""
|
|
1184
|
+
wd, files = run_artifacts(run_id, limit=limit, cache=False)
|
|
1185
|
+
if not wd:
|
|
1186
|
+
return []
|
|
1187
|
+
from . import attachments
|
|
1188
|
+
refs = attachments.asset_refs(wd, [{"path": f.get("name") or f.get("path") or ""}
|
|
1189
|
+
for f in files if isinstance(f, dict)])
|
|
1190
|
+
try:
|
|
1191
|
+
from . import assets
|
|
1192
|
+
run = get_run(run_id) or {}
|
|
1193
|
+
for ref in refs:
|
|
1194
|
+
saved = assets.register(wd, ref.get("path"), owner_type="run",
|
|
1195
|
+
owner_id=run_id, name=ref.get("name"),
|
|
1196
|
+
mime=ref.get("mime"))
|
|
1197
|
+
if saved:
|
|
1198
|
+
ref.update({k: saved[k] for k in ("asset_id", "revision_id",
|
|
1199
|
+
"content_sha256")})
|
|
1200
|
+
except Exception:
|
|
1201
|
+
pass
|
|
1202
|
+
return refs
|
|
1203
|
+
|
|
1204
|
+
|
|
1041
1205
|
def task_step_count(task_id):
|
|
1042
1206
|
"""该任务所有 run 的步骤总数(含进行中)。
|
|
1043
1207
|
|
|
@@ -1529,30 +1693,47 @@ def rename_task(task_id, title):
|
|
|
1529
1693
|
|
|
1530
1694
|
|
|
1531
1695
|
def update_task_params(task_id, patch):
|
|
1532
|
-
"""更新任务编排参数(对话条三件套:mode/thinking/direct_provider_id/direct_model
|
|
1533
|
-
|
|
1696
|
+
"""更新任务编排参数(对话条三件套:mode/thinking/direct_provider_id/direct_model,
|
|
1697
|
+
另有 timeout_s 任务总时限)。
|
|
1698
|
+
只在任务未在跑时生效——运行中改参数不会影响当前执行,静默跳过避免误导。
|
|
1699
|
+
|
|
1700
|
+
修订 CAS(借鉴 WorkDSH 契约纪律 expectedRevision):patch 携带整数
|
|
1701
|
+
expected_rev 时与任务当前 rev 比对,不符=别处已先改——拒绝写入并说明,
|
|
1702
|
+
不做过期覆盖;不带 expected_rev 的旧调用保持原行为。写成功 rev+1,
|
|
1703
|
+
调用方(路由)从 get_task 回读新 rev 交前端续用。"""
|
|
1534
1704
|
if not _valid_id(task_id):
|
|
1535
1705
|
return False, "非法的任务 ID"
|
|
1706
|
+
patch = dict(patch) if isinstance(patch, dict) else {}
|
|
1707
|
+
expected_rev = patch.pop("expected_rev", None)
|
|
1536
1708
|
with LOCK:
|
|
1537
1709
|
task = _TASKS.get(task_id)
|
|
1538
1710
|
if not task:
|
|
1539
1711
|
return False, "任务不存在"
|
|
1540
1712
|
if task.get("status") in ("queued", "running"):
|
|
1541
1713
|
return False, "任务运行中,参数不可改"
|
|
1714
|
+
if expected_rev is not None:
|
|
1715
|
+
try:
|
|
1716
|
+
want_rev = int(expected_rev)
|
|
1717
|
+
cur_rev = int(task.get("rev") or 1)
|
|
1718
|
+
except (TypeError, ValueError):
|
|
1719
|
+
return False, "expected_rev 必须是整数"
|
|
1720
|
+
if want_rev != cur_rev:
|
|
1721
|
+
return False, ("任务参数已被其他窗口修改(当前修订 %d),"
|
|
1722
|
+
"已拒绝过期写入,界面稍后自动同步最新值" % cur_rev)
|
|
1542
1723
|
changed = False
|
|
1543
|
-
mode = str(
|
|
1724
|
+
mode = str(patch.get("mode") or "").strip()
|
|
1544
1725
|
if mode in ("auto", "fast", "expert", "manual"):
|
|
1545
1726
|
if task.get("mode") != mode:
|
|
1546
1727
|
task["mode"] = mode
|
|
1547
1728
|
changed = True
|
|
1548
|
-
thinking = str(
|
|
1729
|
+
thinking = str(patch.get("thinking") or "").strip()
|
|
1549
1730
|
if thinking in ("auto", "low", "standard", "high"):
|
|
1550
1731
|
if task.get("thinking") != thinking:
|
|
1551
1732
|
task["thinking"] = thinking
|
|
1552
1733
|
changed = True
|
|
1553
1734
|
if task.get("engine") == "direct":
|
|
1554
|
-
pv = str(
|
|
1555
|
-
mv = str(
|
|
1735
|
+
pv = str(patch.get("direct_provider_id") or "").strip()[:80]
|
|
1736
|
+
mv = str(patch.get("direct_model") or "").strip()[:160]
|
|
1556
1737
|
if mv and not pv:
|
|
1557
1738
|
return False, "指定对话模型时必须同时指定厂商"
|
|
1558
1739
|
if task.get("direct_provider_id") != pv or task.get("direct_model") != mv:
|
|
@@ -1561,17 +1742,28 @@ def update_task_params(task_id, patch):
|
|
|
1561
1742
|
changed = True
|
|
1562
1743
|
# 执行 CLI 可改/可清(清空 = 回内置智能体或模型绑定);与模型绑定
|
|
1563
1744
|
# 允许共存,运行时 CLI 优先
|
|
1564
|
-
av = str(
|
|
1745
|
+
av = str(patch.get("direct_agent") or "").strip()[:80]
|
|
1565
1746
|
if task.get("direct_agent") != av and (av or task.get("direct_agent")):
|
|
1566
1747
|
task["direct_agent"] = av
|
|
1567
1748
|
changed = True
|
|
1749
|
+
# 任务总时限(秒):建任务时写入,此后靠这里改——下次重试/续跑的
|
|
1750
|
+
# create_run 会按它重新给满预算(2026-09-24 戍边骑奴案:12 章连载
|
|
1751
|
+
# 默认 1 小时在慢链天跑不完,只能人工反复重试)。走 _task_timeout_s
|
|
1752
|
+
# 统一钳制 [1s, 7天],脏值回落默认;空闲时才可改(与三件套同闸)。
|
|
1753
|
+
raw_ts = patch.get("timeout_s")
|
|
1754
|
+
if raw_ts not in (None, ""):
|
|
1755
|
+
new_ts = _task_timeout_s(raw_ts)
|
|
1756
|
+
if task.get("timeout_s") != new_ts:
|
|
1757
|
+
task["timeout_s"] = new_ts
|
|
1758
|
+
changed = True
|
|
1568
1759
|
if changed:
|
|
1760
|
+
task["rev"] = int(task.get("rev") or 1) + 1
|
|
1569
1761
|
_save_json(paths.TASKS_DIR / (task_id + ".json"), task)
|
|
1570
1762
|
bump_state()
|
|
1571
1763
|
return True, ""
|
|
1572
1764
|
|
|
1573
1765
|
|
|
1574
|
-
def add_step(run_id, role, agent_id, agent_label, note="", model=""):
|
|
1766
|
+
def add_step(run_id, role, agent_id, agent_label, note="", model="", provider=""):
|
|
1575
1767
|
with LOCK:
|
|
1576
1768
|
run = _RUNS.get(run_id)
|
|
1577
1769
|
if not run:
|
|
@@ -1583,6 +1775,10 @@ def add_step(run_id, role, agent_id, agent_label, note="", model=""):
|
|
|
1583
1775
|
# 起跑即记解析到的模型:运行中蜂巢卡/步骤列表就能看到「在用哪个模型」,
|
|
1584
1776
|
# 不必等 finish_step 用实际结果覆盖(换将成功后以实际为准)
|
|
1585
1777
|
"model": str(model or "")[:80],
|
|
1778
|
+
# 绑定速记(借鉴 WorkDSH ResolvedExecutionBinding):本步实际用的
|
|
1779
|
+
# provider 链与上游 host——烧错配额/模型漂移直接对账步骤行。
|
|
1780
|
+
# 只记身份不记密钥;老数据无此键,读取一律 .get
|
|
1781
|
+
"provider": str(provider or "")[:120],
|
|
1586
1782
|
"status": "running", "started_at": time.strftime("%H:%M:%S"),
|
|
1587
1783
|
"ended_at": None, "duration_s": None, "exit_code": None,
|
|
1588
1784
|
"summary": "", "log": None, "cost_usd": 0.0, "tokens": 0,
|