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

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

2

3 from __future__ import annotations

4

5 import argparse

6 import atexit

7 import os

8 import re

9 from pathlib import Path

10

11 from 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)

19 from little_loops.logger import Logger

20

21

22 def 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)