codebee 0.1.67 → 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 (113) hide show
  1. package/CHANGELOG.md +543 -532
  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 -100
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/artifacts.py +148 -148
  14. package/app/core/assets.py +110 -110
  15. package/app/core/attachments.py +623 -623
  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 -930
  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 -43
  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 +2 -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 +29 -25
  45. package/app/core/knowledge.py +7 -0
  46. package/app/core/manager.py +2122 -2122
  47. package/app/core/market.py +362 -362
  48. package/app/core/mocks.py +64 -64
  49. package/app/core/modelhub.py +22 -3
  50. package/app/core/notify.py +106 -106
  51. package/app/core/operations.py +228 -228
  52. package/app/core/paihang.py +138 -138
  53. package/app/core/paths.py +2 -2
  54. package/app/core/pipeline.py +16 -1
  55. package/app/core/planner.py +2 -0
  56. package/app/core/portguard.py +111 -111
  57. package/app/core/preview.py +293 -293
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/auto.py +367 -367
  60. package/app/core/publish/browser.py +539 -539
  61. package/app/core/publish/fanqie.py +159 -159
  62. package/app/core/publish/flow.py +440 -440
  63. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  64. package/app/core/publish/flows-qimao-calibrated.json +7 -3
  65. package/app/core/publish/ledger.py +203 -203
  66. package/app/core/publish/manager.py +692 -692
  67. package/app/core/publish/qimao.py +141 -141
  68. package/app/core/publish/ws.py +139 -139
  69. package/app/core/redact.py +38 -38
  70. package/app/core/repeat_guard.py +129 -129
  71. package/app/core/router.py +398 -398
  72. package/app/core/runner.py +2067 -2065
  73. package/app/core/selfupdate.py +384 -384
  74. package/app/core/session_log.py +161 -161
  75. package/app/core/sessions.py +312 -312
  76. package/app/core/settings.py +149 -149
  77. package/app/core/settings_schema.py +262 -262
  78. package/app/core/share_page.py +114 -114
  79. package/app/core/skill_scan.py +122 -114
  80. package/app/core/skillpacks/fanqie-novel.md +80 -80
  81. package/app/core/skillpacks/market/character-bible.md +66 -66
  82. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  83. package/app/core/skillpacks/market/git-workflow.md +57 -57
  84. package/app/core/skillpacks/market/release-notes.md +72 -72
  85. package/app/core/skillpacks/market/weekly-report.md +71 -71
  86. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  87. package/app/core/skills.py +15 -1
  88. package/app/core/step_runner.py +132 -132
  89. package/app/core/store.py +75 -34
  90. package/app/core/task_compile.py +199 -199
  91. package/app/core/telemetry.py +292 -292
  92. package/app/core/tlsctx.py +64 -64
  93. package/app/core/token_meter.py +147 -147
  94. package/app/core/tracing.py +144 -0
  95. package/app/core/upstream.py +41 -41
  96. package/app/core/usage.py +862 -858
  97. package/app/core/volumes.py +389 -389
  98. package/app/core/wxdigest.py +858 -858
  99. package/app/core/zentao.py +2465 -2465
  100. package/app/main.py +2981 -2957
  101. package/app/pick_dialog.py +110 -110
  102. package/app/ui/app.js +14399 -14384
  103. package/app/ui/artdiff.js +74 -74
  104. package/app/ui/board.html +162 -162
  105. package/app/ui/board.js +305 -305
  106. package/app/ui/i18n.js +2770 -2766
  107. package/app/ui/icons/bee.svg +79 -79
  108. package/app/ui/manifest.json +16 -16
  109. package/app/ui/qrcode.js +2297 -2297
  110. package/app/ui/style.css +16 -16
  111. package/bin/tutti.js +147 -147
  112. package/package.json +46 -46
  113. package/tools/wx_reader.py +261 -261
@@ -1,133 +1,133 @@
1
- # -*- coding: utf-8 -*-
2
- """Step 执行器:压缩触发的重试守门(复用已渲染 prompt,不重复组装)。
3
-
4
- 设计稿:docs/migration/02-context-compaction.md §1D。
5
- 参考 dsh packages/core/agent-loop/src/agent.ts:307-378:
6
- 失败重试只在「前进 surface generation」时返回 {kind:'retry'},
7
- 复用已渲染 PromptAssembly,不重跑 pre-step / 组装 / 用户消息准入。
8
-
9
- Tutti 语境:runner.run_agent 自带 prompt(已渲染);重试的风险不是重复渲染
10
- (prompt 是同一个字符串),而是「不压缩直接重试」必然再次撑爆。
11
- 本模块的守门:仅当 maybe_compact 真正前进了 surface generation 才重试一次。
12
- """
13
- from __future__ import annotations
14
-
15
- import logging
16
-
17
- from .compaction import maybe_compact, DEFAULT_PRESSURE_THRESHOLD
18
- from .error_codes import ErrorCode
19
-
20
- log = logging.getLogger(__name__)
21
-
22
- # 触发「压缩后重试」的错误码
23
- _OVERFLOW_CODES = {ErrorCode.CONTEXT_OVERFLOW, ErrorCode.MAX_TOKENS}
24
-
25
- # 事前预检阈值:最近一次调用的上下文锚点已占本步模型容量九成 → 第一个请求
26
- # 大概率被供应商拒。只拦「大概率必死」,不提前压缩平时的高压(used() 是累计
27
- # 口径,比真实上下文偏大;0.8 的响应式阈值语义与此不同,勿混用)。
28
- _PRECHECK_RATIO = 0.9
29
-
30
-
31
- def _v2_compaction_tuning():
32
- """settings_v2 orchestrator.compaction 微调 → (threshold|None, retain|None)。
33
-
34
- None = 未配置(走 maybe_compact 的模块默认);读取/注册失败静默回落。
35
- retain 允许 0(尾部不保留),与 None(未配置)语义不同,勿合并。"""
36
- try:
37
- from .settings_schema import get as ss_get, register_default_namespaces
38
- register_default_namespaces()
39
- c = (ss_get("orchestrator") or {}).get("compaction") or {}
40
- th = c.get("pressure_threshold")
41
- rt = c.get("retain_tail_tokens")
42
- return (float(th) if th else None,
43
- int(rt) if rt is not None else None)
44
- except Exception:
45
- return None, None
46
-
47
-
48
- def execute_step(session, run_agent_fn, prompt, *, model: str = "",
49
- llm_caller=None, retain_tail_tokens=None, **kwargs):
50
- """执行一次 step;撑爆时压缩并守门重试一次。
51
-
52
- 事前预检(借鉴 freebuff base-chat 每步容量重估):换将切到小窗口模型后,
53
- 响应式路径要等供应商报 CONTEXT_OVERFLOW 才压缩——报得不干净就直接卡死。
54
- 先用「最近一次调用上下文 vs 本步模型容量」判一次,超阈先压缩再发。
55
-
56
- Args:
57
- session: session_log.Session(记录 replace_generation)
58
- run_agent_fn: runner.run_agent(或测试替身),签名 (prompt, **kwargs) -> dict
59
- prompt: 已渲染的提示词(重试时原样复用,不重新渲染)
60
- model: 本步将要使用的模型名(容量与压力估算都按它算)
61
- llm_caller: 压缩摘要 LLM(None = 不压缩,永不重试)
62
- **kwargs: 透传 run_agent_fn
63
-
64
- Returns:
65
- (result, retried: bool)
66
- """
67
- v2_th, v2_rt = _v2_compaction_tuning()
68
- compact_kwargs = {}
69
- if retain_tail_tokens is not None:
70
- compact_kwargs["retain_tail_tokens"] = retain_tail_tokens
71
- elif v2_rt is not None:
72
- compact_kwargs["retain_tail_tokens"] = v2_rt
73
- if v2_th is not None:
74
- compact_kwargs["threshold"] = v2_th
75
- if llm_caller is not None and model:
76
- try:
77
- from .token_meter import token_meter
78
- ctx = token_meter.last_context(session.run_id)
79
- cap = token_meter.capacity(model)
80
- if ctx > 0 and cap > 0 and ctx > cap * _PRECHECK_RATIO:
81
- gen_p0 = session.replace_generation()
82
- if maybe_compact(session, model=model, llm_caller=llm_caller,
83
- run_id=session.run_id, **compact_kwargs) \
84
- and session.replace_generation() > gen_p0:
85
- log.info("precheck: last ctx~%d > %.0f%% of cap(%s)=%d, "
86
- "compacted before step", ctx, _PRECHECK_RATIO * 100,
87
- model, cap)
88
- except Exception:
89
- log.debug("precheck skipped", exc_info=True)
90
- gen0 = session.replace_generation()
91
- result = run_agent_fn(prompt, **kwargs)
92
- code = result.get("error_code") or ""
93
- if code not in _OVERFLOW_CODES or llm_caller is None:
94
- return result, False
95
- # 撑爆 → 尝试压缩
96
- compacted = maybe_compact(session, model=model, llm_caller=llm_caller,
97
- run_id=session.run_id, **compact_kwargs)
98
- gen1 = session.replace_generation()
99
- if not compacted or gen1 <= gen0:
100
- # 压缩未发生或未前进 generation:重试必然再次撑爆,不重试
101
- log.info("overflow but no compaction progress (gen %s->%s), no retry",
102
- gen0, gen1)
103
- return result, False
104
- # 压缩生效:surface 已折叠。**真正替换 CLI 请求上下文**(2026-09-21
105
- # 清最老待深挖项:此前重试仍发原 prompt + 续旧 resume 会话——摘要只进
106
- # 审计日志,CLI 看到的上下文一点没小,重试必然再爆):
107
- # ①重试 prompt 改用 session 派生转录(折叠摘要 + 近尾消息,体量骤降);
108
- # ②弃 resume(旧会话本体仍是膨胀态,续上就前功尽弃)。
109
- msgs = []
110
- try:
111
- msgs = session.derive_messages()
112
- except Exception:
113
- log.debug("derive_messages failed on retry", exc_info=True)
114
- kwargs2 = dict(kwargs)
115
- if msgs:
116
- transcript = "\n\n".join("[%s] %s" % (m.get("role"), m.get("content"))
117
- for m in msgs)
118
- if len(transcript) > 24_000:
119
- transcript = transcript[-24_000:]
120
- retry_prompt = transcript
121
- # 本步指令必须在场:被折叠/截断掉就显式补在尾部(缺失指令比重复严重)
122
- tail = transcript[-2000:]
123
- if prompt[:400] not in tail:
124
- retry_prompt = transcript + "\n\n" + prompt
125
- kwargs2.pop("resume", None)
126
- log.info("compaction advanced gen %s->%s, retrying with derived "
127
- "context (%d msgs, resume dropped)", gen0, gen1, len(msgs))
128
- result2 = run_agent_fn(retry_prompt, **kwargs2)
129
- else:
130
- log.info("compaction advanced gen %s->%s but no surface derived, "
131
- "retrying same prompt", gen0, gen1)
132
- result2 = run_agent_fn(prompt, **kwargs)
1
+ # -*- coding: utf-8 -*-
2
+ """Step 执行器:压缩触发的重试守门(复用已渲染 prompt,不重复组装)。
3
+
4
+ 设计稿:docs/migration/02-context-compaction.md §1D。
5
+ 参考 dsh packages/core/agent-loop/src/agent.ts:307-378:
6
+ 失败重试只在「前进 surface generation」时返回 {kind:'retry'},
7
+ 复用已渲染 PromptAssembly,不重跑 pre-step / 组装 / 用户消息准入。
8
+
9
+ Tutti 语境:runner.run_agent 自带 prompt(已渲染);重试的风险不是重复渲染
10
+ (prompt 是同一个字符串),而是「不压缩直接重试」必然再次撑爆。
11
+ 本模块的守门:仅当 maybe_compact 真正前进了 surface generation 才重试一次。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ from .compaction import maybe_compact, DEFAULT_PRESSURE_THRESHOLD
18
+ from .error_codes import ErrorCode
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # 触发「压缩后重试」的错误码
23
+ _OVERFLOW_CODES = {ErrorCode.CONTEXT_OVERFLOW, ErrorCode.MAX_TOKENS}
24
+
25
+ # 事前预检阈值:最近一次调用的上下文锚点已占本步模型容量九成 → 第一个请求
26
+ # 大概率被供应商拒。只拦「大概率必死」,不提前压缩平时的高压(used() 是累计
27
+ # 口径,比真实上下文偏大;0.8 的响应式阈值语义与此不同,勿混用)。
28
+ _PRECHECK_RATIO = 0.9
29
+
30
+
31
+ def _v2_compaction_tuning():
32
+ """settings_v2 orchestrator.compaction 微调 → (threshold|None, retain|None)。
33
+
34
+ None = 未配置(走 maybe_compact 的模块默认);读取/注册失败静默回落。
35
+ retain 允许 0(尾部不保留),与 None(未配置)语义不同,勿合并。"""
36
+ try:
37
+ from .settings_schema import get as ss_get, register_default_namespaces
38
+ register_default_namespaces()
39
+ c = (ss_get("orchestrator") or {}).get("compaction") or {}
40
+ th = c.get("pressure_threshold")
41
+ rt = c.get("retain_tail_tokens")
42
+ return (float(th) if th else None,
43
+ int(rt) if rt is not None else None)
44
+ except Exception:
45
+ return None, None
46
+
47
+
48
+ def execute_step(session, run_agent_fn, prompt, *, model: str = "",
49
+ llm_caller=None, retain_tail_tokens=None, **kwargs):
50
+ """执行一次 step;撑爆时压缩并守门重试一次。
51
+
52
+ 事前预检(借鉴 freebuff base-chat 每步容量重估):换将切到小窗口模型后,
53
+ 响应式路径要等供应商报 CONTEXT_OVERFLOW 才压缩——报得不干净就直接卡死。
54
+ 先用「最近一次调用上下文 vs 本步模型容量」判一次,超阈先压缩再发。
55
+
56
+ Args:
57
+ session: session_log.Session(记录 replace_generation)
58
+ run_agent_fn: runner.run_agent(或测试替身),签名 (prompt, **kwargs) -> dict
59
+ prompt: 已渲染的提示词(重试时原样复用,不重新渲染)
60
+ model: 本步将要使用的模型名(容量与压力估算都按它算)
61
+ llm_caller: 压缩摘要 LLM(None = 不压缩,永不重试)
62
+ **kwargs: 透传 run_agent_fn
63
+
64
+ Returns:
65
+ (result, retried: bool)
66
+ """
67
+ v2_th, v2_rt = _v2_compaction_tuning()
68
+ compact_kwargs = {}
69
+ if retain_tail_tokens is not None:
70
+ compact_kwargs["retain_tail_tokens"] = retain_tail_tokens
71
+ elif v2_rt is not None:
72
+ compact_kwargs["retain_tail_tokens"] = v2_rt
73
+ if v2_th is not None:
74
+ compact_kwargs["threshold"] = v2_th
75
+ if llm_caller is not None and model:
76
+ try:
77
+ from .token_meter import token_meter
78
+ ctx = token_meter.last_context(session.run_id)
79
+ cap = token_meter.capacity(model)
80
+ if ctx > 0 and cap > 0 and ctx > cap * _PRECHECK_RATIO:
81
+ gen_p0 = session.replace_generation()
82
+ if maybe_compact(session, model=model, llm_caller=llm_caller,
83
+ run_id=session.run_id, **compact_kwargs) \
84
+ and session.replace_generation() > gen_p0:
85
+ log.info("precheck: last ctx~%d > %.0f%% of cap(%s)=%d, "
86
+ "compacted before step", ctx, _PRECHECK_RATIO * 100,
87
+ model, cap)
88
+ except Exception:
89
+ log.debug("precheck skipped", exc_info=True)
90
+ gen0 = session.replace_generation()
91
+ result = run_agent_fn(prompt, **kwargs)
92
+ code = result.get("error_code") or ""
93
+ if code not in _OVERFLOW_CODES or llm_caller is None:
94
+ return result, False
95
+ # 撑爆 → 尝试压缩
96
+ compacted = maybe_compact(session, model=model, llm_caller=llm_caller,
97
+ run_id=session.run_id, **compact_kwargs)
98
+ gen1 = session.replace_generation()
99
+ if not compacted or gen1 <= gen0:
100
+ # 压缩未发生或未前进 generation:重试必然再次撑爆,不重试
101
+ log.info("overflow but no compaction progress (gen %s->%s), no retry",
102
+ gen0, gen1)
103
+ return result, False
104
+ # 压缩生效:surface 已折叠。**真正替换 CLI 请求上下文**(2026-09-21
105
+ # 清最老待深挖项:此前重试仍发原 prompt + 续旧 resume 会话——摘要只进
106
+ # 审计日志,CLI 看到的上下文一点没小,重试必然再爆):
107
+ # ①重试 prompt 改用 session 派生转录(折叠摘要 + 近尾消息,体量骤降);
108
+ # ②弃 resume(旧会话本体仍是膨胀态,续上就前功尽弃)。
109
+ msgs = []
110
+ try:
111
+ msgs = session.derive_messages()
112
+ except Exception:
113
+ log.debug("derive_messages failed on retry", exc_info=True)
114
+ kwargs2 = dict(kwargs)
115
+ if msgs:
116
+ transcript = "\n\n".join("[%s] %s" % (m.get("role"), m.get("content"))
117
+ for m in msgs)
118
+ if len(transcript) > 24_000:
119
+ transcript = transcript[-24_000:]
120
+ retry_prompt = transcript
121
+ # 本步指令必须在场:被折叠/截断掉就显式补在尾部(缺失指令比重复严重)
122
+ tail = transcript[-2000:]
123
+ if prompt[:400] not in tail:
124
+ retry_prompt = transcript + "\n\n" + prompt
125
+ kwargs2.pop("resume", None)
126
+ log.info("compaction advanced gen %s->%s, retrying with derived "
127
+ "context (%d msgs, resume dropped)", gen0, gen1, len(msgs))
128
+ result2 = run_agent_fn(retry_prompt, **kwargs2)
129
+ else:
130
+ log.info("compaction advanced gen %s->%s but no surface derived, "
131
+ "retrying same prompt", gen0, gen1)
132
+ result2 = run_agent_fn(prompt, **kwargs)
133
133
  return result2, True
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,6 @@ 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"))
163
185
  # 流程修订出处(借鉴 WorkDSH ADR-0010):创建时钉住当时流程定义的指纹。
164
186
  # 固化参数已保证「改流程不影响本任务」,digest 再补上可对账的一面——
165
187
  # 续跑/重试时若流程已改,flows.flow_drift 能给出明确诊断而非静默换新。
@@ -234,11 +256,11 @@ def create_task(payload):
234
256
  if serial_value is None or (isinstance(serial_value, dict) and
235
257
  serial_value.get("enabled") is False):
236
258
  serial = None
237
- elif isinstance(serial_value, dict):
238
- serial = serial_value
239
- else:
240
- serial = flow.get("serial")
241
- 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"):
242
264
  try:
243
265
  s = {
244
266
  # 续写批次允许只续 1 章,下限放宽到 1(全新连载仍由前端约束 ≥2)
@@ -290,10 +312,13 @@ def create_task(payload):
290
312
  if vol_spec:
291
313
  s["volumes"] = vol_spec
292
314
  vper = volumes.norm_per(serial.get("volume_chapters"))
293
- if vper:
294
- s["volume_chapters"] = vper
295
- task["serial"] = s
296
- 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")
297
322
  if isinstance(critics, list) and critics:
298
323
  task["critics"] = [str(c) for c in critics]
299
324
  # 初始故事圣经必须在任务入队前落盘,保证首个章节步骤就能读到设定。
@@ -704,8 +729,8 @@ def _execution_snapshot(task):
704
729
  TERMINAL_STATUSES = ("done", "failed", "cancelled", "timeout")
705
730
 
706
731
 
707
- def create_run(kind, title, task_id=None, entry_id=None, op=None):
708
- run = {
732
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
733
+ run = {
709
734
  "id": _new_id("r" if kind == "orchestration" else "m"),
710
735
  "kind": kind, # orchestration | mgmt
711
736
  "title": title,
@@ -715,11 +740,19 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
715
740
  "started_at": None, "ended_at": None,
716
741
  "deadline_at": None,
717
742
  "cost_usd": 0.0, "tokens": 0, "error": "",
718
- "verdict": None, "summary": "",
719
- }
720
- if kind == "orchestration" and task_id:
721
- task = get_task(task_id) or {}
722
- 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"]
723
756
  run["execution_snapshot"] = _execution_snapshot(task)
724
757
  try:
725
758
  eta = _RUN_ESTIMATOR(
@@ -1746,10 +1779,9 @@ def update_task_params(task_id, patch):
1746
1779
  if task.get("direct_agent") != av and (av or task.get("direct_agent")):
1747
1780
  task["direct_agent"] = av
1748
1781
  changed = True
1749
- # 任务总时限(秒):建任务时写入,此后靠这里改——下次重试/续跑的
1750
- # create_run 会按它重新给满预算(2026-09-24 戍边骑奴案:12 章连载
1751
- # 默认 1 小时在慢链天跑不完,只能人工反复重试)。走 _task_timeout_s
1752
- # 统一钳制 [1s, 7天],脏值回落默认;空闲时才可改(与三件套同闸)。
1782
+ # 任务总时限(秒):建任务时按工作量给默认值,此处支持空闲时显式改。
1783
+ # 下次重试/续跑按新值重新给预算;已在跑的 run 保持原截止时间。
1784
+ # 统一钳制 [1s, 7天],脏值回落基础默认。
1753
1785
  raw_ts = patch.get("timeout_s")
1754
1786
  if raw_ts not in (None, ""):
1755
1787
  new_ts = _task_timeout_s(raw_ts)
@@ -1769,7 +1801,7 @@ def add_step(run_id, role, agent_id, agent_label, note="", model="", provider=""
1769
1801
  if not run:
1770
1802
  return None, None
1771
1803
  n = len(run["steps"]) + 1
1772
- step = {
1804
+ step = {
1773
1805
  "n": n, "role": role, "agent": agent_id, "agent_label": agent_label,
1774
1806
  "note": note,
1775
1807
  # 起跑即记解析到的模型:运行中蜂巢卡/步骤列表就能看到「在用哪个模型」,
@@ -1779,8 +1811,13 @@ def add_step(run_id, role, agent_id, agent_label, note="", model="", provider=""
1779
1811
  # provider 链与上游 host——烧错配额/模型漂移直接对账步骤行。
1780
1812
  # 只记身份不记密钥;老数据无此键,读取一律 .get
1781
1813
  "provider": str(provider or "")[:120],
1782
- "status": "running", "started_at": time.strftime("%H:%M:%S"),
1783
- "ended_at": None, "duration_s": None, "exit_code": None,
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,
1784
1821
  "summary": "", "log": None, "cost_usd": 0.0, "tokens": 0,
1785
1822
  }
1786
1823
  run["steps"].append(step)
@@ -1850,8 +1887,9 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1850
1887
  return
1851
1888
  for s in run["steps"]:
1852
1889
  if s["n"] == n:
1853
- s["status"] = status
1854
- 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()
1855
1893
  # 步骤摘要是 CLI 文本的汇聚点(失败时=错误尾巴,成功时=智能体结论):
1856
1894
  # 落盘前统一清洗,避免 ANSI/覆写/乱码墙进 UI 与报告
1857
1895
  s["summary"] = runner.clean_cli_text(summary)
@@ -1860,8 +1898,11 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1860
1898
  s["tokens"] = tokens
1861
1899
  if model:
1862
1900
  s["model"] = str(model)[:80]
1863
- if duration_s is not None:
1864
- 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)
1865
1906
  if output is not None:
1866
1907
  # output 是智能体正文(对话气泡直读):只剥 ANSI,不做噪声折叠
1867
1908
  # ——正文里的装饰性长串是作者写的,不能替它省略