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

266 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -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 getattr(args, "max_steps", None): 

119 fsm.max_steps = args.max_steps 

120 if getattr(args, "max_iterations", None): 

121 fsm.max_iterations = args.max_iterations 

122 if args.delay is not None: 

123 fsm.backoff = args.delay 

124 if args.no_llm: 

125 fsm.llm.enabled = False 

126 if args.llm_model: 

127 fsm.llm.model = args.llm_model 

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

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

130 raw = args.input 

131 try: 

132 parsed = json.loads(raw) 

133 if isinstance(parsed, dict): 

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

135 if matched: 

136 fsm.context.update(matched) 

137 else: 

138 fsm.context[fsm.input_key] = raw 

139 else: 

140 fsm.context[fsm.input_key] = raw 

141 except (json.JSONDecodeError, ValueError): 

142 fsm.context[fsm.input_key] = raw 

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

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

145 program_md_path = ( 

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

147 ) 

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

149 fsm.context[key] = value 

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

151 if "=" not in kv: 

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

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

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

155 

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

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

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

159 else: 

160 _pre_instance_id = _make_instance_id(loop_name) 

161 

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

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

164 if "run_dir" not in fsm.context: 

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

166 

167 # Inject input hash for checkpoint fingerprinting. 

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

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

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

171 

172 # Inject loop metadata into context so templates can reference 

173 # ${context.max_steps} in state actions and evaluator prompts. 

174 # --context max_steps=VALUE (already applied above) takes precedence. 

175 # Must run after the --max-steps CLI override so the context value reflects 

176 # any CLI-supplied override. 

177 if "max_steps" not in fsm.context: 

178 fsm.context["max_steps"] = fsm.max_steps 

179 if fsm.max_iterations is not None and "max_iterations" not in fsm.context: 

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

181 

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

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

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

185 

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

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

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

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

190 

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

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

193 

194 from little_loops.config import BRConfig 

195 from little_loops.design_tokens import load_design_tokens, render_as_prompt_context 

196 

197 _config = BRConfig(Path.cwd()) 

198 

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

200 _tokens = load_design_tokens(_config) 

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

202 

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

204 _highlight_color = _config.cli.colors.fsm_active_state 

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

206 

207 # Dry run 

208 if args.dry_run: 

209 from little_loops.cli.loop.diagram_modes import resolve_facets 

210 from little_loops.cli.loop.layout import _render_fsm_diagram 

211 from little_loops.cli.output import terminal_width 

212 

213 facets = resolve_facets(args) 

214 if facets is not None: 

215 tw = terminal_width() 

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

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

218 diagram = _render_fsm_diagram( 

219 fsm, 

220 highlight_color=_highlight_color, 

221 edge_label_colors=_edge_label_colors, 

222 badges=_badges, 

223 mode=facets.scope, 

224 suppress_labels=not facets.edge_labels, 

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

226 ) 

227 print(diagram) 

228 print() 

229 print_execution_plan(fsm, edge_label_colors=_edge_label_colors) 

230 return 0 

231 

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

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

234 missing_keys: set[str] = set() 

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

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

237 if state.evaluate and state.evaluate.prompt: 

238 templates.append(state.evaluate.prompt) 

239 for template in templates: 

240 for m in _ctx_var_re.finditer(template): 

241 key = m.group(1) 

242 if key not in fsm.context: 

243 missing_keys.add(key) 

244 if missing_keys: 

245 for key in sorted(missing_keys): 

246 logger.error( 

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

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

249 ) 

250 return 1 

251 

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

253 for key in fsm.required_inputs: 

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

255 logger.error( 

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

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

258 ) 

259 return 1 

260 

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

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

263 if baseline_enabled: 

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

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

266 fsm.context["_baseline"] = { 

267 "enabled": True, 

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

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

270 "cross_host": getattr(args, "cross_host", False), 

271 } 

272 

273 # Background mode: spawn detached process and return 

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

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

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

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

278 raise SystemExit( 

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

280 ) 

281 return run_background(loop_name, args, loops_dir) 

282 

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

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

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

286 running_dir = loops_dir / ".running" 

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

288 _reconcile_stale_runs(loops_dir) 

289 instance_id = _pre_instance_id 

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

291 foreground_pid_file: Path | None = pid_file 

292 

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

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

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

296 

297 def _cleanup_pid() -> None: 

298 pid_file.unlink(missing_ok=True) 

299 

300 atexit.register(_cleanup_pid) 

301 

302 # Scope-based locking 

303 lock_manager = LockManager(loops_dir) 

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

305 _queue_entry_file: Path | None = None 

306 

307 def _cleanup_queue_entry() -> None: 

308 if _queue_entry_file is not None: 

309 _queue_entry_file.unlink(missing_ok=True) 

310 

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

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

313 conflict = lock_manager.find_conflict(scope) 

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

315 # Write queue entry so dashboard shows the waiting loop 

316 queue_dir = loops_dir / ".queue" 

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

318 entry_id = str(uuid.uuid4()) 

319 entry = { 

320 "id": entry_id, 

321 "loopName": loop_name, 

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

323 "context": { 

324 "waitingFor": conflict.loop_name, 

325 "scope": conflict.scope, 

326 "pid": os.getpid(), 

327 }, 

328 } 

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

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

331 atexit.register(_cleanup_queue_entry) 

332 

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

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

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

336 acquired = False 

337 _wait_start = time.time() 

338 _budget = _config.loops.queue_wait_timeout_seconds 

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

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

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

342 _cleanup_queue_entry() 

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

344 return 1 

345 if not _is_earliest_waiter(entry_id, queue_dir): 

346 time.sleep(1) 

347 continue 

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

349 acquired = True 

350 break 

351 if not acquired: 

352 _cleanup_queue_entry() 

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

354 return 1 

355 # Lock acquired - no longer queued 

356 _cleanup_queue_entry() 

357 elif conflict: 

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

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

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

361 return 1 

362 else: 

363 # Unexpected: find_conflict returned None but acquire failed 

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

365 return 1 

366 

367 executor: PersistentExecutor | None = None 

368 try: 

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

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

371 from little_loops.config import BRConfig as _MainBRConfig 

372 from little_loops.parallel.git_lock import GitLock 

373 from little_loops.worktree_utils import cleanup_worktree, setup_worktree 

374 

375 _main_config = _MainBRConfig(Path.cwd()) 

376 _worktree_base = _main_config.get_worktree_base() 

377 _copy_files = _main_config.parallel.worktree_copy_files 

378 _repo_path = Path.cwd() 

379 

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

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

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

383 _worktree_path = _worktree_base / _branch_name 

384 

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

386 _git_lock = GitLock(logger) 

387 

388 setup_worktree( 

389 repo_path=_repo_path, 

390 worktree_path=_worktree_path, 

391 branch_name=_branch_name, 

392 copy_files=_copy_files, 

393 logger=logger, 

394 git_lock=_git_lock, 

395 ) 

396 

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

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

399 

400 def _cleanup_worktree_on_exit() -> None: 

401 cleanup_worktree( 

402 worktree_path=_worktree_path, 

403 repo_path=_repo_path, 

404 logger=logger, 

405 git_lock=_git_lock, 

406 delete_branch=True, 

407 ) 

408 

409 atexit.register(_cleanup_worktree_on_exit) 

410 

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

412 os.chdir(_worktree_path) 

413 

414 circuit = ( 

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

416 if _config.commands.rate_limits.circuit_breaker_enabled 

417 else None 

418 ) 

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

420 executor = PersistentExecutor( 

421 fsm, 

422 loops_dir=loops_dir, 

423 circuit=circuit, 

424 instance_id=instance_id, 

425 pid=os.getpid(), 

426 run_model=getattr(args, "run_model", None) or None, 

427 ) 

428 

429 # Register signal handlers for graceful shutdown 

430 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

431 

432 from little_loops.extension import wire_extensions 

433 from little_loops.transport import wire_transports 

434 

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

436 wire_transports(executor.event_bus, _config.events) 

437 return run_foreground( 

438 executor, 

439 fsm, 

440 args, 

441 highlight_color=_highlight_color, 

442 edge_label_colors=_edge_label_colors, 

443 badges=_badges, 

444 instance_id=instance_id, 

445 loop_path=path, 

446 running_dir=running_dir, 

447 model=fsm.llm.model, 

448 ) 

449 finally: 

450 if executor is not None: 

451 executor.close_transports() 

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

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