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

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 _read_pid_file(pid_file: Path) -> int | None: 

23 """Read and validate a PID file. 

24 

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 

34 

35 

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 

45 

46 persistence = StatePersistence(loop_name, loops_dir) 

47 state = persistence.load_state() 

48 

49 if state is None: 

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

51 return 1 

52 

53 running_dir = loops_dir / ".running" 

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

55 pid = _read_pid_file(pid_file) 

56 

57 if getattr(args, "json", False): 

58 d = state.to_dict() 

59 d["pid"] = pid 

60 print_json(d) 

61 return 0 

62 

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

69 

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

76 

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 

84 

85 

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 

93 

94 persistence = StatePersistence(loop_name, loops_dir) 

95 state = persistence.load_state() 

96 

97 if state is None: 

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

99 return 1 

100 

101 if state.status != "running": 

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

103 return 1 

104 

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

139 

140 return 0 

141 

142 

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 

151 

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

155 

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 

160 

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 

165 

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

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

168 

169 def _cleanup_pid() -> None: 

170 pid_file.unlink(missing_ok=True) 

171 

172 atexit.register(_cleanup_pid) 

173 

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 

182 

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

188 

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

190 fsm.backoff = args.delay 

191 

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

204 

205 executor = PersistentExecutor(fsm, loops_dir=loops_dir) 

206 

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

208 register_loop_signal_handlers(executor, pid_file=foreground_pid_file) 

209 

210 result = executor.resume() 

211 

212 if result is None: 

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

214 return 1 

215 

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" 

223 

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)