Coverage for little_loops / cli / loop / run.py: 0%

262 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -0500

1"""ll-loop run subcommand.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import atexit 

7import hashlib 

8import json 

9import os 

10import re 

11import time 

12import uuid 

13from datetime import UTC, datetime 

14from pathlib import Path 

15 

16from little_loops.cli.loop._helpers import ( 

17 _is_earliest_waiter, 

18 _make_instance_id, 

19 get_builtin_loops_dir, 

20 print_execution_plan, 

21 register_loop_signal_handlers, 

22 resolve_loop_path, 

23 run_background, 

24 run_foreground, 

25) 

26from little_loops.logger import Logger 

27 

28 

29def _parse_program_md(path: Path) -> dict[str, str]: 

30 """Parse .ll/program.md heading sections into context key-value pairs. 

31 

32 Sections mapped: 

33 ## Directive → directive (prose) 

34 ## Targets → targets (space-joined list items) 

35 ## Benchmark → each key: value pair injected directly 

36 ## Budget → budget (prose) 

37 ## Constraints → constraints (prose) 

38 """ 

39 if not path.exists(): 

40 return {} 

41 try: 

42 content = path.read_text() 

43 except OSError: 

44 return {} 

45 

46 def _extract(heading: str) -> str: 

47 m = re.search(rf"^##\s+{re.escape(heading)}\s*$", content, re.MULTILINE | re.IGNORECASE) 

48 if not m: 

49 return "" 

50 start = m.end() 

51 nxt = re.search(r"^##\s", content[start:], re.MULTILINE) 

52 return content[start : start + nxt.start()].strip() if nxt else content[start:].strip() 

53 

54 result: dict[str, str] = {} 

55 

56 directive = _extract("Directive") 

57 if directive: 

58 result["directive"] = directive 

59 

60 targets_text = _extract("Targets") 

61 if targets_text: 

62 items = [ 

63 line.lstrip("-* \t").strip() 

64 for line in targets_text.splitlines() 

65 if line.strip().startswith(("-", "*")) 

66 ] 

67 result["targets"] = " ".join(items) if items else targets_text 

68 

69 benchmark_text = _extract("Benchmark") 

70 if benchmark_text: 

71 for line in benchmark_text.splitlines(): 

72 if ":" in line: 

73 k, _, v = line.partition(":") 

74 k, v = k.strip(), v.strip() 

75 if k and v: 

76 result[k] = v 

77 

78 budget = _extract("Budget") 

79 if budget: 

80 result["budget"] = budget 

81 

82 constraints = _extract("Constraints") 

83 if constraints: 

84 result["constraints"] = constraints 

85 

86 return result 

87 

88 

89def cmd_run( 

90 loop_name: str, 

91 args: argparse.Namespace, 

92 loops_dir: Path, 

93 logger: Logger, 

94) -> int: 

95 """Run a loop.""" 

96 from little_loops.fsm.concurrency import LockManager, resolve_scope 

97 from little_loops.fsm.persistence import PersistentExecutor, _reconcile_stale_runs 

98 from little_loops.fsm.rate_limit_circuit import RateLimitCircuit 

99 from little_loops.fsm.validation import load_and_validate 

100 

101 try: 

102 if getattr(args, "builtin", False): 

103 path = get_builtin_loops_dir() / f"{loop_name}.yaml" 

104 if not path.exists(): 

105 logger.error(f"Built-in loop not found: {loop_name!r}") 

106 return 1 

107 else: 

108 path = resolve_loop_path(loop_name, loops_dir) 

109 fsm, _ = load_and_validate(path) 

110 except FileNotFoundError as e: 

111 logger.error(str(e)) 

112 return 1 

113 except ValueError as e: 

114 logger.error(f"Validation error: {e}") 

115 return 1 

116 

117 # Apply overrides 

118 if args.max_iterations: 

119 fsm.max_iterations = args.max_iterations 

120 if args.delay is not None: 

121 fsm.backoff = args.delay 

122 if args.no_llm: 

123 fsm.llm.enabled = False 

124 if args.llm_model: 

125 fsm.llm.model = args.llm_model 

126 # Inject positional input arg before --context so --context can override 

127 if getattr(args, "input", None) is not None: 

128 raw = args.input 

129 try: 

130 parsed = json.loads(raw) 

131 if isinstance(parsed, dict): 

132 matched = {k: v for k, v in parsed.items() if k in fsm.context} 

133 if matched: 

134 fsm.context.update(matched) 

135 else: 

136 fsm.context[fsm.input_key] = raw 

137 else: 

138 fsm.context[fsm.input_key] = raw 

139 except (json.JSONDecodeError, ValueError): 

140 fsm.context[fsm.input_key] = raw 

141 # Inject program.md fields before --context so --context can override 

142 program_md_arg = getattr(args, "program_md", None) 

143 program_md_path = ( 

144 program_md_arg if program_md_arg is not None else Path.cwd() / ".ll" / "program.md" 

145 ) 

146 for key, value in _parse_program_md(program_md_path).items(): 

147 fsm.context[key] = value 

148 for kv in getattr(args, "context", None) or []: 

149 if "=" not in kv: 

150 raise SystemExit(f"Invalid --context format: {kv!r} (expected KEY=VALUE)") 

151 key, _, value = kv.partition("=") 

152 fsm.context[key.strip()] = value.strip() 

153 

154 # Generate instance_id early so run_dir can be derived before the validation scan 

155 if getattr(args, "foreground_internal", False): 

156 _pre_instance_id: str | None = getattr(args, "instance_id", None) 

157 else: 

158 _pre_instance_id = _make_instance_id(loop_name) 

159 

160 # Inject run_dir into context before validation so ${context.run_dir} resolves. 

161 # --context run_dir=VALUE (already applied above) takes precedence. 

162 if "run_dir" not in fsm.context: 

163 fsm.context["run_dir"] = str(loops_dir / "runs" / (_pre_instance_id or loop_name)) + "/" 

164 

165 # Inject input hash for checkpoint fingerprinting. 

166 # --context input_hash=VALUE (already applied above) takes precedence. 

167 if "input_hash" not in fsm.context and isinstance(fsm.context.get("input"), str): 

168 fsm.context["input_hash"] = hashlib.sha256(fsm.context["input"].encode()).hexdigest()[:12] 

169 

170 # Inject loop metadata into context so templates can reference 

171 # ${context.max_iterations} in state actions and evaluator prompts. 

172 # --context max_iterations=VALUE (already applied above) takes precedence. 

173 # Must run after the --max-iterations CLI override (line 118-119) so the 

174 # context value reflects any CLI-supplied override. 

175 if "max_iterations" not in fsm.context: 

176 fsm.context["max_iterations"] = fsm.max_iterations 

177 

178 # Apply YAML loop config env-var overrides (CLI flags below overwrite these) 

179 if fsm.config is not None and isinstance(fsm.config.handoff_threshold, int): 

180 os.environ["LL_HANDOFF_THRESHOLD"] = str(fsm.config.handoff_threshold) 

181 

182 if getattr(args, "handoff_threshold", None) is not None: 

183 if not (1 <= args.handoff_threshold <= 100): 

184 raise SystemExit("--handoff-threshold must be between 1 and 100") 

185 os.environ["LL_HANDOFF_THRESHOLD"] = str(args.handoff_threshold) 

186 

187 if getattr(args, "context_limit", None) is not None: 

188 os.environ["LL_CONTEXT_LIMIT"] = str(args.context_limit) 

189 

190 from little_loops.config import BRConfig 

191 from little_loops.design_tokens import load_design_tokens, render_as_prompt_context 

192 

193 _config = BRConfig(Path.cwd()) 

194 

195 if not fsm.context.get("design_tokens_context"): 

196 _tokens = load_design_tokens(_config) 

197 fsm.context["design_tokens_context"] = render_as_prompt_context(_tokens) if _tokens else "" 

198 

199 _edge_label_colors = _config.cli.colors.fsm_edge_labels.to_dict() 

200 _highlight_color = _config.cli.colors.fsm_active_state 

201 _badges = _config.loops.glyphs.to_dict() 

202 

203 # Dry run 

204 if args.dry_run: 

205 from little_loops.cli.loop.diagram_modes import resolve_facets 

206 from little_loops.cli.loop.layout import _render_fsm_diagram 

207 from little_loops.cli.output import terminal_width 

208 

209 facets = resolve_facets(args) 

210 if facets is not None: 

211 tw = terminal_width() 

212 header_text = f"== loop: {fsm.name} " 

213 print(header_text + "=" * max(0, tw - len(header_text))) 

214 diagram = _render_fsm_diagram( 

215 fsm, 

216 highlight_color=_highlight_color, 

217 edge_label_colors=_edge_label_colors, 

218 badges=_badges, 

219 mode=facets.scope, 

220 suppress_labels=not facets.edge_labels, 

221 title_only=facets.state_detail == "title", 

222 ) 

223 print(diagram) 

224 print() 

225 print_execution_plan(fsm, edge_label_colors=_edge_label_colors) 

226 return 0 

227 

228 # Pre-run validation: check required context variables are present 

229 _ctx_var_re = re.compile(r"\$\{context\.([^}.]+)") 

230 missing_keys: set[str] = set() 

231 for state in fsm.states.values(): 

232 templates = [state.action] if state.action else [] 

233 if state.evaluate and state.evaluate.prompt: 

234 templates.append(state.evaluate.prompt) 

235 for template in templates: 

236 for m in _ctx_var_re.finditer(template): 

237 key = m.group(1) 

238 if key not in fsm.context: 

239 missing_keys.add(key) 

240 if missing_keys: 

241 for key in sorted(missing_keys): 

242 logger.error( 

243 f"Missing required context variable: '{key}'. " 

244 f"Run with: ll-loop run {loop_name} --context {key}=VALUE" 

245 ) 

246 return 1 

247 

248 # Pre-run validation: check required_inputs are present and non-empty 

249 for key in fsm.required_inputs: 

250 if not fsm.context.get(key, ""): 

251 logger.error( 

252 f"loop '{loop_name}' requires input '{key}' but none was provided. " 

253 f"Pass it via: ll-loop run {loop_name} <value>" 

254 ) 

255 return 1 

256 

257 # Baseline mode: apply to context before background gate so flags forward correctly 

258 baseline_enabled = getattr(args, "baseline", False) 

259 if baseline_enabled: 

260 if getattr(args, "worktree", False): 

261 raise SystemExit("--baseline and --worktree cannot be combined") 

262 fsm.context["_baseline"] = { 

263 "enabled": True, 

264 "skill": getattr(args, "baseline_skill", None), 

265 "items": getattr(args, "items", None), 

266 } 

267 

268 # Background mode: spawn detached process and return 

269 if getattr(args, "background", False): 

270 if getattr(args, "worktree", False): 

271 raise SystemExit("--worktree and --background cannot be combined") 

272 if getattr(args, "follow", False): 

273 raise SystemExit( 

274 "--follow and --background cannot be combined; use 'll-logs tail' to watch a background loop" 

275 ) 

276 return run_background(loop_name, args, loops_dir) 

277 

278 # Register PID file for all foreground runs so cmd_stop can send SIGTERM (BUG-639). 

279 # Background-spawned processes (foreground_internal=True) have their PID written by the 

280 # parent in run_background(); plain foreground runs must write their own PID here. 

281 running_dir = loops_dir / ".running" 

282 running_dir.mkdir(parents=True, exist_ok=True) 

283 _reconcile_stale_runs(loops_dir) 

284 instance_id = _pre_instance_id 

285 pid_file = running_dir / f"{instance_id or loop_name}.pid" 

286 foreground_pid_file: Path | None = pid_file 

287 

288 if not getattr(args, "foreground_internal", False): 

289 pid_file.parent.mkdir(parents=True, exist_ok=True) 

290 pid_file.write_text(str(os.getpid())) 

291 

292 def _cleanup_pid() -> None: 

293 pid_file.unlink(missing_ok=True) 

294 

295 atexit.register(_cleanup_pid) 

296 

297 # Scope-based locking 

298 lock_manager = LockManager(loops_dir) 

299 scope = resolve_scope(fsm.scope or ["."], fsm.context) 

300 _queue_entry_file: Path | None = None 

301 

302 def _cleanup_queue_entry() -> None: 

303 if _queue_entry_file is not None: 

304 _queue_entry_file.unlink(missing_ok=True) 

305 

306 if not getattr(args, "no_lock", False): 

307 if not lock_manager.acquire(fsm.name, scope, instance_id=instance_id): 

308 conflict = lock_manager.find_conflict(scope) 

309 if conflict and getattr(args, "queue", False): 

310 # Write queue entry so dashboard shows the waiting loop 

311 queue_dir = loops_dir / ".queue" 

312 queue_dir.mkdir(parents=True, exist_ok=True) 

313 entry_id = str(uuid.uuid4()) 

314 entry = { 

315 "id": entry_id, 

316 "loopName": loop_name, 

317 "enqueuedAt": datetime.now(UTC).isoformat(), 

318 "context": { 

319 "waitingFor": conflict.loop_name, 

320 "scope": conflict.scope, 

321 "pid": os.getpid(), 

322 }, 

323 } 

324 _queue_entry_file = queue_dir / f"{entry_id}.json" 

325 _queue_entry_file.write_text(json.dumps(entry, indent=2)) 

326 atexit.register(_cleanup_queue_entry) 

327 

328 logger.info(f"Waiting for conflicting loop '{conflict.loop_name}' to finish...") 

329 # Retry loop: when N waiters are released simultaneously, only one wins 

330 # acquire(); losers loop back and wait again rather than exiting (BUG-1281). 

331 acquired = False 

332 _wait_start = time.time() 

333 _budget = _config.loops.queue_wait_timeout_seconds 

334 while time.time() - _wait_start < _budget: 

335 _remaining = _budget - (time.time() - _wait_start) 

336 if not lock_manager.wait_for_scope(scope, timeout=int(_remaining)): 

337 _cleanup_queue_entry() 

338 logger.error("Timeout waiting for scope to become available") 

339 return 1 

340 if not _is_earliest_waiter(entry_id, queue_dir): 

341 time.sleep(1) 

342 continue 

343 if lock_manager.acquire(fsm.name, scope, instance_id=instance_id): 

344 acquired = True 

345 break 

346 if not acquired: 

347 _cleanup_queue_entry() 

348 logger.error("Failed to acquire lock after waiting") 

349 return 1 

350 # Lock acquired - no longer queued 

351 _cleanup_queue_entry() 

352 elif conflict: 

353 logger.error(f"Scope conflict with running loop: {conflict.loop_name}") 

354 logger.info(f" Conflicting scope: {conflict.scope}") 

355 logger.info(" Use --queue to wait for it to finish") 

356 return 1 

357 else: 

358 # Unexpected: find_conflict returned None but acquire failed 

359 logger.error("Failed to acquire scope lock (unknown reason)") 

360 return 1 

361 

362 executor: PersistentExecutor | None = None 

363 try: 

364 # Worktree isolation: create branch + directory before anything reads Path.cwd() 

365 if getattr(args, "worktree", False): 

366 from little_loops.config import BRConfig as _MainBRConfig 

367 from little_loops.parallel.git_lock import GitLock 

368 from little_loops.worktree_utils import cleanup_worktree, setup_worktree 

369 

370 _main_config = _MainBRConfig(Path.cwd()) 

371 _worktree_base = _main_config.get_worktree_base() 

372 _copy_files = _main_config.parallel.worktree_copy_files 

373 _repo_path = Path.cwd() 

374 

375 _timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 

376 _safe_name = re.sub(r"[^a-zA-Z0-9-]", "-", loop_name) 

377 _branch_name = f"{_timestamp}-{_safe_name}" 

378 _worktree_path = _worktree_base / _branch_name 

379 

380 _worktree_base.mkdir(parents=True, exist_ok=True) 

381 _git_lock = GitLock(logger) 

382 

383 setup_worktree( 

384 repo_path=_repo_path, 

385 worktree_path=_worktree_path, 

386 branch_name=_branch_name, 

387 copy_files=_copy_files, 

388 logger=logger, 

389 git_lock=_git_lock, 

390 ) 

391 

392 logger.info(f"Worktree: {_worktree_path}") 

393 logger.info(f"Branch: {_branch_name}") 

394 

395 def _cleanup_worktree_on_exit() -> None: 

396 cleanup_worktree( 

397 worktree_path=_worktree_path, 

398 repo_path=_repo_path, 

399 logger=logger, 

400 git_lock=_git_lock, 

401 delete_branch=True, 

402 ) 

403 

404 atexit.register(_cleanup_worktree_on_exit) 

405 

406 os.environ["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1" 

407 os.chdir(_worktree_path) 

408 

409 circuit = ( 

410 RateLimitCircuit(Path(_config.commands.rate_limits.circuit_breaker_path)) 

411 if _config.commands.rate_limits.circuit_breaker_enabled 

412 else None 

413 ) 

414 Path(fsm.context["run_dir"]).mkdir(parents=True, exist_ok=True) 

415 executor = PersistentExecutor( 

416 fsm, loops_dir=loops_dir, circuit=circuit, instance_id=instance_id, pid=os.getpid() 

417 ) 

418 

419 # Register signal handlers for graceful shutdown 

420 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

421 

422 from little_loops.extension import wire_extensions 

423 from little_loops.transport import wire_transports 

424 

425 wire_extensions(executor.event_bus, _config.extensions, executor=executor) 

426 wire_transports(executor.event_bus, _config.events) 

427 return run_foreground( 

428 executor, 

429 fsm, 

430 args, 

431 highlight_color=_highlight_color, 

432 edge_label_colors=_edge_label_colors, 

433 badges=_badges, 

434 instance_id=instance_id, 

435 loop_path=path, 

436 running_dir=running_dir, 

437 model=fsm.llm.model, 

438 ) 

439 finally: 

440 if executor is not None: 

441 executor.close_transports() 

442 if not getattr(args, "no_lock", False): 

443 lock_manager.release(fsm.name, instance_id=instance_id)