specmodule 0.1.0__py3-none-any.whl
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.
- llm/__init__.py +21 -0
- llm/client.py +654 -0
- llm/config.py +213 -0
- module_harness/__init__.py +201 -0
- module_harness/align.py +39 -0
- module_harness/builtins.py +29 -0
- module_harness/checkpoint.py +336 -0
- module_harness/cli.py +1492 -0
- module_harness/command.py +115 -0
- module_harness/config.py +95 -0
- module_harness/consistency.py +123 -0
- module_harness/entry.py +74 -0
- module_harness/events.py +149 -0
- module_harness/feed.py +197 -0
- module_harness/graph_builder.py +334 -0
- module_harness/harness.py +181 -0
- module_harness/loader.py +215 -0
- module_harness/module.py +452 -0
- module_harness/outputfmt.py +139 -0
- module_harness/prompt.py +84 -0
- module_harness/query.py +216 -0
- module_harness/registry.py +180 -0
- module_harness/scaffold.py +404 -0
- module_harness/spec.py +209 -0
- module_harness/status.py +96 -0
- module_harness/store.py +482 -0
- module_harness/submodule.py +268 -0
- module_harness/templates/builtin/codereview.json +32 -0
- module_harness/templates/builtin/docwrite.json +30 -0
- module_harness/templates/builtin/summarize.json +24 -0
- module_harness/templates/builtin/translate.json +27 -0
- module_harness/translator.py +314 -0
- specmodule-0.1.0.dist-info/METADATA +321 -0
- specmodule-0.1.0.dist-info/RECORD +38 -0
- specmodule-0.1.0.dist-info/WHEEL +5 -0
- specmodule-0.1.0.dist-info/entry_points.txt +2 -0
- specmodule-0.1.0.dist-info/licenses/LICENSE +21 -0
- specmodule-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# module_harness/checkpoint.py
|
|
2
|
+
"""Module 层快照/回滚:运行输入存档 + 兼容性校验(roadmap #5)。
|
|
3
|
+
|
|
4
|
+
- ``ModuleInputStore``:run.sqlite 内 ``module_inputs`` 表(本次运行使用的
|
|
5
|
+
spec/tasklist 存档,供兼容性对比与跨进程查询)。
|
|
6
|
+
- ``check_resume_compat``:新 tasklist 与已执行节点的兼容性校验。
|
|
7
|
+
|
|
8
|
+
零修改 tickflow:全部实现位于 module_harness 层;module_inputs 表独立于
|
|
9
|
+
SqliteBackend 的 snapshots/firings/checkpoints 表,通过独立 sqlite3 连接
|
|
10
|
+
打开同一 run.sqlite(WAL 模式多连接安全)。
|
|
11
|
+
|
|
12
|
+
注:自动检查点(auto_checkpoints 表)已退役(S2)——每 tick 快照由
|
|
13
|
+
tickflow 的 _persist_tick 直接写入 snapshots 表(最小快照,D1)。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import asdict, dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from tickflow import Graph
|
|
28
|
+
|
|
29
|
+
from .graph_builder import _is_constant_ref
|
|
30
|
+
from .spec import TaskDefinition, Tasklist
|
|
31
|
+
from .translator import prepare_flow
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run_db_path(module_id: str, base_dir: Path | None = None) -> Path:
|
|
37
|
+
"""``<base_dir>/.specmodule/runs/<module_id>/run.sqlite``(与 Module._persist_dir 对齐)。"""
|
|
38
|
+
base = base_dir if base_dir is not None else Path.cwd()
|
|
39
|
+
return base / ".specmodule" / "runs" / module_id / "run.sqlite"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tasklist_to_dict(tl: Tasklist) -> dict[str, Any]:
|
|
43
|
+
"""Tasklist → JSON 可序列化 dict(``Tasklist.to_dict`` 薄封装,导出兼容)。"""
|
|
44
|
+
return tl.to_dict()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def tasklist_from_dict(d: dict[str, Any]) -> Tasklist:
|
|
48
|
+
"""tasklist_to_dict 的逆操作。"""
|
|
49
|
+
return Tasklist.from_json(d)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ModuleInputStore:
|
|
53
|
+
"""run.sqlite 内运行输入存档(module_inputs 表)。
|
|
54
|
+
|
|
55
|
+
``module_inputs(id INT PK CHECK(id=1), spec TEXT, tasklist TEXT,
|
|
56
|
+
saved_at REAL)``——单行,覆盖式,供兼容性校验(警告 1)与跨进程查询
|
|
57
|
+
"这次 run 用了什么输入"。
|
|
58
|
+
|
|
59
|
+
连接策略:构造时打开独立连接(WAL 模式,与 SqliteBackend 并存安全);
|
|
60
|
+
写失败仅 log 不阻断(对齐 status.json 容错哲学)。
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, module_id: str, base_dir: Path | None = None) -> None:
|
|
64
|
+
self.module_id = module_id
|
|
65
|
+
self.db_path = _run_db_path(module_id, base_dir)
|
|
66
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
self._conn = sqlite3.connect(str(self.db_path))
|
|
68
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
69
|
+
self._init_tables()
|
|
70
|
+
|
|
71
|
+
def _init_tables(self) -> None:
|
|
72
|
+
self._conn.executescript(
|
|
73
|
+
"""
|
|
74
|
+
CREATE TABLE IF NOT EXISTS module_inputs (
|
|
75
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
76
|
+
spec TEXT NOT NULL,
|
|
77
|
+
tasklist TEXT NOT NULL,
|
|
78
|
+
saved_at REAL NOT NULL
|
|
79
|
+
);
|
|
80
|
+
"""
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
"""关闭连接。Module 生命周期结束或临时 store 用完后调用。"""
|
|
85
|
+
try:
|
|
86
|
+
self._conn.close()
|
|
87
|
+
except sqlite3.Error:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def save_module_inputs(self, spec: dict[str, Any], tasklist: dict[str, Any]) -> None:
|
|
91
|
+
"""覆盖式存档本次运行的 spec/tasklist(JSON 深拷贝语义)。"""
|
|
92
|
+
try:
|
|
93
|
+
self._conn.execute(
|
|
94
|
+
"INSERT OR REPLACE INTO module_inputs(id, spec, tasklist, saved_at) "
|
|
95
|
+
"VALUES (1, ?, ?, ?)",
|
|
96
|
+
(json.dumps(spec, ensure_ascii=False),
|
|
97
|
+
json.dumps(tasklist, ensure_ascii=False),
|
|
98
|
+
time.time()),
|
|
99
|
+
)
|
|
100
|
+
self._conn.commit()
|
|
101
|
+
except (sqlite3.Error, OSError, TypeError):
|
|
102
|
+
log.exception("module_inputs 存档失败(不阻断): %s", self.db_path)
|
|
103
|
+
|
|
104
|
+
def load_module_inputs(self) -> dict[str, Any] | None:
|
|
105
|
+
"""读回存档;无存档或损坏返回 None。返回 ``{"spec": dict, "tasklist": dict}``。"""
|
|
106
|
+
try:
|
|
107
|
+
row = self._conn.execute(
|
|
108
|
+
"SELECT spec, tasklist FROM module_inputs WHERE id = 1"
|
|
109
|
+
).fetchone()
|
|
110
|
+
except sqlite3.Error:
|
|
111
|
+
log.exception("module_inputs 读取失败: %s", self.db_path)
|
|
112
|
+
return None
|
|
113
|
+
if row is None:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return {"spec": json.loads(row[0]), "tasklist": json.loads(row[1])}
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
log.warning("module_inputs 存档损坏,忽略")
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# 兼容性校验
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ResumeError(Exception):
|
|
128
|
+
"""resume 兼容性硬错误。含全部错误明细(换行分隔)。"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, errors: list[str]) -> None:
|
|
131
|
+
self.errors = list(errors)
|
|
132
|
+
super().__init__("\n".join(errors))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class ResumeCheck:
|
|
137
|
+
"""兼容性校验结果。hard_errors 非空则拒绝 resume。"""
|
|
138
|
+
|
|
139
|
+
hard_errors: list[str]
|
|
140
|
+
warnings: list[str]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_transitive_upstream(graph: Graph, producer: str, consumer: str) -> bool:
|
|
144
|
+
"""BFS:producer 能否沿出边到达 consumer(含 producer == consumer)。"""
|
|
145
|
+
seen = {producer}
|
|
146
|
+
stack = [producer]
|
|
147
|
+
while stack:
|
|
148
|
+
n = stack.pop()
|
|
149
|
+
if n == consumer:
|
|
150
|
+
return True
|
|
151
|
+
for e in graph.out_edges(n):
|
|
152
|
+
if e.dst not in seen:
|
|
153
|
+
seen.add(e.dst)
|
|
154
|
+
stack.append(e.dst)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _reachable_from_marking(
|
|
159
|
+
graph: Graph,
|
|
160
|
+
executed_nodes: set[str],
|
|
161
|
+
marking_slots: dict[str, bool],
|
|
162
|
+
armed_starts: set[str] | list[str] | None = None,
|
|
163
|
+
) -> set[str]:
|
|
164
|
+
"""不动点模拟:从检查点 marking 出发,判定哪些未执行非 start 节点最终会 fire。
|
|
165
|
+
|
|
166
|
+
警告 3 的核心问题:只看节点自身入边在检查点的 slot 直接值,会误报
|
|
167
|
+
深回退(回退到 ≥2 层上游)场景——入边已满足的上游节点尚未执行,它
|
|
168
|
+
一旦 fire 就会产出下游节点的入边 slot。此处模拟"将 fire"的传播:
|
|
169
|
+
|
|
170
|
+
1. 初始 ``satisfied`` = 检查点 marking 中值为 True 的边键集合
|
|
171
|
+
(键格式 ``"dst|src"``,与 Marking.to_json 一致)∪ 武装 start 的
|
|
172
|
+
出边键(见下)。
|
|
173
|
+
2. 迭代:对每个未执行且非 start 的节点 M,若 M 的所有入边(AND join)
|
|
174
|
+
或任一入边(OR join)都在 ``satisfied`` 中(与
|
|
175
|
+
``engine._join_satisfied`` 的语义一致)→ M 将 fire → M 的所有出边
|
|
176
|
+
加入 ``satisfied``。
|
|
177
|
+
3. 循环至 ``satisfied`` 不再增长(不动点)。
|
|
178
|
+
|
|
179
|
+
``armed_starts`` 分支(``engine._join_satisfied`` 首条分支,
|
|
180
|
+
engine.py:103-106):武装的 start 在续跑的第一个 tick 无条件 fire 并写
|
|
181
|
+
下游 slot——即使其入边在检查点全部未满足。典型场景是 resume 到"运行前
|
|
182
|
+
手动检查点"(build_runner() 后、run 前打点:armed_starts 非空、slots
|
|
183
|
+
全空)。模拟与之一致:把每个在图中且武装的 start 的所有出边键并入初始
|
|
184
|
+
``satisfied``(guard 出边同样乐观加入,与下述取舍一致)。格式与
|
|
185
|
+
``Marking.to_json`` 的 ``armed_starts`` 一致(排序 list,engine.py:72);
|
|
186
|
+
为 None/空时跳过。
|
|
187
|
+
|
|
188
|
+
guard 边(``e.guard is not None``)**乐观加入**:guard 结果运行时才知,
|
|
189
|
+
此处假定为 True。取舍:警告语义是"可能不会执行"的提示性警告——乐观
|
|
190
|
+
会减少误报(深回退是核心工作流,上游重跑后 guard 通常复现原结果,如
|
|
191
|
+
loop 的 guard);代价是 guard 实际为 False 时可能漏报(节点确实不执行
|
|
192
|
+
但没警告)。提示性警告宁可少误报,故乐观传播。
|
|
193
|
+
|
|
194
|
+
返回判定为"将 fire"的节点集合(已执行节点与 start 永不参与)。
|
|
195
|
+
"""
|
|
196
|
+
satisfied = {k for k, v in marking_slots.items() if v}
|
|
197
|
+
for n in set(armed_starts or []):
|
|
198
|
+
if n in graph.nodes:
|
|
199
|
+
satisfied.update(f"{e.dst}|{e.src}" for e in graph.out_edges(n))
|
|
200
|
+
reachable: set[str] = set()
|
|
201
|
+
changed = True
|
|
202
|
+
while changed:
|
|
203
|
+
changed = False
|
|
204
|
+
for n in graph.nodes:
|
|
205
|
+
if n in executed_nodes or n in graph.starts or n in reachable:
|
|
206
|
+
continue
|
|
207
|
+
in_edges = [f"{e.dst}|{e.src}" for e in graph.edges if e.dst == n]
|
|
208
|
+
if not in_edges:
|
|
209
|
+
# 无入边的非 start 节点永不 fire(engine 空 producer 规则),跳过
|
|
210
|
+
continue
|
|
211
|
+
if graph.nodes[n].join == "OR":
|
|
212
|
+
fire = any(k in satisfied for k in in_edges)
|
|
213
|
+
else:
|
|
214
|
+
fire = all(k in satisfied for k in in_edges)
|
|
215
|
+
if not fire:
|
|
216
|
+
continue
|
|
217
|
+
reachable.add(n)
|
|
218
|
+
for e in graph.out_edges(n):
|
|
219
|
+
key = f"{e.dst}|{e.src}"
|
|
220
|
+
if key not in satisfied:
|
|
221
|
+
satisfied.add(key)
|
|
222
|
+
changed = True
|
|
223
|
+
return reachable
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def check_resume_compat(
|
|
227
|
+
new_tasklist: Tasklist,
|
|
228
|
+
graph: Graph,
|
|
229
|
+
executed_nodes: set[str],
|
|
230
|
+
old_tasklist: Tasklist | None = None,
|
|
231
|
+
marking_slots: dict[str, bool] | None = None,
|
|
232
|
+
armed_starts: set[str] | list[str] | None = None,
|
|
233
|
+
) -> ResumeCheck:
|
|
234
|
+
"""新 tasklist 与已执行节点的兼容性校验。
|
|
235
|
+
|
|
236
|
+
- 硬错误 1:新 task 的 inputs 引用的 producer 不在新图节点集合中
|
|
237
|
+
(常量引用跳过:``{spec.xxx}`` 与裸 token ``{spec}``/``{tasklist}``/
|
|
238
|
+
``{node}``——graph_builder 注册时解析为 spec_inputs,此处复用
|
|
239
|
+
``_is_constant_ref`` 保持单一事实源)。
|
|
240
|
+
- 硬错误 2:新图中**新成为** start 且有历史输出的节点(armed_starts
|
|
241
|
+
一次性,永不重跑;底层 ``_warn_graph_changes`` 在 remap old==new 时
|
|
242
|
+
不触发,此处补上)。"新成为"判定:与旧 tasklist flow 的 start 集合
|
|
243
|
+
对比(flow 先经 ``prepare_flow`` 规范化——无 ``[`` 标记时自动把首
|
|
244
|
+
token 包成 start,与 graph 构建一致——再正则 ``\\[(\\w+)\\]`` 提取
|
|
245
|
+
start 集合)——正常 resume 里旧图 start 有历史是常态,不能误报。无存档
|
|
246
|
+
(old_tasklist=None)时降级为警告(保守)。
|
|
247
|
+
- 警告 1:已执行节点在新 tasklist 中被修改(对比 module_inputs 存档,
|
|
248
|
+
修改对已执行部分不生效)。
|
|
249
|
+
- 警告 2:inputs 引用的 producer 未执行、且不是 consumer 的拓扑上游
|
|
250
|
+
(运行时 resolve 为 Missing,prompt 占位符保留字面量)。
|
|
251
|
+
- 警告 3:未执行且非 start 的节点,从检查点 marking 出发经不动点模拟
|
|
252
|
+
仍不可达——该节点永远不会 fire。``remap_graph`` 移植 slot 时新边取
|
|
253
|
+
``old_slots.get(key, False)``(runner.py:405):新节点/改名节点的入边
|
|
254
|
+
在旧 marking 中不存在 → False;旧边已消费也是 False。不动点模拟考虑
|
|
255
|
+
"入边已满足的节点将 fire 并产出下游 slot"(深回退场景上游将重跑),
|
|
256
|
+
guard 边乐观传播(见 ``_reachable_from_marking`` 的取舍说明)。需回退
|
|
257
|
+
到更早检查点让其上游重新执行,或设为 start。``marking_slots`` 为检查点
|
|
258
|
+
snapshot 的 ``marking.slots``,键格式 ``"dst|src"``(与
|
|
259
|
+
``Marking.to_json`` 一致,engine.py:71);为 None 时跳过本检查。
|
|
260
|
+
``armed_starts`` 为检查点 snapshot 的 ``marking.armed_starts``(排序
|
|
261
|
+
list,engine.py:72)——武装的 start 续跑时无条件 fire 并写下游 slot
|
|
262
|
+
(``engine._join_satisfied`` 首分支,engine.py:103-106),模拟将其出边
|
|
263
|
+
并入初始 satisfied(如 resume 到"运行前手动检查点":armed_starts 非空、
|
|
264
|
+
slots 全空);为 None 时跳过。
|
|
265
|
+
|
|
266
|
+
返回 ResumeCheck;调用方在 hard_errors 非空时 raise ResumeError。
|
|
267
|
+
"""
|
|
268
|
+
hard_errors: list[str] = []
|
|
269
|
+
warnings: list[str] = []
|
|
270
|
+
tasks = new_tasklist.tasks
|
|
271
|
+
|
|
272
|
+
for key, task in tasks.items():
|
|
273
|
+
for field, producer in (task.inputs or {}).items():
|
|
274
|
+
if _is_constant_ref(producer):
|
|
275
|
+
continue
|
|
276
|
+
if producer not in graph.nodes:
|
|
277
|
+
hard_errors.append(
|
|
278
|
+
f"Task '{key}': inputs 引用 '{producer}' 不在新图中"
|
|
279
|
+
)
|
|
280
|
+
elif producer not in executed_nodes and not _is_transitive_upstream(
|
|
281
|
+
graph, producer, key
|
|
282
|
+
):
|
|
283
|
+
warnings.append(
|
|
284
|
+
f"Task '{key}': inputs 引用 '{producer}' 未执行且非其拓扑上游"
|
|
285
|
+
f"——运行时可能 resolve 为 Missing"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# 硬错误 2:新图中"新成为" start 且有历史输出。旧图 start 有历史是正常
|
|
289
|
+
# resume 场景(如回退到中途,A 是 start 且已执行),不能误报——用旧
|
|
290
|
+
# tasklist flow 的 start 集合判定"新成为"。flow 先经 prepare_flow 规范化
|
|
291
|
+
# (无 [ 标记时首 token 自动包成 start,与 graph 构建一致),再提取 [A]。
|
|
292
|
+
old_starts: set[str] = set()
|
|
293
|
+
if old_tasklist is not None:
|
|
294
|
+
old_starts = set(re.findall(r"\[(\w+)\]", prepare_flow(old_tasklist.flow)))
|
|
295
|
+
for n in graph.starts:
|
|
296
|
+
if n in executed_nodes:
|
|
297
|
+
if old_tasklist is not None and n not in old_starts:
|
|
298
|
+
hard_errors.append(
|
|
299
|
+
f"Node '{n}' 新成为 start 但已有执行历史——armed_starts "
|
|
300
|
+
f"一次性,永不重跑。请回退到更早的 tick 或改回非 start。"
|
|
301
|
+
)
|
|
302
|
+
elif old_tasklist is None:
|
|
303
|
+
warnings.append(
|
|
304
|
+
f"Node '{n}' 是 start 且已有执行历史(无存档可对比是否"
|
|
305
|
+
f"新成为)——若期望其重跑需回退到 tick 0"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if old_tasklist is not None:
|
|
309
|
+
for n in sorted(executed_nodes & set(tasks)):
|
|
310
|
+
old = old_tasklist.tasks.get(n)
|
|
311
|
+
if old is not None and asdict(old) != asdict(tasks[n]):
|
|
312
|
+
warnings.append(
|
|
313
|
+
f"已执行节点 '{n}' 的 task 定义被修改——修改对已执行部分不生效,"
|
|
314
|
+
f"需回退到更早的检查点"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# 警告 3:未执行非 start 节点,从检查点 marking 出发经不动点模拟仍不可达
|
|
318
|
+
# → 永不 fire。模拟考虑"入边已满足的上游节点将 fire 并产出下游 slot",
|
|
319
|
+
# 消除深回退(回退到 ≥2 层上游)场景的误报——那是核心工作流。
|
|
320
|
+
if marking_slots is not None:
|
|
321
|
+
reachable = _reachable_from_marking(
|
|
322
|
+
graph, executed_nodes, marking_slots, armed_starts
|
|
323
|
+
)
|
|
324
|
+
for n in graph.nodes:
|
|
325
|
+
if n in executed_nodes or n in graph.starts or n in reachable:
|
|
326
|
+
continue
|
|
327
|
+
in_edges = [f"{e.dst}|{e.src}" for e in graph.edges if e.dst == n]
|
|
328
|
+
if not in_edges:
|
|
329
|
+
continue
|
|
330
|
+
warnings.append(
|
|
331
|
+
f"Node '{n}' 的入边在检查点均未满足(新边或已消费)——"
|
|
332
|
+
f"该节点不会自动执行。需回退到更早检查点使其上游重新执行,"
|
|
333
|
+
f"或将其设为 start。"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return ResumeCheck(hard_errors=hard_errors, warnings=warnings)
|