specmodule 0.1.2__tar.gz → 0.1.4__tar.gz

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 (47) hide show
  1. {specmodule-0.1.2/specmodule.egg-info → specmodule-0.1.4}/PKG-INFO +1 -1
  2. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/__init__.py +11 -0
  3. specmodule-0.1.4/module_harness/call.py +89 -0
  4. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/cli.py +47 -1
  5. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/consistency.py +28 -35
  6. specmodule-0.1.4/module_harness/control.py +175 -0
  7. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/module.py +107 -25
  8. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/query.py +219 -0
  9. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/registry.py +9 -0
  10. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/status.py +1 -1
  11. specmodule-0.1.4/module_harness/stream.py +65 -0
  12. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/submodule.py +9 -2
  13. {specmodule-0.1.2 → specmodule-0.1.4}/pyproject.toml +10 -2
  14. {specmodule-0.1.2 → specmodule-0.1.4/specmodule.egg-info}/PKG-INFO +1 -1
  15. {specmodule-0.1.2 → specmodule-0.1.4}/specmodule.egg-info/SOURCES.txt +3 -0
  16. {specmodule-0.1.2 → specmodule-0.1.4}/LICENSE +0 -0
  17. {specmodule-0.1.2 → specmodule-0.1.4}/README.md +0 -0
  18. {specmodule-0.1.2 → specmodule-0.1.4}/llm/__init__.py +0 -0
  19. {specmodule-0.1.2 → specmodule-0.1.4}/llm/client.py +0 -0
  20. {specmodule-0.1.2 → specmodule-0.1.4}/llm/config.py +0 -0
  21. {specmodule-0.1.2 → specmodule-0.1.4}/llm/mock.py +0 -0
  22. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/align.py +0 -0
  23. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/builtins.py +0 -0
  24. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/checkpoint.py +0 -0
  25. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/command.py +0 -0
  26. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/config.py +0 -0
  27. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/entry.py +0 -0
  28. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/events.py +0 -0
  29. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/feed.py +0 -0
  30. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/graph_builder.py +0 -0
  31. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/harness.py +0 -0
  32. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/loader.py +0 -0
  33. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/outputfmt.py +0 -0
  34. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/prompt.py +0 -0
  35. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/scaffold.py +0 -0
  36. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/spec.py +0 -0
  37. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/store.py +0 -0
  38. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/templates/builtin/codereview.json +0 -0
  39. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/templates/builtin/docwrite.json +0 -0
  40. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/templates/builtin/summarize.json +0 -0
  41. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/templates/builtin/translate.json +0 -0
  42. {specmodule-0.1.2 → specmodule-0.1.4}/module_harness/translator.py +0 -0
  43. {specmodule-0.1.2 → specmodule-0.1.4}/setup.cfg +0 -0
  44. {specmodule-0.1.2 → specmodule-0.1.4}/specmodule.egg-info/dependency_links.txt +0 -0
  45. {specmodule-0.1.2 → specmodule-0.1.4}/specmodule.egg-info/entry_points.txt +0 -0
  46. {specmodule-0.1.2 → specmodule-0.1.4}/specmodule.egg-info/requires.txt +0 -0
  47. {specmodule-0.1.2 → specmodule-0.1.4}/specmodule.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specmodule
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: 可审计、可调试、可完全掌控的 LLM 使用框架(tickflow + llm + module_harness)
5
5
  License: MIT
6
6
  Keywords: llm,workflow,petri-net,agent
@@ -24,6 +24,7 @@ from .events import (
24
24
  )
25
25
  from .command import Command, CommandConfig
26
26
  from .harness import Harness
27
+ from .call import HarnessCallError, HarnessCallResult, call_harness
27
28
  from .registry import HarnessRegistry
28
29
  from .spec import (
29
30
  Spec,
@@ -57,6 +58,7 @@ from .query import (
57
58
  ReviewTimeline,
58
59
  build_checkpoints,
59
60
  build_timeline,
61
+ check_resume_compat_from_run,
60
62
  checkpoints_to_dict,
61
63
  create_checkpoint,
62
64
  filter_failed,
@@ -95,6 +97,10 @@ from .store import (
95
97
  validate_pack_dir,
96
98
  )
97
99
 
100
+ # 嵌入者最小面(用法见 docs/guides/embedding.md):
101
+ # task 级 = call_harness / HarnessCallResult / HarnessCallError
102
+ # 图级 = Module / HarnessRegistry + HarnessConfig / OutputFormat / EventBus
103
+ # + TemplateLoader + register_builtin_harnesses
98
104
  __all__ = [
99
105
  # 配置
100
106
  "HarnessConfig",
@@ -118,6 +124,10 @@ __all__ = [
118
124
  "ScriptFailed",
119
125
  # 核心
120
126
  "Harness",
127
+ # task 级 API 地板(嵌入者消费面,docs/dev/superpowers/specs/2026-09-01-embedder-face-design.md)
128
+ "HarnessCallResult",
129
+ "HarnessCallError",
130
+ "call_harness",
121
131
  "HarnessRegistry",
122
132
  "Command",
123
133
  "CommandConfig",
@@ -168,6 +178,7 @@ __all__ = [
168
178
  "ResumeCheck",
169
179
  "ResumeError",
170
180
  "check_resume_compat",
181
+ "check_resume_compat_from_run",
171
182
  # 模块入口(roadmap Phase 0:CLI 使用)
172
183
  "ModuleEntry",
173
184
  "discover_modules",
@@ -0,0 +1,89 @@
1
+ # module_harness/call.py
2
+ """task 级 API 地板 —— 独立调用 harness(嵌入者消费面)。
3
+
4
+ API 金字塔自此 task → graph → run:嵌入者一次函数调用即得 harness 节点的
5
+ 全部执行语义(三层 prompt / 输出校验 / 事件),不经图与 run。
6
+ 零新执行语义:内部即 Harness.build_body + 一次 body 调用,仅一份执行配方。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from tickflow import Failure
15
+ from tickflow.views import DictView, Resolved
16
+
17
+ from .config import HarnessConfig
18
+ from .events import EventBus
19
+ from .harness import Harness
20
+
21
+
22
+ @dataclass
23
+ class HarnessCallResult:
24
+ """独立调用结果:校验后输出 + LLM 原始输出 + token 用量。"""
25
+
26
+ value: Any # 校验后的输出(json_object → 解析值;text → str)
27
+ raw: str # LLM 原始输出(审计链)
28
+ usage: dict[str, int] # token 用量
29
+
30
+
31
+ class HarnessCallError(RuntimeError):
32
+ """独立调用失败(LLM 错误 / 输出不合法)。异常即审计:携带诊断链。"""
33
+
34
+ def __init__(
35
+ self,
36
+ failure: Failure,
37
+ *,
38
+ prompt: str | None = None,
39
+ raw: str | None = None,
40
+ usage: dict[str, int] | None = None,
41
+ ) -> None:
42
+ self.failure = failure
43
+ self.prompt = prompt
44
+ self.raw = raw
45
+ self.usage = usage
46
+ super().__init__(failure.error)
47
+
48
+
49
+ async def call_harness(
50
+ config: HarnessConfig,
51
+ values: dict[str, Any],
52
+ *,
53
+ llm_client: Any,
54
+ promptmode: str | None = None,
55
+ prompt_extra: str | None = None,
56
+ event_bus: EventBus | None = None,
57
+ ) -> HarnessCallResult:
58
+ """独立调用一个 harness:一次函数调用拿到校验后的输出。
59
+
60
+ ``values``:prompt 占位符取值 {key: value}。task 层的占位符兜底就是它
61
+ (无 spec_inputs / input_aliases —— 那些是图概念)。
62
+
63
+ ``event_bus``:传则收全套 harness 事件(PromptRendered / LlmToken /
64
+ OutputValidated / ...),不传零开销(EventBus.null())。
65
+
66
+ 失败(LLM 错误 / 输出校验不通过)抛 HarnessCallError,携带 failure 与
67
+ 渲染 prompt / 原始输出 / usage 诊断链。task 层没有"下游跳过"概念,
68
+ Failure 一律翻译为异常;promptmode 缺 key → KeyError 原样冒出。
69
+ """
70
+ bus = event_bus or EventBus.null()
71
+ body = Harness(config, llm_client, bus).build_body(
72
+ promptmode=promptmode,
73
+ prompt_extra=prompt_extra,
74
+ )
75
+ state: dict[str, Any] = {}
76
+ view = DictView(
77
+ {key: Resolved(value=val, k=None) for key, val in values.items()},
78
+ state=state,
79
+ node="__call__",
80
+ )
81
+ result = await body(view)
82
+
83
+ prompt = state.get("_prompt")
84
+ raw = state.get("_llm_raw")
85
+ usage = state.get("_usage")
86
+
87
+ if isinstance(result, Failure):
88
+ raise HarnessCallError(result, prompt=prompt, raw=raw, usage=usage)
89
+ return HarnessCallResult(value=result, raw=raw, usage=usage)
@@ -12,6 +12,7 @@
12
12
  python -m module_harness.cli snapshot [<tick>] [--run-id xxx] [--json] [--out FILE]
13
13
  python -m module_harness.cli rollback <目标> --module <名> [--spec ...]
14
14
  python -m module_harness.cli checkpoint <label> [<tick>] [--run-id xxx]
15
+ python -m module_harness.cli cancel | pause | unpause [--run-id xxx]
15
16
  python -m module_harness.cli visualize --module <名> [--tasklist x.json | --run-id xxx] [--out FILE]
16
17
 
17
18
  场景归属:使用者层面(usage scenario)——第二级用户只写 spec/tasklist,
@@ -430,10 +431,21 @@ def _run_resume_cmd(args: argparse.Namespace, *, require_target: bool) -> int:
430
431
  _check_spec_schema(res, spec)
431
432
  llm_client = _build_llm_client(args.mock)
432
433
  template_name = args.template or res.default_template
434
+ tasklist = _load_tasklist(args.tasklist) if args.tasklist else None
433
435
  if args.tasklist:
434
436
  # tasklist 路径:跳过翻译,template_name 置 None(与 Module
435
437
  # "template/tasklist 二选一"不变量对齐)
436
438
  template_name = None
439
+ elif template_name is None and tasklist is None:
440
+ # 流程来源兜底:显式参数 > entry.default_template > module_inputs
441
+ # 归档 tasklist(续跑语义本该默认沿用原任务书——tasklist 通道
442
+ # 启动的 run 无模板可回落,此前只能靠显式 --tasklist 续跑)
443
+ from .query import read_module_inputs
444
+
445
+ archived = read_module_inputs(module_id)
446
+ if archived and archived.get("tasklist"):
447
+ tasklist = Tasklist.from_json(archived["tasklist"])
448
+ print("流程来源:沿用 module_inputs 归档 tasklist", file=sys.stderr)
437
449
  # 回退目标:显式直传;resume 缺省(None)由库解析为最新 tick 快照
438
450
  # (须在 Module 构造前检查——构造即写 status.json idle,会覆盖前次终态)
439
451
  rollback_to = args.rollback
@@ -469,7 +481,7 @@ def _run_resume_cmd(args: argparse.Namespace, *, require_target: bool) -> int:
469
481
  mod = res.entry.build_module(
470
482
  spec,
471
483
  template_name=template_name,
472
- tasklist=_load_tasklist(args.tasklist) if args.tasklist else None,
484
+ tasklist=tasklist,
473
485
  llm_client=llm_client,
474
486
  module_id=module_id,
475
487
  hooks=display.hooks(),
@@ -524,6 +536,29 @@ def _latest_run_id() -> str | None:
524
536
  return max(dirs, key=lambda d: d.stat().st_mtime).name
525
537
 
526
538
 
539
+ def _cmd_control(args: argparse.Namespace) -> int:
540
+ """cancel/pause/unpause 共享实现:写控制文件(运行进程 tick 边界消费)。
541
+
542
+ 纯数据操作(file 即通道),不接触运行进程;前置只校验目标 run 存在
543
+ (status.json 落盘)。生效时机取决于运行进程的下一 tick 边界。
544
+ """
545
+ from .control import request_control
546
+
547
+ module_id = args.run_id or _latest_run_id()
548
+ if module_id is None or not (
549
+ _persist_dir(module_id).parent / "status.json"
550
+ ).exists():
551
+ print(f"无运行记录: {module_id or '(无任何运行)'}", file=sys.stderr)
552
+ return 1
553
+ try:
554
+ request_control(module_id, args.command, reason=getattr(args, "reason", None))
555
+ except ValueError as e:
556
+ print(f"错误: {e}", file=sys.stderr)
557
+ return 1
558
+ print(f"已请求 {args.command}: {module_id}(运行进程将在下一 tick 边界生效)")
559
+ return 0
560
+
561
+
527
562
  def _cmd_init(args: argparse.Namespace) -> int:
528
563
  """生成模块脚手架:--as-dir 目录形态(pack 同构)或单文件形态 + 项目文件补齐。"""
529
564
  try:
@@ -1303,6 +1338,17 @@ def main(argv: list[str] | None = None) -> int:
1303
1338
  p_checkpoint.add_argument("--run-id", help="运行 id(默认最近运行)")
1304
1339
  p_checkpoint.set_defaults(func=_cmd_checkpoint)
1305
1340
 
1341
+ for _name, _help in (
1342
+ ("cancel", "请求取消运行(协作式:下一 tick 边界生效,phase→cancelled)"),
1343
+ ("pause", "请求暂停运行(tick 边界挂起,tick 计数不前进)"),
1344
+ ("unpause", "释放暂停,运行继续"),
1345
+ ):
1346
+ _p = sub.add_parser(_name, help=_help)
1347
+ _p.add_argument("--run-id", help="运行 id(默认最近运行)")
1348
+ if _name == "cancel":
1349
+ _p.add_argument("--reason", help="取消原因(透传 runner.cancel)")
1350
+ _p.set_defaults(func=_cmd_control)
1351
+
1306
1352
  p_visualize = sub.add_parser(
1307
1353
  "visualize", help="渲染 tasklist 对应图(mermaid 导出)"
1308
1354
  )
@@ -1,18 +1,16 @@
1
1
  # module_harness/consistency.py
2
2
  """一致性审核 — spec + tasklist 语义一致性检查。
3
3
 
4
- 独立于翻译通道:审核不经过模板,直接调用注册的审核 harness body(不走 tickflow)。
4
+ 独立于翻译通道:审核不经过模板,经 call_harness 直接调用注册的审核
5
+ harness 配置(不走 tickflow 图)。
5
6
  """
6
7
 
7
8
  from __future__ import annotations
8
9
 
9
10
  import json
10
11
  from dataclasses import dataclass
11
- from typing import Any
12
-
13
- from tickflow import Failure
14
- from tickflow.views import DictView, Resolved
15
12
 
13
+ from .call import HarnessCallError, call_harness
16
14
  from .config import HarnessConfig
17
15
  from .outputfmt import OutputFormat
18
16
  from .registry import HarnessRegistry
@@ -60,7 +58,14 @@ def register_review_harness(
60
58
 
61
59
 
62
60
  class ConsistencyReviewer:
63
- """调用审核 harness body,返回 ConsistencyReport。"""
61
+ """调用审核 harness,返回 ConsistencyReport。
62
+
63
+ 审核走 call_harness(task 级地板):不传 bus,内部中间事件静默
64
+ (ConsistencyReviewed 领域事件由 Module 直接发射,不经此处)。
65
+ 按 register_review_harness 契约,审核 harness 只带 config 注册
66
+ (注册期 promptmode/prompt_extra/spec_inputs/input_aliases 对审核器无意义,
67
+ call_harness 路径不传)。
68
+ """
64
69
 
65
70
  def __init__(
66
71
  self, registry: HarnessRegistry, harness_name: str = "spec_tasklist_review"
@@ -70,41 +75,32 @@ class ConsistencyReviewer:
70
75
 
71
76
  async def review(self, spec: Spec, tasklist: Tasklist) -> ConsistencyReport:
72
77
  """执行一致性审核。审核失败(LLM 错误/输出不合法)抛 ValueError。"""
73
- if self.reg.harness_config(self.harness_name) is None:
78
+ config = self.reg.harness_config(self.harness_name)
79
+ if config is None:
74
80
  raise ValueError(
75
81
  f"审核 harness '{self.harness_name}' 未注册。"
76
82
  f"请先调用 register_review_harness(reg) 注册内置审核器,"
77
83
  f"或自行 reg.harness('{self.harness_name}', ...)。"
78
84
  )
79
- body = self.reg.get_body(self.harness_name)
80
-
81
- tasklist_dict = tasklist.to_dict()
82
- state: dict[str, Any] = {}
83
- view = DictView(
84
- {
85
- "spec": Resolved(value=spec.to_dict(), k=None),
86
- "tasklist": Resolved(
87
- value=json.dumps(tasklist_dict, ensure_ascii=False), k=None
88
- ),
89
- },
90
- state=state,
91
- node="__review__",
92
- )
93
- result = await body(view)
94
85
 
95
- if isinstance(result, Failure):
96
- raise ValueError(f"审核 harness 返回 Failure: {result.error}")
86
+ try:
87
+ call = await call_harness(
88
+ config,
89
+ {
90
+ "spec": spec.to_dict(),
91
+ "tasklist": json.dumps(tasklist.to_dict(), ensure_ascii=False),
92
+ },
93
+ llm_client=self.reg.llm_client,
94
+ )
95
+ except HarnessCallError as e:
96
+ raise ValueError(f"审核 harness 返回 Failure: {e.failure.error}") from e
97
97
 
98
- if isinstance(result, str):
98
+ data = call.value
99
+ if isinstance(data, str):
99
100
  try:
100
- data = json.loads(result)
101
+ data = json.loads(data)
101
102
  except json.JSONDecodeError as e:
102
103
  raise ValueError(f"审核输出不是合法 JSON: {e}") from e
103
- elif isinstance(result, dict):
104
- data = result
105
- else:
106
- raise ValueError(f"审核输出类型异常: {type(result).__name__}")
107
-
108
104
  if not isinstance(data, dict):
109
105
  raise ValueError(f"审核输出必须是 JSON 对象: {data!r}")
110
106
 
@@ -115,9 +111,6 @@ class ConsistencyReviewer:
115
111
  if not isinstance(suggestions, str):
116
112
  raise ValueError(f"审核输出 'suggestions' 必须是字符串: {data!r}")
117
113
 
118
- raw = state.get("_llm_raw")
119
- if raw is None:
120
- raw = result if isinstance(result, str) else json.dumps(data, ensure_ascii=False)
121
114
  return ConsistencyReport(
122
- consistent=consistent, suggestions=suggestions, raw=raw
115
+ consistent=consistent, suggestions=suggestions, raw=call.raw
123
116
  )
@@ -0,0 +1,175 @@
1
+ # module_harness/control.py
2
+ """跨进程运行控制——控制文件协议(cancel/pause/unpause)。
3
+
4
+ status.json 的反向通道:status.json 把运行状态带出运行进程,control.json
5
+ 把控制请求带进运行进程——文件即协议,任何消费端(CLI/Web/TUI)可写,
6
+ 运行进程在 tick 边界协作式消费。零依赖、不触碰运行状态的单写者规则
7
+ (status.json 仍只由运行进程写)。
8
+
9
+ 协议:``.specmodule/runs/<run_id>/control.json``,单发一次性请求::
10
+
11
+ {"action": "cancel" | "pause" | "unpause", "reason": str|null,
12
+ "requested_at": float}
13
+
14
+ - 写方:``request_control`` 原子写(tmp + os.replace,与 status.json 同款)。
15
+ - 读方:``control_tick_start`` / ``control_tick_end`` 工厂返回 hook 回调,
16
+ 由 Module 注册到 runner(``control=False`` 关闭):
17
+ - **cancel 在 ``on_tick_end`` 消费**——引擎每个 tick 结束都会无条件重写
18
+ ``runner.status``(RUNNING/IDLE),tick_start 期设置的 CANCELLED 终态
19
+ 会被同 tick 末尾的赋值冲掉;tick_end hook 在赋值之后运行,此刻
20
+ ``runner.cancel()`` 设置的终态能活到 ``run_until_idle`` 的下轮
21
+ terminal 检查,循环即停。请求写在 tick N 内 → N(或 N+1)末尾生效,
22
+ 即取消有一 tick 延迟(当前 tick 内已开始的 firing 会跑完)。
23
+ - **pause 在 ``on_tick_start`` 挂起**——即将 fire 的 tick 不启动,tick
24
+ 计数不前进(max_ticks 不消耗),轮询等待 unpause 或 cancel;挂起期间
25
+ 保留文件(文件本身就是"暂停中"状态,监控方 ``read_control`` 可读)。
26
+ 挂起中见到 cancel:不清除文件、直接放行——留给 tick_end 消费(同上
27
+ 冲掉问题),当前 tick 跑完后停。
28
+ - 消费即删(delete-on-consume):动作执行后删除文件,防重放。
29
+ - 新执行清场:``Module.run()/resume()`` 开始时 ``clear_control``——启动新
30
+ 执行即作废陈旧请求(进程崩溃残留的 pause 不会拖住下一次运行)。
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import json
37
+ import logging
38
+ import os
39
+ import time
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+ __all__ = [
46
+ "ACTIONS",
47
+ "clear_control",
48
+ "control_path",
49
+ "control_tick_end",
50
+ "control_tick_start",
51
+ "read_control",
52
+ "request_control",
53
+ ]
54
+
55
+ #: 合法控制动作。
56
+ ACTIONS: tuple[str, ...] = ("cancel", "pause", "unpause")
57
+
58
+ #: pause 挂起期间的轮询间隔(秒)。
59
+ POLL_SECONDS = 0.5
60
+
61
+
62
+ def control_path(module_id: str, base_dir: Path | None = None) -> Path:
63
+ """控制文件路径:``<base_dir>/.specmodule/runs/<run_id>/control.json``。"""
64
+ return (base_dir or Path.cwd()) / ".specmodule" / "runs" / module_id / "control.json"
65
+
66
+
67
+ def read_control(module_id: str, base_dir: Path | None = None) -> dict[str, Any] | None:
68
+ """读当前控制请求;无请求 / 文件损坏 / action 非法 → None(容错读)。"""
69
+ path = control_path(module_id, base_dir)
70
+ if not path.exists():
71
+ return None
72
+ try:
73
+ data = json.loads(path.read_text(encoding="utf-8"))
74
+ except (json.JSONDecodeError, OSError):
75
+ log.warning("control.json 损坏或不可读(忽略): %s", path)
76
+ return None
77
+ if not isinstance(data, dict) or data.get("action") not in ACTIONS:
78
+ log.warning("control.json 内容非法(忽略): %s", path)
79
+ return None
80
+ return data
81
+
82
+
83
+ def request_control(
84
+ module_id: str,
85
+ action: str,
86
+ *,
87
+ reason: str | None = None,
88
+ base_dir: Path | None = None,
89
+ ) -> dict[str, Any]:
90
+ """写入控制请求(原子写)。action 非法 → ValueError。返回写入的请求。"""
91
+ if action not in ACTIONS:
92
+ raise ValueError(
93
+ f"未知控制动作: {action!r}(可用: {'/'.join(ACTIONS)})"
94
+ )
95
+ req: dict[str, Any] = {
96
+ "action": action,
97
+ "reason": reason,
98
+ "requested_at": time.time(),
99
+ }
100
+ path = control_path(module_id, base_dir)
101
+ tmp = path.with_suffix(".json.tmp")
102
+ try:
103
+ tmp.parent.mkdir(parents=True, exist_ok=True)
104
+ tmp.write_text(json.dumps(req, ensure_ascii=False), encoding="utf-8")
105
+ os.replace(tmp, path)
106
+ except OSError:
107
+ log.exception("写 control.json 失败: %s", path)
108
+ raise
109
+ return req
110
+
111
+
112
+ def clear_control(module_id: str, *, base_dir: Path | None = None) -> None:
113
+ """删除控制文件(消费/清场)。缺失不报错;删除失败仅 log。"""
114
+ try:
115
+ control_path(module_id, base_dir).unlink(missing_ok=True)
116
+ except OSError:
117
+ log.exception("删除 control.json 失败(忽略)")
118
+
119
+
120
+ def control_tick_start(
121
+ runner: Any,
122
+ module_id: str,
123
+ *,
124
+ base_dir: Path | None = None,
125
+ poll: float = POLL_SECONDS,
126
+ ):
127
+ """工厂:注册到 ``runner.on_tick_start`` 的 async pause/unpause hook。
128
+
129
+ 只处理 pause 挂起(cancel 统一由 ``control_tick_end`` 消费——见模块
130
+ docstring 的终态冲掉说明)。``runner`` 仅用于签名一致性(本 hook 不调
131
+ runner 方法);由 Module 在构建 runner 后注册。
132
+ """
133
+ async def _on_tick_start(tick: int, fireable: list[str]) -> None:
134
+ req = read_control(module_id, base_dir=base_dir)
135
+ if req is None or req["action"] == "cancel":
136
+ # cancel 留给 tick_end(不清文件);无请求直接放行
137
+ return
138
+ if req["action"] != "pause":
139
+ return
140
+ # pause:tick 边界挂起;文件保留 = "暂停中"状态(监控方 read_control 可读)
141
+ while True:
142
+ await asyncio.sleep(poll)
143
+ nxt = read_control(module_id, base_dir=base_dir)
144
+ if nxt is None:
145
+ # 挂起期间文件被外部删除(人工清理)→ 视为释放
146
+ return
147
+ if nxt["action"] == "cancel":
148
+ # 不清除、直接放行——tick_end 消费(终态不被本 tick 冲掉)
149
+ return
150
+ if nxt["action"] == "unpause":
151
+ clear_control(module_id, base_dir=base_dir)
152
+ return
153
+
154
+ return _on_tick_start
155
+
156
+
157
+ def control_tick_end(
158
+ runner: Any,
159
+ module_id: str,
160
+ *,
161
+ base_dir: Path | None = None,
162
+ ):
163
+ """工厂:注册到 ``runner.on_tick_end`` 的 cancel 消费 hook。
164
+
165
+ 在引擎每 tick 末尾的状态赋值**之后**运行——此刻 ``runner.cancel()``
166
+ 设置的 CANCELLED 能活到 ``run_until_idle`` 下轮 terminal 检查。
167
+ """
168
+ async def _on_tick_end(tick: int, firings: list) -> None:
169
+ req = read_control(module_id, base_dir=base_dir)
170
+ if req is None or req["action"] != "cancel":
171
+ return
172
+ runner.cancel(req.get("reason") or "cancelled")
173
+ clear_control(module_id, base_dir=base_dir)
174
+
175
+ return _on_tick_end
@@ -20,7 +20,14 @@ from .consistency import ConsistencyError, ConsistencyReport, ConsistencyReviewe
20
20
  from .translator import Translator, TemplateLoader, TasklistValidator
21
21
  from .graph_builder import TasklistTranslator
22
22
  from .registry import HarnessRegistry
23
- from .events import EventBus, ConsistencyReviewed
23
+ from .events import (
24
+ EventBus,
25
+ ConsistencyReviewed,
26
+ HarnessFailed,
27
+ LlmCallCompleted,
28
+ LlmCallStarted,
29
+ LlmToken,
30
+ )
24
31
  from .checkpoint import (
25
32
  ModuleInputStore,
26
33
  ResumeError,
@@ -28,6 +35,9 @@ from .checkpoint import (
28
35
  tasklist_from_dict,
29
36
  tasklist_to_dict,
30
37
  )
38
+ from .query import _executed_nodes
39
+ from .control import clear_control, control_tick_end, control_tick_start
40
+ from .stream import StreamLogWriter, stream_log_path
31
41
 
32
42
  log = logging.getLogger(__name__)
33
43
 
@@ -72,6 +82,11 @@ class Module:
72
82
  keep_records: bool = True,
73
83
  persist: bool = True,
74
84
  status_file: bool = True,
85
+ control: bool = True,
86
+ # True(默认):LLM 流式输出落盘 stream.log(跨进程流式观测,见
87
+ # stream.py)。EventBus.null() 场景只有 run_start 记录(事件被
88
+ # null bus 吞掉,属预期)。
89
+ stream_log: bool = True,
75
90
  modules: dict[str, Any] | None = None,
76
91
  hooks: dict | None = None,
77
92
  ) -> None:
@@ -88,6 +103,14 @@ class Module:
88
103
  # True(默认):写 .specmodule/runs/<module_id>/status.json
89
104
  # (阶段级,跨进程查询通道);False:零残留(快速模式可用)
90
105
  self.status_file = status_file
106
+ # True(默认):注册控制文件 hook(control.json → cancel/pause,
107
+ # 见 control.py)。跨进程取消/暂停的协作式通道;False 关闭。
108
+ self.control = control
109
+ self.stream_log = stream_log
110
+ # stream.log 接线状态:订阅只挂一次(防多次执行重复订阅记录翻倍);
111
+ # writer 每次执行重建(_run_with_phases 生命周期),事件回调读当前属性
112
+ self._stream_subscribed = False
113
+ self._stream_writer: StreamLogWriter | None = None
91
114
  self.review_result: ConsistencyReport | None = None
92
115
  self.module_id = module_id or f"mod_{uuid.uuid4().hex[:8]}"
93
116
  self._base_dir = base_dir or Path.cwd()
@@ -123,7 +146,7 @@ class Module:
123
146
  """原子写 status.json(tmp + os.replace)。失败仅 log,不阻断运行。
124
147
 
125
148
  phase 取值:idle/translating/reviewing/building/ready/running/
126
- done/aborted/cancelled。status_file=False 时不写盘(零残留)。
149
+ done/aborted/cancelled/truncated。status_file=False 时不写盘(零残留)。
127
150
  """
128
151
  if not self.status_file:
129
152
  return
@@ -215,6 +238,17 @@ class Module:
215
238
  _register(_cb)
216
239
  else:
217
240
  log.warning("Module hooks: 未知 runner hook '%s'(忽略)", _hook_name)
241
+ if self.control:
242
+ # 跨进程控制通道(control.json → cancel/pause):与用户 hooks
243
+ # 并存——runner 的 hook 注册表是 list,追加不覆盖。cancel 在
244
+ # tick_end 消费(tick_start 期设终态会被引擎同 tick 赋值冲掉)、
245
+ # pause 在 tick_start 挂起,见 control.py 模块 docstring。
246
+ runner.on_tick_start(
247
+ control_tick_start(runner, self.module_id, base_dir=self._base_dir)
248
+ )
249
+ runner.on_tick_end(
250
+ control_tick_end(runner, self.module_id, base_dir=self._base_dir)
251
+ )
218
252
  return runner
219
253
 
220
254
  # ------------------------------------------------------------------
@@ -337,19 +371,71 @@ class Module:
337
371
 
338
372
  async def _run_with_phases(self, runner: AsyncRunner, max_ticks: int) -> list:
339
373
  """归档本次输入 → 运行 → 按结果映射终态 phase(run/resume 共用)。"""
340
- self._archive_module_inputs()
341
- self._write_phase("running")
374
+ if self.control:
375
+ # 新执行清场:作废陈旧控制请求(崩溃残留的 pause 不拖住新执行)。
376
+ # 位于写 running phase 之前——监控方看到 running 才放开控制按钮,
377
+ # 此时清场已完成,清场与首请求的竞态窗口关闭。
378
+ clear_control(self.module_id, base_dir=self._base_dir)
379
+ writer = self._open_stream_log(max_ticks)
342
380
  try:
343
- firings = await runner.run_until_idle(max_ticks=max_ticks)
344
- except asyncio.CancelledError:
345
- self._write_phase("cancelled", error="cancelled")
346
- raise
347
- except Exception as e:
348
- self._write_phase("aborted", error=str(e))
349
- raise
350
- else:
351
- self._finalize_phase(runner)
352
- return firings
381
+ self._archive_module_inputs()
382
+ self._write_phase("running")
383
+ try:
384
+ firings = await runner.run_until_idle(max_ticks=max_ticks)
385
+ except asyncio.CancelledError:
386
+ self._write_phase("cancelled", error="cancelled")
387
+ raise
388
+ except Exception as e:
389
+ self._write_phase("aborted", error=str(e))
390
+ raise
391
+ else:
392
+ self._finalize_phase(runner, max_ticks)
393
+ return firings
394
+ finally:
395
+ self._close_stream_log(writer)
396
+
397
+ def _open_stream_log(self, max_ticks: int) -> StreamLogWriter | None:
398
+ """stream_log=True 时开本次执行的 writer 并写 run_start 边界。
399
+
400
+ run_start 先于 running phase 写入——监控方见到 running 时锚点记录
401
+ 必已存在(status.json 原子写在同一执行线程、顺序在后)。事件订阅
402
+ 只挂一次(守卫 flag),回调经 Module 当前 writer 属性落盘。
403
+ """
404
+ if not self.stream_log:
405
+ return None
406
+ if not self._stream_subscribed:
407
+ bus = self._reg._event_bus
408
+ for evt in (LlmCallStarted, LlmToken, LlmCallCompleted, HarnessFailed):
409
+ bus.subscribe(evt, self._on_stream_event)
410
+ self._stream_subscribed = True
411
+ writer = StreamLogWriter(stream_log_path(self.module_id, self._base_dir))
412
+ self._stream_writer = writer
413
+ writer.write({"type": "run_start", "pid": os.getpid(), "max_ticks": max_ticks})
414
+ return writer
415
+
416
+ def _close_stream_log(self, writer: StreamLogWriter | None) -> None:
417
+ """执行结束(含异常路径)摘除当前 writer 并关闭。"""
418
+ self._stream_writer = None
419
+ if writer is not None:
420
+ writer.close()
421
+
422
+ def _on_stream_event(self, event: Any) -> None:
423
+ """EventBus → stream.log 记录(四类事件 → 四种记录,见 stream.py)。"""
424
+ w = self._stream_writer
425
+ if w is None:
426
+ return
427
+ if isinstance(event, LlmCallStarted):
428
+ w.write({"type": "call_start", "node": event.node,
429
+ "model": event.model, "prompt_chars": event.prompt_chars})
430
+ elif isinstance(event, LlmToken):
431
+ w.write({"type": "token", "node": event.node, "chunk": event.chunk})
432
+ elif isinstance(event, LlmCallCompleted):
433
+ w.write({"type": "call_end", "node": event.node,
434
+ "content_chars": event.content_chars,
435
+ "finish_reason": event.finish_reason})
436
+ elif isinstance(event, HarnessFailed):
437
+ w.write({"type": "call_error", "node": event.node,
438
+ "reason": event.reason, "failure_type": event.failure_type})
353
439
 
354
440
  def _archive_module_inputs(self) -> None:
355
441
  """归档本次运行的 spec/tasklist 到 module_inputs 表(警告 1 对比源)。
@@ -366,7 +452,7 @@ class Module:
366
452
  self.spec.to_dict(), self._last_tasklist.to_dict()
367
453
  )
368
454
 
369
- def _finalize_phase(self, runner: AsyncRunner) -> None:
455
+ def _finalize_phase(self, runner: AsyncRunner, max_ticks: int) -> None:
370
456
  """按 runner.status 映射终态 phase(run/resume 共用)。"""
371
457
  from tickflow.runner import RunStatus
372
458
  if runner.status == RunStatus.ABORTED:
@@ -376,7 +462,11 @@ class Module:
376
462
  elif runner.status == RunStatus.FAILED:
377
463
  self._write_phase("aborted", error="all nodes failed")
378
464
  elif runner.status == RunStatus.RUNNING:
379
- self._write_phase("running") # max_ticks 截断:仍在运行
465
+ # max_ticks 耗尽(pause 挂起发生在 run_until_idle 内部不返回,
466
+ # RUNNING 是唯一来源)→ 真终态:监控方拿到可 resume 的确定性信号
467
+ self._write_phase(
468
+ "truncated", error=f"max_ticks={max_ticks} 截断(可 resume 续跑)"
469
+ )
380
470
  else:
381
471
  self._write_phase("done")
382
472
 
@@ -413,15 +503,7 @@ class Module:
413
503
  f"回退目标 {rollback_to!r} 不存在"
414
504
  f"(可用 tick: {ticks or '无'};manual: {manual or '无'})"
415
505
  )
416
- # 已执行节点:firings 表中 tick < 快照 tick 的去重节点(S3 后
417
- # 快照不再含 edges 窗口)。快照 tick N 在 tick N-1 结束后落盘,
418
- # tick == N 的 firing 属于 restore 后会被重跑的部分,不算已执行。
419
- # 注:firings 表按 module_id 累积(跨多次 run),前一轮 run 的
420
- # 记录也会计入——仅影响提示性警告 1/3 的准确性,不影响硬错误。
421
- executed_nodes = {
422
- d["node"] for d in backend.list_firings(self.module_id)
423
- if d.get("node") and int(d.get("tick", 0)) < int(snap.get("tick", 0))
424
- }
506
+ executed_nodes = _executed_nodes(backend, self.module_id, int(snap.get("tick", 0)))
425
507
  except KeyError as e:
426
508
  self._write_phase("aborted", error=str(e))
427
509
  raise
@@ -8,11 +8,14 @@ CLI(host + 查询形态)、MCP、Web 三形态共同消费本模块——形
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import json
11
12
  import logging
12
13
  from dataclasses import dataclass, field
13
14
  from pathlib import Path
14
15
  from typing import Any
15
16
 
17
+ from .stream import stream_log_path
18
+
16
19
  log = logging.getLogger(__name__)
17
20
 
18
21
 
@@ -46,6 +49,65 @@ def run_db_path(module_id: str, base_dir: Path | None = None) -> Path:
46
49
  return base / ".specmodule" / "runs" / module_id / "run.sqlite"
47
50
 
48
51
 
52
+ def read_stream(
53
+ run_id: str, *, offset: int = 0, base_dir: Path | None = None
54
+ ) -> dict[str, Any] | None:
55
+ """增量读 stream.log(LLM 流式观测共享读端;Web WS 追尾/未来 TUI 共用)。
56
+
57
+ 二进制 seek(offset) 读到 EOF,按完整行切分;每条记录附行首字节偏移
58
+ ``off``(锚定策略——只显示最近一次执行——由消费方据 off 自行定位,展示
59
+ 策略不进库)。末尾不完整行不消费(next_offset 停在其行首,等补齐后
60
+ 下次读出);JSON 解析失败的行跳过(崩溃瞬间可能出半行);
61
+ ``offset > file_size``(文件被替换/重建)钳回 0 自愈。文件缺失 → None。
62
+
63
+ 返回 ``{"records": [...], "next_offset": int, "file_size": int}``。
64
+ """
65
+ path = stream_log_path(run_id, base_dir)
66
+ if not path.exists():
67
+ return None
68
+ try:
69
+ size = path.stat().st_size
70
+ start = offset if offset <= size else 0
71
+ with path.open("rb") as fh:
72
+ fh.seek(start)
73
+ data = fh.read()
74
+ except OSError:
75
+ log.exception("读 stream.log 失败(返回 None): %s", path)
76
+ return None
77
+ nl = data.rfind(b"\n")
78
+ if nl < 0:
79
+ return {"records": [], "next_offset": start, "file_size": size}
80
+ pos = start
81
+ records: list[dict[str, Any]] = []
82
+ for line in data[:nl].split(b"\n"):
83
+ line_off = pos
84
+ pos += len(line) + 1
85
+ if not line.strip():
86
+ continue
87
+ try:
88
+ rec = json.loads(line.decode("utf-8"))
89
+ except (json.JSONDecodeError, UnicodeDecodeError):
90
+ log.warning("跳过损坏的 stream.log 行(off=%d)", line_off)
91
+ continue
92
+ rec["off"] = line_off
93
+ records.append(rec)
94
+ return {"records": records, "next_offset": start + nl + 1, "file_size": size}
95
+
96
+
97
+ def _executed_nodes(backend: Any, module_id: str, tick: int) -> set[str]:
98
+ """firings 表中 tick < 快照 tick 的去重节点(resume 已执行判定,单一事实源)。
99
+
100
+ 快照 tick N 在 tick N-1 结束后落盘,tick == N 的 firing 属 restore 后
101
+ 会被重跑的部分,不算已执行。firings 按 module_id 累积(跨多次 run),
102
+ 前一轮 run 的记录也会计入——仅影响提示性警告 1/3 的准确性,不影响硬错误。
103
+ """
104
+ return {
105
+ d["node"]
106
+ for d in backend.list_firings(module_id)
107
+ if d.get("node") and int(d.get("tick", 0)) < tick
108
+ }
109
+
110
+
49
111
  def build_timeline(module_id: str, base_dir: Path | None = None) -> ReviewTimeline | None:
50
112
  """从 run.sqlite 构建审阅时间线。无 DB / 读失败 → None。"""
51
113
  db_path = run_db_path(module_id, base_dir=base_dir)
@@ -296,6 +358,163 @@ def load_snapshot_summary(
296
358
  return out
297
359
 
298
360
 
361
+ def read_module_inputs(
362
+ module_id: str, base_dir: Path | None = None
363
+ ) -> dict[str, Any] | None:
364
+ """读运行输入存档(module_inputs 表:本次 run 使用的 spec/tasklist)。
365
+
366
+ 消费场景:resume/rollback 前端预填上次输入(换 spec/tasklist 重传的
367
+ 编辑起点)。db 缺失 / 无存档 / 读失败 → None(查询容错,同上)。
368
+ """
369
+ db_path = run_db_path(module_id, base_dir)
370
+ if not db_path.exists():
371
+ return None
372
+ from .checkpoint import ModuleInputStore
373
+
374
+ store = ModuleInputStore(module_id, base_dir)
375
+ try:
376
+ return store.load_module_inputs()
377
+ except Exception:
378
+ log.exception("读取 module_inputs 失败(返回 None): %s", db_path)
379
+ return None
380
+ finally:
381
+ store.close()
382
+
383
+
384
+ def check_resume_compat_from_run(
385
+ module_name: str,
386
+ run_id: str,
387
+ *,
388
+ new_tasklist: Any = None,
389
+ target: int | str | None = None,
390
+ base_dir: Path | None = None,
391
+ ) -> dict[str, Any] | None:
392
+ """恢复预检:从运行产物组合材料跑兼容性校验——不 spawn、不写状态。
393
+
394
+ resume(module.py)内联组合的第二消费端收编:目标快照解析 →
395
+ executed_nodes(_executed_nodes)→ 旧输入存档(read_module_inputs)→
396
+ 建图(build_run_graph 同款 Mock registry 通道,零 LLM;new_tasklist
397
+ 给出时走直渲染通道)→ check_resume_compat(marking.slots / armed_starts
398
+ 取自目标快照)。
399
+
400
+ - ``new_tasklist``:dict 或 Tasklist;None = 用归档 tasklist(纯续跑预检)。
401
+ - ``target``:tick 号 / ``"manual:<label>"`` / None(最新快照)。
402
+
403
+ 错误契约:run.sqlite 缺失/读失败 → None(查询容错,调用方映射 404);
404
+ tasklist 非法 / 建图失败 → ValueError(消息可直接面向用户,调用方映射
405
+ 400);目标解析失败**不 raise**——作为 ``hard_errors[0]`` 返回(消息与
406
+ module.py 的 KeyError 文案一致并附可用清单),``target``/``target_tick``
407
+ 为 None。
408
+
409
+ 返回 ``{"target": str | None, "target_tick": int | None,
410
+ "executed_nodes": [str], "hard_errors": [str], "warnings": [str]}``。
411
+ """
412
+ from .checkpoint import check_resume_compat, tasklist_from_dict
413
+
414
+ db_path = run_db_path(run_id, base_dir)
415
+ if not db_path.exists():
416
+ return None
417
+ try:
418
+ from tickflow.persistence import SqliteBackend
419
+
420
+ backend = SqliteBackend(db_path)
421
+ try:
422
+ hard_errors: list[str] = []
423
+ snap: dict[str, Any] | None = None
424
+ target_tick: int | None = None
425
+ ticks = backend.list_snapshots(run_id)
426
+ manual = [lbl for lbl, _ in backend.list_checkpoints(run_id)]
427
+ if not ticks and not manual:
428
+ hard_errors.append(f"无可恢复快照: {run_id}(运行未产生任何 tick 快照)")
429
+ elif isinstance(target, int) or (
430
+ isinstance(target, str) and target.isdigit()
431
+ ):
432
+ t = int(target)
433
+ if t in ticks:
434
+ snap = backend.load_snapshot(run_id, t)
435
+ if snap is None:
436
+ hard_errors.append(f"快照 tick {t} 读取失败(数据损坏?)")
437
+ else:
438
+ target_tick = t
439
+ else:
440
+ hard_errors.append(
441
+ f"回退目标 {target!r} 不存在"
442
+ f"(可用 tick: {ticks or '无'};manual: {manual or '无'})"
443
+ )
444
+ elif isinstance(target, str) and target.startswith("manual:"):
445
+ snap = backend.load_checkpoint(run_id, target)
446
+ if snap is None:
447
+ hard_errors.append(
448
+ f"回退目标 {target!r} 不存在"
449
+ f"(可用 tick: {ticks or '无'};manual: {manual or '无'})"
450
+ )
451
+ else:
452
+ target_tick = int(snap.get("tick", 0))
453
+ elif target is not None:
454
+ hard_errors.append(
455
+ f"回退目标 {target!r} 不存在"
456
+ f"(可用 tick: {ticks or '无'};manual: {manual or '无'})"
457
+ )
458
+ else:
459
+ if not ticks:
460
+ hard_errors.append(
461
+ f"无可恢复快照: {run_id}(运行未产生任何 tick 快照)"
462
+ )
463
+ else:
464
+ target_tick = max(ticks)
465
+ snap = backend.load_snapshot(run_id, target_tick)
466
+ if snap is None:
467
+ hard_errors.append(
468
+ f"快照 tick {target_tick} 读取失败(数据损坏?)"
469
+ )
470
+
471
+ executed: set[str] = set()
472
+ if snap is not None:
473
+ executed = _executed_nodes(backend, run_id, int(snap.get("tick", 0)))
474
+ finally:
475
+ backend.close()
476
+ except Exception:
477
+ log.exception("读取 run.sqlite 失败(返回 None): %s", db_path)
478
+ return None
479
+
480
+ if hard_errors:
481
+ return {"target": None, "target_tick": None, "executed_nodes": [],
482
+ "hard_errors": hard_errors, "warnings": []}
483
+
484
+ old_tl = None
485
+ old_inputs = read_module_inputs(run_id, base_dir=base_dir)
486
+ if old_inputs is not None:
487
+ old_tl = tasklist_from_dict(old_inputs["tasklist"])
488
+
489
+ try:
490
+ built = build_run_graph(module_name, run_id, base_dir=base_dir,
491
+ tasklist=new_tasklist)
492
+ except ValueError:
493
+ raise
494
+ except Exception as e:
495
+ raise ValueError(f"预检建图失败: {e}") from e
496
+ if built is None:
497
+ raise ValueError("无 tasklist 可预检(无归档且未传 new_tasklist)")
498
+ graph, new_tl = built
499
+
500
+ marking = (snap or {}).get("marking") or {}
501
+ check = check_resume_compat(
502
+ new_tl, graph, executed,
503
+ old_tasklist=old_tl,
504
+ marking_slots=marking.get("slots"),
505
+ armed_starts=marking.get("armed_starts"),
506
+ )
507
+ target_str = target if isinstance(target, str) else (
508
+ str(target) if target is not None else str(target_tick))
509
+ return {
510
+ "target": target_str,
511
+ "target_tick": target_tick,
512
+ "executed_nodes": sorted(executed),
513
+ "hard_errors": list(check.hard_errors),
514
+ "warnings": list(check.warnings),
515
+ }
516
+
517
+
299
518
  def timeline_to_dict(timeline: ReviewTimeline) -> dict[str, Any]:
300
519
  """JSON 出口(MCP/Web 直接消费同一函数)。"""
301
520
  return {
@@ -155,6 +155,15 @@ class HarnessRegistry(Registry):
155
155
 
156
156
  # ── 查询 ──────────────────────────────────────────────────────
157
157
 
158
+ @property
159
+ def llm_client(self) -> Any:
160
+ """注册表持有的 LLM 客户端(只读)。
161
+
162
+ 供不经图独立调用 harness 的场景使用(如 ConsistencyReviewer
163
+ 走 call_harness)。
164
+ """
165
+ return self._llm_client
166
+
158
167
  def is_harness(self, name: str) -> bool:
159
168
  """name 是否通过 harness() 注册。"""
160
169
  return name in self._harness_cfgs
@@ -25,7 +25,7 @@ class ModuleStatus:
25
25
  """Module 运行状态静态快照。"""
26
26
 
27
27
  module_id: str
28
- phase: str # idle/translating/reviewing/building/ready/running/done/aborted/cancelled
28
+ phase: str # idle/translating/reviewing/building/ready/running/done/aborted/cancelled/truncated
29
29
  status: str | None = None # tickflow RunStatus("running"/"idle"/...;无 DB 时为 None)
30
30
  tick: int | None = None # 最新快照 tick(无 DB 时为 None)
31
31
  fireable: list[str] = field(default_factory=list)
@@ -0,0 +1,65 @@
1
+ # module_harness/stream.py
2
+ """LLM 流式输出落盘——stream.log(跨进程流式观测通道)。
3
+
4
+ Harness 每个LLM chunk 经 EventBus 发 ``LlmToken``,但 EventBus 是进程内的——
5
+ 独立消费进程(Web/TUI)看不到。本模块把流式事件序列化为 JSONL 追加写
6
+ ``.specmodule/runs/<run_id>/stream.log``(与 status.json/run.sqlite 同目录),
7
+ 跨进程可读。写失败仅 log 不抛(观测不阻断运行,同 _write_phase 哲学)。
8
+
9
+ 记录格式(``ts`` 由写入方统一打 wall-clock——harness 事件的 timestamp 是
10
+ ``time.monotonic()``,进程本地时钟,不落盘、不跨进程比较)::
11
+
12
+ {"type": "run_start", "ts", "pid", "max_ticks"}
13
+ {"type": "call_start", "ts", "node", "model", "prompt_chars"}
14
+ {"type": "token", "ts", "node", "chunk"}
15
+ {"type": "call_end", "ts", "node", "content_chars", "finish_reason"}
16
+ {"type": "call_error", "ts", "node", "reason", "failure_type"}
17
+
18
+ append-only:每次执行以 ``run_start`` 开边界,不截断旧执行(崩溃残留可事后
19
+ 查看)。不带 tick 字段——harness 事件恒为 tick=0,落盘假数据不如不写。
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ __all__ = ["StreamLogWriter", "stream_log_path"]
33
+
34
+
35
+ def stream_log_path(module_id: str, base_dir: Path | None = None) -> Path:
36
+ """``<base_dir>/.specmodule/runs/<module_id>/stream.log``。"""
37
+ return (base_dir or Path.cwd()) / ".specmodule" / "runs" / module_id / "stream.log"
38
+
39
+
40
+ class StreamLogWriter:
41
+ """stream.log 追加写器:懒开句柄、逐记录 flush(无 fsync)、close 幂等。"""
42
+
43
+ def __init__(self, path: Path) -> None:
44
+ self._path = path
45
+ self._fh: Any = None
46
+
47
+ def write(self, record: dict[str, Any]) -> None:
48
+ """单条 JSONL 记录落盘(ts 在此统一打 wall-clock)。失败仅 log。"""
49
+ record = {"ts": time.time(), **record}
50
+ try:
51
+ if self._fh is None:
52
+ self._path.parent.mkdir(parents=True, exist_ok=True)
53
+ self._fh = self._path.open("a", encoding="utf-8")
54
+ self._fh.write(json.dumps(record, ensure_ascii=False) + "\n")
55
+ self._fh.flush()
56
+ except OSError:
57
+ log.exception("写 stream.log 失败(不阻断运行): %s", self._path)
58
+
59
+ def close(self) -> None:
60
+ if self._fh is not None:
61
+ try:
62
+ self._fh.close()
63
+ except OSError:
64
+ log.exception("关闭 stream.log 失败: %s", self._path)
65
+ self._fh = None
@@ -175,8 +175,9 @@ class SubModule:
175
175
  - 事件投递与 records/persist 解耦:构造传入 event_bus 时事件始终投递
176
176
  (与 audit 取值无关);未传则静默 EventBus.null()(嵌入零开销)。宿主
177
177
  需失败原因等现场反馈时,传 event_bus 选择性订阅即可,无需开启审计
178
- - persist:False = 快速模式(NullBackend 全内存 + 无 status.json
179
- 零落盘零 I/O);None = 按 mode 决定("fast" → False,否则 True)
178
+ - persist:False = 快速模式(NullBackend 全内存 + 无 status.json +
179
+ stream.log,零落盘零 I/O);None = 按 mode 决定("fast" → False
180
+ 否则 True)
180
181
  - llm_client/event_bus:覆盖实例级注入(宿主进程传入);None 用实例值
181
182
  - hooks:runner hooks 透传(观察通道,与 Module hooks 同语义)
182
183
  """
@@ -204,6 +205,12 @@ class SubModule:
204
205
  keep_records=audit,
205
206
  persist=use_persist,
206
207
  status_file=use_persist,
208
+ # fast 模式 = 零残留模式:三个落盘通道(run.sqlite /
209
+ # status.json / stream.log)由 mode 一并关闭。stream_log 的
210
+ # 默认 True 是刻意的(CLI 拉起的子进程零接线即可流式观测),
211
+ # 故只在具名模式侧统一关——直接构造 Module 无"模式"概念,
212
+ # 每个通道由调用方逐个点名。
213
+ stream_log=use_persist,
207
214
  modules=self.modules,
208
215
  hooks=hooks,
209
216
  )
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specmodule"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "可审计、可调试、可完全掌控的 LLM 使用框架(tickflow + llm + module_harness)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -41,4 +41,12 @@ include = ["llm*", "module_harness*"]
41
41
  exclude = ["module_harness.tests*", "llm.tests*"]
42
42
 
43
43
  [tool.setuptools.package-data]
44
- module_harness = ["templates/builtin/*.json"]
44
+ module_harness = ["templates/builtin/*.json"]
45
+
46
+ [tool.pytest.ini_options]
47
+ # smoke = 真实 LLM 冒烟(活 API 调用:慢、耗额度、非确定性 flake)。
48
+ # 默认全量排除;要看时 pytest -m smoke 显式 opt-in(CLI 的 -m 覆盖 addopts)。
49
+ markers = [
50
+ "smoke: 真实 LLM 冒烟测试(活 API 调用,非确定性;默认排除,pytest -m smoke 显式运行)",
51
+ ]
52
+ addopts = "-m 'not smoke'"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specmodule
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: 可审计、可调试、可完全掌控的 LLM 使用框架(tickflow + llm + module_harness)
5
5
  License: MIT
6
6
  Keywords: llm,workflow,petri-net,agent
@@ -8,11 +8,13 @@ llm/mock.py
8
8
  module_harness/__init__.py
9
9
  module_harness/align.py
10
10
  module_harness/builtins.py
11
+ module_harness/call.py
11
12
  module_harness/checkpoint.py
12
13
  module_harness/cli.py
13
14
  module_harness/command.py
14
15
  module_harness/config.py
15
16
  module_harness/consistency.py
17
+ module_harness/control.py
16
18
  module_harness/entry.py
17
19
  module_harness/events.py
18
20
  module_harness/feed.py
@@ -28,6 +30,7 @@ module_harness/scaffold.py
28
30
  module_harness/spec.py
29
31
  module_harness/status.py
30
32
  module_harness/store.py
33
+ module_harness/stream.py
31
34
  module_harness/submodule.py
32
35
  module_harness/translator.py
33
36
  module_harness/templates/builtin/codereview.json
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes