Coverage for little_loops / cli / loop / lifecycle.py: 0%
137 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 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 _read_pid_file(pid_file: Path) -> int | None:
23 """Read and validate a PID file.
25 Returns:
26 The PID as an integer, or None if the file doesn't exist or is invalid.
27 """
28 if not pid_file.exists():
29 return None
30 try:
31 return int(pid_file.read_text().strip())
32 except (ValueError, OSError):
33 return None
36def cmd_status(
37 loop_name: str,
38 loops_dir: Path,
39 logger: Logger,
40 args: argparse.Namespace | None = None,
41) -> int:
42 """Show loop status."""
43 from little_loops.cli.output import print_json
44 from little_loops.fsm.persistence import StatePersistence
46 persistence = StatePersistence(loop_name, loops_dir)
47 state = persistence.load_state()
49 if state is None:
50 logger.error(f"No state found for: {loop_name}")
51 return 1
53 running_dir = loops_dir / ".running"
54 pid_file = running_dir / f"{loop_name}.pid"
55 pid = _read_pid_file(pid_file)
57 if getattr(args, "json", False):
58 d = state.to_dict()
59 d["pid"] = pid
60 print_json(d)
61 return 0
63 print(f"Loop: {state.loop_name}")
64 print(f"Status: {state.status}")
65 print(f"Current state: {state.current_state}")
66 print(f"Iteration: {state.iteration}")
67 print(f"Started: {state.started_at}")
68 print(f"Updated: {state.updated_at}")
70 # Show PID info if available (background mode)
71 if pid is not None:
72 if _process_alive(pid):
73 print(f"PID: {pid} (running)")
74 else:
75 print(f"PID: {pid} (not running - stale PID file)")
77 if state.continuation_prompt:
78 # Show truncated continuation context
79 prompt_preview = state.continuation_prompt[:200]
80 if len(state.continuation_prompt) > 200:
81 prompt_preview += "..."
82 print(f"Continuation context: {prompt_preview}")
83 return 0
86def cmd_stop(
87 loop_name: str,
88 loops_dir: Path,
89 logger: Logger,
90) -> int:
91 """Stop a running loop."""
92 from little_loops.fsm.persistence import StatePersistence
94 persistence = StatePersistence(loop_name, loops_dir)
95 state = persistence.load_state()
97 if state is None:
98 logger.error(f"No state found for: {loop_name}")
99 return 1
101 if state.status != "running":
102 logger.error(f"Loop not running: {loop_name} (status: {state.status})")
103 return 1
105 # Check PID before modifying state to avoid overwriting the process's own final status.
106 # Race condition: process may finish and write its terminal status between
107 # cmd_stop's state read and a premature state write.
108 running_dir = loops_dir / ".running"
109 pid_file = running_dir / f"{loop_name}.pid"
110 pid = _read_pid_file(pid_file)
111 if pid is not None:
112 if _process_alive(pid):
113 # Process confirmed alive: send SIGTERM, then wait for exit
114 os.kill(pid, signal.SIGTERM)
115 for _ in range(10):
116 time.sleep(1)
117 if not _process_alive(pid):
118 break
119 else:
120 # Still alive after grace period: force kill
121 try:
122 os.kill(pid, signal.SIGKILL)
123 logger.warning(f"Sent SIGKILL to {loop_name} (PID: {pid})")
124 except OSError:
125 pass # Process exited between poll and kill
126 state.status = "interrupted"
127 persistence.save_state(state)
128 pid_file.unlink(missing_ok=True)
129 logger.success(f"Stopped {loop_name} (PID: {pid})")
130 else:
131 # Process already exited: preserve its final status, only clean up PID file
132 logger.info(f"Process {pid} not running, cleaning up PID file")
133 pid_file.unlink(missing_ok=True)
134 else:
135 # No PID file: no background process tracked, update state only
136 state.status = "interrupted"
137 persistence.save_state(state)
138 logger.success(f"Marked {loop_name} as interrupted")
140 return 0
143def cmd_resume(
144 loop_name: str,
145 args: argparse.Namespace,
146 loops_dir: Path,
147 logger: Logger,
148) -> int:
149 """Resume an interrupted loop."""
150 from little_loops.fsm.persistence import PersistentExecutor, StatePersistence
152 # Background mode: spawn detached process and return
153 if getattr(args, "background", False):
154 return run_background(loop_name, args, loops_dir, subcommand="resume")
156 # Register PID file for all foreground runs so cmd_stop can send SIGTERM (BUG-639).
157 # Background-spawned processes (foreground_internal=True) have their PID written by the
158 # parent in run_background(); plain foreground runs must write their own PID here.
159 import os
161 running_dir = loops_dir / ".running"
162 running_dir.mkdir(parents=True, exist_ok=True)
163 pid_file = running_dir / f"{loop_name}.pid"
164 foreground_pid_file: Path | None = pid_file
166 if not getattr(args, "foreground_internal", False):
167 pid_file.write_text(str(os.getpid()))
169 def _cleanup_pid() -> None:
170 pid_file.unlink(missing_ok=True)
172 atexit.register(_cleanup_pid)
174 try:
175 fsm = load_loop(loop_name, loops_dir, logger)
176 except FileNotFoundError as e:
177 logger.error(str(e))
178 return 1
179 except ValueError as e:
180 logger.error(f"Validation error: {e}")
181 return 1
183 for kv in getattr(args, "context", None) or []:
184 if "=" not in kv:
185 raise SystemExit(f"Invalid --context format: {kv!r} (expected KEY=VALUE)")
186 key, _, value = kv.partition("=")
187 fsm.context[key.strip()] = value.strip()
189 if getattr(args, "delay", None) is not None:
190 fsm.backoff = args.delay
192 # Check state before resuming to show context
193 persistence = StatePersistence(loop_name, loops_dir)
194 state = persistence.load_state()
195 if state and state.status == "awaiting_continuation":
196 print(f"Resuming from context handoff (iteration {state.iteration})...")
197 if state.continuation_prompt:
198 # Show truncated continuation context
199 prompt_preview = state.continuation_prompt[:500]
200 if len(state.continuation_prompt) > 500:
201 prompt_preview += "..."
202 print(f"Context: {prompt_preview}")
203 print()
205 executor = PersistentExecutor(fsm, loops_dir=loops_dir)
207 # Register signal handlers for graceful shutdown (same as cmd_run)
208 register_loop_signal_handlers(executor, pid_file=foreground_pid_file)
210 result = executor.resume()
212 if result is None:
213 logger.warning(f"Nothing to resume for: {loop_name}")
214 return 1
216 duration_sec = result.duration_ms / 1000
217 if duration_sec < 60:
218 duration_str = f"{duration_sec:.1f}s"
219 else:
220 minutes = int(duration_sec // 60)
221 seconds = duration_sec % 60
222 duration_str = f"{minutes}m {seconds:.0f}s"
224 logger.success(
225 f"Resumed and completed: {result.final_state} "
226 f"({result.iterations} iterations, {duration_str})"
227 )
228 return EXIT_CODES.get(result.terminated_by, 1)