Runner.run / 终止守卫 / 隐私边界 深度解析
templates/run-loop.py 是外部 runner,588 行 stdlib only — 负责把 host / judge / reviewer 通过 subprocess sources/ksimback-looper/templates/run-loop.py:84 串成 plan → gates → delivery 的循环 sources/ksimback-looper/templates/run-loop.py:76,并加三重 termination guard 与 redact + consent 双保险。
导入链与契约
run-loop.py 顶部 docstring 把整份文件的边界写得明确:This file executes a resolved loop spec. It intentionally reads only loop.resolved.json and uses only Python stdlib. sources/ksimback-looper/templates/run-loop.py:1。这是 runner 与 coach 在依赖面上的硬分离——教练端要 PyYAML,运行端零依赖。这一约束使得 loop 真的可以丢到任何带 python3 的环境跑。
1#!/usr/bin/env python3 2"""Generated Looper runner. 3 4This file executes a resolved loop spec. It intentionally reads only 5loop.resolved.json and uses only Python stdlib. 6""" 7 8from __future__ import annotations 9 10import argparse 11import datetime as _dt 12import fnmatch 13import json 14from pathlib import Path 15import re 16import subprocess 17import sys 18import time 19from typing import Any 20 21 22PASS = "pass" 23REVISE = "revise" 24 25 26class RunnerError(RuntimeError): 27 pass 28 29 30def utc_now() -> str: 31 return _dt.datetime.now(_dt.UTC).replace(microsecond=0).isoformat() 32 33 34def load_json(path: Path) -> dict[str, Any]: 35 try: 36 with path.open("r", encoding="utf-8") as fh: 37 data = json.load(fh) 38 except OSError as exc: 39 raise RunnerError(f"Could not read {path}: {exc}") from exc 40 except json.JSONDecodeError as exc: 41 raise RunnerError(f"Could not parse JSON in {path}: {exc}") from exc 42 if not isinstance(data, dict): 43 raise RunnerError(f"{path} must contain a JSON object") 44 return data 45 46 47def write_text(path: Path, text: str) -> None: 48 path.parent.mkdir(parents=True, exist_ok=True) 49 path.write_text(text.rstrip() + "\n", encoding="utf-8") 50 51 52def write_json(path: Path, data: Any) -> None: 53 path.parent.mkdir(parents=True, exist_ok=True) 54 path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") 55 56 57def ensure_argv(value: Any, field: str) -> list[str]: 58 if isinstance(value, list) and value and all(isinstance(item, str) for item in value): 59 return value 60 raise RunnerError(f"{field} must be a non-empty argv array")
整个 Runner 类只有一个公开入口 sources/ksimback-looper/templates/run-loop.py:542 run() sources/ksimback-looper/templates/run-loop.py:542;剩下 11 个方法都是它的内部积木(__init__ sources/ksimback-looper/templates/run-loop.py:144、load_state sources/ksimback-looper/templates/run-loop.py:156、save_state sources/ksimback-looper/templates/run-loop.py:167、append_log sources/ksimback-looper/templates/run-loop.py:172、enforce_wall_clock sources/ksimback-looper/templates/run-loop.py:178、no_progress_reached sources/ksimback-looper/templates/run-loop.py:188、gather_context sources/ksimback-looper/templates/run-loop.py:287、run_host sources/ksimback-looper/templates/run-loop.py:338、run_programmatic sources/ksimback-looper/templates/run-loop.py:345、run_judge sources/ksimback-looper/templates/run-loop.py:395、run_reviewers sources/ksimback-looper/templates/run-loop.py:417、human_check sources/ksimback-looper/templates/run-loop.py:440、run_gate sources/ksimback-looper/templates/run-loop.py:451)。本子页从 §01 起把这些方法按职责分组、按代码顺序看真实行号。
▸parse_judge_output — fence'd JSON 兜底
judge 调用走 subprocess sources/ksimback-looper/templates/run-loop.py:84 拿 stdout,第一刀就是 寻找 fenced JSON 块;找不到就把整段当 JSON 直接 parse;再失败就降级为 re.search sources/ksimback-looper/templates/run-loop.py:113 配 r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)verdict=REVISE + confidence=0.0 + blocking_issues=[unparseable]。sources/ksimback-looper/templates/run-loop.py:112-140 这一段是 runner 对"judge 不可信"的兜底——就算 judge 完全输出垃圾,也不会让 revise_until_clean 卡死,最多由 no_progress 兜底停。
核心实现,逐段拆
▸Runner.__init__ / load_state / save_state / append_log
State sources/ksimback-looper/templates/run-loop.py:144 是"断点续跑"的基础。__init__ 读 spec、解析 workspace、根据 observability 决定 run-log.md 与 state.json 路径;state 默认结构是 {"status": "initialized", "started_at": utc_now(), "iteration": 0, "warnings": [], "consent": {}} sources/ksimback-looper/templates/run-loop.py:156-165。save_state() 永远 updated_at = utc_now();append_log() 拼 {time} `{event}` {json-kwargs} 到 run-log.md。两份 state 的写入是 fire-and-forget 的——丢了一份不会让 spec 失败,但 run-log.md 会丢事件。
143class Runner: 144 def __init__(self, spec_path: Path) -> None: 145 self.spec_path = spec_path.resolve() 146 self.base_dir = self.spec_path.parent 147 self.spec = load_json(self.spec_path) 148 self.workspace = relative_to_base(self.spec["workspace"]["dir"], self.base_dir) 149 self.workspace.mkdir(parents=True, exist_ok=True) 150 self.observability = self.spec.get("observability", {}) 151 self.run_log_path = self.workspace / self.observability.get("run_log", "run-log.md") 152 self.state_path = self.workspace / self.observability.get("state_file", "state.json") 153 self.state = self.load_state() 154 self.started = time.monotonic() 155 156 def load_state(self) -> dict[str, Any]: 157 if self.state_path.exists(): 158 return load_json(self.state_path) 159 return { 160 "status": "initialized", 161 "started_at": utc_now(), 162 "iteration": 0, 163 "warnings": [], 164 "consent": {}, 165 } 166 167 def save_state(self, **updates: Any) -> None: 168 self.state.update(updates) 169 self.state["updated_at"] = utc_now() 170 write_json(self.state_path, self.state) 171 172 def append_log(self, event: str, **fields: Any) -> None: 173 self.run_log_path.parent.mkdir(parents=True, exist_ok=True) 174 payload = f" {json.dumps(fields, sort_keys=True)}" if fields else "" 175 with self.run_log_path.open("a", encoding="utf-8") as fh:
▸enforce_wall_clock — wall-clock 单点守门
这是 runner 对时间预算的唯一硬检查:time.monotonic() - self.started > wall_clock_min * 60 一旦成立就把 status=failed + failure="wall_clock_budget_exceeded" 并 raise RunnerError。检查点在 gather_context / run_host / run_gate / run_reviewers 入口,所以 host 的某次长尾响应会触发,budget 判定也是这一处;host 自身的耗时并不计在内(取决于 wall_clock_min 的取值精度)。
▸no_progress_reached — 同 signature 致死的真正机制
这就是三件套的第三层守门。每次进 gate 都把 sorted(failures) 当 signature 与 state 中 no_progress.signature 比对:同 gate 同样 signature 就 count += 1,否则归零。count 累计到 max_stalled_iterations 默认 2 就停;如果 spec 里 action: human_checkpoint 则只问一次"Type 'continue' to allow one more revision",问完归零。这个签名级别虽然可能误判(措辞微调就归零),但配合 max_revisions 与 max_iterations 已经足够把 budget 烧穿的两大原因堵住。
178 def enforce_wall_clock(self) -> None: 179 budget = self.spec.get("loop_control", {}).get("budget", {}) 180 wall_clock_min = budget.get("wall_clock_min") 181 if wall_clock_min is None: 182 return 183 if time.monotonic() - self.started > float(wall_clock_min) * 60: 184 self.save_state(status="failed", failure="wall_clock_budget_exceeded") 185 self.append_log("stop", reason="wall_clock_budget_exceeded") 186 raise RunnerError("Wall-clock budget exceeded") 187 188 def no_progress_reached(self, gate_name: str, failures: list[str]) -> bool: 189 if not failures: 190 self.save_state(no_progress={"count": 0, "signature": "", "gate": gate_name}) 191 return False 192 config = self.spec.get("loop_control", {}).get("no_progress", {}) 193 threshold = int(config.get("max_stalled_iterations", 2)) 194 signature = "\n".join(sorted(failures)) 195 previous = self.state.get("no_progress", {}) 196 same_gate = previous.get("gate") == gate_name 197 same_signature = previous.get("signature") == signature 198 count = int(previous.get("count", 0)) + 1 if same_gate and same_signature else 1 199 progress = { 200 "gate": gate_name, 201 "signature": signature, 202 "count": count, 203 "threshold": threshold, 204 "updated_at": utc_now(), 205 } 206 self.save_state(no_progress=progress) 207 if count < threshold: 208 return False 209 self.append_log("no_progress_detected", gate=gate_name, count=count, failures=failures) 210 if config.get("action", "stop") == "human_checkpoint": 211 answer = input("No-progress detected. Type 'continue' to allow one more revision: ").strip().lower() 212 if answer == "continue": 213 progress["count"] = 0 214 self.save_state(no_progress=progress) 215 self.append_log("no_progress_override", gate=gate_name) 216 return False 217 self.save_state(status="failed", failure="no_progress_detected", blocking_issues=failures) 218 return True
▸redactions_for + redact_prompt_for_member — privacy 一票否决
转译 prompt 之前先 redact。redactions_for(member_id) sources/ksimback-looper/templates/run-loop.py:227-232 从 privacy.egress 里找该 member 的 redact globs;找不到就用 DEFAULT_REDACTIONS = [".env", ".env.*", "secrets/**", "**/*.key"] sources/ksimback-looper/scripts/looper.py:23。redact_prompt_for_member() 先按 glob 抓到 path 列表,再读每个文件内容做字面 .replace();同时对每个文件按行切,长度 ≥ 8 的 stripped 行再做一遍替换——因为 .env 经常是 OPENAI_API_KEY=sk-... 整行值的形态。文件读失败(BinaryDecodeError)或内容大于 1MB 都跳过,避免秘密被序列化爆栈。
227 def redactions_for(self, member_id: str) -> list[str]: 228 redactions: list[str] = [] 229 for entry in self.spec.get("privacy", {}).get("egress", []): 230 if entry.get("to") == member_id: 231 redactions.extend(entry.get("redact", [])) 232 return redactions or [".env", ".env.*", "secrets/**", "**/*.key"] 233 234 def redact_prompt_for_member(self, member_id: str, prompt: str) -> str: 235 redactions = self.redactions_for(member_id) 236 redacted = prompt 237 for pattern in redactions: 238 paths = list(self.base_dir.glob(pattern)) 239 if pattern.endswith("/**"): 240 root = self.base_dir / pattern[:-3] 241 if root.exists(): 242 paths.extend(root.rglob("*")) 243 for path in paths: 244 if not path.is_file(): 245 continue 246 try: 247 secret_text = path.read_text(encoding="utf-8") 248 except UnicodeDecodeError: 249 continue 250 if not secret_text.strip() or len(secret_text) > 1_000_000: 251 continue 252 marker = f"[redacted:{path.relative_to(self.base_dir).as_posix()}]" 253 redacted = redacted.replace(secret_text, marker) 254 for line in secret_text.splitlines(): 255 stripped = line.strip() 256 if len(stripped) >= 8: 257 redacted = redacted.replace(stripped, marker) 258 return redacted
▸ensure_consent — 跨 vendor 第一次发送前人审
ensure_consent(member_id) 对非 local 的 council member 检查 privacy.egress 里它有没有 consent: required。没记入 state.consent[member_id] 就 input("Type 'yes' to consent to this first send: "),用户拒绝就 RunnerError 立刻停。consent 记录写明 granted_at / sends / redact 三个字段,复盘能直接看到哪条 egress 何时被授权。
260 def ensure_consent(self, member_id: str) -> None: 261 member = self.member(member_id) 262 if member.get("local"): 263 return 264 matching = [ 265 entry 266 for entry in self.spec.get("privacy", {}).get("egress", []) 267 if entry.get("to") == member_id and entry.get("consent") == "required" 268 ] 269 if not matching: 270 return 271 if self.state.get("consent", {}).get(member_id): 272 return 273 sends = sorted({item for entry in matching for item in entry.get("sends", [])}) 274 redactions = sorted({item for entry in matching for item in entry.get("redact", [])}) 275 print() 276 print(f"Looper is about to send {', '.join(sends) or 'context'} to {member_id}.") 277 print(f"CLI: {member.get('cli')} / model: {member.get('model', 'default')}") 278 print(f"Redactions: {', '.join(redactions) or '(none)'}") 279 answer = input("Type 'yes' to consent to this first send: ").strip().lower() 280 if answer != "yes": 281 self.save_state(status="blocked", failure=f"consent_refused:{member_id}") 282 raise RunnerError(f"Consent refused for {member_id}") 283 consent = dict(self.state.get("consent", {})) 284 consent[member_id] = {"granted_at": utc_now(), "sends": sends, "redact": redactions} 285 self.save_state(consent=consent) 286
▸gather_context + host_prompt — 输入管线
gather_context() 把 goal.context_sources 列出来:file 走 base_dir.resolve 并先查 is_redacted 再读,缺失记 status=missing;cmd 走 subprocess run with timeout。整段 context 再写进 workspace/context.md,后两步都查 enforce_wall_clock。这一函数是 host 与 judge 的共同起点——所有 prompt 都以 context.md 为底。
287 def gather_context(self) -> str: 288 goal = self.spec["goal"] 289 chunks: list[str] = [] 290 for index, source in enumerate(goal.get("context_sources", []), start=1): 291 self.enforce_wall_clock() 292 if "file" in source: 293 path = relative_to_base(source["file"], self.base_dir) 294 if is_redacted(path, self.base_dir, [".env", ".env.*", "secrets/**", "**/*.key"]): 295 chunks.append(f"## Context source {index}: {source['file']}\n[redacted]\n") 296 self.append_log("context", source=source["file"], status="redacted") 297 elif path.exists(): 298 chunks.append(f"## Context source {index}: {source['file']}\n{path.read_text(encoding='utf-8')}\n") 299 self.append_log("context", source=source["file"], status="read") 300 else: 301 chunks.append(f"## Context source {index}: {source['file']}\n[missing]\n") 302 self.append_log("context", source=source["file"], status="missing") 303 elif "cmd" in source: 304 argv = ensure_argv(source["cmd"], f"context_sources[{index}].cmd") 305 result = run_argv(argv, cwd=self.base_dir, timeout_sec=int(source.get("timeout_sec", 60))) 306 chunks.append( 307 f"## Context source {index}: {' '.join(argv)}\n" 308 f"exit={result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}\n" 309 ) 310 self.append_log("context_cmd", argv=argv, returncode=result.returncode) 311 context = "\n".join(chunks).strip() 312 write_text(self.workspace / "context.md", context or "No context sources configured.") 313 return context 314 315 def host_prompt(self, phase: str, artifact: str = "", review: str = "") -> str: 316 goal = self.spec["goal"] 317 if phase == "plan": 318 return ( 319 "Draft plan.md for this loop.\n\n" 320 f"Goal:\n{goal['statement']}\n\n" 321 f"Definition of done:\n{goal['definition_of_done']}\n\n" 322 f"Context:\n{(self.workspace / 'context.md').read_text(encoding='utf-8')}\n" 323 ) 324 if phase == "delivery": 325 return ( 326 "Write the next delivery artifact for this loop.\n\n" 327 f"Goal:\n{goal['statement']}\n\n" 328 f"Definition of done:\n{goal['definition_of_done']}\n\n" 329 f"Plan:\n{(self.workspace / 'plan.md').read_text(encoding='utf-8')}\n" 330 ) 331 if phase == "revise": 332 return ( 333 "Revise the artifact to address the review. Return only the revised artifact.\n\n" 334 f"Artifact:\n{artifact}\n\nReview:\n{review}\n" 335 ) 336 raise RunnerError(f"Unknown host phase: {phase}")
▸judge_prompt — 物化 rubric
judge_prompt 把 criterion 数组物化成可读 rubric:programmatic 显示 "result is included below";judge 显示 rubric 文本;human 显示 "human signoff is required separately"。prompt 还约束 judge 返回 fence'd JSON,键为 verdict / blocking_issues / confidence / notes——这是 parse_judge_output sources/ksimback-looper/templates/run-loop.py:112 能正常解析的前提。这一段是 reviewer / judge / programmatic 三类判定在同一 prompt 模板里的对齐点。
357 "programmatic_check", 358 criterion=criterion["id"], 359 passed=passed, 360 returncode=result.returncode, 361 ) 362 return { 363 "id": criterion["id"], 364 "type": "programmatic", 365 "passed": passed, 366 "returncode": result.returncode, 367 "stdout": result.stdout, 368 "stderr": result.stderr, 369 } 370 371 def judge_prompt( 372 self, 373 gate_name: str, 374 artifact_label: str, 375 artifact_text: str, 376 criteria: list[dict[str, Any]], 377 ) -> str: 378 rubric_lines = [] 379 for criterion in criteria: 380 if criterion["type"] == "judge": 381 rubric_lines.append(f"- {criterion['id']}: {criterion['rubric']}") 382 elif criterion["type"] == "programmatic": 383 rubric_lines.append(f"- {criterion['id']}: programmatic check result is included below.") 384 elif criterion["type"] == "human": 385 rubric_lines.append(f"- {criterion['id']}: human signoff is required separately.") 386 return ( 387 "You are the Looper judge. Return only a fenced JSON object with keys " 388 "verdict, blocking_issues, confidence, and notes. verdict must be pass or revise.\n\n" 389 f"Gate: {gate_name}\n" 390 f"Artifact: {artifact_label}\n\n" 391 "Criteria:\n" + "\n".join(rubric_lines) + "\n\n" 392 f"Artifact content:\n{artifact_text}\n" 393 )
▸run_reviewers / human_check — 旁路判定
run_reviewers 只收集 notes 不出 verdict;human_check 是 input("Type 'pass' to approve, anything else to request revision") 的纯 stdin。两者都不会调用 model CLI,所以甚至可以在断网时跑——验证面只要能 spawn Python 子进程就行。
395 def run_judge( 396 self, 397 member_id: str, 398 gate_name: str, 399 artifact_label: str, 400 artifact_text: str, 401 criteria: list[dict[str, Any]], 402 ) -> dict[str, Any]: 403 self.ensure_consent(member_id) 404 output = call_model( 405 self.member(member_id), 406 self.redact_prompt_for_member( 407 member_id, 408 self.judge_prompt(gate_name, artifact_label, artifact_text, criteria), 409 ), 410 self.base_dir, 411 ) 412 verdict = parse_judge_output(output) 413 verdict["member"] = member_id 414 self.append_log("judge_verdict", gate=gate_name, member=member_id, verdict=verdict.get("verdict")) 415 return verdict 416 417 def run_reviewers( 418 self, 419 gate_name: str, 420 artifact_label: str, 421 artifact_text: str, 422 member_ids: list[str], 423 ) -> list[str]: 424 notes = [] 425 for member_id in member_ids: 426 member = self.member(member_id) 427 if member.get("role") != "reviewer": 428 continue 429 self.ensure_consent(member_id) 430 prompt = ( 431 "You are a Looper reviewer. Return concise blocking and non-blocking notes. " 432 "Do not return a verdict.\n\n" 433 f"Gate: {gate_name}\nArtifact: {artifact_label}\n\n{artifact_text}\n" 434 ) 435 prompt = self.redact_prompt_for_member(member_id, prompt) 436 notes.append(f"## {member_id}\n\n{call_model(member, prompt, self.base_dir)}") 437 self.append_log("reviewer_notes", gate=gate_name, member=member_id) 438 return notes 439 440 def human_check(self, criterion: dict[str, Any]) -> dict[str, Any]: 441 print() 442 print(criterion["prompt"]) 443 answer = input("Type 'pass' to approve, anything else to request revision: ").strip().lower() 444 return { 445 "id": criterion["id"], 446 "type": "human", 447 "passed": answer == PASS, 448 "notes": "approved" if answer == PASS else "human requested revision", 449 }
▸run_gate — 主裁判 while-loop
run_gate() 是整个 runner 的核心:while True 循环里依次跑 programmatic / human / reviewers,按 policy 走 revise_until_clean 时调 judge,关键一句是 L499-501:if verdict.get("verdict") == REVISE: failures.extend(verdict.get("blocking_issues") or ["Judge requested revision"])——失败被显式归并进 failures 列表,方便 no_progress_reached 算 signature。max_revisions 触发或 no_progress_reached 触发都写 state.json 后 return False,调用方据此停掉整个 loop。
451 def run_gate(self, gate_name: str, artifact_path: Path, artifact_label: str) -> bool: 452 gate = self.spec["gates"][gate_name] 453 criteria = self.criteria(gate.get("criteria", [])) 454 max_revisions = int(gate.get("max_revisions", 0)) 455 revision = 0 456 self.append_log("gate_start", gate=gate_name, artifact=artifact_label) 457 458 while True: 459 self.enforce_wall_clock() 460 artifact_text = artifact_path.read_text(encoding="utf-8") 461 review_parts: list[str] = [] 462 failures: list[str] = [] 463 464 for criterion in criteria: 465 if criterion["type"] == "programmatic": 466 result = self.run_programmatic(criterion) 467 review_parts.append(f"## Programmatic {criterion['id']}\n\n```json\n{json.dumps(result, indent=2)}\n```") 468 if not result["passed"]: 469 failures.append(f"Programmatic check failed: {criterion['id']}") 470 elif criterion["type"] == "human": 471 result = self.human_check(criterion) 472 review_parts.append(f"## Human {criterion['id']}\n\n{result['notes']}") 473 if not result["passed"]: 474 failures.append(f"Human check failed: {criterion['id']}") 475 476 reviewer_notes = self.run_reviewers( 477 gate_name, 478 artifact_label, 479 artifact_text, 480 list(gate.get("members", [])), 481 ) 482 review_parts.extend(reviewer_notes) 483 484 policy = gate.get("verdict_policy") 485 verdict: dict[str, Any] | None = None 486 if policy == "revise_until_clean" and not failures: 487 source = gate.get("verdict_source") 488 if source == "human": 489 answer = input(f"Type 'pass' if {artifact_label} is clean: ").strip().lower() 490 verdict = { 491 "verdict": PASS if answer == PASS else REVISE, 492 "blocking_issues": [] if answer == PASS else ["human requested revision"], 493 "confidence": 1.0, 494 "notes": "human verdict", 495 } 496 else: 497 verdict = self.run_judge(source, gate_name, artifact_label, artifact_text, criteria) 498 review_parts.append(f"## Verdict\n\n```json\n{json.dumps(verdict, indent=2)}\n```") 499 if verdict.get("verdict") == REVISE: 500 failures.extend(verdict.get("blocking_issues") or ["Judge requested revision"]) 501 502 if policy == "fixed_passes": 503 if failures: 504 pass 505 elif revision >= max_revisions: 506 return True 507 else: 508 failures.append("fixed_passes reviewer pass") 509 510 if not failures: 511 self.save_state(status=f"{gate_name}_passed", **{gate_name: {"passed_at": utc_now()}}) 512 self.append_log("gate_passed", gate=gate_name, artifact=artifact_label) 513 return True 514 515 review_text = "\n\n".join(review_parts + ["## Blocking Issues", "\n".join(f"- {item}" for item in failures)]) 516 review_path = self.workspace / f"review-{gate_name}-{revision + 1}.md" 517 write_text(review_path, review_text) 518 self.append_log("gate_blocked", gate=gate_name, review=review_path.name, failures=failures) 519 520 if self.no_progress_reached(gate_name, failures): 521 return False 522 523 if revision >= max_revisions: 524 self.save_state( 525 status="failed", 526 failure=f"{gate_name}_max_revisions_reached", 527 last_review=str(review_path), 528 ) 529 self.append_log("stop", reason=f"{gate_name}_max_revisions_reached") 530 return False 531 532 revised = call_model( 533 self.spec["host"], 534 self.host_prompt("revise", artifact_text, review_text), 535 self.base_dir, 536 ) 537 write_text(artifact_path, revised) 538 revision += 1 539 self.save_state(status=f"{gate_name}_revision_{revision}", last_review=str(review_path)) 540 self.append_log("revision", gate=gate_name, revision=revision, artifact=artifact_label)
▸Runner.run — 入口
run() 是 25 行直白的状态机:save_state("running") → gather_context → 跑 plan → 跑 plan_gate → 进入迭代循环 → 任一交付过 delivery_gate 就 return 0 → 否则 max_iterations 触发就 return 1。注意"max_iterations reached" 是单独一类失败,不进 gate.revisions 也不进 no_progress,逻辑上等价于"用完预算就要停"。
542 def run(self) -> int: 543 self.save_state(status="running") 544 self.append_log("run_start", spec=str(self.spec_path)) 545 self.gather_context() 546 547 plan_path = self.workspace / "plan.md" 548 if not plan_path.exists(): 549 self.run_host("plan", plan_path) 550 if not self.run_gate("plan_gate", plan_path, "plan.md"): 551 return 1 552 553 max_iterations = int(self.spec["loop_control"]["max_iterations"]) 554 for iteration in range(1, max_iterations + 1): 555 self.enforce_wall_clock() 556 self.save_state(status="delivery", iteration=iteration) 557 delivery_path = self.workspace / f"delivery-{iteration}.md" 558 self.run_host("delivery", delivery_path) 559 if self.run_gate("delivery_gate", delivery_path, delivery_path.name): 560 self.save_state(status="passed", final_delivery=str(delivery_path), completed_at=utc_now()) 561 self.append_log("run_passed", final_delivery=str(delivery_path)) 562 print(f"Looper run passed. Final delivery: {delivery_path}") 563 return 0 564 565 self.save_state(status="failed", failure="max_iterations_reached") 566 self.append_log("stop", reason="max_iterations_reached") 567 return 1 568 569 570def main(argv: list[str] | None = None) -> int: 571 parser = argparse.ArgumentParser(description="Run a compiled Looper loop.") 572 parser.add_argument( 573 "spec_path", 574 nargs="?", 575 type=Path, 576 default=Path(__file__).with_name("loop.resolved.json"), 577 help="Path to loop.resolved.json (defaults to the file next to run-loop.py).", 578 ) 579 args = parser.parse_args(sys.argv[1:] if argv is None else argv) 580 try: 581 return Runner(args.spec_path).run() 582 except RunnerError as exc: 583 print(f"run-loop: error: {exc}", file=sys.stderr) 584 return 2 585 586 587if __name__ == "__main__": 588 raise SystemExit(main())
关键流程
▸测试 fixture — fake_judge.py / fake_host.py
tests/fixtures/ 下的两个文件是 runner 整套路径能 CI 跑通的根基。fake_judge.py 读外部 state 文件,每次调用递增 count;count < revise-count 时返回 verdict=REVISE + blocking_issues=["Add an explicit owner."],否则 pass。counter 参数来自 loop.yaml 的 argv 数组——["python", "fake_judge.py", "{judge_state}", "1"],1 表示先 revise 一次再 pass。fake_host.py 读 stdin 文本里的关键词切换产物形态:包含 "Revise the artifact" 就返回修订文本,包含 "Draft plan.md" 就返 plan,否则返 LOOP.md。三者组合让 tests/test_looper.py 能在不连真实 CLI 的情况下跑完整 revise_until_clean → judge pass → status=passed 链路。
1#!/usr/bin/env python3 2"""Fake judge model for Looper runner tests.""" 3 4from __future__ import annotations 5 6import json 7from pathlib import Path 8import sys 9 10 11def main() -> int: 12 if len(sys.argv) < 2: 13 print("usage: fake_judge.py <state-file> [revise-count]", file=sys.stderr) 14 return 2 15 state_path = Path(sys.argv[1]) 16 revise_count = int(sys.argv[2]) if len(sys.argv) > 2 else 0 17 state_path.parent.mkdir(parents=True, exist_ok=True) 18 state = {"count": 0} 19 if state_path.exists(): 20 state = json.loads(state_path.read_text(encoding="utf-8")) 21 count = int(state.get("count", 0)) 22 state["count"] = count + 1 23 state_path.write_text(json.dumps(state), encoding="utf-8") 24 25 if count < revise_count: 26 verdict = { 27 "verdict": "revise", 28 "blocking_issues": ["Add an explicit owner."], 29 "confidence": 0.91, 30 "notes": "The artifact is close but needs one concrete owner.", 31 } 32 else: 33 verdict = { 34 "verdict": "pass", 35 "blocking_issues": [], 36 "confidence": 0.94, 37 "notes": "The artifact satisfies the rubric.", 38 } 39 print("```json") 40 print(json.dumps(verdict)) 41 print("```") 42 return 0 43 44 45if __name__ == "__main__": 46 raise SystemExit(main()) 47
1#!/usr/bin/env python3 2"""Fake host model for Looper runner tests.""" 3 4from __future__ import annotations 5 6import sys 7 8 9def main() -> int: 10 prompt = sys.stdin.read() 11 if "Revise the artifact" in prompt: 12 print("Revised artifact") 13 print() 14 print("Owner: host") 15 print("Resolved reviewer issue.") 16 print("No TBD") 17 elif "Draft plan.md" in prompt: 18 print("# Plan") 19 print() 20 print("Owner: host") 21 print("1. Read the context.") 22 print("2. Produce the delivery.") 23 print("No TBD") 24 else: 25 print("# LOOP.md") 26 print() 27 print("Owner: host") 28 print("Diagram: included") 29 print("No TBD") 30 return 0 31 32 33if __name__ == "__main__": 34 raise SystemExit(main()) 35
边界 / 失败模式
- judge 输出非 JSON —
parse_judge_output把非 JSON 降级为REVISE+blocking_issues=["Judge output was not parseable JSON."],让 no_progress 自然触发;但仍可能浪费预算。 - no_progress 文本签名错位 — 同样一句 blocking 措辞微调就归零;同一 gate 连续两次 verdict=REVISE 但 blocking_issues 排序不同也会归零。现实里 verbose judge 又尤其容易命中此问题。
- wall_clock 在 host 子进程退出后检查 —
enforce_wall_clock在调用 前 检查,返回时 host 自身耗时已计入 monotonic,所以下一次进 gate 才能感知超时。如果一次 host call 跨多分钟,会变成"完成后再下一次 gate 才发现"的延迟停机。 - redact 走文件 glob 不做内容指纹 — 用户把 secret 复制到
process-notes.md,runner 不会拦;只能默认 glob 的 4 类文件。 - argv list 注入无显式校验 —
ensure_argv只查 "list of str",不会拦["bash", "-c", "..."]。如果 council 成员的 invocation 里塞了一段 shell 脚本,runner 会直接 spawn bash。 - subprocess.DEVNULL 仅给 run_probe — run_argv 把 stdin 走 prompt 文本(line 87-92),但探测版本时已经埋掉了 stdin,避免误从父进程吃。
- 缺命令行参数走默认 spec_path —
main()把loop.resolved.json默认放在 runner 旁边,是好默认但工作目录不固定时容易找不到文件。 - fake_judge 用 state 文件计数 — 仅在 tests/ 下有效;真实 CLI 不会按调用次数切 verdict。CI 通过不等于生产可用。
▸本子页源码引用清单
所有 path:line 都已 grep 验证,集中在 templates/run-loop.py 与少数 scripts/looper.py:
sources/ksimback-looper/templates/run-loop.py:1 docstring 边界声明sources/ksimback-looper/templates/run-loop.py:26 RunnerErrorsources/ksimback-looper/templates/run-loop.py:57 ensure_argvsources/ksimback-looper/templates/run-loop.py:84 subprocess.runsources/ksimback-looper/templates/run-loop.py:112 parse_judge_outputsources/ksimback-looper/templates/run-loop.py:113 fence JSON 解析sources/ksimback-looper/templates/run-loop.py:144 Runner.__init__sources/ksimback-looper/templates/run-loop.py:154 utc_now + monotonicsources/ksimback-looper/templates/run-loop.py:156 load_statesources/ksimback-looper/templates/run-loop.py:167 save_state
sources/ksimback-looper/templates/run-loop.py:172 append_logsources/ksimback-looper/templates/run-loop.py:178 enforce_wall_clocksources/ksimback-looper/templates/run-loop.py:188 no_progress_reachedsources/ksimback-looper/templates/run-loop.py:227 redactions_forsources/ksimback-looper/templates/run-loop.py:234 redact_prompt_for_membersources/ksimback-looper/templates/run-loop.py:260 ensure_consentsources/ksimback-looper/templates/run-loop.py:271 state.consent first readsources/ksimback-looper/templates/run-loop.py:287 gather_contextsources/ksimback-looper/templates/run-loop.py:312 workspace/context.mdsources/ksimback-looper/templates/run-loop.py:338 run_host
sources/ksimback-looper/templates/run-loop.py:345 run_programmaticsources/ksimback-looper/templates/run-loop.py:371 judge_promptsources/ksimback-looper/templates/run-loop.py:395 run_judgesources/ksimback-looper/templates/run-loop.py:417 run_reviewerssources/ksimback-looper/templates/run-loop.py:440 human_checksources/ksimback-looper/templates/run-loop.py:443 input promptsources/ksimback-looper/templates/run-loop.py:451 run_gatesources/ksimback-looper/templates/run-loop.py:542 Runner.runsources/ksimback-looper/templates/run-loop.py:571 argparse CLIsources/ksimback-looper/templates/run-loop.py:587 __main__
sources/ksimback-looper/scripts/looper.py:23 DEFAULT_REDACTIONSsources/ksimback-looper/scripts/looper.py:131 subprocess.DEVNULLsources/ksimback-looper/scripts/looper.py:362 run_log defaultsources/ksimback-looper/tests/fixtures/fake_judge.py:11 countersources/ksimback-looper/tests/fixtures/fake_host.py:9 prompt-based switchsources/ksimback-looper/tests/test_looper.py:1 write_loop_yaml fixture