Coverage for little_loops / cli / loop / lifecycle.py: 33%
173 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 lifecycle subcommands: status, stop, resume."""
3from __future__ import annotations
5import argparse
6import atexit
7import os
8import signal
9import time
10from pathlib import Path
12from little_loops.cli.loop._helpers import (
13 EXIT_CODES,
14 load_loop,
15 register_loop_signal_handlers,
16 run_background,
17)
18from little_loops.fsm.concurrency import _process_alive
19from little_loops.logger import Logger
22def _format_relative_time(seconds: float) -> str:
23 """Format seconds as a human-readable relative time string (e.g., '3m ago').
25 Delegates to the shared ``format_relative_time`` in ``cli.output``.
26 """
27 from little_loops.cli.output import format_relative_time
29 return format_relative_time(seconds)
32def _read_pid_file(pid_file: Path) -> int | None:
33 """Read and validate a PID file.
35 Returns:
36 The PID as an integer, or None if the file doesn't exist or is invalid.
37 """
38 if not pid_file.exists():
39 return None
40 try:
41 return int(pid_file.read_text().strip())
42 except (ValueError, OSError):
43 return None
46def cmd_status(
47 loop_name: str,
48 loops_dir: Path,
49 logger: Logger,
50 args: argparse.Namespace | None = None,
51) -> int:
52 """Show loop status."""
53 from little_loops.cli.output import print_json
54 from little_loops.fsm.persistence import StatePersistence
56 persistence = StatePersistence(loop_name, loops_dir)
57 state = persistence.load_state()
59 if state is None:
60 logger.error(f"No state found for: {loop_name}")
61 return 1
63 running_dir = loops_dir / ".running"
64 pid_file = running_dir / f"{loop_name}.pid"
65 pid = _read_pid_file(pid_file)
67 log_file = running_dir / f"{loop_name}.log"
68 log_file_str: str | None = None
69 log_updated_ago: str | None = None
70 last_event: str | None = None
71 if log_file.exists():
72 log_file_str = str(log_file)
73 age_seconds = time.time() - log_file.stat().st_mtime
74 log_updated_ago = _format_relative_time(age_seconds)
75 try:
76 lines = log_file.read_text().splitlines()
77 non_empty = [ln for ln in lines if ln.strip()]
78 last_event = non_empty[-1] if non_empty else None
79 except OSError:
80 last_event = None
82 if getattr(args, "json", False):
83 d = state.to_dict()
84 d["pid"] = pid
85 d["log_file"] = log_file_str
86 d["log_updated_ago"] = log_updated_ago
87 d["last_event"] = last_event
88 print_json(d)
89 return 0
91 print(f"Loop: {state.loop_name}")
92 print(f"Status: {state.status}")
93 print(f"Current state: {state.current_state}")
94 print(f"Iteration: {state.iteration}")
95 print(f"Started: {state.started_at}")
96 print(f"Updated: {state.updated_at}")
98 # Show PID info if available (background mode)
99 if pid is not None:
100 if _process_alive(pid):
101 print(f"PID: {pid} (running)")
102 else:
103 print(f"PID: {pid} (not running - stale PID file)")
105 # Show log file info
106 if log_file_str is not None:
107 print(f"Log: {log_file_str}")
108 print(f"Log updated: {log_updated_ago}")
109 if last_event:
110 print(f"Last event: {last_event}")
111 else:
112 print("Log: (not found)")
114 if state.continuation_prompt:
115 # Show truncated continuation context
116 prompt_preview = state.continuation_prompt[:200]
117 if len(state.continuation_prompt) > 200:
118 prompt_preview += "..."
119 print(f"Continuation context: {prompt_preview}")
120 return 0
123def cmd_stop(
124 loop_name: str,
125 loops_dir: Path,
126 logger: Logger,
127) -> int:
128 """Stop a running loop."""
129 from little_loops.fsm.persistence import StatePersistence
131 persistence = StatePersistence(loop_name, loops_dir)
132 state = persistence.load_state()
134 if state is None:
135 logger.error(f"No state found for: {loop_name}")
136 return 1
138 if state.status != "running":
139 logger.error(f"Loop not running: {loop_name} (status: {state.status})")
140 return 1
142 # Check PID before modifying state to avoid overwriting the process's own final status.
143 # Race condition: process may finish and write its terminal status between
144 # cmd_stop's state read and a premature state write.
145 running_dir = loops_dir / ".running"
146 pid_file = running_dir / f"{loop_name}.pid"
147 pid = _read_pid_file(pid_file)
148 if pid is not None:
149 if _process_alive(pid):
150 # Process confirmed alive: send SIGTERM, then wait for exit
151 os.kill(pid, signal.SIGTERM)
152 for _ in range(10):
153 time.sleep(1)
154 if not _process_alive(pid):
155 break
156 else:
157 # Still alive after grace period: force kill
158 try:
159 os.kill(pid, signal.SIGKILL)
160 logger.warning(f"Sent SIGKILL to {loop_name} (PID: {pid})")
161 except OSError:
162 pass # Process exited between poll and kill
163 state.status = "interrupted"
164 persistence.save_state(state)
165 pid_file.unlink(missing_ok=True)
166 logger.success(f"Stopped {loop_name} (PID: {pid})")
167 else:
168 # Process already exited: preserve its final status, only clean up PID file
169 logger.info(f"Process {pid} not running, cleaning up PID file")
170 pid_file.unlink(missing_ok=True)
171 else:
172 # No PID file: no background process tracked, update state only
173 state.status = "interrupted"
174 persistence.save_state(state)
175 logger.success(f"Marked {loop_name} as interrupted")
177 return 0
180def cmd_resume(
181 loop_name: str,
182 args: argparse.Namespace,
183 loops_dir: Path,
184 logger: Logger,
185) -> int:
186 """Resume an interrupted loop."""
187 from little_loops.fsm.persistence import PersistentExecutor, StatePersistence
189 # Background mode: spawn detached process and return
190 if getattr(args, "background", False):
191 return run_background(loop_name, args, loops_dir, subcommand="resume")
193 # Register PID file for all foreground runs so cmd_stop can send SIGTERM (BUG-639).
194 # Background-spawned processes (foreground_internal=True) have their PID written by the
195 # parent in run_background(); plain foreground runs must write their own PID here.
196 import os
198 running_dir = loops_dir / ".running"
199 running_dir.mkdir(parents=True, exist_ok=True)
200 pid_file = running_dir / f"{loop_name}.pid"
201 foreground_pid_file: Path | None = pid_file
203 if not getattr(args, "foreground_internal", False):
204 pid_file.write_text(str(os.getpid()))
206 def _cleanup_pid() -> None:
207 pid_file.unlink(missing_ok=True)
209 atexit.register(_cleanup_pid)
211 try:
212 fsm = load_loop(loop_name, loops_dir, logger)
213 except FileNotFoundError as e:
214 logger.error(str(e))
215 return 1
216 except ValueError as e:
217 logger.error(f"Validation error: {e}")
218 return 1
220 for kv in getattr(args, "context", None) or []:
221 if "=" not in kv:
222 raise SystemExit(f"Invalid --context format: {kv!r} (expected KEY=VALUE)")
223 key, _, value = kv.partition("=")
224 fsm.context[key.strip()] = value.strip()
226 if getattr(args, "delay", None) is not None:
227 fsm.backoff = args.delay
229 # Apply YAML loop config env-var overrides (CLI flags below overwrite these)
230 if fsm.config is not None and isinstance(fsm.config.handoff_threshold, int):
231 os.environ["LL_HANDOFF_THRESHOLD"] = str(fsm.config.handoff_threshold)
233 if getattr(args, "handoff_threshold", None) is not None:
234 if not (1 <= args.handoff_threshold <= 100):
235 raise SystemExit("--handoff-threshold must be between 1 and 100")
236 os.environ["LL_HANDOFF_THRESHOLD"] = str(args.handoff_threshold)
238 # Check state before resuming to show context
239 persistence = StatePersistence(loop_name, loops_dir)
240 state = persistence.load_state()
241 if state and state.status == "awaiting_continuation":
242 print(f"Resuming from context handoff (iteration {state.iteration})...")
243 if state.continuation_prompt:
244 # Show truncated continuation context
245 prompt_preview = state.continuation_prompt[:500]
246 if len(state.continuation_prompt) > 500:
247 prompt_preview += "..."
248 print(f"Context: {prompt_preview}")
249 print()
251 executor = PersistentExecutor(fsm, loops_dir=loops_dir)
253 # Register signal handlers for graceful shutdown (same as cmd_run)
254 register_loop_signal_handlers(executor, pid_file=foreground_pid_file)
256 from little_loops.config import BRConfig
257 from little_loops.extension import wire_extensions
259 config = BRConfig(Path.cwd())
260 wire_extensions(executor.event_bus, config.extensions, executor=executor)
262 result = executor.resume()
264 if result is None:
265 logger.warning(f"Nothing to resume for: {loop_name}")
266 return 1
268 duration_sec = result.duration_ms / 1000
269 if duration_sec < 60:
270 duration_str = f"{duration_sec:.1f}s"
271 else:
272 minutes = int(duration_sec // 60)
273 seconds = duration_sec % 60
274 duration_str = f"{minutes}m {seconds:.0f}s"
276 logger.success(
277 f"Resumed and completed: {result.final_state} "
278 f"({result.iterations} iterations, {duration_str})"
279 )
280 return EXIT_CODES.get(result.terminated_by, 1)