Coverage for little_loops / cli / loop / run.py: 0%
100 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:20 -0500
1"""ll-loop run subcommand."""
3from __future__ import annotations
5import argparse
6import atexit
7import os
8import re
9from pathlib import Path
11from little_loops.cli.loop._helpers import (
12 get_builtin_loops_dir,
13 print_execution_plan,
14 register_loop_signal_handlers,
15 resolve_loop_path,
16 run_background,
17 run_foreground,
18)
19from little_loops.logger import Logger
22def cmd_run(
23 loop_name: str,
24 args: argparse.Namespace,
25 loops_dir: Path,
26 logger: Logger,
27) -> int:
28 """Run a loop."""
29 from little_loops.fsm.concurrency import LockManager
30 from little_loops.fsm.persistence import PersistentExecutor
31 from little_loops.fsm.validation import load_and_validate
33 try:
34 if getattr(args, "builtin", False):
35 path = get_builtin_loops_dir() / f"{loop_name}.yaml"
36 if not path.exists():
37 logger.error(f"Built-in loop not found: {loop_name!r}")
38 return 1
39 else:
40 path = resolve_loop_path(loop_name, loops_dir)
41 fsm, _ = load_and_validate(path)
42 except FileNotFoundError as e:
43 logger.error(str(e))
44 return 1
45 except ValueError as e:
46 logger.error(f"Validation error: {e}")
47 return 1
49 # Apply overrides
50 if args.max_iterations:
51 fsm.max_iterations = args.max_iterations
52 if args.delay is not None:
53 fsm.backoff = args.delay
54 if args.no_llm:
55 fsm.llm.enabled = False
56 if args.llm_model:
57 fsm.llm.model = args.llm_model
58 # Inject positional input arg before --context so --context can override
59 if getattr(args, "input", None) is not None:
60 fsm.context[fsm.input_key] = args.input
61 for kv in getattr(args, "context", None) or []:
62 if "=" not in kv:
63 raise SystemExit(f"Invalid --context format: {kv!r} (expected KEY=VALUE)")
64 key, _, value = kv.partition("=")
65 fsm.context[key.strip()] = value.strip()
67 if getattr(args, "handoff_threshold", None) is not None:
68 if not (1 <= args.handoff_threshold <= 100):
69 raise SystemExit("--handoff-threshold must be between 1 and 100")
70 os.environ["LL_HANDOFF_THRESHOLD"] = str(args.handoff_threshold)
72 # Dry run
73 if args.dry_run:
74 print_execution_plan(fsm)
75 return 0
77 # Pre-run validation: check required context variables are present
78 _ctx_var_re = re.compile(r"\$\{context\.([^}.]+)")
79 missing_keys: set[str] = set()
80 for state in fsm.states.values():
81 templates = [state.action] if state.action else []
82 if state.evaluate and state.evaluate.prompt:
83 templates.append(state.evaluate.prompt)
84 for template in templates:
85 for m in _ctx_var_re.finditer(template):
86 key = m.group(1)
87 if key not in fsm.context:
88 missing_keys.add(key)
89 if missing_keys:
90 for key in sorted(missing_keys):
91 logger.error(
92 f"Missing required context variable: '{key}'. "
93 f"Run with: ll-loop run {loop_name} --context {key}=VALUE"
94 )
95 return 1
97 # Background mode: spawn detached process and return
98 if getattr(args, "background", False):
99 return run_background(loop_name, args, loops_dir)
101 # Register PID file for all foreground runs so cmd_stop can send SIGTERM (BUG-639).
102 # Background-spawned processes (foreground_internal=True) have their PID written by the
103 # parent in run_background(); plain foreground runs must write their own PID here.
104 running_dir = loops_dir / ".running"
105 running_dir.mkdir(parents=True, exist_ok=True)
106 pid_file = running_dir / f"{loop_name}.pid"
107 foreground_pid_file: Path | None = pid_file
109 if not getattr(args, "foreground_internal", False):
110 pid_file.write_text(str(os.getpid()))
112 def _cleanup_pid() -> None:
113 pid_file.unlink(missing_ok=True)
115 atexit.register(_cleanup_pid)
117 # Scope-based locking
118 lock_manager = LockManager(loops_dir)
119 scope = fsm.scope or ["."]
121 if not lock_manager.acquire(fsm.name, scope):
122 conflict = lock_manager.find_conflict(scope)
123 if conflict and getattr(args, "queue", False):
124 logger.info(f"Waiting for conflicting loop '{conflict.loop_name}' to finish...")
125 if not lock_manager.wait_for_scope(scope, timeout=3600):
126 logger.error("Timeout waiting for scope to become available")
127 return 1
128 # Re-acquire after waiting
129 if not lock_manager.acquire(fsm.name, scope):
130 logger.error("Failed to acquire lock after waiting")
131 return 1
132 elif conflict:
133 logger.error(f"Scope conflict with running loop: {conflict.loop_name}")
134 logger.info(f" Conflicting scope: {conflict.scope}")
135 logger.info(" Use --queue to wait for it to finish")
136 return 1
137 else:
138 # Unexpected: find_conflict returned None but acquire failed
139 logger.error("Failed to acquire scope lock (unknown reason)")
140 return 1
142 try:
143 executor = PersistentExecutor(fsm, loops_dir=loops_dir)
145 # Register signal handlers for graceful shutdown
146 register_loop_signal_handlers(executor, pid_file=foreground_pid_file)
148 from little_loops.config import BRConfig
150 highlight_color = BRConfig(Path.cwd()).cli.colors.fsm_active_state
151 return run_foreground(executor, fsm, args, highlight_color=highlight_color)
152 finally:
153 lock_manager.release(fsm.name)