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.
@@ -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)
@@ -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
- pass
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
- return {
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:
@@ -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
- items = [a for a in att_ids if isinstance(a, dict) and a.get("path")]
316
- if not items: # 纯 id 形态 → 从待提交区移入工作目录
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((patch or {}).get("mode") or "").strip()
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((patch or {}).get("thinking") or "").strip()
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((patch or {}).get("direct_provider_id") or "").strip()[:80]
1555
- mv = str((patch or {}).get("direct_model") or "").strip()[:160]
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((patch or {}).get("direct_agent") or "").strip()[:80]
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,