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

1"""ll-loop lifecycle subcommands: status, stop, resume.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import atexit 

7import os 

8import signal 

9import time 

10from pathlib import Path 

11 

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 

20 

21 

22def _format_relative_time(seconds: float) -> str: 

23 """Format seconds as a human-readable relative time string (e.g., '3m ago'). 

24 

25 Delegates to the shared ``format_relative_time`` in ``cli.output``. 

26 """ 

27 from little_loops.cli.output import format_relative_time 

28 

29 return format_relative_time(seconds) 

30 

31 

32def _read_pid_file(pid_file: Path) -> int | None: 

33 """Read and validate a PID file. 

34 

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 

44 

45 

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 

55 

56 persistence = StatePersistence(loop_name, loops_dir) 

57 state = persistence.load_state() 

58 

59 if state is None: 

60 logger.error(f"No state found for: {loop_name}") 

61 return 1 

62 

63 running_dir = loops_dir / ".running" 

64 pid_file = running_dir / f"{loop_name}.pid" 

65 pid = _read_pid_file(pid_file) 

66 

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 

81 

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 

90 

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}") 

97 

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

104 

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

113 

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 

121 

122 

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 

130 

131 persistence = StatePersistence(loop_name, loops_dir) 

132 state = persistence.load_state() 

133 

134 if state is None: 

135 logger.error(f"No state found for: {loop_name}") 

136 return 1 

137 

138 if state.status != "running": 

139 logger.error(f"Loop not running: {loop_name} (status: {state.status})") 

140 return 1 

141 

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

176 

177 return 0 

178 

179 

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 

188 

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

192 

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 

197 

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 

202 

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

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

205 

206 def _cleanup_pid() -> None: 

207 pid_file.unlink(missing_ok=True) 

208 

209 atexit.register(_cleanup_pid) 

210 

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 

219 

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

225 

226 if getattr(args, "delay", None) is not None: 

227 fsm.backoff = args.delay 

228 

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) 

232 

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) 

237 

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

250 

251 executor = PersistentExecutor(fsm, loops_dir=loops_dir) 

252 

253 # Register signal handlers for graceful shutdown (same as cmd_run) 

254 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

255 

256 from little_loops.config import BRConfig 

257 from little_loops.extension import wire_extensions 

258 

259 config = BRConfig(Path.cwd()) 

260 wire_extensions(executor.event_bus, config.extensions, executor=executor) 

261 

262 result = executor.resume() 

263 

264 if result is None: 

265 logger.warning(f"Nothing to resume for: {loop_name}") 

266 return 1 

267 

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" 

275 

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)