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

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import atexit 

7import os 

8import re 

9from pathlib import Path 

10 

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 

20 

21 

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 

32 

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 

48 

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() 

66 

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) 

71 

72 # Dry run 

73 if args.dry_run: 

74 print_execution_plan(fsm) 

75 return 0 

76 

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 

96 

97 # Background mode: spawn detached process and return 

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

99 return run_background(loop_name, args, loops_dir) 

100 

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 

108 

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

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

111 

112 def _cleanup_pid() -> None: 

113 pid_file.unlink(missing_ok=True) 

114 

115 atexit.register(_cleanup_pid) 

116 

117 # Scope-based locking 

118 lock_manager = LockManager(loops_dir) 

119 scope = fsm.scope or ["."] 

120 

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 

141 

142 try: 

143 executor = PersistentExecutor(fsm, loops_dir=loops_dir) 

144 

145 # Register signal handlers for graceful shutdown 

146 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

147 

148 from little_loops.config import BRConfig 

149 

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)