Coverage for little_loops / subprocess_utils.py: 15%

186 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-11 00:29 -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 pathlib import Path 

18 

19logger = logging.getLogger(__name__) 

20 

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

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

23 

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

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

26 

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

28ModelCallback = Callable[[str], None] 

29 

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

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

32 

33# Context handoff detection pattern 

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

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

36 

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

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

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

40 

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

42_GUILLOTINE_TAIL_CHARS = 12_000 

43# Lines of original_command to include for task intent. 

44_GUILLOTINE_MAX_TASK_LINES = 20 

45 

46 

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

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

49 

50 Args: 

51 output: Command output to check 

52 

53 Returns: 

54 True if context handoff was signaled 

55 """ 

56 return bool(CONTEXT_HANDOFF_PATTERN.search(output)) 

57 

58 

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

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

61 

62 Args: 

63 repo_path: Optional repository root path 

64 

65 Returns: 

66 Contents of continuation prompt, or None if not found 

67 """ 

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

69 if prompt_path.exists(): 

70 return prompt_path.read_text() 

71 return None 

72 

73 

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

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

76 

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

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

79 context usage but no CONTEXT_HANDOFF signal. 

80 

81 Args: 

82 repo_path: Optional repository root path 

83 

84 Returns: 

85 Parsed sentinel dict, or None if not present 

86 """ 

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

88 if not sentinel_path.exists(): 

89 return None 

90 try: 

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

92 sentinel_path.unlink(missing_ok=True) 

93 return data 

94 except Exception: 

95 sentinel_path.unlink(missing_ok=True) 

96 return {} 

97 

98 

99def write_sentinel( 

100 repo_path: Path | None = None, 

101 token_count: int = 0, 

102 context_limit: int = 200_000, 

103) -> None: 

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

105 

106 Args: 

107 repo_path: Optional repository root path 

108 token_count: Total tokens used in the session 

109 context_limit: Context window size 

110 """ 

111 import datetime 

112 

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

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

115 try: 

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

117 sentinel_path.write_text( 

118 json.dumps( 

119 { 

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

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

122 ), 

123 "token_count": token_count, 

124 "context_limit": context_limit, 

125 "usage_percent": usage_percent, 

126 } 

127 ) 

128 ) 

129 except Exception: 

130 pass 

131 

132 

133def assemble_guillotine_prompt( 

134 original_command: str, 

135 captured_stdout: str, 

136 token_stats: dict, 

137) -> str: 

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

139 

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

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

142 so it starts with 0 tokens. 

143 

144 Args: 

145 original_command: The original task command / skill invocation 

146 captured_stdout: All Claude text output captured so far 

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

148 trigger_reason (optional) 

149 

150 Returns: 

151 Assembled continuation prompt string 

152 """ 

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

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

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

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

157 

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

159 if not stdout_tail: 

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

161 

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

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

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

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

166 

167 scratch_listing = _list_scratch_files() 

168 

169 return f"""\ 

170⚠ CONTEXT LIMIT REACHED — FRESH SESSION CONTINUATION 

171 

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

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

174that interrupted session. 

175 

176## Original Task 

177{task_excerpt} 

178 

179## Session Progress at Interruption 

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

181- Trigger reason: {trigger_reason} 

182 

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

184{stdout_tail} 

185 

186## Scratch Pad Files Available 

187{scratch_listing} 

188 

189## Instructions for This Session 

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

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

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

1934. Continue implementation from the interruption point 

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

195""" 

196 

197 

198def _list_scratch_files() -> str: 

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

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

201 if not scratch_dir.exists(): 

202 return "None" 

203 try: 

204 files = sorted(scratch_dir.iterdir()) 

205 if not files: 

206 return "None" 

207 lines = [] 

208 for f in files: 

209 try: 

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

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

212 except Exception: 

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

214 return "\n".join(lines) 

215 except Exception: 

216 return "None" 

217 

218 

219def run_claude_command( 

220 command: str, 

221 timeout: int = 3600, 

222 working_dir: Path | None = None, 

223 stream_callback: OutputCallback | None = None, 

224 on_process_start: ProcessCallback | None = None, 

225 on_process_end: ProcessCallback | None = None, 

226 idle_timeout: int = 0, 

227 on_model_detected: ModelCallback | None = None, 

228 on_usage: UsageCallback | None = None, 

229 agent: str | None = None, 

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

231 resume_session: bool = False, 

232) -> subprocess.CompletedProcess[str]: 

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

234 

235 Args: 

236 command: Command to pass to Claude CLI 

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

238 working_dir: Optional working directory for the command 

239 stream_callback: Optional callback for streaming output lines. 

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

241 on_process_start: Optional callback invoked after process starts. 

242 Receives the Popen object for tracking/management. 

243 on_process_end: Optional callback invoked after process completes. 

244 Receives the Popen object. Called in finally block. 

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

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

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

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

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

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

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

252 

253 Returns: 

254 CompletedProcess with stdout/stderr captured 

255 

256 Raises: 

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

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

259 """ 

260 cmd_args = [ 

261 "claude", 

262 "--dangerously-skip-permissions", 

263 "--verbose", 

264 "--output-format", 

265 "stream-json", 

266 ] 

267 if resume_session: 

268 cmd_args.append("--continue") 

269 cmd_args += ["-p", command] 

270 if agent: 

271 cmd_args += ["--agent", agent] 

272 if tools: 

273 cmd_args += ["--tools", ",".join(tools)] 

274 

275 # Set environment to keep Claude in the project working directory (BUG-007) 

276 # This helps prevent file writes from leaking to the main repo in worktrees 

277 env = os.environ.copy() 

278 env["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1" 

279 

280 if working_dir is not None: 

281 git_path = Path(working_dir) / ".git" 

282 if git_path.is_file(): # worktree: .git is a file, not a directory 

283 gitdir_ref = git_path.read_text().strip() 

284 if gitdir_ref.startswith("gitdir: "): 

285 actual_gitdir = gitdir_ref[8:].strip() 

286 # Resolve relative gitdir references to absolute paths 

287 resolved = (Path(working_dir) / actual_gitdir).resolve() 

288 env["GIT_DIR"] = str(resolved) 

289 env["GIT_WORK_TREE"] = str(working_dir) 

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

291 

292 process = subprocess.Popen( 

293 cmd_args, 

294 stdout=subprocess.PIPE, 

295 stderr=subprocess.PIPE, 

296 text=True, 

297 bufsize=1, # Line buffered 

298 cwd=working_dir, 

299 env=env, 

300 ) 

301 

302 if on_process_start: 

303 on_process_start(process) 

304 

305 stdout_lines: list[str] = [] 

306 stderr_lines: list[str] = [] 

307 

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

309 with selectors.DefaultSelector() as sel: 

310 if process.stdout: 

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

312 if process.stderr: 

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

314 

315 start_time = time.time() 

316 last_output_time = start_time 

317 

318 try: 

319 while sel.get_map(): 

320 now = time.time() 

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

322 process.kill() 

323 try: 

324 process.wait(timeout=10) 

325 except subprocess.TimeoutExpired: 

326 logger.warning( 

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

328 process.pid, 

329 ) 

330 raise subprocess.TimeoutExpired(cmd_args, timeout) 

331 

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

333 process.kill() 

334 try: 

335 process.wait(timeout=10) 

336 except subprocess.TimeoutExpired: 

337 logger.warning( 

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

339 process.pid, 

340 ) 

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

342 

343 ready = sel.select(timeout=1.0) 

344 for key, _ in ready: 

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

346 if not line: 

347 sel.unregister(key.fileobj) 

348 continue 

349 

350 last_output_time = time.time() 

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

352 is_stderr = key.fileobj is process.stderr 

353 

354 if not is_stderr: 

355 try: 

356 event = json.loads(line) 

357 etype = event.get("type") 

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

359 if on_model_detected and "model" in event: 

360 on_model_detected(event["model"]) 

361 continue # don't add to stdout_lines 

362 elif etype == "assistant": 

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

364 text_parts = [ 

365 block["text"] 

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

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

368 ] 

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

370 if not text: 

371 continue 

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

373 stdout_lines.append(sub_line) 

374 if stream_callback: 

375 stream_callback(sub_line, is_stderr) 

376 continue 

377 elif etype == "result": 

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

379 if on_usage and usage: 

380 on_usage( 

381 usage.get("input_tokens", 0) 

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

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

384 ) 

385 if event.get("is_error"): 

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

387 if error_text: 

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

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

390 else: 

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

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

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

394 

395 if is_stderr: 

396 stderr_lines.append(line) 

397 else: 

398 stdout_lines.append(line) 

399 

400 if stream_callback: 

401 stream_callback(line, is_stderr) 

402 

403 try: 

404 process.wait(timeout=30) 

405 except subprocess.TimeoutExpired: 

406 logger.warning( 

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

408 process.pid, 

409 ) 

410 process.kill() 

411 try: 

412 process.wait(timeout=10) 

413 except subprocess.TimeoutExpired: 

414 logger.warning( 

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

416 process.pid, 

417 ) 

418 finally: 

419 if on_process_end: 

420 on_process_end(process) 

421 

422 return subprocess.CompletedProcess( 

423 cmd_args, 

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

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

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

427 )