codebee 0.1.66 → 0.1.68

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.
Files changed (116) hide show
  1. package/CHANGELOG.md +543 -520
  2. package/LICENSE +21 -21
  3. package/README.md +11 -7
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -0
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/artifacts.py +148 -148
  14. package/app/core/assets.py +110 -0
  15. package/app/core/attachments.py +623 -532
  16. package/app/core/automation.py +758 -758
  17. package/app/core/backup.py +470 -470
  18. package/app/core/beekeeper.py +183 -183
  19. package/app/core/board.py +209 -209
  20. package/app/core/bookmeta.py +930 -796
  21. package/app/core/bookmeta_catalog.py +180 -180
  22. package/app/core/branching.py +137 -137
  23. package/app/core/builtin_agent.py +1234 -1234
  24. package/app/core/capability.py +144 -144
  25. package/app/core/catalog.py +42 -0
  26. package/app/core/chaptersafe.py +32 -32
  27. package/app/core/cleanup.py +303 -303
  28. package/app/core/compaction.py +201 -201
  29. package/app/core/covergen.py +356 -356
  30. package/app/core/defectretro.py +43 -0
  31. package/app/core/diagnostics.py +114 -114
  32. package/app/core/dispatch.py +173 -173
  33. package/app/core/dispatch_log.py +128 -113
  34. package/app/core/env_scrub.py +85 -85
  35. package/app/core/error_codes.py +23 -23
  36. package/app/core/errorlog.py +137 -137
  37. package/app/core/evaluation.py +174 -174
  38. package/app/core/failure_notify.py +290 -290
  39. package/app/core/flows.py +48 -2
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +374 -374
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +46 -26
  45. package/app/core/knowledge.py +57 -3
  46. package/app/core/manager.py +2122 -2115
  47. package/app/core/market.py +362 -362
  48. package/app/core/market_remote.py +124 -26
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/modelhub.py +51 -3
  51. package/app/core/notify.py +106 -106
  52. package/app/core/operations.py +228 -0
  53. package/app/core/paihang.py +138 -109
  54. package/app/core/paths.py +2 -2
  55. package/app/core/pipeline.py +221 -12
  56. package/app/core/planner.py +6 -1
  57. package/app/core/portguard.py +111 -111
  58. package/app/core/preview.py +293 -293
  59. package/app/core/publish/__init__.py +7 -7
  60. package/app/core/publish/auto.py +367 -367
  61. package/app/core/publish/browser.py +539 -533
  62. package/app/core/publish/fanqie.py +159 -128
  63. package/app/core/publish/flow.py +440 -432
  64. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  65. package/app/core/publish/flows-qimao-calibrated.json +7 -3
  66. package/app/core/publish/ledger.py +203 -198
  67. package/app/core/publish/manager.py +692 -623
  68. package/app/core/publish/qimao.py +141 -141
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/revisions.py +51 -0
  73. package/app/core/router.py +398 -398
  74. package/app/core/runner.py +2067 -2050
  75. package/app/core/selfupdate.py +384 -384
  76. package/app/core/session_log.py +161 -161
  77. package/app/core/sessions.py +312 -312
  78. package/app/core/settings.py +149 -149
  79. package/app/core/settings_schema.py +262 -262
  80. package/app/core/share_page.py +114 -114
  81. package/app/core/skill_scan.py +122 -114
  82. package/app/core/skillpacks/fanqie-novel.md +80 -80
  83. package/app/core/skillpacks/market/character-bible.md +66 -66
  84. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  85. package/app/core/skillpacks/market/git-workflow.md +57 -57
  86. package/app/core/skillpacks/market/release-notes.md +72 -72
  87. package/app/core/skillpacks/market/weekly-report.md +71 -71
  88. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  89. package/app/core/skills.py +109 -2
  90. package/app/core/step_runner.py +132 -132
  91. package/app/core/store.py +278 -41
  92. package/app/core/task_compile.py +199 -199
  93. package/app/core/telemetry.py +292 -292
  94. package/app/core/tlsctx.py +64 -64
  95. package/app/core/token_meter.py +147 -147
  96. package/app/core/tracing.py +144 -0
  97. package/app/core/upstream.py +41 -41
  98. package/app/core/usage.py +862 -858
  99. package/app/core/volumes.py +389 -355
  100. package/app/core/wxdigest.py +858 -858
  101. package/app/core/zentao.py +2465 -2447
  102. package/app/main.py +2981 -2838
  103. package/app/pick_dialog.py +110 -110
  104. package/app/ui/app.js +14399 -13994
  105. package/app/ui/artdiff.js +74 -0
  106. package/app/ui/board.html +162 -162
  107. package/app/ui/board.js +305 -305
  108. package/app/ui/i18n.js +2770 -2716
  109. package/app/ui/icons/bee.svg +79 -79
  110. package/app/ui/index.html +6 -0
  111. package/app/ui/manifest.json +16 -16
  112. package/app/ui/qrcode.js +2297 -2297
  113. package/app/ui/style.css +96 -24
  114. package/bin/tutti.js +147 -147
  115. package/package.json +46 -46
  116. package/tools/wx_reader.py +261 -261
package/app/core/store.py CHANGED
@@ -18,7 +18,7 @@ import threading
18
18
  import time
19
19
  from pathlib import Path
20
20
 
21
- from . import artifacts, paths, runner, volumes
21
+ from . import artifacts, paths, runner, tracing, volumes
22
22
 
23
23
  LOCK = threading.RLock()
24
24
  _TASKS = {}
@@ -30,9 +30,12 @@ _STREAM_LOCK = threading.Lock()
30
30
  _STREAM_TS = {}
31
31
  LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
32
32
 
33
- DEFAULT_TASK_TIMEOUT_S = 3600
34
- MIN_TASK_TIMEOUT_S = 1
35
- MAX_TASK_TIMEOUT_S = 7 * 24 * 3600
33
+ DEFAULT_TASK_TIMEOUT_S = 3600
34
+ MIN_TASK_TIMEOUT_S = 1
35
+ MAX_TASK_TIMEOUT_S = 7 * 24 * 3600
36
+ REVIEW_TASK_TIMEOUT_S = 7200
37
+ LONG_TASK_TIMEOUT_S = 14400
38
+ SERIAL_EXTRA_CHAPTER_S = 1800
36
39
 
37
40
  # ---------------------------------------------------------------- 状态版本(SSE 事件驱动)
38
41
  # 任何落盘写都算状态变化:SSE 连接等版本号变化才构建/推送全量状态,
@@ -83,7 +86,7 @@ def _safe_name(s):
83
86
  return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
84
87
 
85
88
 
86
- def _task_timeout_s(value=None):
89
+ def _task_timeout_s(value=None):
87
90
  """Resolve one bounded wall-clock budget for a task."""
88
91
  raw = value
89
92
  if raw in (None, ""):
@@ -92,7 +95,27 @@ def _task_timeout_s(value=None):
92
95
  seconds = int(float(raw))
93
96
  except (TypeError, ValueError):
94
97
  seconds = DEFAULT_TASK_TIMEOUT_S
95
- return max(MIN_TASK_TIMEOUT_S, min(MAX_TASK_TIMEOUT_S, seconds))
98
+ return max(MIN_TASK_TIMEOUT_S, min(MAX_TASK_TIMEOUT_S, seconds))
99
+
100
+
101
+ def _default_timeout_s(task):
102
+ """Choose a bounded default from the amount of work, not one hour for all."""
103
+ env_value = os.environ.get("TUTTI_TASK_TIMEOUT_S")
104
+ if env_value not in (None, ""):
105
+ return _task_timeout_s(env_value)
106
+ serial = task.get("serial")
107
+ if isinstance(serial, dict) and serial.get("chapters"):
108
+ try:
109
+ chapters = max(1, min(20, int(serial["chapters"])))
110
+ except (TypeError, ValueError):
111
+ chapters = 8
112
+ return min(MAX_TASK_TIMEOUT_S,
113
+ LONG_TASK_TIMEOUT_S + max(0, chapters - 8) * SERIAL_EXTRA_CHAPTER_S)
114
+ if task.get("engine") == "code":
115
+ return LONG_TASK_TIMEOUT_S
116
+ if task.get("engine") == "review":
117
+ return REVIEW_TASK_TIMEOUT_S
118
+ return DEFAULT_TASK_TIMEOUT_S
96
119
 
97
120
 
98
121
  # ---------------------------------------------------------------- 任务
@@ -159,7 +182,19 @@ def create_task(payload):
159
182
  }
160
183
  thinking = str(payload.get("thinking") or "standard").strip().lower()
161
184
  task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
162
- task["timeout_s"] = _task_timeout_s(payload.get("timeout_s"))
185
+ # 流程修订出处(借鉴 WorkDSH ADR-0010):创建时钉住当时流程定义的指纹。
186
+ # 固化参数已保证「改流程不影响本任务」,digest 再补上可对账的一面——
187
+ # 续跑/重试时若流程已改,flows.flow_drift 能给出明确诊断而非静默换新。
188
+ flow_content_sha = flows_mod.flow_content_sha256(flow)
189
+ task["flow_snapshot"] = {"id": flow["id"], "name": (flow.get("name") or "")[:40],
190
+ "engine": flow["engine"],
191
+ "edited": bool(flow.get("edited")),
192
+ "digest": flow_content_sha[:16],
193
+ "revision_id": "flowrev-" + flow_content_sha[:16],
194
+ "content_sha256": flow_content_sha}
195
+ # 参数修订号(借鉴 WorkDSH 契约纪律 expectedRevision):写接口据此做过期
196
+ # 写入检测。老任务无此键按 1 处理;不带 expected_rev 的调用不受影响。
197
+ task["rev"] = 1
163
198
  if flow["engine"] == "direct":
164
199
  task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
165
200
  "direct_provider_id")[:80]
@@ -221,11 +256,11 @@ def create_task(payload):
221
256
  if serial_value is None or (isinstance(serial_value, dict) and
222
257
  serial_value.get("enabled") is False):
223
258
  serial = None
224
- elif isinstance(serial_value, dict):
225
- serial = serial_value
226
- else:
227
- serial = flow.get("serial")
228
- if isinstance(serial, dict) and serial.get("chapters"):
259
+ elif isinstance(serial_value, dict):
260
+ serial = serial_value
261
+ else:
262
+ serial = flow.get("serial")
263
+ if isinstance(serial, dict) and serial.get("chapters"):
229
264
  try:
230
265
  s = {
231
266
  # 续写批次允许只续 1 章,下限放宽到 1(全新连载仍由前端约束 ≥2)
@@ -277,10 +312,13 @@ def create_task(payload):
277
312
  if vol_spec:
278
313
  s["volumes"] = vol_spec
279
314
  vper = volumes.norm_per(serial.get("volume_chapters"))
280
- if vper:
281
- s["volume_chapters"] = vper
282
- task["serial"] = s
283
- critics = payload.get("critics")
315
+ if vper:
316
+ s["volume_chapters"] = vper
317
+ task["serial"] = s
318
+ raw_timeout = payload.get("timeout_s")
319
+ task["timeout_s"] = (_task_timeout_s(raw_timeout)
320
+ if raw_timeout not in (None, "") else _default_timeout_s(task))
321
+ critics = payload.get("critics")
284
322
  if isinstance(critics, list) and critics:
285
323
  task["critics"] = [str(c) for c in critics]
286
324
  # 初始故事圣经必须在任务入队前落盘,保证首个章节步骤就能读到设定。
@@ -312,8 +350,20 @@ def create_task(payload):
312
350
  att_ids = payload.get("attachments")
313
351
  if isinstance(att_ids, list) and att_ids:
314
352
  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 形态 → 从待提交区移入工作目录
353
+ dict_items = [a for a in att_ids if isinstance(a, dict) and a.get("path")]
354
+ if dict_items:
355
+ root = Path(str(wd)).resolve()
356
+ items = []
357
+ for raw in dict_items[:att_mod.MAX_FILES]:
358
+ rel = att_mod.norm_rel(raw)
359
+ fp = (root / rel).resolve() if rel else root / "__invalid__"
360
+ if rel and root in fp.parents and fp.is_file():
361
+ item = dict(raw)
362
+ item["path"] = rel
363
+ items.append(item)
364
+ if not items:
365
+ raise ValueError("附件路径必须位于工作目录的 _attachments/ 内")
366
+ else: # 纯 id 形态 → 从待提交区移入工作目录
317
367
  try:
318
368
  items = att_mod.commit_to_workdir(
319
369
  str(wd), [str(x) for x in att_ids][:att_mod.MAX_FILES])
@@ -321,6 +371,18 @@ def create_task(payload):
321
371
  raise ValueError("附件落盘失败: %s" % e)
322
372
  if items:
323
373
  task["attachments"] = items
374
+ task["asset_refs"] = att_mod.asset_refs(str(wd), items)
375
+ try:
376
+ from . import assets
377
+ for ref in task["asset_refs"]:
378
+ saved = assets.register(str(wd), ref.get("path"),
379
+ owner_type="task", owner_id=task["id"],
380
+ name=ref.get("name"), mime=ref.get("mime"))
381
+ if saved:
382
+ ref.update({k: saved[k] for k in ("asset_id", "revision_id",
383
+ "content_sha256")})
384
+ except Exception:
385
+ pass
324
386
  task["context"] = att_mod.merge_context(
325
387
  task["context"], items, workdir=str(wd))
326
388
  with LOCK:
@@ -569,12 +631,106 @@ def set_run_auditor(auditor):
569
631
  _RUN_AUDITOR = auditor if callable(auditor) else None
570
632
 
571
633
 
634
+ def _execution_snapshot(task):
635
+ """Capture the redacted execution inputs at run creation time.
636
+
637
+ This is intentionally a snapshot of identities and revisions, never API
638
+ keys or task bodies. Later binding/skill/knowledge changes therefore do
639
+ not rewrite the explanation of an existing run.
640
+ """
641
+ task = task or {}
642
+ flow_snapshot = dict(task.get("flow_snapshot") or {})
643
+ flow_digest = str(flow_snapshot.get("digest") or "")
644
+ flow_content_sha = str(flow_snapshot.get("content_sha256") or "")
645
+ flow_revision_id = str(flow_snapshot.get("revision_id") or "")
646
+ if not flow_content_sha and len(flow_digest) == 64:
647
+ flow_content_sha = flow_digest
648
+ if not flow_revision_id and flow_digest:
649
+ flow_revision_id = "flowrev-" + flow_digest[:16]
650
+
651
+ def _redact_command(command):
652
+ value = str(command or "")[:300]
653
+ if not value:
654
+ return ""
655
+ pattern = re.compile(
656
+ r"(?i)(api[_-]?key|access[_-]?token|auth(?:orization)?|password|passwd|secret|token)"
657
+ r"(\s*[:=]\s*|\s+)([^\s]+)")
658
+ return pattern.sub(lambda m: m.group(1) + m.group(2) + "<redacted>", value)
659
+
660
+ snapshot = {
661
+ "captured_at": time.strftime("%Y-%m-%d %H:%M:%S"),
662
+ "task_id": task.get("id") or "",
663
+ "flow_revision": flow_digest,
664
+ "flow_revision_id": flow_revision_id,
665
+ "flow_content_sha256": flow_content_sha,
666
+ "flow_snapshot": flow_snapshot,
667
+ "workdir": str(task.get("workdir") or "")[:500],
668
+ "git_rev": str(task.get("git_rev") or "")[:160],
669
+ "verify_command": _redact_command(task.get("verify_command") or ""),
670
+ "quality_gate": {
671
+ "threshold": task.get("threshold"),
672
+ "rubric": list(task.get("rubric") or [])[:8],
673
+ "rounds": task.get("rounds"),
674
+ "best_of": task.get("best_of"),
675
+ },
676
+ }
677
+ try:
678
+ from . import knowledge, modelhub, skills
679
+ snapshot["skills"] = skills.revision_for_task(task)
680
+ snapshot["knowledge"] = knowledge.revision_for_task(task)
681
+ providers = {str(p.get("id") or ""): p for p in modelhub.providers()}
682
+ bindings = {}
683
+ for agent_id, binding in (modelhub.bindings() or {}).items():
684
+ chain = []
685
+ for item in (binding or {}).get("chain") or []:
686
+ pid = str(item.get("provider_id") or "")
687
+ prov = providers.get(pid) or {}
688
+ try:
689
+ key_ids = [str(k.get("id") or "") for k in modelhub._provider_keys(prov)
690
+ if k.get("id")]
691
+ except Exception:
692
+ key_ids = []
693
+ entry = {
694
+ "provider_id": pid,
695
+ "model": str(item.get("model") or "")[:160],
696
+ "key_ids": key_ids[:8],
697
+ }
698
+ if pid:
699
+ entry.update(modelhub.connection_identity(pid, key_ids[0] if key_ids else ""))
700
+ chain.append(entry)
701
+ bindings[str(agent_id)[:80]] = {
702
+ "difficulty_routing": bool((binding or {}).get("difficulty_routing")),
703
+ "chain": chain,
704
+ }
705
+ snapshot["agent_bindings"] = bindings
706
+ if task.get("direct_provider_id") or task.get("direct_model"):
707
+ direct = {
708
+ "provider_id": str(task.get("direct_provider_id") or "")[:80],
709
+ "model": str(task.get("direct_model") or "")[:160],
710
+ "agent": str(task.get("direct_agent") or "")[:80],
711
+ }
712
+ direct_pid = str(task.get("direct_provider_id") or "")
713
+ direct_prov = providers.get(direct_pid) or {}
714
+ direct_keys = modelhub._provider_keys(direct_prov)
715
+ if direct_pid:
716
+ direct.update(modelhub.connection_identity(
717
+ direct_pid, str((direct_keys[0] if direct_keys else {}).get("id") or "")))
718
+ snapshot["direct"] = direct
719
+ except Exception:
720
+ # A snapshot is diagnostic metadata. A broken optional catalog must
721
+ # never prevent the task from being created.
722
+ snapshot.setdefault("skills", {})
723
+ snapshot.setdefault("knowledge", {})
724
+ snapshot.setdefault("agent_bindings", {})
725
+ return snapshot
726
+
727
+
572
728
  # run 终态集合:与 CAS 收口、状态回填共用同一份定义
573
729
  TERMINAL_STATUSES = ("done", "failed", "cancelled", "timeout")
574
730
 
575
731
 
576
- def create_run(kind, title, task_id=None, entry_id=None, op=None):
577
- run = {
732
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
733
+ run = {
578
734
  "id": _new_id("r" if kind == "orchestration" else "m"),
579
735
  "kind": kind, # orchestration | mgmt
580
736
  "title": title,
@@ -584,11 +740,20 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
584
740
  "started_at": None, "ended_at": None,
585
741
  "deadline_at": None,
586
742
  "cost_usd": 0.0, "tokens": 0, "error": "",
587
- "verdict": None, "summary": "",
588
- }
589
- if kind == "orchestration" and task_id:
590
- task = get_task(task_id) or {}
591
- run["deadline_at"] = time.time() + _task_timeout_s(task.get("timeout_s"))
743
+ "verdict": None, "summary": "",
744
+ }
745
+ # Persist trace identity at run creation so replay remains stable across
746
+ # process restarts and legacy readers can still use the normal run shape.
747
+ run["trace_schema_version"] = tracing.SCHEMA_VERSION
748
+ run["trace_id"] = tracing.trace_id(run["id"])
749
+ run["root_span_id"] = tracing.root_span_id(run["id"])
750
+ if kind == "orchestration" and task_id:
751
+ task = get_task(task_id) or {}
752
+ run["timeout_budget_s"] = _task_timeout_s(task.get("timeout_s"))
753
+ # Provisional for direct callers; jobs resets it at worker claim so
754
+ # queued/backoff time does not shorten the actual execution budget.
755
+ run["deadline_at"] = time.time() + run["timeout_budget_s"]
756
+ run["execution_snapshot"] = _execution_snapshot(task)
592
757
  try:
593
758
  eta = _RUN_ESTIMATOR(
594
759
  task_type=task.get("type") or "", mode=task.get("mode") or "auto",
@@ -816,7 +981,6 @@ def update_run(run_id, expected_status=None, **fields):
816
981
  s["summary"] = ("步骤未正常收尾(任务总时限已到)"
817
982
  if st == "timeout" else
818
983
  "步骤未正常收尾(终态自动恢复)")
819
- _save_json(paths.RUNS_DIR / run_id / "run.json", run)
820
984
  # 状态回填:起跑与结束都同步任务状态。只回填终态的话,run 在跑、
821
985
  # 任务永远显示「排队中」(2026-09-18 实案:「# 重写·续」run 已 running
822
986
  # 写了一小时,任务卡 queued,用户以为卡死连点重试)。
@@ -826,6 +990,16 @@ def update_run(run_id, expected_status=None, **fields):
826
990
  if task:
827
991
  task["status"] = st
828
992
  _save_json(paths.TASKS_DIR / (tid + ".json"), task)
993
+ if st in TERMINAL_STATUSES:
994
+ try:
995
+ from . import acceptance
996
+ run["acceptance"] = acceptance.evaluate_run(
997
+ run, (_TASKS.get(run.get("task_id")) if run.get("task_id") else None) or {})
998
+ except Exception:
999
+ # Acceptance reporting is evidence enrichment; it must not
1000
+ # prevent the primary run from reaching a terminal state.
1001
+ pass
1002
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
829
1003
  if st in TERMINAL_STATUSES and prev_status not in TERMINAL_STATUSES:
830
1004
  # 事后对账:只有「非终态 → 终态」这一次翻转才记账,重复写终态
831
1005
  # (UI 补写、迟到写手)不得二次入账。快照在锁内浅拷,锁外调用
@@ -1038,6 +1212,29 @@ def run_artifacts(run_id, limit=200, cache=False):
1038
1212
  running=running, cache=cache)
1039
1213
 
1040
1214
 
1215
+ def run_asset_refs(run_id, limit=200):
1216
+ """Return content-addressed references for this run's output files."""
1217
+ wd, files = run_artifacts(run_id, limit=limit, cache=False)
1218
+ if not wd:
1219
+ return []
1220
+ from . import attachments
1221
+ refs = attachments.asset_refs(wd, [{"path": f.get("name") or f.get("path") or ""}
1222
+ for f in files if isinstance(f, dict)])
1223
+ try:
1224
+ from . import assets
1225
+ run = get_run(run_id) or {}
1226
+ for ref in refs:
1227
+ saved = assets.register(wd, ref.get("path"), owner_type="run",
1228
+ owner_id=run_id, name=ref.get("name"),
1229
+ mime=ref.get("mime"))
1230
+ if saved:
1231
+ ref.update({k: saved[k] for k in ("asset_id", "revision_id",
1232
+ "content_sha256")})
1233
+ except Exception:
1234
+ pass
1235
+ return refs
1236
+
1237
+
1041
1238
  def task_step_count(task_id):
1042
1239
  """该任务所有 run 的步骤总数(含进行中)。
1043
1240
 
@@ -1529,30 +1726,47 @@ def rename_task(task_id, title):
1529
1726
 
1530
1727
 
1531
1728
  def update_task_params(task_id, patch):
1532
- """更新任务编排参数(对话条三件套:mode/thinking/direct_provider_id/direct_model)。
1533
- 只在任务未在跑时生效——运行中改参数不会影响当前执行,静默跳过避免误导。"""
1729
+ """更新任务编排参数(对话条三件套:mode/thinking/direct_provider_id/direct_model,
1730
+ 另有 timeout_s 任务总时限)。
1731
+ 只在任务未在跑时生效——运行中改参数不会影响当前执行,静默跳过避免误导。
1732
+
1733
+ 修订 CAS(借鉴 WorkDSH 契约纪律 expectedRevision):patch 携带整数
1734
+ expected_rev 时与任务当前 rev 比对,不符=别处已先改——拒绝写入并说明,
1735
+ 不做过期覆盖;不带 expected_rev 的旧调用保持原行为。写成功 rev+1,
1736
+ 调用方(路由)从 get_task 回读新 rev 交前端续用。"""
1534
1737
  if not _valid_id(task_id):
1535
1738
  return False, "非法的任务 ID"
1739
+ patch = dict(patch) if isinstance(patch, dict) else {}
1740
+ expected_rev = patch.pop("expected_rev", None)
1536
1741
  with LOCK:
1537
1742
  task = _TASKS.get(task_id)
1538
1743
  if not task:
1539
1744
  return False, "任务不存在"
1540
1745
  if task.get("status") in ("queued", "running"):
1541
1746
  return False, "任务运行中,参数不可改"
1747
+ if expected_rev is not None:
1748
+ try:
1749
+ want_rev = int(expected_rev)
1750
+ cur_rev = int(task.get("rev") or 1)
1751
+ except (TypeError, ValueError):
1752
+ return False, "expected_rev 必须是整数"
1753
+ if want_rev != cur_rev:
1754
+ return False, ("任务参数已被其他窗口修改(当前修订 %d),"
1755
+ "已拒绝过期写入,界面稍后自动同步最新值" % cur_rev)
1542
1756
  changed = False
1543
- mode = str((patch or {}).get("mode") or "").strip()
1757
+ mode = str(patch.get("mode") or "").strip()
1544
1758
  if mode in ("auto", "fast", "expert", "manual"):
1545
1759
  if task.get("mode") != mode:
1546
1760
  task["mode"] = mode
1547
1761
  changed = True
1548
- thinking = str((patch or {}).get("thinking") or "").strip()
1762
+ thinking = str(patch.get("thinking") or "").strip()
1549
1763
  if thinking in ("auto", "low", "standard", "high"):
1550
1764
  if task.get("thinking") != thinking:
1551
1765
  task["thinking"] = thinking
1552
1766
  changed = True
1553
1767
  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]
1768
+ pv = str(patch.get("direct_provider_id") or "").strip()[:80]
1769
+ mv = str(patch.get("direct_model") or "").strip()[:160]
1556
1770
  if mv and not pv:
1557
1771
  return False, "指定对话模型时必须同时指定厂商"
1558
1772
  if task.get("direct_provider_id") != pv or task.get("direct_model") != mv:
@@ -1561,30 +1775,49 @@ def update_task_params(task_id, patch):
1561
1775
  changed = True
1562
1776
  # 执行 CLI 可改/可清(清空 = 回内置智能体或模型绑定);与模型绑定
1563
1777
  # 允许共存,运行时 CLI 优先
1564
- av = str((patch or {}).get("direct_agent") or "").strip()[:80]
1778
+ av = str(patch.get("direct_agent") or "").strip()[:80]
1565
1779
  if task.get("direct_agent") != av and (av or task.get("direct_agent")):
1566
1780
  task["direct_agent"] = av
1567
1781
  changed = True
1782
+ # 任务总时限(秒):建任务时按工作量给默认值,此处支持空闲时显式改。
1783
+ # 下次重试/续跑按新值重新给预算;已在跑的 run 保持原截止时间。
1784
+ # 统一钳制 [1s, 7天],脏值回落基础默认。
1785
+ raw_ts = patch.get("timeout_s")
1786
+ if raw_ts not in (None, ""):
1787
+ new_ts = _task_timeout_s(raw_ts)
1788
+ if task.get("timeout_s") != new_ts:
1789
+ task["timeout_s"] = new_ts
1790
+ changed = True
1568
1791
  if changed:
1792
+ task["rev"] = int(task.get("rev") or 1) + 1
1569
1793
  _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
1570
1794
  bump_state()
1571
1795
  return True, ""
1572
1796
 
1573
1797
 
1574
- def add_step(run_id, role, agent_id, agent_label, note="", model=""):
1798
+ def add_step(run_id, role, agent_id, agent_label, note="", model="", provider=""):
1575
1799
  with LOCK:
1576
1800
  run = _RUNS.get(run_id)
1577
1801
  if not run:
1578
1802
  return None, None
1579
1803
  n = len(run["steps"]) + 1
1580
- step = {
1804
+ step = {
1581
1805
  "n": n, "role": role, "agent": agent_id, "agent_label": agent_label,
1582
1806
  "note": note,
1583
1807
  # 起跑即记解析到的模型:运行中蜂巢卡/步骤列表就能看到「在用哪个模型」,
1584
1808
  # 不必等 finish_step 用实际结果覆盖(换将成功后以实际为准)
1585
1809
  "model": str(model or "")[:80],
1586
- "status": "running", "started_at": time.strftime("%H:%M:%S"),
1587
- "ended_at": None, "duration_s": None, "exit_code": None,
1810
+ # 绑定速记(借鉴 WorkDSH ResolvedExecutionBinding):本步实际用的
1811
+ # provider 链与上游 host——烧错配额/模型漂移直接对账步骤行。
1812
+ # 只记身份不记密钥;老数据无此键,读取一律 .get
1813
+ "provider": str(provider or "")[:120],
1814
+ "status": "running", "started_at": time.strftime("%H:%M:%S"),
1815
+ "started_at_epoch": time.time(),
1816
+ "trace_id": str(run.get("trace_id") or tracing.trace_id(run_id)),
1817
+ "span_id": tracing.span_id(run_id, n),
1818
+ "parent_span_id": str(run.get("root_span_id") or
1819
+ tracing.root_span_id(run_id)),
1820
+ "ended_at": None, "duration_s": None, "exit_code": None,
1588
1821
  "summary": "", "log": None, "cost_usd": 0.0, "tokens": 0,
1589
1822
  }
1590
1823
  run["steps"].append(step)
@@ -1654,8 +1887,9 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1654
1887
  return
1655
1888
  for s in run["steps"]:
1656
1889
  if s["n"] == n:
1657
- s["status"] = status
1658
- s["ended_at"] = time.strftime("%H:%M:%S")
1890
+ s["status"] = status
1891
+ s["ended_at"] = time.strftime("%H:%M:%S")
1892
+ s["ended_at_epoch"] = time.time()
1659
1893
  # 步骤摘要是 CLI 文本的汇聚点(失败时=错误尾巴,成功时=智能体结论):
1660
1894
  # 落盘前统一清洗,避免 ANSI/覆写/乱码墙进 UI 与报告
1661
1895
  s["summary"] = runner.clean_cli_text(summary)
@@ -1664,8 +1898,11 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1664
1898
  s["tokens"] = tokens
1665
1899
  if model:
1666
1900
  s["model"] = str(model)[:80]
1667
- if duration_s is not None:
1668
- s["duration_s"] = round(duration_s, 1)
1901
+ if duration_s is not None:
1902
+ s["duration_s"] = round(duration_s, 1)
1903
+ elif s.get("started_at_epoch") is not None:
1904
+ s["duration_s"] = round(max(
1905
+ 0.0, s["ended_at_epoch"] - s["started_at_epoch"]), 1)
1669
1906
  if output is not None:
1670
1907
  # output 是智能体正文(对话气泡直读):只剥 ANSI,不做噪声折叠
1671
1908
  # ——正文里的装饰性长串是作者写的,不能替它省略