Coverage for little_loops / subprocess_utils.py: 20%

188 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

1"""Subprocess utilities for Claude CLI invocation. 

2 

3Provides shared functionality for running Claude CLI commands with 

4real-time output streaming, timeout handling, and context handoff detection. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import logging 

11import os 

12import re 

13import selectors 

14import subprocess 

15import time 

16from collections.abc import Callable 

17from dataclasses import dataclass 

18from pathlib import Path 

19 

20from little_loops.host_runner import resolve_host 

21 

22logger = logging.getLogger(__name__) 

23 

24# Callback type: (line: str, is_stderr: bool) -> None 

25OutputCallback = Callable[[str, bool], None] 

26 

27# Process lifecycle callback: (process: Popen) -> None 

28ProcessCallback = Callable[[subprocess.Popen[str]], None] 

29 

30# Model detection callback: (model: str) -> None 

31ModelCallback = Callable[[str], None] 

32 

33# Usage callback: (input_tokens: int, output_tokens: int) -> None 

34# Kept for back-compat with issue_manager.py and worker_pool.py callers. 

35UsageCallback = Callable[[int, int], None] 

36 

37 

38@dataclass 

39class TokenUsage: 

40 """Token usage from a single host-CLI invocation.""" 

41 

42 input_tokens: int 

43 output_tokens: int 

44 cache_read_tokens: int 

45 cache_creation_tokens: int 

46 model: str 

47 

48 

49# Detailed usage callback — receives all four token fields plus model ID. 

50DetailedUsageCallback = Callable[[TokenUsage], None] 

51 

52# Context handoff detection pattern 

53CONTEXT_HANDOFF_PATTERN = re.compile(r"CONTEXT_HANDOFF:\s*Ready for fresh session") 

54CONTINUATION_PROMPT_PATH = Path(".ll/ll-continue-prompt.md") 

55 

56# Sentinel file written when a session ends with high context usage (Option G). 

57# Consumed by run_with_continuation; NOT deleted by session-cleanup.sh. 

58SENTINEL_PATH = Path(".ll/ll-context-handoff-needed") 

59 

60# Chars of captured_stdout to include in Option J guillotine prompt (≈3K tokens). 

61_GUILLOTINE_TAIL_CHARS = 12_000 

62# Lines of original_command to include for task intent. 

63_GUILLOTINE_MAX_TASK_LINES = 20 

64 

65 

66def detect_context_handoff(output: str) -> bool: 

67 """Check if output contains a context handoff signal. 

68 

69 Args: 

70 output: Command output to check 

71 

72 Returns: 

73 True if context handoff was signaled 

74 """ 

75 return bool(CONTEXT_HANDOFF_PATTERN.search(output)) 

76 

77 

78def read_continuation_prompt(repo_path: Path | None = None) -> str | None: 

79 """Read the continuation prompt file if it exists. 

80 

81 Args: 

82 repo_path: Optional repository root path 

83 

84 Returns: 

85 Contents of continuation prompt, or None if not found 

86 """ 

87 prompt_path = (repo_path or Path.cwd()) / CONTINUATION_PROMPT_PATH 

88 if prompt_path.exists(): 

89 return prompt_path.read_text() 

90 return None 

91 

92 

93def read_sentinel(repo_path: Path | None = None) -> dict | None: 

94 """Read and consume the context-handoff sentinel file if it exists. 

95 

96 The sentinel is written by context-handoff-sentinel.sh (Stop hook) or 

97 the Python layer in run_with_continuation when a session ends with high 

98 context usage but no CONTEXT_HANDOFF signal. 

99 

100 Args: 

101 repo_path: Optional repository root path 

102 

103 Returns: 

104 Parsed sentinel dict, or None if not present 

105 """ 

106 sentinel_path = (repo_path or Path.cwd()) / SENTINEL_PATH 

107 if not sentinel_path.exists(): 

108 return None 

109 try: 

110 data = json.loads(sentinel_path.read_text()) 

111 sentinel_path.unlink(missing_ok=True) 

112 return data 

113 except Exception: 

114 sentinel_path.unlink(missing_ok=True) 

115 return {} 

116 

117 

118def write_sentinel( 

119 repo_path: Path | None = None, 

120 token_count: int = 0, 

121 context_limit: int = 200_000, 

122) -> None: 

123 """Write the context-handoff sentinel file. 

124 

125 Args: 

126 repo_path: Optional repository root path 

127 token_count: Total tokens used in the session 

128 context_limit: Context window size 

129 """ 

130 import datetime 

131 

132 sentinel_path = (repo_path or Path.cwd()) / SENTINEL_PATH 

133 usage_percent = int(token_count * 100 / context_limit) if context_limit > 0 else 0 

134 try: 

135 sentinel_path.parent.mkdir(parents=True, exist_ok=True) 

136 sentinel_path.write_text( 

137 json.dumps( 

138 { 

139 "written_at": datetime.datetime.now(datetime.UTC).strftime( 

140 "%Y-%m-%dT%H:%M:%SZ" 

141 ), 

142 "token_count": token_count, 

143 "context_limit": context_limit, 

144 "usage_percent": usage_percent, 

145 } 

146 ) 

147 ) 

148 except Exception: 

149 pass 

150 

151 

152def assemble_guillotine_prompt( 

153 original_command: str, 

154 captured_stdout: str, 

155 token_stats: dict, 

156) -> str: 

157 """Assemble a fresh-session continuation prompt for Option J (parent-side guillotine). 

158 

159 Called when context > 90% or "Prompt is too long" is detected with no handoff. 

160 The resulting prompt is passed to a BRAND-NEW claude -p session (not --resume), 

161 so it starts with 0 tokens. 

162 

163 Args: 

164 original_command: The original task command / skill invocation 

165 captured_stdout: All Claude text output captured so far 

166 token_stats: Dict with keys: input_tokens, output_tokens, context_limit, 

167 trigger_reason (optional) 

168 

169 Returns: 

170 Assembled continuation prompt string 

171 """ 

172 task_lines = original_command.strip().splitlines()[:_GUILLOTINE_MAX_TASK_LINES] 

173 task_excerpt = "\n".join(task_lines) 

174 if len(original_command.strip().splitlines()) > _GUILLOTINE_MAX_TASK_LINES: 

175 task_excerpt += f"\n... (truncated to {_GUILLOTINE_MAX_TASK_LINES} lines)" 

176 

177 stdout_tail = (captured_stdout or "")[-_GUILLOTINE_TAIL_CHARS:] 

178 if not stdout_tail: 

179 stdout_tail = "(no output captured before interruption)" 

180 

181 input_tokens = token_stats.get("input_tokens", 0) 

182 output_tokens = token_stats.get("output_tokens", 0) 

183 context_limit = token_stats.get("context_limit", 200_000) 

184 trigger_reason = token_stats.get("trigger_reason", "context > 90%") 

185 

186 scratch_listing = _list_scratch_files() 

187 

188 return f"""\ 

189⚠ CONTEXT LIMIT REACHED — FRESH SESSION CONTINUATION 

190 

191The previous automation session exhausted its context window before completing. 

192This fresh session (new context window, starts at 0 tokens) is continuing from 

193that interrupted session. 

194 

195## Original Task 

196{task_excerpt} 

197 

198## Session Progress at Interruption 

199- Approximate tokens used: {input_tokens + output_tokens:,} / {context_limit:,} 

200- Trigger reason: {trigger_reason} 

201 

202## Last Session Output (what was happening at interruption) 

203{stdout_tail} 

204 

205## Scratch Pad Files Available 

206{scratch_listing} 

207 

208## Instructions for This Session 

2091. Do NOT restart from scratch — the previous session made progress (see above) 

2102. Read the "Last Session Output" section to understand exactly where we were 

2113. Check the scratch pad files before re-running expensive operations 

2124. Continue implementation from the interruption point 

2135. Complete normally: test, commit, close the issue as usual 

214""" 

215 

216 

217def _list_scratch_files() -> str: 

218 """List files in .loops/tmp/scratch/ with sizes for the guillotine prompt.""" 

219 scratch_dir = Path(".loops/tmp/scratch") 

220 if not scratch_dir.exists(): 

221 return "None" 

222 try: 

223 files = sorted(scratch_dir.iterdir()) 

224 if not files: 

225 return "None" 

226 lines = [] 

227 for f in files: 

228 try: 

229 size_kb = f.stat().st_size // 1024 

230 lines.append(f" {f.name} ({size_kb}KB)") 

231 except Exception: 

232 lines.append(f" {f.name}") 

233 return "\n".join(lines) 

234 except Exception: 

235 return "None" 

236 

237 

238def run_claude_command( 

239 command: str, 

240 timeout: int = 3600, 

241 working_dir: Path | None = None, 

242 stream_callback: OutputCallback | None = None, 

243 on_process_start: ProcessCallback | None = None, 

244 on_process_end: ProcessCallback | None = None, 

245 idle_timeout: int = 0, 

246 on_model_detected: ModelCallback | None = None, 

247 on_usage: UsageCallback | None = None, 

248 on_usage_detailed: DetailedUsageCallback | None = None, 

249 agent: str | None = None, 

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

251 resume_session: bool = False, 

252) -> subprocess.CompletedProcess[str]: 

253 """Invoke Claude CLI command with real-time output streaming. 

254 

255 Args: 

256 command: Command to pass to Claude CLI 

257 timeout: Timeout in seconds (0 for no timeout) 

258 working_dir: Optional working directory for the command 

259 stream_callback: Optional callback for streaming output lines. 

260 Called with (line, is_stderr) for each line of output. 

261 on_process_start: Optional callback invoked after process starts. 

262 Receives the Popen object for tracking/management. 

263 on_process_end: Optional callback invoked after process completes. 

264 Receives the Popen object. Called in finally block. 

265 idle_timeout: Kill process if no output for this many seconds (0 to disable). 

266 on_model_detected: Optional callback invoked with the model name from the 

267 stream-json system/init event. Called at most once per invocation. 

268 on_usage: Optional callback invoked with (input_tokens, output_tokens) from 

269 the stream-json result event. input_tokens includes cache_read_input_tokens. 

270 on_usage_detailed: Optional callback invoked with a TokenUsage dataclass 

271 carrying all four token fields (input, output, cache_read, cache_creation) 

272 plus the model ID from the stream-json result event. 

273 resume_session: If True, passes --continue to the Claude CLI to continue the 

274 most recent conversation. Used for the Option E explicit-handoff path. 

275 

276 Returns: 

277 CompletedProcess with stdout/stderr captured 

278 

279 Raises: 

280 subprocess.TimeoutExpired: If command exceeds timeout or idle timeout. 

281 When triggered by idle timeout, the output field is set to "idle_timeout". 

282 """ 

283 runner = resolve_host() 

284 invocation = runner.build_streaming( 

285 prompt=command, 

286 working_dir=working_dir, 

287 resume=resume_session, 

288 agent=agent, 

289 tools=tools, 

290 ) 

291 cmd_args = [invocation.binary, *invocation.args] 

292 

293 env = os.environ.copy() 

294 env.update(invocation.env) 

295 if "GIT_DIR" in invocation.env: 

296 logger.debug("Worktree detected: GIT_DIR=%s", invocation.env["GIT_DIR"]) 

297 

298 process = subprocess.Popen( 

299 cmd_args, 

300 stdout=subprocess.PIPE, 

301 stderr=subprocess.PIPE, 

302 text=True, 

303 bufsize=1, # Line buffered 

304 cwd=working_dir, 

305 env=env, 

306 ) 

307 

308 if on_process_start: 

309 on_process_start(process) 

310 

311 stdout_lines: list[str] = [] 

312 stderr_lines: list[str] = [] 

313 detected_model: str = "unknown" 

314 

315 # Use selectors for non-blocking read from both streams 

316 with selectors.DefaultSelector() as sel: 

317 if process.stdout: 

318 sel.register(process.stdout, selectors.EVENT_READ) 

319 if process.stderr: 

320 sel.register(process.stderr, selectors.EVENT_READ) 

321 

322 start_time = time.time() 

323 last_output_time = start_time 

324 

325 try: 

326 while sel.get_map(): 

327 now = time.time() 

328 if timeout and (now - start_time) > timeout: 

329 process.kill() 

330 try: 

331 process.wait(timeout=10) 

332 except subprocess.TimeoutExpired: 

333 logger.warning( 

334 "Process %s did not terminate within 10s after kill", 

335 process.pid, 

336 ) 

337 raise subprocess.TimeoutExpired(cmd_args, timeout) 

338 

339 if idle_timeout and (now - last_output_time) > idle_timeout: 

340 process.kill() 

341 try: 

342 process.wait(timeout=10) 

343 except subprocess.TimeoutExpired: 

344 logger.warning( 

345 "Process %s did not terminate within 10s after kill", 

346 process.pid, 

347 ) 

348 raise subprocess.TimeoutExpired(cmd_args, idle_timeout, output="idle_timeout") 

349 

350 ready = sel.select(timeout=1.0) 

351 for key, _ in ready: 

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

353 if not line: 

354 sel.unregister(key.fileobj) 

355 continue 

356 

357 last_output_time = time.time() 

358 line = line.rstrip("\n") 

359 is_stderr = key.fileobj is process.stderr 

360 

361 if not is_stderr: 

362 try: 

363 event = json.loads(line) 

364 etype = event.get("type") 

365 if etype == "system" and event.get("subtype") == "init": 

366 if "model" in event: 

367 detected_model = event["model"] 

368 if on_model_detected: 

369 on_model_detected(event["model"]) 

370 continue # don't add to stdout_lines 

371 elif etype == "assistant": 

372 msg = event.get("message", {}) 

373 text_parts = [ 

374 block["text"] 

375 for block in msg.get("content", []) 

376 if block.get("type") == "text" 

377 ] 

378 text = "\n\n".join(text_parts) 

379 if not text: 

380 continue 

381 for sub_line in text.splitlines() or [""]: 

382 stdout_lines.append(sub_line) 

383 if stream_callback: 

384 stream_callback(sub_line, is_stderr) 

385 continue 

386 elif etype == "result": 

387 usage = event.get("usage", {}) 

388 if on_usage and usage: 

389 on_usage( 

390 usage.get("input_tokens", 0) 

391 + usage.get("cache_read_input_tokens", 0), 

392 usage.get("output_tokens", 0), 

393 ) 

394 if on_usage_detailed and usage: 

395 on_usage_detailed( 

396 TokenUsage( 

397 input_tokens=usage.get("input_tokens", 0), 

398 output_tokens=usage.get("output_tokens", 0), 

399 cache_read_tokens=usage.get( 

400 "cache_read_input_tokens", 0 

401 ), 

402 cache_creation_tokens=usage.get( 

403 "cache_creation_input_tokens", 0 

404 ), 

405 model=event.get("model", detected_model), 

406 ) 

407 ) 

408 if event.get("is_error"): 

409 error_text = event.get("error") or event.get("result", "") 

410 if error_text: 

411 stderr_lines.append(f"[result] {error_text}") 

412 continue # skip other event types (tool_use, etc.) 

413 else: 

414 continue # skip other event types (tool_use, etc.) 

415 except (json.JSONDecodeError, KeyError, TypeError): 

416 pass # non-JSON line: pass through as raw text 

417 

418 if is_stderr: 

419 stderr_lines.append(line) 

420 else: 

421 stdout_lines.append(line) 

422 

423 if stream_callback: 

424 stream_callback(line, is_stderr) 

425 

426 try: 

427 process.wait(timeout=30) 

428 except subprocess.TimeoutExpired: 

429 logger.warning( 

430 "Process %s did not exit within 30s after streams closed, killing", 

431 process.pid, 

432 ) 

433 process.kill() 

434 try: 

435 process.wait(timeout=10) 

436 except subprocess.TimeoutExpired: 

437 logger.warning( 

438 "Process %s did not terminate within 10s after kill", 

439 process.pid, 

440 ) 

441 finally: 

442 if on_process_end: 

443 on_process_end(process) 

444 

445 return subprocess.CompletedProcess( 

446 cmd_args, 

447 process.returncode if process.returncode is not None else -9, 

448 stdout="\n".join(stdout_lines), 

449 stderr="\n".join(stderr_lines), 

450 )