1"""FSM action runner implementations.

2

3 Provides 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

9 from __future__ import annotations

10

11 import selectors

12 import subprocess

13 import sys

14 import time

15 from collections.abc import Callable

16 from dataclasses import dataclass, field

17 from typing import Protocol

18

19 from little_loops.fsm.types import ActionResult

20 from little_loops.subprocess_utils import (

21 DetailedUsageCallback,

22 TokenUsage,

23 UsageCallback,

24 _kill_process_group,

25 run_claude_command,

26)

27

28

29 def _now_ms() -> int:

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

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

32

33

34 class 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

67 class 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

239 class 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