Coverage for little_loops / subprocess_utils.py: 20%

203 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:12 -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.host_runner import resolve_host 

23 

24if TYPE_CHECKING: 

25 from little_loops.parallel.types import SprintWorkerContext 

26 

27logger = logging.getLogger(__name__) 

28 

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

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

31 

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

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

34 

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

36ModelCallback = Callable[[str], None] 

37 

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

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

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

41 

42 

43@dataclass 

44class TokenUsage: 

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

46 

47 input_tokens: int 

48 output_tokens: int 

49 cache_read_tokens: int 

50 cache_creation_tokens: int 

51 model: str 

52 

53 

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

55DetailedUsageCallback = Callable[[TokenUsage], None] 

56 

57# Context handoff detection pattern 

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

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

60 

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

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

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

64 

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

66_GUILLOTINE_TAIL_CHARS = 12_000 

67# Lines of original_command to include for task intent. 

68_GUILLOTINE_MAX_TASK_LINES = 20 

69 

70 

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

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

73 

74 Args: 

75 output: Command output to check 

76 

77 Returns: 

78 True if context handoff was signaled 

79 """ 

80 return bool(CONTEXT_HANDOFF_PATTERN.search(output)) 

81 

82 

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

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

85 

86 Args: 

87 repo_path: Optional repository root path 

88 

89 Returns: 

90 Contents of continuation prompt, or None if not found 

91 """ 

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

93 if prompt_path.exists(): 

94 return prompt_path.read_text() 

95 return None 

96 

97 

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

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

100 

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

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

103 context usage but no CONTEXT_HANDOFF signal. 

104 

105 Args: 

106 repo_path: Optional repository root path 

107 

108 Returns: 

109 Parsed sentinel dict, or None if not present 

110 """ 

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

112 if not sentinel_path.exists(): 

113 return None 

114 try: 

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

116 sentinel_path.unlink(missing_ok=True) 

117 return data 

118 except Exception: 

119 sentinel_path.unlink(missing_ok=True) 

120 return {} 

121 

122 

123def write_sentinel( 

124 repo_path: Path | None = None, 

125 token_count: int = 0, 

126 context_limit: int = 200_000, 

127) -> None: 

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

129 

130 Args: 

131 repo_path: Optional repository root path 

132 token_count: Total tokens used in the session 

133 context_limit: Context window size 

134 """ 

135 import datetime 

136 

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

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

139 try: 

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

141 sentinel_path.write_text( 

142 json.dumps( 

143 { 

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

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

146 ), 

147 "token_count": token_count, 

148 "context_limit": context_limit, 

149 "usage_percent": usage_percent, 

150 } 

151 ) 

152 ) 

153 except Exception: 

154 pass 

155 

156 

157def assemble_guillotine_prompt( 

158 original_command: str, 

159 captured_stdout: str, 

160 token_stats: dict, 

161 sprint_context: SprintWorkerContext | None = None, 

162) -> str: 

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

164 

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

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

167 so it starts with 0 tokens. 

168 

169 Args: 

170 original_command: The original task command / skill invocation 

171 captured_stdout: All Claude text output captured so far 

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

173 trigger_reason (optional) 

174 

175 Returns: 

176 Assembled continuation prompt string 

177 """ 

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

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

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

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

182 

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

184 if not stdout_tail: 

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

186 

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

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

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

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

191 

192 scratch_listing = _list_scratch_files() 

193 

194 body = f"""\ 

195⚠ CONTEXT LIMIT REACHED — FRESH SESSION CONTINUATION 

196 

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

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

199that interrupted session. 

200 

201## Original Task 

202{task_excerpt} 

203 

204## Session Progress at Interruption 

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

206- Trigger reason: {trigger_reason} 

207 

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

209{stdout_tail} 

210 

211## Scratch Pad Files Available 

212{scratch_listing} 

213 

214## Instructions for This Session 

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

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

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

2184. Continue implementation from the interruption point 

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

220""" 

221 

222 if sprint_context is not None: 

223 framing = ( 

224 f"## Sprint Worker Context\n" 

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

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

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

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

229 ) 

230 return framing + body 

231 

232 return body 

233 

234 

235def _list_scratch_files() -> str: 

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

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

238 if not scratch_dir.exists(): 

239 return "None" 

240 try: 

241 files = sorted(scratch_dir.iterdir()) 

242 if not files: 

243 return "None" 

244 lines = [] 

245 for f in files: 

246 try: 

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

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

249 except Exception: 

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

251 return "\n".join(lines) 

252 except Exception: 

253 return "None" 

254 

255 

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

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

258 

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

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

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

262 """ 

263 try: 

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

265 except (ProcessLookupError, PermissionError, AttributeError): 

266 process.kill() 

267 

268 

269def run_claude_command( 

270 command: str, 

271 timeout: int = 3600, 

272 working_dir: Path | None = None, 

273 stream_callback: OutputCallback | None = None, 

274 on_process_start: ProcessCallback | None = None, 

275 on_process_end: ProcessCallback | None = None, 

276 idle_timeout: int = 0, 

277 on_model_detected: ModelCallback | None = None, 

278 on_usage: UsageCallback | None = None, 

279 on_usage_detailed: DetailedUsageCallback | None = None, 

280 agent: str | None = None, 

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

282 resume_session: bool = False, 

283 model: str | None = None, 

284) -> subprocess.CompletedProcess[str]: 

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

286 

287 Args: 

288 command: Command to pass to Claude CLI 

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

290 working_dir: Optional working directory for the command 

291 stream_callback: Optional callback for streaming output lines. 

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

293 on_process_start: Optional callback invoked after process starts. 

294 Receives the Popen object for tracking/management. 

295 on_process_end: Optional callback invoked after process completes. 

296 Receives the Popen object. Called in finally block. 

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

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

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

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

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

302 on_usage_detailed: Optional callback invoked with a TokenUsage dataclass 

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

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

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

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

307 

308 Returns: 

309 CompletedProcess with stdout/stderr captured 

310 

311 Raises: 

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

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

314 """ 

315 runner = resolve_host() 

316 invocation = runner.build_streaming( 

317 prompt=command, 

318 working_dir=working_dir, 

319 resume=resume_session, 

320 agent=agent, 

321 tools=tools, 

322 model=model, 

323 ) 

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

325 

326 env = os.environ.copy() 

327 env.update(invocation.env) 

328 if "GIT_DIR" in invocation.env: 

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

330 

331 process = subprocess.Popen( 

332 cmd_args, 

333 stdout=subprocess.PIPE, 

334 stderr=subprocess.PIPE, 

335 text=True, 

336 bufsize=1, # Line buffered 

337 cwd=working_dir, 

338 env=env, 

339 start_new_session=True, 

340 ) 

341 

342 if on_process_start: 

343 on_process_start(process) 

344 

345 stdout_lines: list[str] = [] 

346 stderr_lines: list[str] = [] 

347 detected_model: str = "unknown" 

348 

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

350 with selectors.DefaultSelector() as sel: 

351 if process.stdout: 

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

353 if process.stderr: 

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

355 

356 start_time = time.time() 

357 last_output_time = start_time 

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

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

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

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

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

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

364 # timeout fires on a successful run. 

365 result_seen = False 

366 

367 try: 

368 while sel.get_map(): 

369 now = time.time() 

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

371 _kill_process_group(process) 

372 try: 

373 process.wait(timeout=10) 

374 except subprocess.TimeoutExpired: 

375 logger.warning( 

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

377 process.pid, 

378 ) 

379 raise subprocess.TimeoutExpired(cmd_args, timeout) 

380 

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

382 _kill_process_group(process) 

383 try: 

384 process.wait(timeout=10) 

385 except subprocess.TimeoutExpired: 

386 logger.warning( 

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

388 process.pid, 

389 ) 

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

391 

392 ready = sel.select(timeout=1.0) 

393 for key, _ in ready: 

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

395 if not line: 

396 sel.unregister(key.fileobj) 

397 continue 

398 

399 last_output_time = time.time() 

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

401 is_stderr = key.fileobj is process.stderr 

402 

403 if not is_stderr: 

404 try: 

405 event = json.loads(line) 

406 etype = event.get("type") 

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

408 if "model" in event: 

409 detected_model = event["model"] 

410 if on_model_detected: 

411 on_model_detected(event["model"]) 

412 continue # don't add to stdout_lines 

413 elif etype == "assistant": 

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

415 text_parts = [ 

416 block["text"] 

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

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

419 ] 

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

421 if not text: 

422 continue 

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

424 stdout_lines.append(sub_line) 

425 if stream_callback: 

426 stream_callback(sub_line, is_stderr) 

427 continue 

428 elif etype == "result": 

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

430 if on_usage and usage: 

431 on_usage( 

432 usage.get("input_tokens", 0) 

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

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

435 ) 

436 if on_usage_detailed and usage: 

437 on_usage_detailed( 

438 TokenUsage( 

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

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

441 cache_read_tokens=usage.get( 

442 "cache_read_input_tokens", 0 

443 ), 

444 cache_creation_tokens=usage.get( 

445 "cache_creation_input_tokens", 0 

446 ), 

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

448 ) 

449 ) 

450 if event.get("is_error"): 

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

452 if error_text: 

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

454 # Turn is done. Finish draining the current ready 

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

456 # then break the loop below instead of blocking on 

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

458 # never deliver. 

459 result_seen = True 

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

461 else: 

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

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

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

465 

466 if is_stderr: 

467 stderr_lines.append(line) 

468 else: 

469 stdout_lines.append(line) 

470 

471 if stream_callback: 

472 stream_callback(line, is_stderr) 

473 

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

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

476 # for a pipe EOF that may never arrive. 

477 if result_seen: 

478 break 

479 

480 try: 

481 process.wait(timeout=30) 

482 except subprocess.TimeoutExpired: 

483 logger.warning( 

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

485 process.pid, 

486 ) 

487 _kill_process_group(process) 

488 try: 

489 process.wait(timeout=10) 

490 except subprocess.TimeoutExpired: 

491 logger.warning( 

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

493 process.pid, 

494 ) 

495 finally: 

496 if on_process_end: 

497 on_process_end(process) 

498 

499 return subprocess.CompletedProcess( 

500 cmd_args, 

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

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

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

504 )