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

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

2

3 from __future__ import annotations

4

5 import argparse

6 import atexit

7 import os

8 import signal

9 import time

10 from pathlib import Path

11

12 from little_loops.cli.loop._helpers import (

13 EXIT_CODES,

14 load_loop,

15 register_loop_signal_handlers,

16 run_background,

17)

18 from little_loops.fsm.concurrency import _process_alive

19 from little_loops.logger import Logger

20

21

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

36 def 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

86 def 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

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