codebee 0.1.68 → 0.1.70

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 (133) hide show
  1. package/CHANGELOG.md +580 -543
  2. package/LICENSE +21 -21
  3. package/README.md +11 -0
  4. package/app/browser-final-1440.png +0 -0
  5. package/app/browser-final-390.png +0 -0
  6. package/app/browser-google-fallback.png +0 -0
  7. package/app/browser-google-test.png +0 -0
  8. package/app/browser-workbench-1440.png +0 -0
  9. package/app/browser-workbench-390.png +0 -0
  10. package/app/browser-workspace-google.png +0 -0
  11. package/app/browser-workspace-smoke.png +0 -0
  12. package/app/browser-workspace.png +0 -0
  13. package/app/core/acceptance.py +100 -100
  14. package/app/core/aiflavor.py +197 -197
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -0
  19. package/app/core/automation.py +853 -758
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +95 -0
  23. package/app/core/board.py +209 -209
  24. package/app/core/bookmeta.py +935 -930
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +1673 -1234
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +7 -1
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/cleanup.py +303 -303
  32. package/app/core/compaction.py +201 -201
  33. package/app/core/covergen.py +356 -356
  34. package/app/core/defectretro.py +43 -43
  35. package/app/core/diagnostics.py +114 -114
  36. package/app/core/dispatch.py +190 -173
  37. package/app/core/dispatch_log.py +128 -128
  38. package/app/core/env_scrub.py +85 -85
  39. package/app/core/error_codes.py +33 -24
  40. package/app/core/errorlog.py +137 -137
  41. package/app/core/evalbench.py +720 -0
  42. package/app/core/evaluation.py +174 -174
  43. package/app/core/failure_notify.py +290 -290
  44. package/app/core/flows.py +228 -3
  45. package/app/core/goal_service.py +167 -167
  46. package/app/core/health.py +374 -374
  47. package/app/core/history.py +38 -38
  48. package/app/core/hooks.py +162 -162
  49. package/app/core/jobs.py +51 -30
  50. package/app/core/manager.py +2143 -2122
  51. package/app/core/market.py +362 -362
  52. package/app/core/market_remote.py +10 -0
  53. package/app/core/mcp_client.py +287 -0
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/modelhub.py +193 -10
  56. package/app/core/notify.py +201 -106
  57. package/app/core/novel_quality.py +189 -0
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +138 -138
  60. package/app/core/paths.py +6 -2
  61. package/app/core/pipeline.py +426 -28
  62. package/app/core/planner.py +243 -22
  63. package/app/core/plugins.py +525 -0
  64. package/app/core/portguard.py +111 -111
  65. package/app/core/preview.py +293 -293
  66. package/app/core/publish/__init__.py +7 -7
  67. package/app/core/publish/auto.py +367 -367
  68. package/app/core/publish/browser.py +655 -539
  69. package/app/core/publish/fanqie.py +165 -159
  70. package/app/core/publish/flow.py +589 -440
  71. package/app/core/publish/flows-fanqie-calibrated.json +336 -332
  72. package/app/core/publish/ledger.py +229 -203
  73. package/app/core/publish/manager.py +824 -692
  74. package/app/core/publish/qimao.py +146 -141
  75. package/app/core/publish/ws.py +139 -139
  76. package/app/core/redact.py +38 -38
  77. package/app/core/remotesync.py +121 -0
  78. package/app/core/repeat_guard.py +129 -129
  79. package/app/core/router.py +398 -398
  80. package/app/core/runner.py +2142 -2067
  81. package/app/core/selfupdate.py +384 -384
  82. package/app/core/session_log.py +161 -161
  83. package/app/core/sessions.py +312 -312
  84. package/app/core/settings.py +186 -149
  85. package/app/core/settings_schema.py +270 -262
  86. package/app/core/share_page.py +114 -114
  87. package/app/core/skill_scan.py +122 -122
  88. package/app/core/skillpacks/fanqie-novel.md +80 -80
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +104 -82
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tracing.py +144 -144
  102. package/app/core/upstream.py +41 -41
  103. package/app/core/usage.py +1035 -862
  104. package/app/core/volumes.py +389 -389
  105. package/app/core/wxdigest.py +858 -858
  106. package/app/core/zentao.py +2614 -2465
  107. package/app/current-browser.png +0 -0
  108. package/app/main.py +3245 -2981
  109. package/app/mcp_server.py +175 -0
  110. package/app/pet.py +82 -21
  111. package/app/pick_dialog.py +110 -110
  112. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  113. package/app/plugins/code-review-helper/skills/review.md +16 -0
  114. package/app/ui/app.js +15706 -14399
  115. package/app/ui/artdiff.js +74 -74
  116. package/app/ui/board.html +162 -162
  117. package/app/ui/board.js +305 -305
  118. package/app/ui/i18n.js +3012 -2770
  119. package/app/ui/icons/bee.svg +79 -79
  120. package/app/ui/index.html +277 -13
  121. package/app/ui/manifest.json +16 -16
  122. package/app/ui/qrcode.js +2297 -2297
  123. package/app/ui/style.css +802 -16
  124. package/bin/tutti.js +147 -147
  125. package/package.json +47 -46
  126. package/tools/wx_reader.py +261 -261
  127. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  128. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  129. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  131. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  132. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  133. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -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, tracing, volumes
21
+ from . import artifacts, audit, paths, runner, tracing, volumes
22
22
 
23
23
  LOCK = threading.RLock()
24
24
  _TASKS = {}
@@ -30,12 +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
36
- REVIEW_TASK_TIMEOUT_S = 7200
37
- LONG_TASK_TIMEOUT_S = 14400
38
- SERIAL_EXTRA_CHAPTER_S = 1800
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
39
39
 
40
40
  # ---------------------------------------------------------------- 状态版本(SSE 事件驱动)
41
41
  # 任何落盘写都算状态变化:SSE 连接等版本号变化才构建/推送全量状态,
@@ -86,7 +86,7 @@ def _safe_name(s):
86
86
  return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
87
87
 
88
88
 
89
- def _task_timeout_s(value=None):
89
+ def _task_timeout_s(value=None):
90
90
  """Resolve one bounded wall-clock budget for a task."""
91
91
  raw = value
92
92
  if raw in (None, ""):
@@ -95,27 +95,27 @@ def _task_timeout_s(value=None):
95
95
  seconds = int(float(raw))
96
96
  except (TypeError, ValueError):
97
97
  seconds = DEFAULT_TASK_TIMEOUT_S
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
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
119
119
 
120
120
 
121
121
  # ---------------------------------------------------------------- 任务
@@ -148,7 +148,7 @@ def create_task(payload):
148
148
  raise ValueError("目标描述不能为空")
149
149
  title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
150
150
  if not workdir:
151
- # 未指定目录 → 用「默认保存路径」(设置里可改;内置回落 <data 同级>/workspace)。
151
+ # 未指定目录 → 用「默认保存路径」(设置里可改;内置回落 <数据目录>/workspace)。
152
152
  # 默认路径允许自动创建;用户显式给的目录仍必须已存在。
153
153
  from . import settings as settings_mod
154
154
  workdir = settings_mod.default_workdir()
@@ -256,11 +256,11 @@ def create_task(payload):
256
256
  if serial_value is None or (isinstance(serial_value, dict) and
257
257
  serial_value.get("enabled") is False):
258
258
  serial = None
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"):
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"):
264
264
  try:
265
265
  s = {
266
266
  # 续写批次允许只续 1 章,下限放宽到 1(全新连载仍由前端约束 ≥2)
@@ -312,13 +312,13 @@ def create_task(payload):
312
312
  if vol_spec:
313
313
  s["volumes"] = vol_spec
314
314
  vper = volumes.norm_per(serial.get("volume_chapters"))
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")
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")
322
322
  if isinstance(critics, list) and critics:
323
323
  task["critics"] = [str(c) for c in critics]
324
324
  # 初始故事圣经必须在任务入队前落盘,保证首个章节步骤就能读到设定。
@@ -487,8 +487,8 @@ def get_task(task_id):
487
487
  return None
488
488
 
489
489
 
490
- def list_tasks(limit=100, archived=None):
491
- """archived=None 返回全部;False 仅未归档;True 仅已归档。按 id(含时间戳)倒序。"""
490
+ def list_tasks(limit=None, archived=None):
491
+ """按 id 倒序列任务。archived 筛选归档态;limit=None 返回全部。"""
492
492
  with LOCK:
493
493
  ids = sorted(_TASKS.keys(), reverse=True)
494
494
  out = []
@@ -497,7 +497,7 @@ def list_tasks(limit=100, archived=None):
497
497
  if archived is not None and bool(t.get("archived")) != archived:
498
498
  continue
499
499
  out.append(t)
500
- if len(out) >= limit:
500
+ if limit is not None and len(out) >= limit:
501
501
  break
502
502
  return out
503
503
 
@@ -729,8 +729,8 @@ def _execution_snapshot(task):
729
729
  TERMINAL_STATUSES = ("done", "failed", "cancelled", "timeout")
730
730
 
731
731
 
732
- def create_run(kind, title, task_id=None, entry_id=None, op=None):
733
- run = {
732
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
733
+ run = {
734
734
  "id": _new_id("r" if kind == "orchestration" else "m"),
735
735
  "kind": kind, # orchestration | mgmt
736
736
  "title": title,
@@ -740,19 +740,19 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
740
740
  "started_at": None, "ended_at": None,
741
741
  "deadline_at": None,
742
742
  "cost_usd": 0.0, "tokens": 0, "error": "",
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"]
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
756
  run["execution_snapshot"] = _execution_snapshot(task)
757
757
  try:
758
758
  eta = _RUN_ESTIMATOR(
@@ -1435,8 +1435,12 @@ def archive_task(task_id, archived=True):
1435
1435
  return True, ""
1436
1436
 
1437
1437
 
1438
- def delete_task(task_id):
1439
- """删除任务及其全部运行记录(含日志与报告目录)。返回 (ok, 错误信息)。"""
1438
+ def delete_task(task_id, actor=""):
1439
+ """删除任务及其全部运行记录(含日志与报告目录)。返回 (ok, 错误信息)。
1440
+
1441
+ 不可逆且无回收站,故落审计(谁/何时/删了什么——2026-09-28 连载根任务
1442
+ 被删案:事后无人能回答这两问)。连载链的前序任务被后续任务 serial.continues
1443
+ 依赖,删了会把整条链的「继续连载/断点续跑」地基抽掉——这类任务只许归档。"""
1440
1444
  if not _valid_id(task_id):
1441
1445
  return False, "非法的任务 ID"
1442
1446
  with LOCK:
@@ -1445,6 +1449,11 @@ def delete_task(task_id):
1445
1449
  return False, "任务不存在"
1446
1450
  if task.get("status") in ("queued", "running"):
1447
1451
  return False, "运行中的任务不能删除,请先取消"
1452
+ if task.get("type") == "serial_novel":
1453
+ for other in _TASKS.values():
1454
+ if other.get("id") != task_id and \
1455
+ (other.get("serial") or {}).get("continues") == task_id:
1456
+ return False, "该任务是连载链的前序任务(后续任务依赖它),请用「归档」隐藏而不是删除"
1448
1457
  run_ids = []
1449
1458
  for rid, r in _RUNS.items():
1450
1459
  if r.get("task_id") != task_id:
@@ -1458,6 +1467,15 @@ def delete_task(task_id):
1458
1467
  for rid in run_ids:
1459
1468
  _schedule_run_dir_cleanup(rid)
1460
1469
  (paths.TASKS_DIR / (task_id + ".json")).unlink(missing_ok=True)
1470
+ audit.record("task_delete", {
1471
+ "task_id": task_id,
1472
+ "title": task.get("title") or "",
1473
+ "type": task.get("type") or "",
1474
+ "status": task.get("status") or "",
1475
+ "workdir": task.get("workdir") or "",
1476
+ "run_ids": run_ids,
1477
+ "actor": actor or "local",
1478
+ })
1461
1479
  bump_state()
1462
1480
  return True, ""
1463
1481
 
@@ -1779,9 +1797,9 @@ def update_task_params(task_id, patch):
1779
1797
  if task.get("direct_agent") != av and (av or task.get("direct_agent")):
1780
1798
  task["direct_agent"] = av
1781
1799
  changed = True
1782
- # 任务总时限(秒):建任务时按工作量给默认值,此处支持空闲时显式改。
1783
- # 下次重试/续跑按新值重新给预算;已在跑的 run 保持原截止时间。
1784
- # 统一钳制 [1s, 7天],脏值回落基础默认。
1800
+ # 任务总时限(秒):建任务时按工作量给默认值,此处支持空闲时显式改。
1801
+ # 下次重试/续跑按新值重新给预算;已在跑的 run 保持原截止时间。
1802
+ # 统一钳制 [1s, 7天],脏值回落基础默认。
1785
1803
  raw_ts = patch.get("timeout_s")
1786
1804
  if raw_ts not in (None, ""):
1787
1805
  new_ts = _task_timeout_s(raw_ts)
@@ -1801,7 +1819,7 @@ def add_step(run_id, role, agent_id, agent_label, note="", model="", provider=""
1801
1819
  if not run:
1802
1820
  return None, None
1803
1821
  n = len(run["steps"]) + 1
1804
- step = {
1822
+ step = {
1805
1823
  "n": n, "role": role, "agent": agent_id, "agent_label": agent_label,
1806
1824
  "note": note,
1807
1825
  # 起跑即记解析到的模型:运行中蜂巢卡/步骤列表就能看到「在用哪个模型」,
@@ -1811,13 +1829,13 @@ def add_step(run_id, role, agent_id, agent_label, note="", model="", provider=""
1811
1829
  # provider 链与上游 host——烧错配额/模型漂移直接对账步骤行。
1812
1830
  # 只记身份不记密钥;老数据无此键,读取一律 .get
1813
1831
  "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,
1832
+ "status": "running", "started_at": time.strftime("%H:%M:%S"),
1833
+ "started_at_epoch": time.time(),
1834
+ "trace_id": str(run.get("trace_id") or tracing.trace_id(run_id)),
1835
+ "span_id": tracing.span_id(run_id, n),
1836
+ "parent_span_id": str(run.get("root_span_id") or
1837
+ tracing.root_span_id(run_id)),
1838
+ "ended_at": None, "duration_s": None, "exit_code": None,
1821
1839
  "summary": "", "log": None, "cost_usd": 0.0, "tokens": 0,
1822
1840
  }
1823
1841
  run["steps"].append(step)
@@ -1880,16 +1898,16 @@ def clear_stream_state(run_id=None, n=None):
1880
1898
 
1881
1899
  def finish_step(run_id, n, status, summary="", exit_code=None,
1882
1900
  cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
1883
- followups=None, thinking=None):
1901
+ followups=None, thinking=None, partial=False):
1884
1902
  with LOCK:
1885
1903
  run = _RUNS.get(run_id)
1886
1904
  if not run:
1887
1905
  return
1888
1906
  for s in run["steps"]:
1889
1907
  if s["n"] == n:
1890
- s["status"] = status
1891
- s["ended_at"] = time.strftime("%H:%M:%S")
1892
- s["ended_at_epoch"] = time.time()
1908
+ s["status"] = status
1909
+ s["ended_at"] = time.strftime("%H:%M:%S")
1910
+ s["ended_at_epoch"] = time.time()
1893
1911
  # 步骤摘要是 CLI 文本的汇聚点(失败时=错误尾巴,成功时=智能体结论):
1894
1912
  # 落盘前统一清洗,避免 ANSI/覆写/乱码墙进 UI 与报告
1895
1913
  s["summary"] = runner.clean_cli_text(summary)
@@ -1898,11 +1916,11 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1898
1916
  s["tokens"] = tokens
1899
1917
  if model:
1900
1918
  s["model"] = str(model)[:80]
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)
1919
+ if duration_s is not None:
1920
+ s["duration_s"] = round(duration_s, 1)
1921
+ elif s.get("started_at_epoch") is not None:
1922
+ s["duration_s"] = round(max(
1923
+ 0.0, s["ended_at_epoch"] - s["started_at_epoch"]), 1)
1906
1924
  if output is not None:
1907
1925
  # output 是智能体正文(对话气泡直读):只剥 ANSI,不做噪声折叠
1908
1926
  # ——正文里的装饰性长串是作者写的,不能替它省略
@@ -1911,6 +1929,10 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1911
1929
  s["thinking"] = runner.strip_ansi(str(thinking))[:LIVE_TEXT_MAX]
1912
1930
  if followups:
1913
1931
  s["followups"] = list(followups)[:3]
1932
+ if partial:
1933
+ s["partial"] = True
1934
+ else:
1935
+ s.pop("partial", None)
1914
1936
  # 收尾即清运行中态:stream/activity/live 只是过程快照,留着会让
1915
1937
  # 前端把「已结束」的步骤仍当实时流渲染(也白占 run.json 体积)
1916
1938
  s.pop("stream", None)