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

268 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -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 # Inject include allowlist from config default. 

200 # --context include=VALUE (already applied above) takes precedence. 

201 if "include" not in fsm.context and _config.loops.run_defaults.include: 

202 fsm.context["include"] = _config.loops.run_defaults.include 

203 

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

205 _tokens = load_design_tokens(_config) 

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

207 

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

209 _highlight_color = _config.cli.colors.fsm_active_state 

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

211 

212 # Dry run 

213 if args.dry_run: 

214 from little_loops.cli.loop.diagram_modes import resolve_facets 

215 from little_loops.cli.loop.layout import _render_fsm_diagram 

216 from little_loops.cli.output import terminal_width 

217 

218 facets = resolve_facets(args) 

219 if facets is not None: 

220 tw = terminal_width() 

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

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

223 diagram = _render_fsm_diagram( 

224 fsm, 

225 highlight_color=_highlight_color, 

226 edge_label_colors=_edge_label_colors, 

227 badges=_badges, 

228 mode=facets.scope, 

229 suppress_labels=not facets.edge_labels, 

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

231 ) 

232 print(diagram) 

233 print() 

234 print_execution_plan(fsm, edge_label_colors=_edge_label_colors) 

235 return 0 

236 

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

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

239 missing_keys: set[str] = set() 

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

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

242 if state.evaluate and state.evaluate.prompt: 

243 templates.append(state.evaluate.prompt) 

244 for template in templates: 

245 for m in _ctx_var_re.finditer(template): 

246 key = m.group(1) 

247 if key not in fsm.context: 

248 missing_keys.add(key) 

249 if missing_keys: 

250 for key in sorted(missing_keys): 

251 logger.error( 

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

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

254 ) 

255 return 1 

256 

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

258 for key in fsm.required_inputs: 

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

260 logger.error( 

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

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

263 ) 

264 return 1 

265 

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

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

268 if baseline_enabled: 

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

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

271 fsm.context["_baseline"] = { 

272 "enabled": True, 

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

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

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

276 } 

277 

278 # Background mode: spawn detached process and return 

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

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

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

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

283 raise SystemExit( 

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

285 ) 

286 return run_background(loop_name, args, loops_dir) 

287 

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

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

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

291 running_dir = loops_dir / ".running" 

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

293 _reconcile_stale_runs(loops_dir) 

294 instance_id = _pre_instance_id 

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

296 foreground_pid_file: Path | None = pid_file 

297 

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

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

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

301 

302 def _cleanup_pid() -> None: 

303 pid_file.unlink(missing_ok=True) 

304 

305 atexit.register(_cleanup_pid) 

306 

307 # Scope-based locking 

308 lock_manager = LockManager(loops_dir) 

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

310 _queue_entry_file: Path | None = None 

311 

312 def _cleanup_queue_entry() -> None: 

313 if _queue_entry_file is not None: 

314 _queue_entry_file.unlink(missing_ok=True) 

315 

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

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

318 conflict = lock_manager.find_conflict(scope) 

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

320 # Write queue entry so dashboard shows the waiting loop 

321 queue_dir = loops_dir / ".queue" 

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

323 entry_id = str(uuid.uuid4()) 

324 entry = { 

325 "id": entry_id, 

326 "loopName": loop_name, 

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

328 "context": { 

329 "waitingFor": conflict.loop_name, 

330 "scope": conflict.scope, 

331 "pid": os.getpid(), 

332 }, 

333 } 

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

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

336 atexit.register(_cleanup_queue_entry) 

337 

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

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

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

341 acquired = False 

342 _wait_start = time.time() 

343 _budget = _config.loops.queue_wait_timeout_seconds 

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

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

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

347 _cleanup_queue_entry() 

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

349 return 1 

350 if not _is_earliest_waiter(entry_id, queue_dir): 

351 time.sleep(1) 

352 continue 

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

354 acquired = True 

355 break 

356 if not acquired: 

357 _cleanup_queue_entry() 

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

359 return 1 

360 # Lock acquired - no longer queued 

361 _cleanup_queue_entry() 

362 elif conflict: 

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

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

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

366 return 1 

367 else: 

368 # Unexpected: find_conflict returned None but acquire failed 

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

370 return 1 

371 

372 executor: PersistentExecutor | None = None 

373 try: 

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

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

376 from little_loops.config import BRConfig as _MainBRConfig 

377 from little_loops.parallel.git_lock import GitLock 

378 from little_loops.worktree_utils import cleanup_worktree, setup_worktree 

379 

380 _main_config = _MainBRConfig(Path.cwd()) 

381 _worktree_base = _main_config.get_worktree_base() 

382 _copy_files = _main_config.parallel.worktree_copy_files 

383 _repo_path = Path.cwd() 

384 

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

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

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

388 _worktree_path = _worktree_base / _branch_name 

389 

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

391 _git_lock = GitLock(logger) 

392 

393 setup_worktree( 

394 repo_path=_repo_path, 

395 worktree_path=_worktree_path, 

396 branch_name=_branch_name, 

397 copy_files=_copy_files, 

398 logger=logger, 

399 git_lock=_git_lock, 

400 ) 

401 

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

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

404 

405 def _cleanup_worktree_on_exit() -> None: 

406 cleanup_worktree( 

407 worktree_path=_worktree_path, 

408 repo_path=_repo_path, 

409 logger=logger, 

410 git_lock=_git_lock, 

411 delete_branch=True, 

412 ) 

413 

414 atexit.register(_cleanup_worktree_on_exit) 

415 

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

417 os.chdir(_worktree_path) 

418 

419 circuit = ( 

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

421 if _config.commands.rate_limits.circuit_breaker_enabled 

422 else None 

423 ) 

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

425 executor = PersistentExecutor( 

426 fsm, 

427 loops_dir=loops_dir, 

428 circuit=circuit, 

429 instance_id=instance_id, 

430 pid=os.getpid(), 

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

432 ) 

433 

434 # Register signal handlers for graceful shutdown 

435 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

436 

437 from little_loops.extension import wire_extensions 

438 from little_loops.transport import wire_transports 

439 

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

441 wire_transports(executor.event_bus, _config.events) 

442 return run_foreground( 

443 executor, 

444 fsm, 

445 args, 

446 highlight_color=_highlight_color, 

447 edge_label_colors=_edge_label_colors, 

448 badges=_badges, 

449 instance_id=instance_id, 

450 loop_path=path, 

451 running_dir=running_dir, 

452 model=fsm.llm.model, 

453 ) 

454 finally: 

455 if executor is not None: 

456 executor.close_transports() 

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

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