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

133 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -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 except Exception as exc: 

146 return ActionResult( 

147 output="", 

148 stderr=f"Action failed: {exc}", 

149 exit_code=1, 

150 duration_ms=_now_ms() - start, 

151 ) 

152 return ActionResult( 

153 output=completed.stdout, 

154 stderr=completed.stderr, 

155 exit_code=completed.returncode, 

156 duration_ms=_now_ms() - start, 

157 usage_events=collected_usage, 

158 ) 

159 

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

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

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

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

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

165 # shell process hung before producing any output. 

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

167 process = subprocess.Popen( 

168 cmd, 

169 stdout=subprocess.PIPE, 

170 stderr=subprocess.PIPE, 

171 text=True, 

172 ) 

173 self._current_process = process 

174 deadline = time.time() + timeout 

175 

176 output_chunks: list[str] = [] 

177 stderr_chunks: list[str] = [] 

178 

179 sel = selectors.DefaultSelector() 

180 if process.stdout is not None: 

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

182 if process.stderr is not None: 

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

184 

185 timed_out = False 

186 try: 

187 while sel.get_map(): 

188 # Bounded poll — never block longer than 1 second 

189 remaining = deadline - time.time() 

190 if remaining <= 0: 

191 timed_out = True 

192 break 

193 poll_timeout = min(1.0, remaining) 

194 ready = sel.select(timeout=poll_timeout) 

195 if not ready: 

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

197 continue 

198 for key, _mask in ready: 

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

200 if line: 

201 if key.data == "stdout": 

202 output_chunks.append(line) 

203 if on_output_line: 

204 on_output_line(line.rstrip()) 

205 else: 

206 stderr_chunks.append(line) 

207 else: 

208 # EOF on this pipe — unregister it 

209 sel.unregister(key.fileobj) 

210 finally: 

211 sel.close() 

212 self._current_process = None 

213 

214 if timed_out: 

215 _kill_process_group(process) 

216 # Drain any remaining output after the kill 

217 try: 

218 process.wait(timeout=5) 

219 except subprocess.TimeoutExpired: 

220 process.kill() 

221 process.wait() 

222 return ActionResult( 

223 output="".join(output_chunks), 

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

225 exit_code=124, 

226 duration_ms=timeout * 1000, 

227 ) 

228 

229 process.wait(timeout=5) 

230 return ActionResult( 

231 output="".join(output_chunks), 

232 stderr="".join(stderr_chunks), 

233 exit_code=process.returncode, 

234 duration_ms=_now_ms() - start, 

235 ) 

236 

237 

238@dataclass 

239class SimulationActionRunner: 

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

241 

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

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

244 predefined scenarios. 

245 

246 Attributes: 

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

248 call_count: Number of actions simulated so far 

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

250 """ 

251 

252 scenario: str | None = None 

253 call_count: int = 0 

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

255 

256 def run( 

257 self, 

258 action: str, 

259 timeout: int, 

260 is_slash_command: bool, 

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

262 agent: str | None = None, 

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

264 on_usage: UsageCallback | None = None, 

265 on_usage_detailed: DetailedUsageCallback | None = None, 

266 model: str | None = None, 

267 ) -> ActionResult: 

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

269 

270 Args: 

271 action: The command that would be executed 

272 timeout: Timeout (ignored in simulation) 

273 is_slash_command: Whether this is a slash command 

274 on_output_line: Ignored in simulation 

275 agent: Ignored in simulation 

276 tools: Ignored in simulation 

277 on_usage: Ignored in simulation 

278 on_usage_detailed: Ignored in simulation 

279 model: Ignored in simulation 

280 

281 Returns: 

282 ActionResult with simulated exit code 

283 """ 

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

285 self.calls.append(action) 

286 self.call_count += 1 

287 

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

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

290 

291 if self.scenario: 

292 exit_code = self._scenario_result() 

293 scenario_label = { 

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

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

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

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

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

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

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

301 else: 

302 exit_code = self._prompt_result() 

303 

304 return ActionResult( 

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

306 stderr="", 

307 exit_code=exit_code, 

308 duration_ms=0, 

309 ) 

310 

311 def _scenario_result(self) -> int: 

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

313 

314 Returns: 

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

316 """ 

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

318 return 0 

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

320 return 1 

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

322 return 2 

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

324 # First call fails, rest pass 

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

326 elif self.scenario == "alternating": 

327 # Odd calls fail, even calls pass 

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

329 return 0 

330 

331 def _prompt_result(self) -> int: 

332 """Prompt user for simulated exit code. 

333 

334 Returns: 

335 Exit code based on user selection 

336 """ 

337 print() 

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

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

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

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

342 

343 while True: 

344 try: 

345 sys.stdout.write(" > ") 

346 sys.stdout.flush() 

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

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

349 return 0 

350 elif choice == "2": 

351 return 1 

352 elif choice == "3": 

353 return 2 

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

355 except (EOFError, KeyboardInterrupt): 

356 print() 

357 return 0