Coverage for little_loops / cli / loop / run.py: 26%
144 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
1"""ll-loop run subcommand."""
3from __future__ import annotations
5import argparse
6import atexit
7import json
8import os
9import re
10from pathlib import Path
12from little_loops.cli.loop._helpers import (
13 get_builtin_loops_dir,
14 print_execution_plan,
15 register_loop_signal_handlers,
16 resolve_loop_path,
17 run_background,
18 run_foreground,
19)
20from little_loops.logger import Logger
23def cmd_run(
24 loop_name: str,
25 args: argparse.Namespace,
26 loops_dir: Path,
27 logger: Logger,
28) -> int:
29 """Run a loop."""
30 from little_loops.fsm.concurrency import LockManager
31 from little_loops.fsm.persistence import PersistentExecutor
32 from little_loops.fsm.validation import load_and_validate
34 try:
35 if getattr(args, "builtin", False):
36 path = get_builtin_loops_dir() / f"{loop_name}.yaml"
37 if not path.exists():
38 logger.error(f"Built-in loop not found: {loop_name!r}")
39 return 1
40 else:
41 path = resolve_loop_path(loop_name, loops_dir)
42 fsm, _ = load_and_validate(path)
43 except FileNotFoundError as e:
44 logger.error(str(e))
45 return 1
46 except ValueError as e:
47 logger.error(f"Validation error: {e}")
48 return 1
50 # Apply overrides
51 if args.max_iterations:
52 fsm.max_iterations = args.max_iterations
53 if args.delay is not None:
54 fsm.backoff = args.delay
55 if args.no_llm:
56 fsm.llm.enabled = False
57 if args.llm_model:
58 fsm.llm.model = args.llm_model
59 # Inject positional input arg before --context so --context can override
60 if getattr(args, "input", None) is not None:
61 raw = args.input
62 try:
63 parsed = json.loads(raw)
64 if isinstance(parsed, dict):
65 matched = {k: v for k, v in parsed.items() if k in fsm.context}
66 if matched:
67 fsm.context.update(matched)
68 else:
69 fsm.context[fsm.input_key] = raw
70 else:
71 fsm.context[fsm.input_key] = raw
72 except (json.JSONDecodeError, ValueError):
73 fsm.context[fsm.input_key] = raw
74 for kv in getattr(args, "context", None) or []:
75 if "=" not in kv:
76 raise SystemExit(f"Invalid --context format: {kv!r} (expected KEY=VALUE)")
77 key, _, value = kv.partition("=")
78 fsm.context[key.strip()] = value.strip()
80 # Apply YAML loop config env-var overrides (CLI flags below overwrite these)
81 if fsm.config is not None and isinstance(fsm.config.handoff_threshold, int):
82 os.environ["LL_HANDOFF_THRESHOLD"] = str(fsm.config.handoff_threshold)
84 if getattr(args, "handoff_threshold", None) is not None:
85 if not (1 <= args.handoff_threshold <= 100):
86 raise SystemExit("--handoff-threshold must be between 1 and 100")
87 os.environ["LL_HANDOFF_THRESHOLD"] = str(args.handoff_threshold)
89 if getattr(args, "context_limit", None) is not None:
90 os.environ["LL_CONTEXT_LIMIT"] = str(args.context_limit)
92 # Dry run
93 if args.dry_run:
94 print_execution_plan(fsm)
95 return 0
97 # Pre-run validation: check required context variables are present
98 _ctx_var_re = re.compile(r"\$\{context\.([^}.]+)")
99 missing_keys: set[str] = set()
100 for state in fsm.states.values():
101 templates = [state.action] if state.action else []
102 if state.evaluate and state.evaluate.prompt:
103 templates.append(state.evaluate.prompt)
104 for template in templates:
105 for m in _ctx_var_re.finditer(template):
106 key = m.group(1)
107 if key not in fsm.context:
108 missing_keys.add(key)
109 if missing_keys:
110 for key in sorted(missing_keys):
111 logger.error(
112 f"Missing required context variable: '{key}'. "
113 f"Run with: ll-loop run {loop_name} --context {key}=VALUE"
114 )
115 return 1
117 # Background mode: spawn detached process and return
118 if getattr(args, "background", False):
119 return run_background(loop_name, args, loops_dir)
121 # Register PID file for all foreground runs so cmd_stop can send SIGTERM (BUG-639).
122 # Background-spawned processes (foreground_internal=True) have their PID written by the
123 # parent in run_background(); plain foreground runs must write their own PID here.
124 running_dir = loops_dir / ".running"
125 running_dir.mkdir(parents=True, exist_ok=True)
126 pid_file = running_dir / f"{loop_name}.pid"
127 foreground_pid_file: Path | None = pid_file
129 if not getattr(args, "foreground_internal", False):
130 pid_file.write_text(str(os.getpid()))
132 def _cleanup_pid() -> None:
133 pid_file.unlink(missing_ok=True)
135 atexit.register(_cleanup_pid)
137 # Scope-based locking
138 lock_manager = LockManager(loops_dir)
139 scope = fsm.scope or ["."]
141 if not lock_manager.acquire(fsm.name, scope):
142 conflict = lock_manager.find_conflict(scope)
143 if conflict and getattr(args, "queue", False):
144 logger.info(f"Waiting for conflicting loop '{conflict.loop_name}' to finish...")
145 if not lock_manager.wait_for_scope(scope, timeout=3600):
146 logger.error("Timeout waiting for scope to become available")
147 return 1
148 # Re-acquire after waiting
149 if not lock_manager.acquire(fsm.name, scope):
150 logger.error("Failed to acquire lock after waiting")
151 return 1
152 elif conflict:
153 logger.error(f"Scope conflict with running loop: {conflict.loop_name}")
154 logger.info(f" Conflicting scope: {conflict.scope}")
155 logger.info(" Use --queue to wait for it to finish")
156 return 1
157 else:
158 # Unexpected: find_conflict returned None but acquire failed
159 logger.error("Failed to acquire scope lock (unknown reason)")
160 return 1
162 try:
163 # Worktree isolation: create branch + directory before anything reads Path.cwd()
164 if getattr(args, "worktree", False):
165 from datetime import datetime
167 from little_loops.config import BRConfig as _MainBRConfig
168 from little_loops.parallel.git_lock import GitLock
169 from little_loops.worktree_utils import cleanup_worktree, setup_worktree
171 _main_config = _MainBRConfig(Path.cwd())
172 _worktree_base = _main_config.get_worktree_base()
173 _copy_files = _main_config.parallel.worktree_copy_files
174 _repo_path = Path.cwd()
176 _timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
177 _safe_name = re.sub(r"[^a-zA-Z0-9-]", "-", loop_name)
178 _branch_name = f"{_timestamp}-{_safe_name}"
179 _worktree_path = _worktree_base / _branch_name
181 _worktree_base.mkdir(parents=True, exist_ok=True)
182 _git_lock = GitLock(logger)
184 setup_worktree(
185 repo_path=_repo_path,
186 worktree_path=_worktree_path,
187 branch_name=_branch_name,
188 copy_files=_copy_files,
189 logger=logger,
190 git_lock=_git_lock,
191 )
193 logger.info(f"Worktree: {_worktree_path}")
194 logger.info(f"Branch: {_branch_name}")
196 def _cleanup_worktree_on_exit() -> None:
197 cleanup_worktree(
198 worktree_path=_worktree_path,
199 repo_path=_repo_path,
200 logger=logger,
201 git_lock=_git_lock,
202 delete_branch=True,
203 )
205 atexit.register(_cleanup_worktree_on_exit)
207 os.environ["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1"
208 os.chdir(_worktree_path)
210 executor = PersistentExecutor(fsm, loops_dir=loops_dir)
212 # Register signal handlers for graceful shutdown
213 register_loop_signal_handlers(executor, pid_file=foreground_pid_file)
215 from little_loops.config import BRConfig
216 from little_loops.extension import wire_extensions
218 config = BRConfig(Path.cwd())
219 wire_extensions(executor.event_bus, config.extensions, executor=executor)
220 cli_colors = config.cli.colors
221 highlight_color = cli_colors.fsm_active_state
222 edge_label_colors = cli_colors.fsm_edge_labels.to_dict()
223 badges = config.loops.glyphs.to_dict()
224 return run_foreground(
225 executor,
226 fsm,
227 args,
228 highlight_color=highlight_color,
229 edge_label_colors=edge_label_colors,
230 badges=badges,
231 )
232 finally:
233 lock_manager.release(fsm.name)