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

131 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:12 -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 selectors 

12import subprocess 

13import sys 

14import time 

15from collections.abc import Callable 

16from dataclasses import dataclass, field 

17from typing import Protocol 

18 

19from little_loops.fsm.types import ActionResult 

20from little_loops.subprocess_utils import ( 

21 DetailedUsageCallback, 

22 TokenUsage, 

23 UsageCallback, 

24 _kill_process_group, 

25 run_claude_command, 

26) 

27 

28 

29def _now_ms() -> int: 

30 """Get current time in milliseconds.""" 

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

32 

33 

34class ActionRunner(Protocol): 

35 """Protocol for action execution.""" 

36 

37 def run( 

38 self, 

39 action: str, 

40 timeout: int, 

41 is_slash_command: bool, 

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

43 agent: str | None = None, 

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

45 on_usage: UsageCallback | None = None, 

46 on_usage_detailed: DetailedUsageCallback | None = None, 

47 model: str | None = None, 

48 ) -> ActionResult: 

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

50 

51 Args: 

52 action: The command to execute 

53 timeout: Timeout in seconds 

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

55 on_output_line: Optional callback invoked for each output line 

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

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

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

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

60 

61 Returns: 

62 ActionResult with output, stderr, exit_code, duration_ms 

63 """ 

64 ... 

65 

66 

67class DefaultActionRunner: 

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

69 

70 def __init__(self) -> None: 

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

72 

73 def run( 

74 self, 

75 action: str, 

76 timeout: int, 

77 is_slash_command: bool, 

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

79 agent: str | None = None, 

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

81 on_usage: UsageCallback | None = None, 

82 on_usage_detailed: DetailedUsageCallback | None = None, 

83 model: str | None = None, 

84 ) -> ActionResult: 

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

86 

87 Args: 

88 action: The command to execute 

89 timeout: Timeout in seconds 

90 is_slash_command: True if action starts with / 

91 on_output_line: Optional callback invoked for each stdout line 

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

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

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

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

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

97 

98 Returns: 

99 ActionResult with execution details 

100 """ 

101 start = _now_ms() 

102 

103 if is_slash_command: 

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

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

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

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

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

109 if not is_stderr and on_output_line: 

110 on_output_line(line) 

111 

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

113 self._current_process = p 

114 

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

116 self._current_process = None 

117 

118 collected_usage: list[TokenUsage] = [] 

119 

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

121 collected_usage.append(u) 

122 if on_usage_detailed: 

123 on_usage_detailed(u) 

124 

125 try: 

126 completed = run_claude_command( 

127 command=action, 

128 timeout=timeout, 

129 stream_callback=_stream_cb, 

130 on_process_start=_on_proc_start, 

131 on_process_end=_on_proc_end, 

132 agent=agent, 

133 tools=tools, 

134 on_usage=on_usage, 

135 on_usage_detailed=_collect_usage, 

136 model=model, 

137 ) 

138 except subprocess.TimeoutExpired: 

139 return ActionResult( 

140 output="", 

141 stderr="Action timed out", 

142 exit_code=124, 

143 duration_ms=timeout * 1000, 

144 ) 

145 return ActionResult( 

146 output=completed.stdout, 

147 stderr=completed.stderr, 

148 exit_code=completed.returncode, 

149 duration_ms=_now_ms() - start, 

150 usage_events=collected_usage, 

151 ) 

152 

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

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

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

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

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

158 # shell process hung before producing any output. 

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

160 process = subprocess.Popen( 

161 cmd, 

162 stdout=subprocess.PIPE, 

163 stderr=subprocess.PIPE, 

164 text=True, 

165 ) 

166 self._current_process = process 

167 deadline = time.time() + timeout 

168 

169 output_chunks: list[str] = [] 

170 stderr_chunks: list[str] = [] 

171 

172 sel = selectors.DefaultSelector() 

173 if process.stdout is not None: 

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

175 if process.stderr is not None: 

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

177 

178 timed_out = False 

179 try: 

180 while sel.get_map(): 

181 # Bounded poll — never block longer than 1 second 

182 remaining = deadline - time.time() 

183 if remaining <= 0: 

184 timed_out = True 

185 break 

186 poll_timeout = min(1.0, remaining) 

187 ready = sel.select(timeout=poll_timeout) 

188 if not ready: 

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

190 continue 

191 for key, _mask in ready: 

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

193 if line: 

194 if key.data == "stdout": 

195 output_chunks.append(line) 

196 if on_output_line: 

197 on_output_line(line.rstrip()) 

198 else: 

199 stderr_chunks.append(line) 

200 else: 

201 # EOF on this pipe — unregister it 

202 sel.unregister(key.fileobj) 

203 finally: 

204 sel.close() 

205 self._current_process = None 

206 

207 if timed_out: 

208 _kill_process_group(process) 

209 # Drain any remaining output after the kill 

210 try: 

211 process.wait(timeout=5) 

212 except subprocess.TimeoutExpired: 

213 process.kill() 

214 process.wait() 

215 return ActionResult( 

216 output="".join(output_chunks), 

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

218 exit_code=124, 

219 duration_ms=timeout * 1000, 

220 ) 

221 

222 process.wait(timeout=5) 

223 return ActionResult( 

224 output="".join(output_chunks), 

225 stderr="".join(stderr_chunks), 

226 exit_code=process.returncode, 

227 duration_ms=_now_ms() - start, 

228 ) 

229 

230 

231@dataclass 

232class SimulationActionRunner: 

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

234 

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

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

237 predefined scenarios. 

238 

239 Attributes: 

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

241 call_count: Number of actions simulated so far 

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

243 """ 

244 

245 scenario: str | None = None 

246 call_count: int = 0 

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

248 

249 def run( 

250 self, 

251 action: str, 

252 timeout: int, 

253 is_slash_command: bool, 

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

255 agent: str | None = None, 

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

257 on_usage: UsageCallback | None = None, 

258 on_usage_detailed: DetailedUsageCallback | None = None, 

259 model: str | None = None, 

260 ) -> ActionResult: 

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

262 

263 Args: 

264 action: The command that would be executed 

265 timeout: Timeout (ignored in simulation) 

266 is_slash_command: Whether this is a slash command 

267 on_output_line: Ignored in simulation 

268 agent: Ignored in simulation 

269 tools: Ignored in simulation 

270 on_usage: Ignored in simulation 

271 on_usage_detailed: Ignored in simulation 

272 model: Ignored in simulation 

273 

274 Returns: 

275 ActionResult with simulated exit code 

276 """ 

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

278 self.calls.append(action) 

279 self.call_count += 1 

280 

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

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

283 

284 if self.scenario: 

285 exit_code = self._scenario_result() 

286 scenario_label = { 

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

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

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

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

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

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

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

294 else: 

295 exit_code = self._prompt_result() 

296 

297 return ActionResult( 

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

299 stderr="", 

300 exit_code=exit_code, 

301 duration_ms=0, 

302 ) 

303 

304 def _scenario_result(self) -> int: 

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

306 

307 Returns: 

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

309 """ 

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

311 return 0 

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

313 return 1 

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

315 return 2 

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

317 # First call fails, rest pass 

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

319 elif self.scenario == "alternating": 

320 # Odd calls fail, even calls pass 

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

322 return 0 

323 

324 def _prompt_result(self) -> int: 

325 """Prompt user for simulated exit code. 

326 

327 Returns: 

328 Exit code based on user selection 

329 """ 

330 print() 

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

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

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

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

335 

336 while True: 

337 try: 

338 sys.stdout.write(" > ") 

339 sys.stdout.flush() 

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

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

342 return 0 

343 elif choice == "2": 

344 return 1 

345 elif choice == "3": 

346 return 2 

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

348 except (EOFError, KeyboardInterrupt): 

349 print() 

350 return 0