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

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import atexit 

7import json 

8import os 

9import re 

10from pathlib import Path 

11 

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 

21 

22 

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 

33 

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 

49 

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

79 

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) 

83 

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) 

88 

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

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

91 

92 # Dry run 

93 if args.dry_run: 

94 print_execution_plan(fsm) 

95 return 0 

96 

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 

116 

117 # Background mode: spawn detached process and return 

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

119 return run_background(loop_name, args, loops_dir) 

120 

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 

128 

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

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

131 

132 def _cleanup_pid() -> None: 

133 pid_file.unlink(missing_ok=True) 

134 

135 atexit.register(_cleanup_pid) 

136 

137 # Scope-based locking 

138 lock_manager = LockManager(loops_dir) 

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

140 

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 

161 

162 try: 

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

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

165 from datetime import datetime 

166 

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 

170 

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

175 

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 

180 

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

182 _git_lock = GitLock(logger) 

183 

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 ) 

192 

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

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

195 

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 ) 

204 

205 atexit.register(_cleanup_worktree_on_exit) 

206 

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

208 os.chdir(_worktree_path) 

209 

210 executor = PersistentExecutor(fsm, loops_dir=loops_dir) 

211 

212 # Register signal handlers for graceful shutdown 

213 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

214 

215 from little_loops.config import BRConfig 

216 from little_loops.extension import wire_extensions 

217 

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)