Coverage for little_loops / subprocess_utils.py: 19%

212 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -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 signal 

15import subprocess 

16import time 

17from collections.abc import Callable 

18from dataclasses import dataclass 

19from pathlib import Path 

20from typing import TYPE_CHECKING 

21 

22from little_loops.context_window import context_window_for 

23from little_loops.host_runner import resolve_host 

24 

25if TYPE_CHECKING: 

26 from little_loops.parallel.types import SprintWorkerContext 

27 

28logger = logging.getLogger(__name__) 

29 

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

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

32 

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

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

35 

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

37ModelCallback = Callable[[str], None] 

38 

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

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

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

42 

43 

44@dataclass 

45class TokenUsage: 

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

47 

48 input_tokens: int 

49 output_tokens: int 

50 cache_read_tokens: int 

51 cache_creation_tokens: int 

52 model: str 

53 

54 

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

56DetailedUsageCallback = Callable[[TokenUsage], None] 

57 

58# Context handoff detection pattern 

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

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

61 

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

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

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

65 

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

67_GUILLOTINE_TAIL_CHARS = 12_000 

68# Lines of original_command to include for task intent. 

69_GUILLOTINE_MAX_TASK_LINES = 20 

70 

71 

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

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

74 

75 Args: 

76 output: Command output to check 

77 

78 Returns: 

79 True if context handoff was signaled 

80 """ 

81 return bool(CONTEXT_HANDOFF_PATTERN.search(output)) 

82 

83 

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

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

86 

87 Args: 

88 repo_path: Optional repository root path 

89 

90 Returns: 

91 Contents of continuation prompt, or None if not found 

92 """ 

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

94 if prompt_path.exists(): 

95 return prompt_path.read_text() 

96 return None 

97 

98 

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

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

101 

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

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

104 context usage but no CONTEXT_HANDOFF signal. 

105 

106 Args: 

107 repo_path: Optional repository root path 

108 

109 Returns: 

110 Parsed sentinel dict, or None if not present 

111 """ 

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

113 if not sentinel_path.exists(): 

114 return None 

115 try: 

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

117 sentinel_path.unlink(missing_ok=True) 

118 return data 

119 except Exception: 

120 sentinel_path.unlink(missing_ok=True) 

121 return {} 

122 

123 

124def write_sentinel( 

125 repo_path: Path | None = None, 

126 token_count: int = 0, 

127 context_limit: int | None = None, 

128) -> None: 

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

130 

131 Args: 

132 repo_path: Optional repository root path 

133 token_count: Total tokens used in the session 

134 context_limit: Context window size 

135 """ 

136 import datetime 

137 

138 if context_limit is None: 

139 context_limit = context_window_for(None) 

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

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

142 try: 

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

144 sentinel_path.write_text( 

145 json.dumps( 

146 { 

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

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

149 ), 

150 "token_count": token_count, 

151 "context_limit": context_limit, 

152 "usage_percent": usage_percent, 

153 } 

154 ) 

155 ) 

156 except Exception: 

157 pass 

158 

159 

160def assemble_guillotine_prompt( 

161 original_command: str, 

162 captured_stdout: str, 

163 token_stats: dict, 

164 sprint_context: SprintWorkerContext | None = None, 

165 issue_id: str | None = None, 

166) -> str: 

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

168 

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

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

171 so it starts with 0 tokens. 

172 

173 Args: 

174 original_command: The original task command / skill invocation 

175 captured_stdout: All Claude text output captured so far 

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

177 trigger_reason (optional) 

178 

179 Returns: 

180 Assembled continuation prompt string 

181 """ 

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

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

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

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

186 

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

188 if not stdout_tail: 

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

190 

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

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

193 context_limit = token_stats.get("context_limit") or context_window_for(None) 

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

195 

196 scratch_listing = _list_scratch_files() 

197 

198 body = f"""\ 

199⚠ CONTEXT LIMIT REACHED — FRESH SESSION CONTINUATION 

200 

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

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

203that interrupted session. 

204 

205## Original Task 

206{task_excerpt} 

207 

208## Session Progress at Interruption 

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

210- Trigger reason: {trigger_reason} 

211 

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

213{stdout_tail} 

214 

215## Scratch Pad Files Available 

216{scratch_listing} 

217 

218## Instructions for This Session 

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

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

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

2224. Continue implementation from the interruption point 

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

224""" 

225 

226 if sprint_context is not None: 

227 framing = ( 

228 f"## Sprint Worker Context\n" 

229 f"You are a sprint worker. Process exactly ONE issue: {sprint_context.issue_id}\n" 

230 f"After completing this issue, exit immediately — do NOT process other issues.\n" 

231 f"Do NOT ask for further instructions. Exit with code 0.\n" 

232 f"Branch: {sprint_context.branch}\n\n" 

233 ) 

234 return framing + body 

235 

236 if issue_id is not None: 

237 framing = ( 

238 f"## Scope Constraint\n" 

239 f"Process exactly ONE issue: {issue_id}\n" 

240 f"After completing this issue, exit immediately — do NOT process other issues.\n" 

241 f"Do NOT ask for further instructions. Exit with code 0.\n\n" 

242 ) 

243 return framing + body 

244 

245 return body 

246 

247 

248def _list_scratch_files() -> str: 

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

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

251 if not scratch_dir.exists(): 

252 return "None" 

253 try: 

254 files = sorted(scratch_dir.iterdir()) 

255 if not files: 

256 return "None" 

257 lines = [] 

258 for f in files: 

259 try: 

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

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

262 except Exception: 

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

264 return "\n".join(lines) 

265 except Exception: 

266 return "None" 

267 

268 

269def _kill_process_group(process: subprocess.Popen) -> None: 

270 """Send SIGKILL to the process group; fall back to single-PID kill on error. 

271 

272 Uses os.getpgid / os.killpg (POSIX) so background Workflow/Task children 

273 launched by the session are reaped together with the main process. 

274 AttributeError catches platforms where os.killpg is absent (Windows). 

275 """ 

276 try: 

277 os.killpg(os.getpgid(process.pid), signal.SIGKILL) 

278 except (ProcessLookupError, PermissionError, AttributeError): 

279 process.kill() 

280 

281 

282def run_claude_command( 

283 command: str, 

284 timeout: int = 3600, 

285 working_dir: Path | None = None, 

286 stream_callback: OutputCallback | None = None, 

287 on_process_start: ProcessCallback | None = None, 

288 on_process_end: ProcessCallback | None = None, 

289 idle_timeout: int = 0, 

290 on_model_detected: ModelCallback | None = None, 

291 on_usage: UsageCallback | None = None, 

292 on_usage_detailed: DetailedUsageCallback | None = None, 

293 agent: str | None = None, 

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

295 resume_session: bool = False, 

296 model: str | None = None, 

297) -> subprocess.CompletedProcess[str]: 

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

299 

300 Args: 

301 command: Command to pass to Claude CLI 

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

303 working_dir: Optional working directory for the command 

304 stream_callback: Optional callback for streaming output lines. 

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

306 on_process_start: Optional callback invoked after process starts. 

307 Receives the Popen object for tracking/management. 

308 on_process_end: Optional callback invoked after process completes. 

309 Receives the Popen object. Called in finally block. 

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

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

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

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

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

315 on_usage_detailed: Optional callback invoked with a TokenUsage dataclass 

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

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

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

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

320 

321 Returns: 

322 CompletedProcess with stdout/stderr captured 

323 

324 Raises: 

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

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

327 """ 

328 runner = resolve_host() 

329 invocation = runner.build_streaming( 

330 prompt=command, 

331 working_dir=working_dir, 

332 resume=resume_session, 

333 agent=agent, 

334 tools=tools, 

335 model=model, 

336 ) 

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

338 

339 env = os.environ.copy() 

340 env.update(invocation.env) 

341 if "GIT_DIR" in invocation.env: 

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

343 

344 try: 

345 process = subprocess.Popen( 

346 cmd_args, 

347 stdout=subprocess.PIPE, 

348 stderr=subprocess.PIPE, 

349 text=True, 

350 bufsize=1, # Line buffered 

351 cwd=working_dir, 

352 env=env, 

353 start_new_session=True, 

354 ) 

355 except Exception as exc: 

356 return subprocess.CompletedProcess( 

357 args=cmd_args, 

358 returncode=1, 

359 stdout="", 

360 stderr=f"Subprocess spawn failed: {exc}", 

361 ) 

362 

363 if on_process_start: 

364 on_process_start(process) 

365 

366 stdout_lines: list[str] = [] 

367 stderr_lines: list[str] = [] 

368 detected_model: str = "unknown" 

369 

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

371 with selectors.DefaultSelector() as sel: 

372 if process.stdout: 

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

374 if process.stderr: 

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

376 

377 start_time = time.time() 

378 last_output_time = start_time 

379 # End-of-turn detection: the stream-json "result" event is the canonical 

380 # signal that the headless `claude -p` session is done. We break on it 

381 # instead of waiting for pipe EOF, because background Workflow/Task child 

382 # processes inherit the stdout/stderr write-ends and a pipe only reports 

383 # EOF when the *last* writer closes it — so EOF may never arrive even 

384 # though the turn finished, hanging the reader until the wall-clock 

385 # timeout fires on a successful run. 

386 result_seen = False 

387 

388 try: 

389 while sel.get_map(): 

390 now = time.time() 

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

392 _kill_process_group(process) 

393 try: 

394 process.wait(timeout=10) 

395 except subprocess.TimeoutExpired: 

396 logger.warning( 

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

398 process.pid, 

399 ) 

400 raise subprocess.TimeoutExpired(cmd_args, timeout) 

401 

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

403 _kill_process_group(process) 

404 try: 

405 process.wait(timeout=10) 

406 except subprocess.TimeoutExpired: 

407 logger.warning( 

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

409 process.pid, 

410 ) 

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

412 

413 ready = sel.select(timeout=1.0) 

414 for key, _ in ready: 

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

416 if not line: 

417 sel.unregister(key.fileobj) 

418 continue 

419 

420 last_output_time = time.time() 

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

422 is_stderr = key.fileobj is process.stderr 

423 

424 if not is_stderr: 

425 try: 

426 event = json.loads(line) 

427 etype = event.get("type") 

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

429 if "model" in event: 

430 detected_model = event["model"] 

431 if on_model_detected: 

432 on_model_detected(event["model"]) 

433 continue # don't add to stdout_lines 

434 elif etype == "assistant": 

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

436 text_parts = [ 

437 block["text"] 

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

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

440 ] 

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

442 if not text: 

443 continue 

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

445 stdout_lines.append(sub_line) 

446 if stream_callback: 

447 stream_callback(sub_line, is_stderr) 

448 continue 

449 elif etype == "result": 

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

451 if on_usage and usage: 

452 on_usage( 

453 usage.get("input_tokens", 0) 

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

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

456 ) 

457 if on_usage_detailed and usage: 

458 on_usage_detailed( 

459 TokenUsage( 

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

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

462 cache_read_tokens=usage.get( 

463 "cache_read_input_tokens", 0 

464 ), 

465 cache_creation_tokens=usage.get( 

466 "cache_creation_input_tokens", 0 

467 ), 

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

469 ) 

470 ) 

471 if event.get("is_error"): 

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

473 if error_text: 

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

475 # Turn is done. Finish draining the current ready 

476 # batch (so trailing buffered lines aren't lost), 

477 # then break the loop below instead of blocking on 

478 # a pipe EOF that inherited background-task FDs may 

479 # never deliver. 

480 result_seen = True 

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

482 else: 

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

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

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

486 

487 if is_stderr: 

488 stderr_lines.append(line) 

489 else: 

490 stdout_lines.append(line) 

491 

492 if stream_callback: 

493 stream_callback(line, is_stderr) 

494 

495 # The "result" event ended the turn and the current ready batch 

496 # has now been fully drained; stop reading rather than blocking 

497 # for a pipe EOF that may never arrive. 

498 if result_seen: 

499 break 

500 

501 try: 

502 process.wait(timeout=30) 

503 except subprocess.TimeoutExpired: 

504 logger.warning( 

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

506 process.pid, 

507 ) 

508 _kill_process_group(process) 

509 try: 

510 process.wait(timeout=10) 

511 except subprocess.TimeoutExpired: 

512 logger.warning( 

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

514 process.pid, 

515 ) 

516 finally: 

517 if on_process_end: 

518 on_process_end(process) 

519 

520 return subprocess.CompletedProcess( 

521 cmd_args, 

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

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

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

525 )