Coverage for little_loops / fsm / runners.py: 20%

133 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:30 -0500

1"""FSM action runner implementations. 

2 

3Provides the protocol and concrete implementations for action execution: 

4- ActionRunner: Protocol defining the runner interface 

5- DefaultActionRunner: Subprocess-based runner for real execution 

6- SimulationActionRunner: Interactive/scenario-based runner for testing and dry-runs 

7""" 

8 

9from __future__ import annotations 

10 

11import os 

12import selectors 

13import signal 

14import subprocess 

15import sys 

16import time 

17from collections.abc import Callable 

18from dataclasses import dataclass, field 

19from typing import Protocol 

20 

21from little_loops.fsm.types import ActionResult 

22from little_loops.subprocess_utils import ( 

23 DetailedUsageCallback, 

24 TokenUsage, 

25 UsageCallback, 

26 _kill_process_group, 

27 run_claude_command, 

28) 

29 

30 

31def _now_ms() -> int: 

32 """Get current time in milliseconds.""" 

33 return int(time.time() * 1000) 

34 

35 

36class ActionRunner(Protocol): 

37 """Protocol for action execution.""" 

38 

39 def run( 

40 self, 

41 action: str, 

42 timeout: int, 

43 is_slash_command: bool, 

44 on_output_line: Callable[[str], None] | None = None, 

45 agent: str | None = None, 

46 tools: list[str] | None = None, 

47 on_usage: UsageCallback | None = None, 

48 on_usage_detailed: DetailedUsageCallback | None = None, 

49 model: str | None = None, 

50 ) -> ActionResult: 

51 """Execute an action and return the result. 

52 

53 Args: 

54 action: The command to execute 

55 timeout: Timeout in seconds 

56 is_slash_command: True if this is a slash command (starts with /) 

57 on_output_line: Optional callback invoked for each output line 

58 agent: Optional agent name to pass as --agent to Claude CLI (prompt-mode only) 

59 tools: Optional list of tool names to pass as --tools CSV to Claude CLI (prompt-mode only) 

60 on_usage: Optional callback invoked with (input_tokens, output_tokens) on completion 

61 on_usage_detailed: Optional callback invoked with a TokenUsage dataclass on completion 

62 

63 Returns: 

64 ActionResult with output, stderr, exit_code, duration_ms 

65 """ 

66 ... 

67 

68 

69class DefaultActionRunner: 

70 """Execute actions via subprocess or Claude CLI.""" 

71 

72 def __init__(self) -> None: 

73 self._current_process: subprocess.Popen[str] | None = None 

74 

75 def run( 

76 self, 

77 action: str, 

78 timeout: int, 

79 is_slash_command: bool, 

80 on_output_line: Callable[[str], None] | None = None, 

81 agent: str | None = None, 

82 tools: list[str] | None = None, 

83 on_usage: UsageCallback | None = None, 

84 on_usage_detailed: DetailedUsageCallback | None = None, 

85 model: str | None = None, 

86 ) -> ActionResult: 

87 """Execute action and return result, streaming output line by line. 

88 

89 Args: 

90 action: The command to execute 

91 timeout: Timeout in seconds 

92 is_slash_command: True if action starts with / 

93 on_output_line: Optional callback invoked for each stdout line 

94 agent: Optional agent name to pass as --agent to Claude CLI (prompt-mode only) 

95 tools: Optional list of tool names to pass as --tools CSV (prompt-mode only) 

96 on_usage: Optional callback invoked with (input_tokens, output_tokens) on completion 

97 on_usage_detailed: Optional callback invoked with a TokenUsage dataclass on completion 

98 model: Optional model override to pass as --model to Claude CLI (prompt-mode only) 

99 

100 Returns: 

101 ActionResult with execution details 

102 """ 

103 start = _now_ms() 

104 

105 if is_slash_command: 

106 # Execute via Claude CLI using run_claude_command() so that the 

107 # subprocess loads the full plugin/tool context (including deferred 

108 # tools like Skill). The old --no-session-persistence path prevented 

109 # ToolSearch from resolving deferred tool schemas (BUG-946). 

110 def _stream_cb(line: str, is_stderr: bool) -> None: 

111 if not is_stderr and on_output_line: 

112 on_output_line(line) 

113 

114 def _on_proc_start(p: subprocess.Popen[str]) -> None: 

115 self._current_process = p 

116 

117 def _on_proc_end(p: subprocess.Popen[str]) -> None: 

118 self._current_process = None 

119 

120 collected_usage: list[TokenUsage] = [] 

121 

122 def _collect_usage(u: TokenUsage) -> None: 

123 collected_usage.append(u) 

124 if on_usage_detailed: 

125 on_usage_detailed(u) 

126 

127 try: 

128 completed = run_claude_command( 

129 command=action, 

130 timeout=timeout, 

131 stream_callback=_stream_cb, 

132 on_process_start=_on_proc_start, 

133 on_process_end=_on_proc_end, 

134 agent=agent, 

135 tools=tools, 

136 on_usage=on_usage, 

137 on_usage_detailed=_collect_usage, 

138 model=model, 

139 ) 

140 except subprocess.TimeoutExpired: 

141 return ActionResult( 

142 output="", 

143 stderr="Action timed out", 

144 exit_code=124, 

145 duration_ms=timeout * 1000, 

146 ) 

147 return ActionResult( 

148 output=completed.stdout, 

149 stderr=completed.stderr, 

150 exit_code=completed.returncode, 

151 duration_ms=_now_ms() - start, 

152 usage_events=collected_usage, 

153 ) 

154 

155 # Shell command — selector-based I/O with wall-clock timeout enforcement. 

156 # Uses selectors.DefaultSelector to read stdout and stderr concurrently 

157 # with bounded polling (sel.select(timeout=1.0)), checking the wall-clock 

158 # deadline before each read. This closes the timeout dead-zone in the old 

159 # `for line in process.stdout` pattern which blocked indefinitely when a 

160 # shell process hung before producing any output. 

161 cmd = ["bash", "-c", action] 

162 process = subprocess.Popen( 

163 cmd, 

164 stdout=subprocess.PIPE, 

165 stderr=subprocess.PIPE, 

166 text=True, 

167 ) 

168 self._current_process = process 

169 deadline = time.time() + timeout 

170 

171 output_chunks: list[str] = [] 

172 stderr_chunks: list[str] = [] 

173 

174 sel = selectors.DefaultSelector() 

175 if process.stdout is not None: 

176 sel.register(process.stdout, selectors.EVENT_READ, data="stdout") 

177 if process.stderr is not None: 

178 sel.register(process.stderr, selectors.EVENT_READ, data="stderr") 

179 

180 timed_out = False 

181 try: 

182 while sel.get_map(): 

183 # Bounded poll — never block longer than 1 second 

184 remaining = deadline - time.time() 

185 if remaining <= 0: 

186 timed_out = True 

187 break 

188 poll_timeout = min(1.0, remaining) 

189 ready = sel.select(timeout=poll_timeout) 

190 if not ready: 

191 # No pipes ready within poll window — loop re-checks deadline 

192 continue 

193 for key, _mask in ready: 

194 line = key.fileobj.readline() # type: ignore[union-attr] 

195 if line: 

196 if key.data == "stdout": 

197 output_chunks.append(line) 

198 if on_output_line: 

199 on_output_line(line.rstrip()) 

200 else: 

201 stderr_chunks.append(line) 

202 else: 

203 # EOF on this pipe — unregister it 

204 sel.unregister(key.fileobj) 

205 finally: 

206 sel.close() 

207 self._current_process = None 

208 

209 if timed_out: 

210 _kill_process_group(process) 

211 # Drain any remaining output after the kill 

212 try: 

213 process.wait(timeout=5) 

214 except subprocess.TimeoutExpired: 

215 process.kill() 

216 process.wait() 

217 return ActionResult( 

218 output="".join(output_chunks), 

219 stderr="".join(stderr_chunks) or "Action timed out", 

220 exit_code=124, 

221 duration_ms=timeout * 1000, 

222 ) 

223 

224 process.wait(timeout=5) 

225 return ActionResult( 

226 output="".join(output_chunks), 

227 stderr="".join(stderr_chunks), 

228 exit_code=process.returncode, 

229 duration_ms=_now_ms() - start, 

230 ) 

231 

232 

233@dataclass 

234class SimulationActionRunner: 

235 """Action runner for simulation mode - prompts user instead of executing. 

236 

237 This runner allows users to trace through FSM logic without executing 

238 real commands. It can either prompt interactively for results or use 

239 predefined scenarios. 

240 

241 Attributes: 

242 scenario: Predefined result pattern ("all-pass", "all-fail", "first-fail", "alternating") 

243 call_count: Number of actions simulated so far 

244 calls: List of all actions that would have been executed 

245 """ 

246 

247 scenario: str | None = None 

248 call_count: int = 0 

249 calls: list[str] = field(default_factory=list) 

250 

251 def run( 

252 self, 

253 action: str, 

254 timeout: int, 

255 is_slash_command: bool, 

256 on_output_line: Callable[[str], None] | None = None, 

257 agent: str | None = None, 

258 tools: list[str] | None = None, 

259 on_usage: UsageCallback | None = None, 

260 on_usage_detailed: DetailedUsageCallback | None = None, 

261 model: str | None = None, 

262 ) -> ActionResult: 

263 """Prompt user for simulated result instead of executing. 

264 

265 Args: 

266 action: The command that would be executed 

267 timeout: Timeout (ignored in simulation) 

268 is_slash_command: Whether this is a slash command 

269 on_output_line: Ignored in simulation 

270 agent: Ignored in simulation 

271 tools: Ignored in simulation 

272 on_usage: Ignored in simulation 

273 on_usage_detailed: Ignored in simulation 

274 model: Ignored in simulation 

275 

276 Returns: 

277 ActionResult with simulated exit code 

278 """ 

279 del timeout, on_output_line, agent, tools, on_usage, model # unused in simulation 

280 self.calls.append(action) 

281 self.call_count += 1 

282 

283 cmd_type = "slash command" if is_slash_command else "shell command" 

284 print(f" [SIMULATED] Would execute ({cmd_type}): {action}") 

285 

286 if self.scenario: 

287 exit_code = self._scenario_result() 

288 scenario_label = { 

289 "all-pass": "Success (scenario: all-pass)", 

290 "all-fail": "Failure (scenario: all-fail)", 

291 "all-error": "Error (scenario: all-error)", 

292 "first-fail": "Failure" if self.call_count == 1 else "Success", 

293 "alternating": "Failure" if self.call_count % 2 == 1 else "Success", 

294 }.get(self.scenario, "Success") 

295 print(f" [AUTO] Result: {scenario_label}") 

296 else: 

297 exit_code = self._prompt_result() 

298 

299 return ActionResult( 

300 output=f"[simulated output for: {action}]", 

301 stderr="", 

302 exit_code=exit_code, 

303 duration_ms=0, 

304 ) 

305 

306 def _scenario_result(self) -> int: 

307 """Return exit code based on scenario pattern. 

308 

309 Returns: 

310 0 for success, 1 for failure, 2 for error based on scenario logic 

311 """ 

312 if self.scenario == "all-pass": 

313 return 0 

314 elif self.scenario == "all-fail": 

315 return 1 

316 elif self.scenario == "all-error": 

317 return 2 

318 elif self.scenario == "first-fail": 

319 # First call fails, rest pass 

320 return 1 if self.call_count == 1 else 0 

321 elif self.scenario == "alternating": 

322 # Odd calls fail, even calls pass 

323 return 1 if self.call_count % 2 == 1 else 0 

324 return 0 

325 

326 def _prompt_result(self) -> int: 

327 """Prompt user for simulated exit code. 

328 

329 Returns: 

330 Exit code based on user selection 

331 """ 

332 print() 

333 print(" ? What should the simulated result be?") 

334 print(" 1) Success (exit 0) [default]") 

335 print(" 2) Failure (exit 1)") 

336 print(" 3) Error (exit 2)") 

337 

338 while True: 

339 try: 

340 sys.stdout.write(" > ") 

341 sys.stdout.flush() 

342 choice = sys.stdin.readline().strip() 

343 if choice in ("1", ""): 

344 return 0 

345 elif choice == "2": 

346 return 1 

347 elif choice == "3": 

348 return 2 

349 print(" Invalid choice. Enter 1, 2, or 3.") 

350 except (EOFError, KeyboardInterrupt): 

351 print() 

352 return 0